From b072d87a11476021bf5af1f4865cc8c664f943bf Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Tue, 10 Mar 2026 00:10:34 +0200 Subject: [PATCH 01/19] Adding raw waveform plot --- general functions/plotRawWaveforms.asv | 266 +++ general functions/plotRawWaveforms.m | 226 +++ .../@VStimAnalysis/PlotZScoreComparison.asv | 1506 ----------------- .../Run_Bombcell_Automatic_Sorting.asv | 157 ++ .../Run_Bombcell_Automatic_Sorting.m | 32 + 5 files changed, 681 insertions(+), 1506 deletions(-) create mode 100644 general functions/plotRawWaveforms.asv create mode 100644 general functions/plotRawWaveforms.m delete mode 100644 visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.asv create mode 100644 visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv diff --git a/general functions/plotRawWaveforms.asv b/general functions/plotRawWaveforms.asv new file mode 100644 index 0000000..9b352f2 --- /dev/null +++ b/general functions/plotRawWaveforms.asv @@ -0,0 +1,266 @@ +function plotRawWaveforms(obj, unitID, params) +% plotRawWaveforms - Plot raw spike waveforms from KS4 output, Phy-style +% Optionally plots an auto-correlogram. +% +% INPUTS: +% obj - Visual stimulation object with spikeSortingFolder and dataObj +% unitID - cluster ID to plot (single unit) +% params - (optional) struct with any of the following fields: +% +% WAVEFORM params: +% nWaveforms - number of random waveforms to plot (default: 100) +% nChanAround - channels above/below max amp channel (default: 4) +% nPre - samples before spike peak (default: 20) +% nPost - samples after spike peak (default: 61) +% +% CORRELOGRAM params: +% showCorr - plot auto-correlogram (default: false) +% corrWin - correlogram half-window in ms (default: 100) +% corrBin - correlogram bin size in ms (default: 1) +% +% EXAMPLE: +% % Just waveforms with defaults +% plotRawWaveforms(obj, 42) +% +% % Custom params +% params.nWaveforms = 200; +% params.nChanAround = 6; +% params.showCorr = true; +% params.corrWin = 50; +% params.corrBin = 0.5; +% plotRawWaveforms(obj, 42, params) + +arguments (Input) + obj + unitID (1,1) double + params.nWaveforms = 200; + params.nChanAround = 6; + params.showCorr = true; + params.corrWin = 50; + params.corrBin = 0.5; +end + +%% Parse params with defaults +params = parseParams(params); + +%% 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); + +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 +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] + +%% Find template index for this unit +unit_ids = (0 : size(templates, 1) - 1)'; +tmpl_idx = find(unit_ids == unitID); +if isempty(tmpl_idx) + error('Unit %d not found in templates.npy', unitID); +end + +%% Find best channel (max peak-to-peak across template channels) +unit_template = squeeze(templates(tmpl_idx, :, :)); % [T x nCh] +p2p = max(unit_template) - min(unit_template); +[~, best_tmpl_chan] = max(p2p); + +% Channels to extract: nChanAround above/below best channel +chan_indices = (best_tmpl_chan - params.nChanAround) : (best_tmpl_chan + params.nChanAround); +chan_indices = chan_indices(chan_indices >= 1 & chan_indices <= size(templates, 3)); +n_chans_plot = numel(chan_indices); + +% Index of best channel within the plotted subset +best_local_idx = find(chan_indices == best_tmpl_chan); + +% Map to binary file channels (1-indexed for MATLAB) +bin_chans = chan_map(chan_indices) + 1; +best_bin_chan = bin_chans(best_local_idx); + +%% Get spike times for this unit +st = double(spike_times(spike_clusters == unitID)); +if numel(st) < 2, error('Unit %d has fewer than 2 spikes.', unitID); end +fprintf('Unit %d: %d total spikes, extracting %d waveforms\n', ... + unitID, numel(st), min(params.nWaveforms, numel(st))); + +idx = randperm(numel(st), min(params.nWaveforms, numel(st))); +st_sub = st(idx); + +%% Extract waveforms from binary +waveform_len = params.nPre + params.nPost + 1; +finfo = dir(binPath); +n_samp_total = finfo.bytes / (n_channels * 2); +fid = fopen(binPath, 'rb'); + +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 +fclose(fid); + +% Baseline subtract (mean of pre-spike window) +baseline = mean(waveforms(:, 1:params.nPre, :), 2, 'omitnan'); +waveforms = waveforms - baseline; + +%% Compute correlogram if requested +if params.showCorr + [ccg_counts, ccg_bins] = computeACG(st, sample_rate, params.corrWin, params.corrBin); +end + +%% ---- Build layout ---- +t_ms = (-params.nPre : params.nPost) / sample_rate * 1000; +mean_wf = mean(waveforms, 3, 'omitnan'); +std_wf = std(waveforms, 0, 3, 'omitnan'); + +chan_depths = chan_pos(chan_indices, 2); +[~, depth_order] = sort(chan_depths, 'descend'); % shallowest at top + +colors = lines(n_chans_plot); +fig = figure('Color', 'w', 'Name', sprintf('Unit %d', unitID)); + +if params.showCorr + % Two-column layout: waveforms | correlogram + outer = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact'); + title(outer, sprintf('Unit %d | %d waveforms | best ch: %d', ... + unitID, numel(st_sub), best_bin_chan), 'FontSize', 12); + + % Left: nested layout for per-channel waveforms + ax_wf_container = nexttile(outer, 1); + wf_layout = tiledlayout(ax_wf_container.Parent, n_chans_plot, 1, ... + 'TileSpacing', 'none', 'Padding', 'compact'); + wf_layout.Layout.Tile = 1; + xlabel(wf_layout, 'Time (ms)'); + + % Right: correlogram axes + ax_corr = nexttile(outer, 2); +else + % Single-column layout: waveforms only + wf_layout = tiledlayout(fig, n_chans_plot, 1, ... + 'TileSpacing', 'none', 'Padding', 'compact'); + title(wf_layout, sprintf('Unit %d | %d waveforms | best ch: %d', ... + unitID, numel(st_sub), best_bin_chan), 'FontSize', 12); + xlabel(wf_layout, 'Time (ms)'); +end + +%% Plot one tile per channel +wf_axes = gobjects(n_chans_plot, 1); +for ci = 1:n_chans_plot + plot_ci = depth_order(ci); + ax = nexttile(wf_layout); + wf_axes(ci) = ax; + + % Individual waveforms (translucent) + wf_ci = squeeze(waveforms(plot_ci, :, :)); + plot(ax, t_ms, wf_ci, 'Color', [colors(plot_ci,:), 0.15], 'LineWidth', 0.5); + hold(ax, 'on'); + + % Std shading + upper = mean_wf(plot_ci,:) + std_wf(plot_ci,:); + lower = mean_wf(plot_ci,:) - std_wf(plot_ci,:); + fill(ax, [t_ms, fliplr(t_ms)], [upper, fliplr(lower)], ... + colors(plot_ci,:), 'FaceAlpha', 0.2, 'EdgeColor', 'none'); + + % Mean waveform + plot(ax, t_ms, mean_wf(plot_ci,:), 'Color', colors(plot_ci,:), 'LineWidth', 2); + + xline(ax, 0, '--k', 'Alpha', 0.3); + + % Highlight best channel with yellow background + if plot_ci == best_local_idx + set(ax, 'Color', [1 1 0.85]); + end + + % Channel label + depth + ylabel(ax, sprintf('ch%d\n%.0fµm', bin_chans(plot_ci), chan_depths(plot_ci)), ... + 'FontSize', 7, 'Rotation', 0, 'HorizontalAlignment', 'right'); + + if ci < n_chans_plot + set(ax, 'XTickLabel', []); + end + box(ax, 'off'); +end + +% Shared amplitude scale across all channels +linkaxes(wf_axes, 'y'); + +%% Plot correlogram +if params.showCorr + bar(ax_corr, ccg_bins, ccg_counts, 1, ... + 'FaceColor', [0.3 0.5 0.8], 'EdgeColor', 'none'); + hold(ax_corr, 'on'); + xline(ax_corr, 0, '--k', 'Alpha', 0.4); + + % Shade refractory period (±2 ms) + ylims = ylim(ax_corr); + patch(ax_corr, [-2 2 2 -2], [0 0 ylims(2) ylims(2)], ... + 'r', 'FaceAlpha', 0.1, 'EdgeColor', 'none'); + + xlabel(ax_corr, 'Lag (ms)'); + ylabel(ax_corr, 'Spike count'); + title(ax_corr, sprintf('ACG | bin %.1f ms | win ±%d ms', ... + params.corrBin, params.corrWin), 'FontSize', 10); + xlim(ax_corr, [-params.corrWin params.corrWin]); + box(ax_corr, 'off'); +end + +end % main function + + +%% ========================================================================= +function params = parseParams(params) +% Fill in defaults for any missing fields +if ~isfield(params, 'nWaveforms'), params.nWaveforms = 100; end +if ~isfield(params, 'nChanAround'), params.nChanAround = 4; end +if ~isfield(params, 'nPre'), params.nPre = 20; end +if ~isfield(params, 'nPost'), params.nPost = 61; end +if ~isfield(params, 'showCorr'), params.showCorr = false; end +if ~isfield(params, 'corrWin'), params.corrWin = 100; end % ms +if ~isfield(params, 'corrBin'), params.corrBin = 1; end % ms +end + + +%% ========================================================================= +function [counts, bin_centers] = computeACG(spike_times_samples, fs, win_ms, bin_ms) +% Compute auto-correlogram for a single unit +% spike_times_samples - spike times in samples +% fs - sampling rate (Hz) +% win_ms - half-window in ms +% bin_ms - bin size in ms + +st_ms = spike_times_samples / fs * 1000; % convert to ms +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); % lag to all other spikes + diffs(i) = NaN; % exclude self + diffs = diffs(diffs > -win_ms & diffs < win_ms); % within window + counts = counts + histcounts(diffs, edges); +end +end \ No newline at end of file diff --git a/general functions/plotRawWaveforms.m b/general functions/plotRawWaveforms.m new file mode 100644 index 0000000..e84954c --- /dev/null +++ b/general functions/plotRawWaveforms.m @@ -0,0 +1,226 @@ +function plotRawWaveforms(obj, unitID, params) +% plotRawWaveforms - Plot raw spike waveforms from KS4 output, Phy-style +% Optionally plots an auto-correlogram. +% +% INPUTS: +% obj - Visual stimulation object with spikeSortingFolder and dataObj +% unitID - cluster ID to plot (single unit) +% +% OPTIONAL NAME-VALUE PARAMS: +% nWaveforms - number of random waveforms to plot (default: 100) +% nChanAround - channels above/below max amp channel (default: 4) +% nPre - samples before spike peak (default: 20) +% nPost - samples after spike peak (default: 61) +% showCorr - plot auto-correlogram (default: false) +% corrWin - correlogram half-window in ms (default: 100) +% corrBin - correlogram bin size in ms (default: 1) +% +% EXAMPLES: +% plotRawWaveforms(obj, 42) +% plotRawWaveforms(obj, 42, nWaveforms=200, nChanAround=6) +% plotRawWaveforms(obj, 42, showCorr=true, corrWin=50, corrBin=0.5) + +arguments (Input) + obj + unitID (1,1) double + params.nWaveforms (1,1) double = 100 + params.nChanAround (1,1) double = 4 + 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 + +%% 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); + +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 +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] + +%% Find template index for this unit +unit_ids = (0 : size(templates, 1) - 1)'; +tmpl_idx = find(unit_ids == unitID); +if isempty(tmpl_idx) + error('Unit %d not found in templates.npy', unitID); +end + +%% Find best channel (max peak-to-peak across template channels) +unit_template = squeeze(templates(tmpl_idx, :, :)); % [T x nCh] +p2p = max(unit_template) - min(unit_template); +[~, best_tmpl_chan] = max(p2p); + +% Channels to extract: nChanAround above/below best channel +chan_indices = (best_tmpl_chan - params.nChanAround) : (best_tmpl_chan + params.nChanAround); +chan_indices = chan_indices(chan_indices >= 1 & chan_indices <= size(templates, 3)); +n_chans_plot = numel(chan_indices); + +% Index of best channel within the plotted subset +best_local_idx = find(chan_indices == best_tmpl_chan); + +% Map to binary file channels (1-indexed for MATLAB) +bin_chans = chan_map(chan_indices) + 1; +best_bin_chan = bin_chans(best_local_idx); + +%% Get spike times for this unit +st = double(spike_times(spike_clusters == unitID)); +if numel(st) < 2, error('Unit %d has fewer than 2 spikes.', unitID); end +fprintf('Unit %d: %d total spikes, extracting %d waveforms\n', ... + unitID, numel(st), min(params.nWaveforms, numel(st))); + +idx = randperm(numel(st), min(params.nWaveforms, numel(st))); +st_sub = st(idx); + +%% Extract waveforms from binary +waveform_len = params.nPre + params.nPost + 1; +finfo = dir(binPath); +n_samp_total = finfo.bytes / (n_channels * 2); +fid = fopen(binPath, 'rb'); + +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 +fclose(fid); + +% Baseline subtract (mean of pre-spike window) +baseline = mean(waveforms(:, 1:params.nPre, :), 2, 'omitnan'); +waveforms = waveforms - baseline; + +%% Compute correlogram if requested +if params.showCorr + [ccg_counts, ccg_bins] = computeACG(st, sample_rate, params.corrWin, params.corrBin); +end + +%% ---- Waveform figure ---- +t_ms = (-params.nPre : params.nPost) / sample_rate * 1000; +mean_wf = mean(waveforms, 3, 'omitnan'); +std_wf = std(waveforms, 0, 3, 'omitnan'); + +chan_depths = chan_pos(chan_indices, 2); +[~, depth_order] = sort(chan_depths, 'descend'); % shallowest at top + +colors = lines(n_chans_plot); + +figure('Color', 'w', 'Name', sprintf('Unit %d — Waveforms', unitID)); +wf_layout = tiledlayout(n_chans_plot, 1, 'TileSpacing', 'none', 'Padding', 'compact'); +title(wf_layout, sprintf('Unit %d | %d waveforms | best ch: %d', ... + unitID, numel(st_sub), best_bin_chan), 'FontSize', 12); +xlabel(wf_layout, 'Time (ms)'); + +wf_axes = gobjects(n_chans_plot, 1); +for ci = 1:n_chans_plot + plot_ci = depth_order(ci); + ax = nexttile(wf_layout); + wf_axes(ci) = ax; + + % Individual waveforms (translucent) + wf_ci = squeeze(waveforms(plot_ci, :, :)); + plot(ax, t_ms, wf_ci, 'Color', [colors(plot_ci,:), 0.15], 'LineWidth', 0.5); + hold(ax, 'on'); + + % Std shading + upper = mean_wf(plot_ci,:) + std_wf(plot_ci,:); + lower = mean_wf(plot_ci,:) - std_wf(plot_ci,:); + fill(ax, [t_ms, fliplr(t_ms)], [upper, fliplr(lower)], ... + colors(plot_ci,:), 'FaceAlpha', 0.2, 'EdgeColor', 'none'); + + % Mean waveform + plot(ax, t_ms, mean_wf(plot_ci,:), 'Color', colors(plot_ci,:), 'LineWidth', 2); + + xline(ax, 0, '--k', 'Alpha', 0.3); + + % Highlight best channel with yellow background + if plot_ci == best_local_idx + set(ax, 'Color', [1 1 0.85]); + end + + % Channel label + depth + ylabel(ax, sprintf('ch%d\n%.0fµm', bin_chans(plot_ci), chan_depths(plot_ci)), ... + 'FontSize', 7, 'Rotation', 0, 'HorizontalAlignment', 'right'); + + % Only show x tick labels on bottom subplot + if ci < n_chans_plot + set(ax, 'XTickLabel', []); + end + box(ax, 'off'); +end + +% Shared amplitude scale across all channels +linkaxes(wf_axes, 'y'); + +%% ---- ACG figure (separate) ---- +if params.showCorr + figure('Color', 'w', 'Name', sprintf('Unit %d — ACG', unitID)); + ax_corr = axes; + + bar(ax_corr, ccg_bins, ccg_counts, 1, ... + 'FaceColor', [0.3 0.5 0.8], 'EdgeColor', 'none'); + hold(ax_corr, 'on'); + xline(ax_corr, 0, '--k', 'Alpha', 0.4); + + % Shade refractory period (±2 ms) + ylims = ylim(ax_corr); + patch(ax_corr, [-2 2 2 -2], [0 0 ylims(2) ylims(2)], ... + 'r', 'FaceAlpha', 0.1, 'EdgeColor', 'none'); + + xlabel(ax_corr, 'Lag (ms)'); + ylabel(ax_corr, 'Spike count'); + title(ax_corr, sprintf('Unit %d | ACG | bin %.1f ms | win ±%d ms', ... + unitID, params.corrBin, params.corrWin), 'FontSize', 12); + xlim(ax_corr, [-params.corrWin params.corrWin]); + box(ax_corr, 'off'); +end + +end % main function + + +%% ========================================================================= +function [counts, bin_centers] = computeACG(spike_times_samples, fs, win_ms, bin_ms) +% Compute auto-correlogram for a single unit +% spike_times_samples - spike times in samples +% fs - sampling rate (Hz) +% win_ms - half-window in ms +% bin_ms - bin size in 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/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/Run_Bombcell_Automatic_Sorting.asv b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv new file mode 100644 index 0000000..64d7cc1 --- /dev/null +++ b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv @@ -0,0 +1,157 @@ + +%% Run/load bombcell and confusion matrices + +% +exp = [49:54,64:97];% +%tiledlayout(numel(exp),1) +for ex = exp%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); + + %convertPhySorting2tIc(obj,pathToPhyResults,tStart,BombCelled) + + % + % goodUnits = unitType == 1; + % muaUnits = unitType == 2; + % noiseUnits = unitType == 0; + % nonSomaticUnits = unitType == 3; + + % Concordance analysis + % bc load_manual_classifications(vs.spikeSortingFolder) + % pMC = vs.dataObj.convertPhySorting2tIc(vs.spikeSortingFolder,0,0,1); + % pBC = vs.dataObj.convertPhySorting2tIc(vs.spikeSortingFolder,0,1,1); + + bombcell_table = readtable([vs.spikeSortingFolder filesep 'cluster_bc_unitType.tsv'], 'FileType', 'text', 'Delimiter', '\t'); + manual_table = readtable([vs.spikeSortingFolder filesep 'cluster_info.tsv'],'FileType','delimitedtext'); + + manual_table = manual_table(:,{'cluster_id','KSLabel','group'}); + sum(strcmp(pKS.label, 'good')) + + + % Load and prepare data + % Assume: + % bombcell_table: Nx2 table, columns: [id, bc_label] ("GOOD","MUA","NON-SOMA","NOISE") + % manual_table: Mx3 table, columns: [id, KS_label, group] ("good","mua","noise") + + % Rename columns for clarity (adjust if yours differ) + bombcell_table.Properties.VariableNames = {'id', 'bc_label'}; + manual_table.Properties.VariableNames = {'id', 'KS_label', 'group'}; + + % Remove NON-SOMA from bombcell + bc = bombcell_table(~strcmp(bombcell_table.bc_label, 'NON-SOMA'), :); + + % Match IDs — keep only IDs present in both tables + [~, ia, ib] = intersect(bc.id, manual_table.id); + bc_matched = bc(ia, :); + man_matched = manual_table(ib, :); + + % Harmonize labels to lowercase for comparison + bc_labels = lower(bc_matched.bc_label); % "good","mua","noise" + ks_labels = lower(man_matched.KS_label); % "good","mua","noise" + man_labels = lower(man_matched.group); % "good","mua","noise" + + %%Define category order + cats = {'good', 'mua', 'noise'}; + + bc_cat = categorical(bc_labels, cats); + ks_cat = categorical(ks_labels, cats); + man_cat = categorical(man_labels, cats); + + % --- Confusion Matrix 1: Manual curation vs BombCell --- + % figure('Position', [100, 100, 700, 600]); + % + % tiledlayout(3,2) + % nexttile + % cm1 = confusionchart(man_cat, bc_cat, ... + % 'Title', sprintf('%s-Manual curation vs BombCell',NP.recordingName),... + % 'XLabel', 'BombCell', ... + % 'YLabel', 'Manual Curation', ... + % 'RowSummary', 'row-normalized', ... + % 'ColumnSummary', 'column-normalized'); + % + % cm1.FontSize = 9; + % + % % Give the chart more room inside the figure + % %cm1.Position = [10, 10, 680, 580]; + + % --- Confusion Matrix 2: KS label vs BombCell --- + fig = figure('Position', [100, 100, 700, 600]); + %tl = nexttile; + cm2 = confusionchart(ks_cat, bc_cat, ... + 'XLabel', 'BombCell', ... + 'YLabel', 'KS Label', ... + 'RowSummary', 'row-normalized', ... + 'ColumnSummary', 'column-normalized'); + cm2.FontSize = 9; + title(sprintf('%KS Label vs BombCell',NP.recordingName)); + + + + % %% --- Confusion Matrix 3: KS label vs Manual curation --- + % figure; + % cm3 = confusionchart(ks_cat, man_cat, ... + % 'Title', printf('KS Label vs Manual Curation',NP.recordingName), ... + % 'XLabel', 'Manual Curation', ... + % 'YLabel', 'KS Label', ... + % 'RowSummary', 'row-normalized', ... + % 'ColumnSummary', 'column-normalized'); + + % --- Print mismatch summary --- + % fprintf('\n=== Manual vs BombCell ===\n') + % mismatch_man_bc = man_cat ~= bc_cat; + % fprintf('Total mismatches: %d / %d (%.1f%%)\n', ... + % sum(mismatch_man_bc), numel(mismatch_man_bc), ... + % 100*mean(mismatch_man_bc)); + + fprintf('\n=== KS Label vs BombCell ===\n') + mismatch_ks_bc = ks_cat ~= bc_cat; + fprintf('Total mismatches: %d / %d (%.1f%%)\n', ... + sum(mismatch_ks_bc), numel(mismatch_ks_bc), ... + 100*mean(mismatch_ks_bc)); + + vs.printFig(fig,sprintf('%KS Label vs BombCell',NP.recordingName),PaperFig =1) + + close + + % fprintf('\n=== KS Label vs Manual Curation ===\n') + % mismatch_ks_man = ks_cat ~= man_cat; + % fprintf('Total mismatches: %d / %d (%.1f%%)\n', ... + % sum(mismatch_ks_man), numel(mismatch_ks_man), ... + % 100*mean(mismatch_ks_man)); + + % ks = Neuropixel.KilosortDataset(vs.spikeSortingFolder); + % 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 = 'recording.bin'; +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))); \ No newline at end of file diff --git a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m index 7abbcf3..d55b448 100644 --- a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m +++ b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m @@ -7,6 +7,7 @@ for ex = exp%GoodRecordingsPV%allGoodRec %GoodRecordings%GoodRecordingsPV%GoodRecordingsPV%selecN{1}(1,:) %1:size(data,1) %%%%%%%%%%%% Load data and data paremeters %1. Load NP class + ex=69 NP = loadNPclassFromTable(ex); vs = linearlyMovingBallAnalysis(NP,Session=1); KSversion =4; @@ -124,8 +125,39 @@ % 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))); + +%% +plotRawWaveforms(vs, 47, showCorr=true, corrWin=50, corrBin=0.5) \ No newline at end of file From 0e25919f9f5dc7d1c2775967e98eb031ebc17494 Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Wed, 11 Mar 2026 18:42:13 +0200 Subject: [PATCH 02/19] spattial plotting of waveforms --- general functions/plotRawWaveforms.asv | 115 +++++-------- general functions/plotRawWaveforms.m | 157 ++++++++++++------ .../Run_Bombcell_Automatic_Sorting.asv | 18 +- .../Run_Bombcell_Automatic_Sorting.m | 4 +- 4 files changed, 160 insertions(+), 134 deletions(-) diff --git a/general functions/plotRawWaveforms.asv b/general functions/plotRawWaveforms.asv index 9b352f2..86c2464 100644 --- a/general functions/plotRawWaveforms.asv +++ b/general functions/plotRawWaveforms.asv @@ -5,44 +5,33 @@ function plotRawWaveforms(obj, unitID, params) % INPUTS: % obj - Visual stimulation object with spikeSortingFolder and dataObj % unitID - cluster ID to plot (single unit) -% params - (optional) struct with any of the following fields: % -% WAVEFORM params: -% nWaveforms - number of random waveforms to plot (default: 100) -% nChanAround - channels above/below max amp channel (default: 4) -% nPre - samples before spike peak (default: 20) -% nPost - samples after spike peak (default: 61) +% OPTIONAL NAME-VALUE PARAMS: +% nWaveforms - number of random waveforms to plot (default: 100) +% nChanAround - channels above/below max amp channel (default: 4) +% nPre - samples before spike peak (default: 20) +% nPost - samples after spike peak (default: 61) +% showCorr - plot auto-correlogram (default: false) +% corrWin - correlogram half-window in ms (default: 100) +% corrBin - correlogram bin size in ms (default: 1) % -% CORRELOGRAM params: -% showCorr - plot auto-correlogram (default: false) -% corrWin - correlogram half-window in ms (default: 100) -% corrBin - correlogram bin size in ms (default: 1) -% -% EXAMPLE: -% % Just waveforms with defaults +% EXAMPLES: % plotRawWaveforms(obj, 42) -% -% % Custom params -% params.nWaveforms = 200; -% params.nChanAround = 6; -% params.showCorr = true; -% params.corrWin = 50; -% params.corrBin = 0.5; -% plotRawWaveforms(obj, 42, params) +% plotRawWaveforms(obj, 42, nWaveforms=200, nChanAround=6) +% plotRawWaveforms(obj, 42, showCorr=true, corrWin=50, corrBin=0.5) arguments (Input) obj - unitID (1,1) double - params.nWaveforms = 200; - params.nChanAround = 6; - params.showCorr = true; - params.corrWin = 50; - params.corrBin = 0.5; + unitID (1,1) double + params.nWaveforms (1,1) double = 100 + params.nChanAround (1,1) double = 4 + 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 -%% Parse params with defaults -params = parseParams(params); - %% Paths ksDir = obj.spikeSortingFolder; recordingDir = obj.dataObj.recordingDir; @@ -51,6 +40,7 @@ recordingDir = obj.dataObj.recordingDir; n_channels = str2double(obj.dataObj.nSavedChansImec); sample_rate = obj.dataObj.samplingFrequency; uV_per_bit = unique(obj.dataObj.MicrovoltsPerAD); +chPos = obj.dataObj.chLayoutPositions; fprintf('Settings — nCh: %d | Fs: %d Hz | uV/bit: %.4f\n', ... n_channels, sample_rate, uV_per_bit); @@ -132,7 +122,7 @@ if params.showCorr [ccg_counts, ccg_bins] = computeACG(st, sample_rate, params.corrWin, params.corrBin); end -%% ---- Build layout ---- +%% ---- Waveform figure ---- t_ms = (-params.nPre : params.nPost) / sample_rate * 1000; mean_wf = mean(waveforms, 3, 'omitnan'); std_wf = std(waveforms, 0, 3, 'omitnan'); @@ -141,33 +131,13 @@ chan_depths = chan_pos(chan_indices, 2); [~, depth_order] = sort(chan_depths, 'descend'); % shallowest at top colors = lines(n_chans_plot); -fig = figure('Color', 'w', 'Name', sprintf('Unit %d', unitID)); -if params.showCorr - % Two-column layout: waveforms | correlogram - outer = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact'); - title(outer, sprintf('Unit %d | %d waveforms | best ch: %d', ... - unitID, numel(st_sub), best_bin_chan), 'FontSize', 12); - - % Left: nested layout for per-channel waveforms - ax_wf_container = nexttile(outer, 1); - wf_layout = tiledlayout(ax_wf_container.Parent, n_chans_plot, 1, ... - 'TileSpacing', 'none', 'Padding', 'compact'); - wf_layout.Layout.Tile = 1; - xlabel(wf_layout, 'Time (ms)'); - - % Right: correlogram axes - ax_corr = nexttile(outer, 2); -else - % Single-column layout: waveforms only - wf_layout = tiledlayout(fig, n_chans_plot, 1, ... - 'TileSpacing', 'none', 'Padding', 'compact'); - title(wf_layout, sprintf('Unit %d | %d waveforms | best ch: %d', ... - unitID, numel(st_sub), best_bin_chan), 'FontSize', 12); - xlabel(wf_layout, 'Time (ms)'); -end +figure('Color', 'w', 'Name', sprintf('Unit %d — Waveforms', unitID)); +wf_layout = tiledlayout(n_chans_plot, 1, 'TileSpacing', 'none', 'Padding', 'compact'); +title(wf_layout, sprintf('Unit %d | %d waveforms | best ch: %d', ... + unitID, numel(st_sub), best_bin_chan), 'FontSize', 12); +xlabel(wf_layout, 'Time (ms)'); -%% Plot one tile per channel wf_axes = gobjects(n_chans_plot, 1); for ci = 1:n_chans_plot plot_ci = depth_order(ci); @@ -199,6 +169,7 @@ for ci = 1:n_chans_plot ylabel(ax, sprintf('ch%d\n%.0fµm', bin_chans(plot_ci), chan_depths(plot_ci)), ... 'FontSize', 7, 'Rotation', 0, 'HorizontalAlignment', 'right'); + % Only show x tick labels on bottom subplot if ci < n_chans_plot set(ax, 'XTickLabel', []); end @@ -208,8 +179,11 @@ end % Shared amplitude scale across all channels linkaxes(wf_axes, 'y'); -%% Plot correlogram +%% ---- ACG figure (separate) ---- if params.showCorr + figure('Color', 'w', 'Name', sprintf('Unit %d — ACG', unitID)); + ax_corr = axes; + bar(ax_corr, ccg_bins, ccg_counts, 1, ... 'FaceColor', [0.3 0.5 0.8], 'EdgeColor', 'none'); hold(ax_corr, 'on'); @@ -222,8 +196,8 @@ if params.showCorr xlabel(ax_corr, 'Lag (ms)'); ylabel(ax_corr, 'Spike count'); - title(ax_corr, sprintf('ACG | bin %.1f ms | win ±%d ms', ... - params.corrBin, params.corrWin), 'FontSize', 10); + title(ax_corr, sprintf('Unit %d | ACG | bin %.1f ms | win ±%d ms', ... + unitID, params.corrBin, params.corrWin), 'FontSize', 12); xlim(ax_corr, [-params.corrWin params.corrWin]); box(ax_corr, 'off'); end @@ -231,19 +205,6 @@ end end % main function -%% ========================================================================= -function params = parseParams(params) -% Fill in defaults for any missing fields -if ~isfield(params, 'nWaveforms'), params.nWaveforms = 100; end -if ~isfield(params, 'nChanAround'), params.nChanAround = 4; end -if ~isfield(params, 'nPre'), params.nPre = 20; end -if ~isfield(params, 'nPost'), params.nPost = 61; end -if ~isfield(params, 'showCorr'), params.showCorr = false; end -if ~isfield(params, 'corrWin'), params.corrWin = 100; end % ms -if ~isfield(params, 'corrBin'), params.corrBin = 1; end % ms -end - - %% ========================================================================= function [counts, bin_centers] = computeACG(spike_times_samples, fs, win_ms, bin_ms) % Compute auto-correlogram for a single unit @@ -252,15 +213,15 @@ function [counts, bin_centers] = computeACG(spike_times_samples, fs, win_ms, bin % win_ms - half-window in ms % bin_ms - bin size in ms -st_ms = spike_times_samples / fs * 1000; % convert to 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); % lag to all other spikes - diffs(i) = NaN; % exclude self - diffs = diffs(diffs > -win_ms & diffs < win_ms); % within window - counts = counts + histcounts(diffs, edges); + 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/plotRawWaveforms.m b/general functions/plotRawWaveforms.m index e84954c..84ad274 100644 --- a/general functions/plotRawWaveforms.m +++ b/general functions/plotRawWaveforms.m @@ -1,9 +1,10 @@ function plotRawWaveforms(obj, unitID, params) % plotRawWaveforms - Plot raw spike waveforms from KS4 output, Phy-style -% Optionally plots an auto-correlogram. +% Channels are drawn at their true probe x/y positions. +% Optionally plots an auto-correlogram in a separate figure. % % INPUTS: -% obj - Visual stimulation object with spikeSortingFolder and dataObj +% obj - Visual stimulation object % unitID - cluster ID to plot (single unit) % % OPTIONAL NAME-VALUE PARAMS: @@ -24,7 +25,7 @@ function plotRawWaveforms(obj, unitID, params) obj unitID (1,1) double params.nWaveforms (1,1) double = 100 - params.nChanAround (1,1) double = 4 + params.nChanAround (1,1) double = 10 params.nPre (1,1) double = 20 params.nPost (1,1) double = 61 params.showCorr (1,1) logical = false @@ -40,6 +41,7 @@ function plotRawWaveforms(obj, unitID, params) 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); @@ -54,9 +56,9 @@ function plotRawWaveforms(obj, unitID, params) %% Load KS4 output 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] +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] %% Find template index for this unit unit_ids = (0 : size(templates, 1) - 1)'; @@ -70,16 +72,20 @@ function plotRawWaveforms(obj, unitID, params) p2p = max(unit_template) - min(unit_template); [~, best_tmpl_chan] = max(p2p); -% Channels to extract: nChanAround above/below best channel -chan_indices = (best_tmpl_chan - params.nChanAround) : (best_tmpl_chan + params.nChanAround); -chan_indices = chan_indices(chan_indices >= 1 & chan_indices <= size(templates, 3)); +% Get probe positions for all template channels via chan_map +% chan_pos is [nTemplateCh x 2]: col1=x, col2=y (from KS4, in µm) +% Find nChanAround closest channels to best channel by Euclidean distance +best_xy = chan_pos(best_tmpl_chan, :); % [1 x 2] +dists = sqrt(sum((chan_pos - best_xy).^2, 2)); % [nTemplateCh x 1] +[~, sorted_idx] = sort(dists, 'ascend'); +chan_indices = sorted_idx(1 : min(params.nChanAround + 1, numel(dists)))'; n_chans_plot = numel(chan_indices); % Index of best channel within the plotted subset best_local_idx = find(chan_indices == best_tmpl_chan); % Map to binary file channels (1-indexed for MATLAB) -bin_chans = chan_map(chan_indices) + 1; +bin_chans = chan_map(chan_indices) + 1; % [n_chans_plot x 1], 1-indexed best_bin_chan = bin_chans(best_local_idx); %% Get spike times for this unit @@ -121,62 +127,111 @@ function plotRawWaveforms(obj, unitID, params) [ccg_counts, ccg_bins] = computeACG(st, sample_rate, params.corrWin, params.corrBin); end -%% ---- Waveform figure ---- +%% ---- Spatial positions for plotted channels ---- +% chPos is [2 x nAllCh]: row 1 = x (shank col), row 2 = y (depth) +ch_x = chPos(1, bin_chans); % [1 x n_chans_plot] +ch_y = chPos(2, bin_chans); % [1 x n_chans_plot] + +% Detect inter-channel pitch from all channels on the probe +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; % fallback NP1 column pitch +end +if isempty(y_spacing) || numel(y_unique) == 1 + y_spacing = 20; % fallback NP1 row pitch +end + +% Time axis scaled to fit in x_spacing (80% fill) t_ms = (-params.nPre : params.nPost) / sample_rate * 1000; +t_scale = 0.8 * x_spacing / (t_ms(end) - t_ms(1)); % µm per ms + +% Amplitude scale: normalise so max p2p fits in y_spacing (80% fill) mean_wf = mean(waveforms, 3, 'omitnan'); std_wf = std(waveforms, 0, 3, 'omitnan'); +max_p2p = max(max(mean_wf, [], 2) - min(mean_wf, [], 2)); +if max_p2p == 0, max_p2p = 1; end +amp_scale = 0.8 * y_spacing / max_p2p; % µm per µV -chan_depths = chan_pos(chan_indices, 2); -[~, depth_order] = sort(chan_depths, 'descend'); % shallowest at top - -colors = lines(n_chans_plot); +%% ---- Colours: best channel = red, all others = blue ---- +col_default = [0.25 0.45 0.75]; % blue +col_best = [0.85 0.20 0.15]; % red +%% ---- Waveform figure ---- figure('Color', 'w', 'Name', sprintf('Unit %d — Waveforms', unitID)); -wf_layout = tiledlayout(n_chans_plot, 1, 'TileSpacing', 'none', 'Padding', 'compact'); -title(wf_layout, sprintf('Unit %d | %d waveforms | best ch: %d', ... - unitID, numel(st_sub), best_bin_chan), 'FontSize', 12); -xlabel(wf_layout, 'Time (ms)'); +ax = axes('Color', 'w'); +hold(ax, 'on'); -wf_axes = gobjects(n_chans_plot, 1); for ci = 1:n_chans_plot - plot_ci = depth_order(ci); - ax = nexttile(wf_layout); - wf_axes(ci) = ax; + cx = ch_x(ci); + cy = ch_y(ci); + + if ci == best_local_idx + col = col_best; + else + col = col_default; + end + + x_wf = cx + t_ms * t_scale; % Individual waveforms (translucent) - wf_ci = squeeze(waveforms(plot_ci, :, :)); - plot(ax, t_ms, wf_ci, 'Color', [colors(plot_ci,:), 0.15], 'LineWidth', 0.5); - hold(ax, 'on'); + wf_ci = squeeze(waveforms(ci, :, :)); % [nSamples x nWaveforms] + y_wf = cy + wf_ci * amp_scale; + plot(ax, x_wf, y_wf, 'Color', [col, 0.12], 'LineWidth', 0.5); % Std shading - upper = mean_wf(plot_ci,:) + std_wf(plot_ci,:); - lower = mean_wf(plot_ci,:) - std_wf(plot_ci,:); - fill(ax, [t_ms, fliplr(t_ms)], [upper, fliplr(lower)], ... - colors(plot_ci,:), 'FaceAlpha', 0.2, 'EdgeColor', 'none'); + 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 - plot(ax, t_ms, mean_wf(plot_ci,:), 'Color', colors(plot_ci,:), 'LineWidth', 2); - - xline(ax, 0, '--k', 'Alpha', 0.3); - - % Highlight best channel with yellow background - if plot_ci == best_local_idx - set(ax, 'Color', [1 1 0.85]); - end - - % Channel label + depth - ylabel(ax, sprintf('ch%d\n%.0fµm', bin_chans(plot_ci), chan_depths(plot_ci)), ... - 'FontSize', 7, 'Rotation', 0, 'HorizontalAlignment', 'right'); - - % Only show x tick labels on bottom subplot - if ci < n_chans_plot - set(ax, 'XTickLabel', []); - end - box(ax, 'off'); + y_mean = cy + mean_wf(ci,:) * amp_scale; + plot(ax, x_wf, y_mean, 'k', 'LineWidth', 2); + + % Channel label: two rows, just left of waveform start + text(ax, x_wf(1) - 2, cy + amp_scale * 0, ... + sprintf('ch%d\n(%g, %g)', bin_chans(ci), cx, cy), ... + 'FontSize', 7, 'HorizontalAlignment', 'right', ... + 'VerticalAlignment', 'middle', 'Color', col); end -% Shared amplitude scale across all channels -linkaxes(wf_axes, 'y'); +%% ---- L-shaped scale bar ---- +% Fixed scale: 2 ms horizontal, 200 µV vertical +sb_ms = 1; % ms +sb_uv = 200; % µV +sb_xlen = sb_ms * t_scale; % µm +sb_ylen = sb_uv * amp_scale; % µm + +% Position: to the right of the bottom-right waveform, at the same y level +[~, bottom_right_ci] = min(ch_y - ch_x * 1e-6); % lowest y, rightmost x as tiebreak +br_cx = ch_x(bottom_right_ci); +br_cy = ch_y(bottom_right_ci); + +sb_gap = 0.2 * x_spacing; % horizontal gap from last waveform +sb_ox = br_cx + t_ms(end) * t_scale + sb_gap; % L corner x: just right of waveform end +sb_oy = br_cy; % L corner y: same level as that channel + +% Draw L: vertical arm then horizontal arm, meeting at bottom-left corner +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); + +% Labels +text(ax, sb_ox - 2, sb_oy - sb_ylen/2, sprintf('%d µV', sb_uv), ... + 'FontSize', 8, 'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle', 'Rotation',90); +text(ax, sb_ox + sb_xlen/2, sb_oy + 2, sprintf('%d ms', sb_ms), ... + 'FontSize', 8, 'HorizontalAlignment', 'center', 'VerticalAlignment', 'top'); + +%% Axis cosmetics — no tick marks, no box +title(ax, sprintf('Unit %d | %d waveforms | best ch: %d', ... + unitID, numel(st_sub), best_bin_chan), 'FontSize', 12); +set(ax, 'XTick', [], 'YTick', [], 'YDir', 'normal'); +axis(ax, 'tight'); +box(ax, 'off'); +axis(ax, 'off'); % hide axes entirely — scale bar carries all metric info %% ---- ACG figure (separate) ---- if params.showCorr @@ -195,7 +250,7 @@ function plotRawWaveforms(obj, unitID, params) xlabel(ax_corr, 'Lag (ms)'); ylabel(ax_corr, 'Spike count'); - title(ax_corr, sprintf('Unit %d | ACG | bin %.1f ms | win ±%d ms', ... + title(ax_corr, sprintf('Unit %d ACG | RP 2 ms | bin %.1f ms | win ±%d ms', ... unitID, params.corrBin, params.corrWin), 'FontSize', 12); xlim(ax_corr, [-params.corrWin params.corrWin]); box(ax_corr, 'off'); diff --git a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv index 64d7cc1..f01e17a 100644 --- a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv +++ b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv @@ -7,6 +7,7 @@ exp = [49:54,64:97];% for ex = exp%GoodRecordingsPV%allGoodRec %GoodRecordings%GoodRecordingsPV%GoodRecordingsPV%selecN{1}(1,:) %1:size(data,1) %%%%%%%%%%%% Load data and data paremeters %1. Load NP class + ex=69 NP = loadNPclassFromTable(ex); vs = linearlyMovingBallAnalysis(NP,Session=1); KSversion =4; @@ -124,8 +125,9 @@ for ex = exp%GoodRecordingsPV%allGoodRec %GoodRecordings%GoodRecordingsPV%GoodR % sum(mismatch_ks_man), numel(mismatch_ks_man), ... % 100*mean(mismatch_ks_man)); - % ks = Neuropixel.KilosortDataset(vs.spikeSortingFolder); - % ks.load(); + 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. @@ -137,12 +139,13 @@ end % 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 = 'recording.bin'; +gwfparams.fileName = NP.recordingDir; gwfparams.dataType = 'int16'; gwfparams.nCh = 385; gwfparams.wfWin = [-40 41]; % samples around spike @@ -150,8 +153,13 @@ 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] +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))); \ No newline at end of file +plot(squeeze(mean(wf.waveFormsMean(1,:,:), 2))); + +%% +plotRawWaveforms(vs, 47, showCorr=true, corrWin=50, corrBin=0.5) + +plotRawWaveforms_spatially(vs, 47, showCorr=true, corrWin=50, corrBin=0.5,nChanAround105) \ No newline at end of file diff --git a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m index d55b448..131be4c 100644 --- a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m +++ b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m @@ -160,4 +160,6 @@ plot(squeeze(mean(wf.waveFormsMean(1,:,:), 2))); %% -plotRawWaveforms(vs, 47, showCorr=true, corrWin=50, corrBin=0.5) \ No newline at end of file +plotRawWaveforms(vs, 47, showCorr=true, corrWin=50, corrBin=0.5) + +plotRawWaveforms_spatially(vs, 47, showCorr=true, corrWin=50, corrBin=0.5,nChanAround=10) \ No newline at end of file From 1ef508a2f61c120ad2e5d37c1d98dbadf2be8c1c Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Thu, 12 Mar 2026 21:44:56 +0200 Subject: [PATCH 03/19] Chaanges to waveform plots --- general functions/plotRawWaveforms.asv | 360 +++++++++------ general functions/plotRawWaveforms.m | 416 ++++++++++-------- visualStimulationAnalysis/RunAnalysisClass.m | 4 +- .../Run_Bombcell_Automatic_Sorting.asv | 21 +- .../Run_Bombcell_Automatic_Sorting.m | 25 +- 5 files changed, 484 insertions(+), 342 deletions(-) diff --git a/general functions/plotRawWaveforms.asv b/general functions/plotRawWaveforms.asv index 86c2464..52f21d5 100644 --- a/general functions/plotRawWaveforms.asv +++ b/general functions/plotRawWaveforms.asv @@ -1,30 +1,31 @@ -function plotRawWaveforms(obj, unitID, params) +function [fig1, fig2] = plotRawWaveforms(obj, unitIDs, params) % plotRawWaveforms - Plot raw spike waveforms from KS4 output, Phy-style -% Optionally plots an auto-correlogram. +% 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 with spikeSortingFolder and dataObj -% unitID - cluster ID to plot (single unit) +% 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 - channels above/below max amp channel (default: 4) +% 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 (default: false) +% 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, 42, nWaveforms=200, nChanAround=6) -% plotRawWaveforms(obj, 42, showCorr=true, corrWin=50, corrBin=0.5) +% plotRawWaveforms(obj, [3 7 42], nWaveforms=200, nChanAround=6) +% plotRawWaveforms(obj, [3 7 42], showCorr=true, corrWin=50, corrBin=0.5) arguments (Input) obj - unitID (1,1) double + unitIDs (1,:) double params.nWaveforms (1,1) double = 100 - params.nChanAround (1,1) double = 4 + params.nChanAround (1,1) double = 10 params.nPre (1,1) double = 20 params.nPost (1,1) double = 61 params.showCorr (1,1) logical = false @@ -32,6 +33,8 @@ arguments (Input) params.corrBin (1,1) double = 1 end +nUnits = numel(unitIDs); + %% Paths ksDir = obj.spikeSortingFolder; recordingDir = obj.dataObj.recordingDir; @@ -40,7 +43,7 @@ recordingDir = obj.dataObj.recordingDir; n_channels = str2double(obj.dataObj.nSavedChansImec); sample_rate = obj.dataObj.samplingFrequency; uV_per_bit = unique(obj.dataObj.MicrovoltsPerAD); -chPos = obj.dataObj.chLayoutPositions; +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); @@ -52,154 +55,236 @@ if isempty(binFiles), error('No .bin or .dat file found in: %s', recordingDir); binPath = fullfile(recordingDir, binFiles(1).name); fprintf('Using binary file: %s\n', binPath); -%% Load KS4 output +%% 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] - -%% Find template index for this unit -unit_ids = (0 : size(templates, 1) - 1)'; -tmpl_idx = find(unit_ids == unitID); -if isempty(tmpl_idx) - error('Unit %d not found in templates.npy', unitID); -end - -%% Find best channel (max peak-to-peak across template channels) -unit_template = squeeze(templates(tmpl_idx, :, :)); % [T x nCh] -p2p = max(unit_template) - min(unit_template); -[~, best_tmpl_chan] = max(p2p); - -% Channels to extract: nChanAround above/below best channel -chan_indices = (best_tmpl_chan - params.nChanAround) : (best_tmpl_chan + params.nChanAround); -chan_indices = chan_indices(chan_indices >= 1 & chan_indices <= size(templates, 3)); -n_chans_plot = numel(chan_indices); +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] -% Index of best channel within the plotted subset -best_local_idx = find(chan_indices == best_tmpl_chan); +unit_ids_ks = (0 : size(templates, 1) - 1)'; -% Map to binary file channels (1-indexed for MATLAB) -bin_chans = chan_map(chan_indices) + 1; -best_bin_chan = bin_chans(best_local_idx); +%% 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 -%% Get spike times for this unit -st = double(spike_times(spike_clusters == unitID)); -if numel(st) < 2, error('Unit %d has fewer than 2 spikes.', unitID); end -fprintf('Unit %d: %d total spikes, extracting %d waveforms\n', ... - unitID, numel(st), min(params.nWaveforms, numel(st))); +t_ms = (-params.nPre : params.nPost) / sample_rate * 1000; -idx = randperm(numel(st), min(params.nWaveforms, numel(st))); -st_sub = st(idx); +%% Colours +col_default = [0.25 0.45 0.75]; % blue +col_best = [0.85 0.20 0.15]; % red -%% Extract waveforms from binary -waveform_len = params.nPre + params.nPost + 1; +%% ---- Extract data for each unit ---- finfo = dir(binPath); n_samp_total = finfo.bytes / (n_channels * 2); fid = fopen(binPath, 'rb'); -waveforms = NaN(n_chans_plot, waveform_len, numel(st_sub)); +unitData = struct(); % will hold per-unit results -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 +for ui = 1:nUnits + unitID = unitIDs(ui); - fseek(fid, (s0 - 1) * n_channels * 2, 'bof'); - raw = fread(fid, [n_channels, waveform_len], '*int16'); - if size(raw, 2) < waveform_len, continue; end + % 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 - waveforms(:, :, si) = double(raw(bin_chans, :)) * uV_per_bit; -end -fclose(fid); + % 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 -% Baseline subtract (mean of pre-spike window) -baseline = mean(waveforms(:, 1:params.nPre, :), 2, 'omitnan'); -waveforms = waveforms - baseline; + % 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 -%% Compute correlogram if requested -if params.showCorr - [ccg_counts, ccg_bins] = computeACG(st, sample_rate, params.corrWin, params.corrBin); + % 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; + unitData(ui).mean_wf = mean(waveforms, 3, 'omitnan'); + unitData(ui).std_wf = std(waveforms, 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 ---- -t_ms = (-params.nPre : params.nPost) / sample_rate * 1000; -mean_wf = mean(waveforms, 3, 'omitnan'); -std_wf = std(waveforms, 0, 3, 'omitnan'); - -chan_depths = chan_pos(chan_indices, 2); -[~, depth_order] = sort(chan_depths, 'descend'); % shallowest at top - -colors = lines(n_chans_plot); - -figure('Color', 'w', 'Name', sprintf('Unit %d — Waveforms', unitID)); -wf_layout = tiledlayout(n_chans_plot, 1, 'TileSpacing', 'none', 'Padding', 'compact'); -title(wf_layout, sprintf('Unit %d | %d waveforms | best ch: %d', ... - unitID, numel(st_sub), best_bin_chan), 'FontSize', 12); -xlabel(wf_layout, 'Time (ms)'); - -wf_axes = gobjects(n_chans_plot, 1); -for ci = 1:n_chans_plot - plot_ci = depth_order(ci); - ax = nexttile(wf_layout); - wf_axes(ci) = ax; - - % Individual waveforms (translucent) - wf_ci = squeeze(waveforms(plot_ci, :, :)); - plot(ax, t_ms, wf_ci, 'Color', [colors(plot_ci,:), 0.15], 'LineWidth', 0.5); +%% ---- 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'); - % Std shading - upper = mean_wf(plot_ci,:) + std_wf(plot_ci,:); - lower = mean_wf(plot_ci,:) - std_wf(plot_ci,:); - fill(ax, [t_ms, fliplr(t_ms)], [upper, fliplr(lower)], ... - colors(plot_ci,:), 'FaceAlpha', 0.2, 'EdgeColor', 'none'); - - % Mean waveform - plot(ax, t_ms, mean_wf(plot_ci,:), 'Color', colors(plot_ci,:), 'LineWidth', 2); - - xline(ax, 0, '--k', 'Alpha', 0.3); - - % Highlight best channel with yellow background - if plot_ci == best_local_idx - set(ax, 'Color', [1 1 0.85]); + 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 - % Channel label + depth - ylabel(ax, sprintf('ch%d\n%.0fµm', bin_chans(plot_ci), chan_depths(plot_ci)), ... - 'FontSize', 7, 'Rotation', 0, 'HorizontalAlignment', 'right'); - - % Only show x tick labels on bottom subplot - if ci < n_chans_plot - set(ax, 'XTickLabel', []); - end - box(ax, 'off'); + % 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 -% Shared amplitude scale across all channels -linkaxes(wf_axes, 'y'); - -%% ---- ACG figure (separate) ---- +%% ---- ACG figure: one tile per unit ---- if params.showCorr - figure('Color', 'w', 'Name', sprintf('Unit %d — ACG', unitID)); - ax_corr = axes; - - bar(ax_corr, ccg_bins, ccg_counts, 1, ... - 'FaceColor', [0.3 0.5 0.8], 'EdgeColor', 'none'); - hold(ax_corr, 'on'); - xline(ax_corr, 0, '--k', 'Alpha', 0.4); - - % Shade refractory period (±2 ms) - ylims = ylim(ax_corr); - patch(ax_corr, [-2 2 2 -2], [0 0 ylims(2) ylims(2)], ... - 'r', 'FaceAlpha', 0.1, 'EdgeColor', 'none'); - - xlabel(ax_corr, 'Lag (ms)'); - ylabel(ax_corr, 'Spike count'); - title(ax_corr, sprintf('Unit %d | ACG | bin %.1f ms | win ±%d ms', ... - unitID, params.corrBin, params.corrWin), 'FontSize', 12); - xlim(ax_corr, [-params.corrWin params.corrWin]); - box(ax_corr, 'off'); + 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 @@ -207,17 +292,10 @@ end % main function %% ========================================================================= function [counts, bin_centers] = computeACG(spike_times_samples, fs, win_ms, bin_ms) -% Compute auto-correlogram for a single unit -% spike_times_samples - spike times in samples -% fs - sampling rate (Hz) -% win_ms - half-window in ms -% bin_ms - bin size in 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; diff --git a/general functions/plotRawWaveforms.m b/general functions/plotRawWaveforms.m index 84ad274..b39bfb5 100644 --- a/general functions/plotRawWaveforms.m +++ b/general functions/plotRawWaveforms.m @@ -1,29 +1,29 @@ -function plotRawWaveforms(obj, unitID, params) +function [fig1, fig2] = plotRawWaveforms(obj, unitIDs, params) % plotRawWaveforms - Plot raw spike waveforms from KS4 output, Phy-style -% Channels are drawn at their true probe x/y positions. -% Optionally plots an auto-correlogram in a separate figure. +% 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 -% unitID - cluster ID to plot (single unit) +% 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 - channels above/below max amp channel (default: 4) +% 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 (default: false) +% 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, 42, nWaveforms=200, nChanAround=6) -% plotRawWaveforms(obj, 42, showCorr=true, corrWin=50, corrBin=0.5) +% plotRawWaveforms(obj, [3 7 42], nWaveforms=200, nChanAround=6) +% plotRawWaveforms(obj, [3 7 42], showCorr=true, corrWin=50, corrBin=0.5) arguments (Input) obj - unitID (1,1) double + unitIDs (1,:) double params.nWaveforms (1,1) double = 100 params.nChanAround (1,1) double = 10 params.nPre (1,1) double = 20 @@ -33,6 +33,8 @@ function plotRawWaveforms(obj, unitID, params) params.corrBin (1,1) double = 1 end +nUnits = numel(unitIDs); + %% Paths ksDir = obj.spikeSortingFolder; recordingDir = obj.dataObj.recordingDir; @@ -53,207 +55,246 @@ function plotRawWaveforms(obj, unitID, params) binPath = fullfile(recordingDir, binFiles(1).name); fprintf('Using binary file: %s\n', binPath); -%% Load KS4 output +%% 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] -%% Find template index for this unit -unit_ids = (0 : size(templates, 1) - 1)'; -tmpl_idx = find(unit_ids == unitID); -if isempty(tmpl_idx) - error('Unit %d not found in templates.npy', unitID); -end - -%% Find best channel (max peak-to-peak across template channels) -unit_template = squeeze(templates(tmpl_idx, :, :)); % [T x nCh] -p2p = max(unit_template) - min(unit_template); -[~, best_tmpl_chan] = max(p2p); - -% Get probe positions for all template channels via chan_map -% chan_pos is [nTemplateCh x 2]: col1=x, col2=y (from KS4, in µm) -% Find nChanAround closest channels to best channel by Euclidean distance -best_xy = chan_pos(best_tmpl_chan, :); % [1 x 2] -dists = sqrt(sum((chan_pos - best_xy).^2, 2)); % [nTemplateCh x 1] -[~, sorted_idx] = sort(dists, 'ascend'); -chan_indices = sorted_idx(1 : min(params.nChanAround + 1, numel(dists)))'; -n_chans_plot = numel(chan_indices); - -% Index of best channel within the plotted subset -best_local_idx = find(chan_indices == best_tmpl_chan); - -% Map to binary file channels (1-indexed for MATLAB) -bin_chans = chan_map(chan_indices) + 1; % [n_chans_plot x 1], 1-indexed -best_bin_chan = bin_chans(best_local_idx); - -%% Get spike times for this unit -st = double(spike_times(spike_clusters == unitID)); -if numel(st) < 2, error('Unit %d has fewer than 2 spikes.', unitID); end -fprintf('Unit %d: %d total spikes, extracting %d waveforms\n', ... - unitID, numel(st), min(params.nWaveforms, numel(st))); - -idx = randperm(numel(st), min(params.nWaveforms, numel(st))); -st_sub = st(idx); - -%% Extract waveforms from binary -waveform_len = params.nPre + params.nPost + 1; -finfo = dir(binPath); -n_samp_total = finfo.bytes / (n_channels * 2); -fid = fopen(binPath, 'rb'); - -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 +unit_ids_ks = (0 : size(templates, 1) - 1)'; - waveforms(:, :, si) = double(raw(bin_chans, :)) * uV_per_bit; -end -fclose(fid); - -% Baseline subtract (mean of pre-spike window) -baseline = mean(waveforms(:, 1:params.nPre, :), 2, 'omitnan'); -waveforms = waveforms - baseline; - -%% Compute correlogram if requested -if params.showCorr - [ccg_counts, ccg_bins] = computeACG(st, sample_rate, params.corrWin, params.corrBin); -end - -%% ---- Spatial positions for plotted channels ---- -% chPos is [2 x nAllCh]: row 1 = x (shank col), row 2 = y (depth) -ch_x = chPos(1, bin_chans); % [1 x n_chans_plot] -ch_y = chPos(2, bin_chans); % [1 x n_chans_plot] - -% Detect inter-channel pitch from all channels on the probe +%% 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 -if isempty(x_spacing) || numel(x_unique) == 1 - x_spacing = 32; % fallback NP1 column pitch -end -if isempty(y_spacing) || numel(y_unique) == 1 - y_spacing = 20; % fallback NP1 row pitch -end - -% Time axis scaled to fit in x_spacing (80% fill) -t_ms = (-params.nPre : params.nPost) / sample_rate * 1000; -t_scale = 0.8 * x_spacing / (t_ms(end) - t_ms(1)); % µm per ms - -% Amplitude scale: normalise so max p2p fits in y_spacing (80% fill) -mean_wf = mean(waveforms, 3, 'omitnan'); -std_wf = std(waveforms, 0, 3, 'omitnan'); -max_p2p = max(max(mean_wf, [], 2) - min(mean_wf, [], 2)); -if max_p2p == 0, max_p2p = 1; end -amp_scale = 0.8 * y_spacing / max_p2p; % µm per µV +t_ms = (-params.nPre : params.nPost) / sample_rate * 1000; -%% ---- Colours: best channel = red, all others = blue ---- +%% Colours col_default = [0.25 0.45 0.75]; % blue col_best = [0.85 0.20 0.15]; % red -%% ---- Waveform figure ---- -figure('Color', 'w', 'Name', sprintf('Unit %d — Waveforms', unitID)); -ax = axes('Color', 'w'); -hold(ax, 'on'); +%% ---- Extract data for each unit ---- +finfo = dir(binPath); +n_samp_total = finfo.bytes / (n_channels * 2); +fid = fopen(binPath, 'rb'); -for ci = 1:n_chans_plot - cx = ch_x(ci); - cy = ch_y(ci); +unitData = struct(); % will hold per-unit results - if ci == best_local_idx - col = col_best; - else - col = col_default; +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 - x_wf = cx + t_ms * t_scale; + % 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 - % Individual waveforms (translucent) - wf_ci = squeeze(waveforms(ci, :, :)); % [nSamples x nWaveforms] - y_wf = cy + wf_ci * amp_scale; - plot(ax, x_wf, y_wf, 'Color', [col, 0.12], 'LineWidth', 0.5); + % 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 - % 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'); + % 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); - % Mean waveform - y_mean = cy + mean_wf(ci,:) * amp_scale; - plot(ax, x_wf, y_mean, 'k', 'LineWidth', 2); +%% ---- 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 - % Channel label: two rows, just left of waveform start - text(ax, x_wf(1) - 2, cy + amp_scale * 0, ... - sprintf('ch%d\n(%g, %g)', bin_chans(ci), cx, cy), ... - 'FontSize', 7, 'HorizontalAlignment', 'right', ... - 'VerticalAlignment', 'middle', 'Color', col); + % 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 -%% ---- L-shaped scale bar ---- -% Fixed scale: 2 ms horizontal, 200 µV vertical -sb_ms = 1; % ms -sb_uv = 200; % µV -sb_xlen = sb_ms * t_scale; % µm -sb_ylen = sb_uv * amp_scale; % µm - -% Position: to the right of the bottom-right waveform, at the same y level -[~, bottom_right_ci] = min(ch_y - ch_x * 1e-6); % lowest y, rightmost x as tiebreak -br_cx = ch_x(bottom_right_ci); -br_cy = ch_y(bottom_right_ci); - -sb_gap = 0.2 * x_spacing; % horizontal gap from last waveform -sb_ox = br_cx + t_ms(end) * t_scale + sb_gap; % L corner x: just right of waveform end -sb_oy = br_cy; % L corner y: same level as that channel - -% Draw L: vertical arm then horizontal arm, meeting at bottom-left corner -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); - -% Labels -text(ax, sb_ox - 2, sb_oy - sb_ylen/2, sprintf('%d µV', sb_uv), ... - 'FontSize', 8, 'HorizontalAlignment', 'center', 'VerticalAlignment', 'middle', 'Rotation',90); -text(ax, sb_ox + sb_xlen/2, sb_oy + 2, sprintf('%d ms', sb_ms), ... - 'FontSize', 8, 'HorizontalAlignment', 'center', 'VerticalAlignment', 'top'); - -%% Axis cosmetics — no tick marks, no box -title(ax, sprintf('Unit %d | %d waveforms | best ch: %d', ... - unitID, numel(st_sub), best_bin_chan), 'FontSize', 12); -set(ax, 'XTick', [], 'YTick', [], 'YDir', 'normal'); -axis(ax, 'tight'); -box(ax, 'off'); -axis(ax, 'off'); % hide axes entirely — scale bar carries all metric info - -%% ---- ACG figure (separate) ---- +%% ---- ACG figure: one tile per unit ---- if params.showCorr - figure('Color', 'w', 'Name', sprintf('Unit %d — ACG', unitID)); - ax_corr = axes; - - bar(ax_corr, ccg_bins, ccg_counts, 1, ... - 'FaceColor', [0.3 0.5 0.8], 'EdgeColor', 'none'); - hold(ax_corr, 'on'); - xline(ax_corr, 0, '--k', 'Alpha', 0.4); - - % Shade refractory period (±2 ms) - ylims = ylim(ax_corr); - patch(ax_corr, [-2 2 2 -2], [0 0 ylims(2) ylims(2)], ... - 'r', 'FaceAlpha', 0.1, 'EdgeColor', 'none'); - - xlabel(ax_corr, 'Lag (ms)'); - ylabel(ax_corr, 'Spike count'); - title(ax_corr, sprintf('Unit %d ACG | RP 2 ms | bin %.1f ms | win ±%d ms', ... - unitID, params.corrBin, params.corrWin), 'FontSize', 12); - xlim(ax_corr, [-params.corrWin params.corrWin]); - box(ax_corr, 'off'); + 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 @@ -261,17 +302,10 @@ function plotRawWaveforms(obj, unitID, params) %% ========================================================================= function [counts, bin_centers] = computeACG(spike_times_samples, fs, win_ms, bin_ms) -% Compute auto-correlogram for a single unit -% spike_times_samples - spike times in samples -% fs - sampling rate (Hz) -% win_ms - half-window in ms -% bin_ms - bin size in 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; diff --git a/visualStimulationAnalysis/RunAnalysisClass.m b/visualStimulationAnalysis/RunAnalysisClass.m index 78d5485..fb86853 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -61,8 +61,8 @@ %[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 +VStimAnalysis.PlotZScoreComparison([49:54,64:97],{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=true,overwriteStats=true)%[49:54,57:91] %%Check why I have different array dimensions in MBR %% Gratings diff --git a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv index f01e17a..39e33d0 100644 --- a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv +++ b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv @@ -159,7 +159,22 @@ wf = getWaveForms(gwfparams); % wf.waveForms: [units x waveforms x channels figure; plot(squeeze(mean(wf.waveFormsMean(1,:,:), 2))); -%% -plotRawWaveforms(vs, 47, showCorr=true, corrWin=50, corrBin=0.5) +%% Check low amp waveforms 10 neurons per experiment -plotRawWaveforms_spatially(vs, 47, showCorr=true, corrWin=50, corrBin=0.5,nChanAround105) \ No newline at end of file +PVexps = [49:54,64:97]; +idx = randi(length(PVexps), 1, 4); +selected = PVexps(idx); + + + +for i = selected + NP = loadNPclassFromTable(53); + vs = linearlyMovingBallAnalysis(NP,Session=1); + + p = vs.dataObj.convertPhySorting2tIc(vs.spikeSortingFolder,0,1,1); + phy_IDg = p.phy_ID(string(p.label') == 'good'); + + + plotRawWaveforms(vs, [47:50], showCorr=true, corrWin=50, corrBin=0.5) + +end diff --git a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m index 131be4c..638974d 100644 --- a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m +++ b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m @@ -7,12 +7,12 @@ for ex = exp%GoodRecordingsPV%allGoodRec %GoodRecordings%GoodRecordingsPV%GoodRecordingsPV%selecN{1}(1,:) %1:size(data,1) %%%%%%%%%%%% Load data and data paremeters %1. Load NP class - ex=69 + ex=53 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) @@ -159,7 +159,22 @@ figure; plot(squeeze(mean(wf.waveFormsMean(1,:,:), 2))); -%% -plotRawWaveforms(vs, 47, showCorr=true, corrWin=50, corrBin=0.5) +%% Check low amp waveforms 10 neurons per experiment -plotRawWaveforms_spatially(vs, 47, showCorr=true, corrWin=50, corrBin=0.5,nChanAround=10) \ No newline at end of file +PVexps = [49:54,64:97]; +idx = randi(length(PVexps), 1, 4); +selected = PVexps(idx); + + + +for i = selected + NP = loadNPclassFromTable(53); + vs = linearlyMovingBallAnalysis(NP,Session=1); + + p = vs.dataObj.convertPhySorting2tIc(vs.spikeSortingFolder,0,1,1); + phy_IDg = p.phy_ID(string(p.label') == 'good'); + + + plotRawWaveforms(vs, [47:50], showCorr=true, corrWin=50, corrBin=0.5) + +end From c790253ec3253da2a9422d10f974176160a64406 Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Thu, 19 Mar 2026 23:24:47 +0200 Subject: [PATCH 04/19] Adding new spatial tuning function and cmodified receptive fields --- general functions/plotRawWaveforms.asv | 305 ------------ general functions/plotRawWaveforms.m | 2 +- .../@VStimAnalysis/BootstrapPerNeuron.m | 56 ++- .../CalculateReceptiveFields.m | 109 ++++- .../@linearlyMovingBallAnalysis/plotRaster.m | 98 +++- .../CalculateReceptiveFields.m | 90 +++- .../@rectGridAnalysis/plotRaster.m | 28 +- .../RunAnalysisClass.asv | 210 ++++++++ visualStimulationAnalysis/RunAnalysisClass.m | 38 +- .../Run_Bombcell_Automatic_Sorting.asv | 180 ------- .../Run_Bombcell_Automatic_Sorting.m | 31 +- .../SpatialTuningIndex.asv | 408 +++++++++++++++ .../SpatialTuningIndex.m | 408 +++++++++++++++ .../SpatialTuningIndexV1.m | 246 ++++++++++ visualStimulationAnalysis/plotPSTH_MultiExp.m | 463 ++++++++++++++++++ .../plotSpatialTuningIndex.m | 189 +++++++ 16 files changed, 2302 insertions(+), 559 deletions(-) delete mode 100644 general functions/plotRawWaveforms.asv create mode 100644 visualStimulationAnalysis/RunAnalysisClass.asv delete mode 100644 visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv create mode 100644 visualStimulationAnalysis/SpatialTuningIndex.asv create mode 100644 visualStimulationAnalysis/SpatialTuningIndex.m create mode 100644 visualStimulationAnalysis/SpatialTuningIndexV1.m create mode 100644 visualStimulationAnalysis/plotPSTH_MultiExp.m create mode 100644 visualStimulationAnalysis/plotSpatialTuningIndex.m diff --git a/general functions/plotRawWaveforms.asv b/general functions/plotRawWaveforms.asv deleted file mode 100644 index 52f21d5..0000000 --- a/general functions/plotRawWaveforms.asv +++ /dev/null @@ -1,305 +0,0 @@ -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 = 10 - 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; - unitData(ui).mean_wf = mean(waveforms, 3, 'omitnan'); - unitData(ui).std_wf = std(waveforms, 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/plotRawWaveforms.m b/general functions/plotRawWaveforms.m index b39bfb5..1c129f0 100644 --- a/general functions/plotRawWaveforms.m +++ b/general functions/plotRawWaveforms.m @@ -25,7 +25,7 @@ obj unitIDs (1,:) double params.nWaveforms (1,1) double = 100 - params.nChanAround (1,1) double = 10 + params.nChanAround (1,1) double = 8 params.nPre (1,1) double = 20 params.nPost (1,1) double = 61 params.showCorr (1,1) logical = false diff --git a/visualStimulationAnalysis/@VStimAnalysis/BootstrapPerNeuron.m b/visualStimulationAnalysis/@VStimAnalysis/BootstrapPerNeuron.m index 7c5c987..f216e72 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") diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/CalculateReceptiveFields.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/CalculateReceptiveFields.m index fd97c8f..b587d59 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 = "BootstrapPerNeuron" + 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.ShufflingAnalysis; +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; @@ -101,6 +111,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 +319,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 +369,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 +435,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 +489,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 +586,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 +615,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/plotRaster.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m index 67ea352..2575e8f 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m @@ -6,7 +6,7 @@ function plotRaster(obj,params) params.analysisTime = datetime('now') params.inputParams = false params.preBase = 200 - params.bin = 15 + params.bin = 30 params.exNeurons = 1 params.AllSomaticNeurons = false params.AllResponsiveNeurons = false @@ -15,16 +15,25 @@ 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 = "BootstrapPerNeuron" end NeuronResp = obj.ResponseWindow; -Stats = obj.ShufflingAnalysis; + +if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; +else + Stats = obj.ShufflingAnalysis; +end + + if params.speed ~= "max" fieldName = sprintf('Speed%d', str2double(params.speed)); @@ -46,12 +55,13 @@ 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 C = NeuronResp.(fieldName).C; @@ -73,9 +83,9 @@ 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 @@ -121,7 +131,9 @@ function plotRaster(obj,params) [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); @@ -257,15 +269,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 +339,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 +414,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 +422,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" diff --git a/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m b/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m index 9aae3eb..7465c3e 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m @@ -19,6 +19,8 @@ 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.statType string = "BootstrapPerNeuron" + params.nGrid = 9 end @@ -39,10 +41,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.ShufflingAnalysis; +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'); pvals = Stats.pvalsResponse; C = NeuronResp.C; @@ -76,8 +86,10 @@ 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)); +durationMin = min([params.duration params.durationOff]); + +[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)); % Mr = Mr.*(1000/params.bin); %convert to seconds % Mro = Mro.*(1000/params.bin); %convert to seconds @@ -182,7 +194,7 @@ %%Create summary of identical trials - for u = 1:length(goodU) + for u = 1:size(goodU,2) for o = 1:2 @@ -215,6 +227,10 @@ rectData = obj.VST.rectData; +% Before the loop: +XcStore = zeros(1, size(C,1)/trialDiv); +YcStore = zeros(1, size(C,1)/trialDiv); + j=1; for i = 1:trialDiv:length(C) @@ -229,6 +245,9 @@ 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; + XcStore(j) = Xc; % still in pixel coords at this point + YcStore(j) = Yc; + 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; @@ -255,8 +274,61 @@ end -% M = MrMean(:,u)'./Nbase(u); +%%%%%%%%%% Spike rate grid map +nGrid = params.nGrid; +xEdges = linspace(0, screenSide(3)/params.reduceFactor, nGrid+1); % reduced pixel coords +yEdges = linspace(0, screenSide(4)/params.reduceFactor, nGrid+1); + +gridSpikeRate = zeros(nGrid, nGrid, nN, 2, nSize, nLums); +gridSpikeRateShuff = zeros(nGrid, nGrid, nN, nShuffle, 2, nSize, nLums); +trialCount = zeros(nGrid, nGrid, nSize, nLums); + +jj = 1; +for i = 1:trialDiv:nT + + xBin = discretize(XcStore(jj), xEdges); + yBin = discretize(YcStore(jj), yEdges); + + if isnan(xBin) || isnan(yBin) + jj = jj + 1; + continue + end + + sizeIdx = find(uSize == C(i,3)); + lumIdx = find(uLums == C(i,4)); + + trialCount(yBin, xBin, sizeIdx, lumIdx) = trialCount(yBin, xBin, sizeIdx, lumIdx) + 1; + % On and off response + 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]); + + 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; + + for s = 1:nShuffle + shuffRate = reshape(mean(mean(shuffledData(i:i+trialDiv-1,:,:,s), 1), 3), [1 1 nN]); + gridSpikeRateShuff(yBin, xBin, :, s, sizeIdx, lumIdx) = ... + gridSpikeRateShuff(yBin, xBin, :, s, sizeIdx, lumIdx) + shuffRate; + end + + jj = jj + 1; +end + +% Normalize by trial count +for si = 1:nSize + for li = 1:nLums + tc = max(trialCount(:,:,si,li), 1); % [nGrid x nGrid] + for s = 1:nShuffle + gridSpikeRateShuff(:,:,:,s,si,li) = gridSpikeRateShuff(:,:,:,s,si,li) ./ tc; + end + for oi = 1:2 + gridSpikeRate(:,:,:,oi,si,li) = gridSpikeRate(:,:,:,oi,si,li) ./ tc; + end + end +end + +%%%%%% Convolution 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]); @@ -265,7 +337,7 @@ MrMean(NanPos) = 0; -Res = reshape(MrMean,[size(MrMean,1),size(MrMean,2),size(MrMean,3),1,1,size(MrMean,4),nN]).*1000; +Res = reshape(MrMean,[size(MrMean,1),size(MrMean,2),size(MrMean,3),1,1,size(MrMean,4),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]); @@ -298,6 +370,10 @@ S.shuffledData = shuffledData; +S.gridSpikeRateShuff = gridSpikeRateShuff; + +S.gridSpikeRate = gridSpikeRate; + S.params = params; save(filename,'-struct','S'); diff --git a/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m b/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m index a7c1f4e..a21684b 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m @@ -6,7 +6,7 @@ function plotRaster(obj,params) params.analysisTime = datetime('now') params.inputParams = false params.preBase = 200 - params.bin = 40 + params.bin = 15 params.exNeurons = [] params.AllSomaticNeurons = false params.AllResponsiveNeurons = true @@ -18,12 +18,19 @@ function plotRaster(obj,params) 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.ShufflingAnalysis; +end + directimesSorted = NeuronResp.C(:,1)'; nSize = numel(unique(NeuronResp.C(:,3))); @@ -38,10 +45,11 @@ 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 stimDur = NeuronResp.stimDur; @@ -111,7 +119,7 @@ function plotRaster(obj,params) trialsPerCath = trialsPerCath/mergeTrials; nT = nT/mergeTrials; else - Mr2=Mr(:,u,:); + Mr2=Mr(:,ur,:); mergeTrials =1; end @@ -288,16 +296,19 @@ 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; trialsPerCath = length(directimesSorted)/(length(unique(seqMatrix))); @@ -345,10 +356,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/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv new file mode 100644 index 0000000..9f7d3fd --- /dev/null +++ b/visualStimulationAnalysis/RunAnalysisClass.asv @@ -0,0 +1,210 @@ +cd('\\sil3\data\Large_scale_mapping_NP') +excelFile = 'Experiment_Excel.xlsx'; + +data = readtable(excelFile); + +%% +%% Rect Grid +for ex = [49:54,64:97] %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 + % close all;vsRe.plotRaster(MergeNtrials=1,overwrite=true,exNeurons=18, selectedLum=255,oneTrial = true,PaperFig = true) %43 + vsRe.CalculateReceptiveFields('overwrite',true) + %[colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=18,allStimParamsCombined=true,PaperFig=true,overwrite=true); + %result = vsRe.BootstrapPerNeuron('overwrite',true); + +end +% vsRe.CalculateReceptiveFields +% vsRe.PlotReceptiveFields("meanAllNeurons",true) + +%% Moving ball + +for ex = [84:97]%97 74:84 (Neurons, 96_74, ) + NP = loadNPclassFromTable(ex); %73 81 + vs = linearlyMovingBallAnalysis(NP,Session=1); + % 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',82,'overwrite',true,'MergeNtrials',1,'OneDirection','up','OneLuminosity','white','PaperFig',true) + % % %vs.plotCorrSpikePattern + % vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'OneDirection','up','OneLuminosity','white','MergeNtrials',1,'PaperFig',true) + + %vs.plotRaster('exNeurons',9,'AllResponsiveNeurons',false,'overwrite',true,'MergeNtrials',3,MaxVal_1=false) + vs.CalculateReceptiveFields('overwrite',true,testConvolution=false); + % colorbarLims=vs.PlotReceptiveFields('exNeurons',82,'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]; +end + +%% PlotZScoreComparison +%[49:54 57:81] MBR all experiments 'NV','NI' +%[44:56,64:88] All experiments +%[28:32,44,45,47,48,56,98] All SA experiments +%Check triggers 45, SA82 44,45,47:54,56,64:88 +% All stim: 'FFF','SDG','MBR','MB','RG','NI','NV' +%[49:54,64:97] %All PV good experiments +% %%[89,90,92,93,95,96,97] %Al NV and NI experiments +%[49:54,84:90,92:96] %All SDG experiments +%solve MBR +%bootsrapRespBase +VStimAnalysis.PlotZScoreComparison([49:54,64:97] ,{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR +%% PSTH for all experiments +plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false); + +%% Calculate spatial tuning +SpatialTuningIndex([52:54,64:97]) + +%% Gratings + +for ex = [54 84:90] + 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); +end + +%% movie + +for ex = [89,90,92,93,95: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); + vs.plotRaster('AllResponsiveNeurons',true,MergeNtrials=1,overwrite=true) +end + + +%% image + +for ex = [89,90,92,93,95: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('AllResponsiveNeurons',true,MergeNtrials=1,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 fb86853..11c74e7 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -5,7 +5,7 @@ %% %% Rect Grid -for ex = [97] %84:91 +for ex = 52 %84:91 NP = loadNPclassFromTable(ex); %73 81 vsRe = rectGridAnalysis(NP); % vsRe.getSessionTime("overwrite",true); @@ -16,10 +16,11 @@ % % vsRe.plotSpatialTuningLFP; % 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.plotRaster(MergeNtrials=1,overwrite=true,AllResponsiveNeurons = true, selectedLum=[],oneTrial = true,PaperFig = true) %43 + % close all;vsRe.plotRaster(MergeNtrials=1,overwrite=true,exNeurons=18, selectedLum=255,oneTrial = true,PaperFig = true) %43 vsRe.CalculateReceptiveFields('overwrite',true) - [colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=43,allStimParamsCombined=true,PaperFig=true,overwrite=true); - result = vsRe.BootstrapPerNeuron('overwrite',true); + %[colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=18,allStimParamsCombined=true,PaperFig=true,overwrite=true); + %result = vsRe.BootstrapPerNeuron('overwrite',true); end % vsRe.CalculateReceptiveFields @@ -27,7 +28,7 @@ %% Moving ball -for ex = [69,81,95,97] %97 +for ex = [84:97]%97 74:84 (Neurons, 96_74, ) NP = loadNPclassFromTable(ex); %73 81 vs = linearlyMovingBallAnalysis(NP,Session=1); % vs.getSessionTime("overwrite",true); @@ -35,17 +36,19 @@ % % %vs.plotDiodeTriggers % vs.getSyncedDiodeTriggers("overwrite",true); % % %vs.plotSpatialTuningSpikes; - r = vs.ResponseWindow('overwrite',true); - results = vs.ShufflingAnalysis('overwrite',true); + % 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); + %vs.plotRaster('exNeurons',82,'overwrite',true,'MergeNtrials',1,'OneDirection','up','OneLuminosity','white','PaperFig',true) + % % %vs.plotCorrSpikePattern + % vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'OneDirection','up','OneLuminosity','white','MergeNtrials',1,'PaperFig',true) + + %vs.plotRaster('exNeurons',9,'AllResponsiveNeurons',false,'overwrite',true,'MergeNtrials',3,MaxVal_1=false) + vs.CalculateReceptiveFields('overwrite',true,testConvolution=false); + % colorbarLims=vs.PlotReceptiveFields('exNeurons',82,'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]; end @@ -61,8 +64,13 @@ %[49:54,84:90,92:96] %All SDG experiments %solve MBR %bootsrapRespBase -VStimAnalysis.PlotZScoreComparison([49:54,64:97],{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... - overwriteResponse=true,overwriteStats=true)%[49:54,57:91] %%Check why I have different array dimensions in MBR +VStimAnalysis.PlotZScoreComparison([49:54,64:97] ,{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR +%% PSTH for all experiments +plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false); + +%% Calculate spatial tuning +SpatialTuningIndex([49:54,64:97], overwrite=true) %% Gratings diff --git a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv deleted file mode 100644 index 39e33d0..0000000 --- a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.asv +++ /dev/null @@ -1,180 +0,0 @@ - -%% Run/load bombcell and confusion matrices - -% -exp = [49:54,64:97];% -%tiledlayout(numel(exp),1) -for ex = exp%GoodRecordingsPV%allGoodRec %GoodRecordings%GoodRecordingsPV%GoodRecordingsPV%selecN{1}(1,:) %1:size(data,1) - %%%%%%%%%%%% Load data and data paremeters - %1. Load NP class - ex=69 - NP = loadNPclassFromTable(ex); - vs = linearlyMovingBallAnalysis(NP,Session=1); - KSversion =4; - - [qMetric,unitType]=NP.getBombCell(NP.recordingDir+"\kilosort4",0,KSversion); - - %convertPhySorting2tIc(obj,pathToPhyResults,tStart,BombCelled) - - % - % goodUnits = unitType == 1; - % muaUnits = unitType == 2; - % noiseUnits = unitType == 0; - % nonSomaticUnits = unitType == 3; - - % Concordance analysis - % bc load_manual_classifications(vs.spikeSortingFolder) - % pMC = vs.dataObj.convertPhySorting2tIc(vs.spikeSortingFolder,0,0,1); - % pBC = vs.dataObj.convertPhySorting2tIc(vs.spikeSortingFolder,0,1,1); - - bombcell_table = readtable([vs.spikeSortingFolder filesep 'cluster_bc_unitType.tsv'], 'FileType', 'text', 'Delimiter', '\t'); - manual_table = readtable([vs.spikeSortingFolder filesep 'cluster_info.tsv'],'FileType','delimitedtext'); - - manual_table = manual_table(:,{'cluster_id','KSLabel','group'}); - sum(strcmp(pKS.label, 'good')) - - - % Load and prepare data - % Assume: - % bombcell_table: Nx2 table, columns: [id, bc_label] ("GOOD","MUA","NON-SOMA","NOISE") - % manual_table: Mx3 table, columns: [id, KS_label, group] ("good","mua","noise") - - % Rename columns for clarity (adjust if yours differ) - bombcell_table.Properties.VariableNames = {'id', 'bc_label'}; - manual_table.Properties.VariableNames = {'id', 'KS_label', 'group'}; - - % Remove NON-SOMA from bombcell - bc = bombcell_table(~strcmp(bombcell_table.bc_label, 'NON-SOMA'), :); - - % Match IDs — keep only IDs present in both tables - [~, ia, ib] = intersect(bc.id, manual_table.id); - bc_matched = bc(ia, :); - man_matched = manual_table(ib, :); - - % Harmonize labels to lowercase for comparison - bc_labels = lower(bc_matched.bc_label); % "good","mua","noise" - ks_labels = lower(man_matched.KS_label); % "good","mua","noise" - man_labels = lower(man_matched.group); % "good","mua","noise" - - %%Define category order - cats = {'good', 'mua', 'noise'}; - - bc_cat = categorical(bc_labels, cats); - ks_cat = categorical(ks_labels, cats); - man_cat = categorical(man_labels, cats); - - % --- Confusion Matrix 1: Manual curation vs BombCell --- - % figure('Position', [100, 100, 700, 600]); - % - % tiledlayout(3,2) - % nexttile - % cm1 = confusionchart(man_cat, bc_cat, ... - % 'Title', sprintf('%s-Manual curation vs BombCell',NP.recordingName),... - % 'XLabel', 'BombCell', ... - % 'YLabel', 'Manual Curation', ... - % 'RowSummary', 'row-normalized', ... - % 'ColumnSummary', 'column-normalized'); - % - % cm1.FontSize = 9; - % - % % Give the chart more room inside the figure - % %cm1.Position = [10, 10, 680, 580]; - - % --- Confusion Matrix 2: KS label vs BombCell --- - fig = figure('Position', [100, 100, 700, 600]); - %tl = nexttile; - cm2 = confusionchart(ks_cat, bc_cat, ... - 'XLabel', 'BombCell', ... - 'YLabel', 'KS Label', ... - 'RowSummary', 'row-normalized', ... - 'ColumnSummary', 'column-normalized'); - cm2.FontSize = 9; - title(sprintf('%KS Label vs BombCell',NP.recordingName)); - - - - % %% --- Confusion Matrix 3: KS label vs Manual curation --- - % figure; - % cm3 = confusionchart(ks_cat, man_cat, ... - % 'Title', printf('KS Label vs Manual Curation',NP.recordingName), ... - % 'XLabel', 'Manual Curation', ... - % 'YLabel', 'KS Label', ... - % 'RowSummary', 'row-normalized', ... - % 'ColumnSummary', 'column-normalized'); - - % --- Print mismatch summary --- - % fprintf('\n=== Manual vs BombCell ===\n') - % mismatch_man_bc = man_cat ~= bc_cat; - % fprintf('Total mismatches: %d / %d (%.1f%%)\n', ... - % sum(mismatch_man_bc), numel(mismatch_man_bc), ... - % 100*mean(mismatch_man_bc)); - - fprintf('\n=== KS Label vs BombCell ===\n') - mismatch_ks_bc = ks_cat ~= bc_cat; - fprintf('Total mismatches: %d / %d (%.1f%%)\n', ... - sum(mismatch_ks_bc), numel(mismatch_ks_bc), ... - 100*mean(mismatch_ks_bc)); - - vs.printFig(fig,sprintf('%KS Label vs BombCell',NP.recordingName),PaperFig =1) - - close - - % fprintf('\n=== KS Label vs Manual Curation ===\n') - % mismatch_ks_man = ks_cat ~= man_cat; - % fprintf('Total mismatches: %d / %d (%.1f%%)\n', ... - % 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); - - - -for i = selected - NP = loadNPclassFromTable(53); - vs = linearlyMovingBallAnalysis(NP,Session=1); - - p = vs.dataObj.convertPhySorting2tIc(vs.spikeSortingFolder,0,1,1); - phy_IDg = p.phy_ID(string(p.label') == 'good'); - - - plotRawWaveforms(vs, [47:50], showCorr=true, corrWin=50, corrBin=0.5) - -end diff --git a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m index 638974d..ebbf9cb 100644 --- a/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m +++ b/visualStimulationAnalysis/Run_Bombcell_Automatic_Sorting.m @@ -4,10 +4,9 @@ % 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 - ex=53 NP = loadNPclassFromTable(ex); vs = linearlyMovingBallAnalysis(NP,Session=1); KSversion =4; @@ -15,7 +14,9 @@ [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; @@ -166,15 +167,29 @@ selected = PVexps(idx); - -for i = selected - NP = loadNPclassFromTable(53); +%% +selected =69; +for i = selected(1:end) + NP = loadNPclassFromTable(i); vs = linearlyMovingBallAnalysis(NP,Session=1); - p = vs.dataObj.convertPhySorting2tIc(vs.spikeSortingFolder,0,1,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']); - plotRawWaveforms(vs, [47:50], showCorr=true, corrWin=50, corrBin=0.5) + [~ ,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.asv b/visualStimulationAnalysis/SpatialTuningIndex.asv new file mode 100644 index 0000000..0f6d98a --- /dev/null +++ b/visualStimulationAnalysis/SpatialTuningIndex.asv @@ -0,0 +1,408 @@ +function results = SpatialTuningIndex(exList, params) + +arguments + exList double + params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] + params.topPercent double = 10 + params.overwrite logical = false + params.statType string = "BootstrapPerNeuron" + params.speed double = 1 + params.plot logical = true + 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.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']; +if ~exist([p '\Combined_lizard_analysis'], 'dir') + cd(p) + mkdir Combined_lizard_analysis +end +saveDir = [p '\Combined_lizard_analysis']; + +stimLabel = strjoin(params.stimTypes, '-'); +nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s.mat', ... + exList(1), exList(end), stimLabel); + +% ------------------------------------------------------------------------- +% Decide whether to compute or load +% ------------------------------------------------------------------------- +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]); + % Jump straight to table building + 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); + + tbl = table(); + + for ei = 1:nExp + + ex = exList(ei); + fprintf('\n=== Experiment %d ===\n', ex); + + try + NP = loadNPclassFromTable(ex); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + continue + end + + nameParts = split(NP.recordingName, '_'); + animalName = nameParts{1}; + + % ---------------------------------------------------------- + % Find union of responsive neurons across ALL stim types + % ---------------------------------------------------------- + % Get phy IDs and responsive units for each stim type + respPhyIDs_all = cell(1, nStim); + phyIDs_all = cell(1, nStim); + + p_s = obj_s.dataObj.convertPhySorting2tIc(obj_s.spikeSortingFolder); + phy_IDg = p_s.phy_ID(string(p_s.label') == 'good'); + + + for s = 1:nStim + stimType = params.stimTypes(s); + try + switch stimType + case "rectGrid" + obj_s = rectGridAnalysis(NP); + case "linearlyMovingBall" + obj_s = linearlyMovingBallAnalysis(NP); + end + + if params.statType == "BootstrapPerNeuron" + Stats = obj_s.BootstrapPerNeuron; + else + Stats = obj_s.ShufflingAnalysis; + end + + + 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 + + respU = find(pvals < 0.05); + phyIDs_all{s} = phy_IDg; % all good unit phy IDs for this stim + respPhyIDs_all{s} = phy_IDg(respU); % only responsive ones + 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); + phyIDs_all{s} = []; + respPhyIDs_all{s} = []; + end + end + + % Union of responsive phy IDs across stim types + sharedPhyIDs = respPhyIDs_all{1}; + for s = 2:nStim + sharedPhyIDs = union(sharedPhyIDs, respPhyIDs_all{s}); + end + + if isempty(sharedPhyIDs) + fprintf(' No responsive neurons in exp %d — skipping.\n', ex); + continue + end + + fprintf(' %d neuron(s) responsive to at least one stim type in exp %d.\n', numel(sharedPhyIDs), ex); + + + for s = 1:nStim + + stimType = params.stimTypes(s); + + % Build 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 grid results + % ---------------------------------------------------------- + S_rf = obj.CalculateReceptiveFields; + + gridSpikeRate = S_rf.gridSpikeRate; + gridSpikeRateShuff = S_rf.gridSpikeRateShuff; + + switch stimType + case "rectGrid" + % Select onOff from both + gridSpikeRateSelected = gridSpikeRate(:,:,:,params.onOff,:,:); % [nGrid nGrid nN nSize nLum] -- but with singleton onOff removed + gridShuffSelected = gridSpikeRateShuff(:,:,:,:,params.onOff,:,:); % [nGrid nGrid nN nShuffle nSize nLum] + case "linearlyMovingBall" + gridSpikeRateSelected = gridSpikeRate; % [nGrid nGrid nN nSize nLum] + gridShuffSelected = gridSpikeRateShuff; % [nGrid nGrid nN nShuffle nSize nLum] + end + + % Find indices in this stim's good units that match sharedPhyIDs + [~, neuronIdx] = ismember(sharedPhyIDs, phyIDs_all{s}); + neuronIdx = neuronIdx(neuronIdx > 0); % remove any not found in this stim + + gridSpikeRateSelected = gridSpikeRateSelected(:,:,neuronIdx,:,:); + gridShuffSelected = gridShuffSelected(:,:,neuronIdx,:,:,:); + + % Average over shuffles and reshape explicitly — no squeeze + gridShuffMean = mean(gridShuffSelected, 4); % [nGrid nGrid nN 1 nSize nLum] + + % Get dimensions explicitly + nN = size(gridSpikeRateSelected, 3); + nSize = size(gridSpikeRateSelected, 5); + nLum = size(gridSpikeRateSelected, 6); + + % Reshape both to clean [nGrid nGrid nN nSize nLum] + gridSpikeRateSelected = reshape(gridSpikeRateSelected, [nGrid nGrid nN nSize nLum]); + gridShuffMean = reshape(gridShuffMean, [nGrid nGrid nN nSize nLum]); + + nCells = nGrid * nGrid; + maxDist = sqrt(2) * (nGrid - 1); + + % Average over shuffles + + + % ---------------------------------------------------------- + % Compute indices + % ---------------------------------------------------------- + + fprintf('gridSpikeRate size: %s\n', num2str(size(gridSpikeRate))); + fprintf('gridSpikeRateShuff size: %s\n', num2str(size(gridSpikeRateShuff))); + fprintf('gridShuffMean size: %s\n', num2str(size(gridShuffMean))); + + for si = 1:nSize + for li = 1:nLum + + rateFlat = reshape(gridSpikeRateSelected(:,:,:,si,li), [nCells, nN]); + rateFlatShuff = reshape(gridShuffMean(:,:,:,si,li), [nCells, nN]); + + L_amplitude = zeros(nN, 1); + L_geometric = zeros(nN, 1); + L_combined = zeros(nN, 1); + + for u = 1:nN + + rateVec = rateFlat(:, u); + rateVecShuff = rateFlatShuff(:, u); + + % Top 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); + + % Amplitude + meanTop = mean(rateVec(topIdx)); + meanRest = mean(rateVec(restIdx)); + meanAll = mean(rateVec); + meanTopShuff = mean(rateVecShuff(topIdxShuff)); + meanRestShuff = mean(rateVecShuff(restIdxShuff)); + meanAllShuff = mean(rateVecShuff); + + if meanAll == 0, meanAll = eps; end + if meanAllShuff == 0, meanAllShuff = eps; end + + L_amplitude(u) = ... + (meanTop - meanRest) / meanAll - ... + (meanTopShuff - meanRestShuff) / meanAllShuff; + + % Geometric + [rowIdx, colIdx] = ind2sub([nGrid nGrid], topIdx); + [rowIdxShuff, colIdxShuff] = ind2sub([nGrid nGrid], topIdxShuff); + + 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 + + L_geometric(u) = (1 - D) - (1 - DShuff); + L_combined(u) = L_amplitude(u) * L_geometric(u); + + end + + % Build rows for this condition + rows = table(); + rows.L_amplitude = L_amplitude; + rows.L_geometric = L_geometric; + rows.L_combined = L_combined; + rows.stimulus = categorical(repmat({char(stimType)}, nN, 1)); + rows.insertion = categorical(repmat(ex, nN, 1)); + rows.animal = categorical(repmat({animalName}, nN, 1)); + rows.NeurID = (1:nN)'; + rows.onOff = repmat(params.onOff, nN, 1); % params.onOff for rectGrid, meaningless but consistent for movingBall + rows.sizeIdx = repmat(si, nN, 1); + rows.lumIdx = repmat(li, nN, 1); + + tbl = [tbl; rows]; + + end + end + + fprintf(' [%s] Indices computed. %d neurons.\n', stimType, nN); + + end % stim loop + end % exp loop + + % Clean categories + tbl.stimulus = removecats(tbl.stimulus); + tbl.animal = removecats(tbl.animal); + tbl.insertion = removecats(tbl.insertion); + + % Save + 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; + +% ========================================================================= +% PLOT +% ========================================================================= +if params.plot + + % Filter table to requested condition + idx = tbl.onOff == params.onOff & ... + tbl.sizeIdx == params.sizeIdx & ... + tbl.lumIdx == params.lumIdx; + + tblPlot = tbl(idx, :); + tblPlot.value = tblPlot.(params.indexType); % select which index to plot + + % ---------------------------------------------------------- + % Compute p-values using hierBoot + % ---------------------------------------------------------- + ps = []; + + pairs = {char(params.stimTypes(1)), char(params.stimTypes(2))}; + + + ps = zeros(size(pairs, 1), 1); + j = 1; + + for i = 1:size(pairs, 1) + 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]; + 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 + + + % ---------------------------------------------------------- + % Plot + % ---------------------------------------------------------- + V1max = max(tblPlot.value, [], 'omitnan'); + + [fig, ~] = plotSwarmBootstrapWithComparisons(tblPlot, pairs, ps, {'value'}, ... + yLegend = params.yLegend, ... + yMaxVis = max(params.yMaxVis, V1max), ... + diff = false, ... + Alpha = params.Alpha, ... + plotMeanSem = true); + + title(sprintf('%s — %s (onOff=%d, size=%d, lum=%d)', ... + params.indexType, strjoin(params.stimTypes, '/'), ... + params.onOff, 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 + + results.fig = fig; + results.ps = ps; + +end + +end \ No newline at end of file diff --git a/visualStimulationAnalysis/SpatialTuningIndex.m b/visualStimulationAnalysis/SpatialTuningIndex.m new file mode 100644 index 0000000..0f6d98a --- /dev/null +++ b/visualStimulationAnalysis/SpatialTuningIndex.m @@ -0,0 +1,408 @@ +function results = SpatialTuningIndex(exList, params) + +arguments + exList double + params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] + params.topPercent double = 10 + params.overwrite logical = false + params.statType string = "BootstrapPerNeuron" + params.speed double = 1 + params.plot logical = true + 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.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']; +if ~exist([p '\Combined_lizard_analysis'], 'dir') + cd(p) + mkdir Combined_lizard_analysis +end +saveDir = [p '\Combined_lizard_analysis']; + +stimLabel = strjoin(params.stimTypes, '-'); +nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s.mat', ... + exList(1), exList(end), stimLabel); + +% ------------------------------------------------------------------------- +% Decide whether to compute or load +% ------------------------------------------------------------------------- +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]); + % Jump straight to table building + 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); + + tbl = table(); + + for ei = 1:nExp + + ex = exList(ei); + fprintf('\n=== Experiment %d ===\n', ex); + + try + NP = loadNPclassFromTable(ex); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + continue + end + + nameParts = split(NP.recordingName, '_'); + animalName = nameParts{1}; + + % ---------------------------------------------------------- + % Find union of responsive neurons across ALL stim types + % ---------------------------------------------------------- + % Get phy IDs and responsive units for each stim type + respPhyIDs_all = cell(1, nStim); + phyIDs_all = cell(1, nStim); + + p_s = obj_s.dataObj.convertPhySorting2tIc(obj_s.spikeSortingFolder); + phy_IDg = p_s.phy_ID(string(p_s.label') == 'good'); + + + for s = 1:nStim + stimType = params.stimTypes(s); + try + switch stimType + case "rectGrid" + obj_s = rectGridAnalysis(NP); + case "linearlyMovingBall" + obj_s = linearlyMovingBallAnalysis(NP); + end + + if params.statType == "BootstrapPerNeuron" + Stats = obj_s.BootstrapPerNeuron; + else + Stats = obj_s.ShufflingAnalysis; + end + + + 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 + + respU = find(pvals < 0.05); + phyIDs_all{s} = phy_IDg; % all good unit phy IDs for this stim + respPhyIDs_all{s} = phy_IDg(respU); % only responsive ones + 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); + phyIDs_all{s} = []; + respPhyIDs_all{s} = []; + end + end + + % Union of responsive phy IDs across stim types + sharedPhyIDs = respPhyIDs_all{1}; + for s = 2:nStim + sharedPhyIDs = union(sharedPhyIDs, respPhyIDs_all{s}); + end + + if isempty(sharedPhyIDs) + fprintf(' No responsive neurons in exp %d — skipping.\n', ex); + continue + end + + fprintf(' %d neuron(s) responsive to at least one stim type in exp %d.\n', numel(sharedPhyIDs), ex); + + + for s = 1:nStim + + stimType = params.stimTypes(s); + + % Build 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 grid results + % ---------------------------------------------------------- + S_rf = obj.CalculateReceptiveFields; + + gridSpikeRate = S_rf.gridSpikeRate; + gridSpikeRateShuff = S_rf.gridSpikeRateShuff; + + switch stimType + case "rectGrid" + % Select onOff from both + gridSpikeRateSelected = gridSpikeRate(:,:,:,params.onOff,:,:); % [nGrid nGrid nN nSize nLum] -- but with singleton onOff removed + gridShuffSelected = gridSpikeRateShuff(:,:,:,:,params.onOff,:,:); % [nGrid nGrid nN nShuffle nSize nLum] + case "linearlyMovingBall" + gridSpikeRateSelected = gridSpikeRate; % [nGrid nGrid nN nSize nLum] + gridShuffSelected = gridSpikeRateShuff; % [nGrid nGrid nN nShuffle nSize nLum] + end + + % Find indices in this stim's good units that match sharedPhyIDs + [~, neuronIdx] = ismember(sharedPhyIDs, phyIDs_all{s}); + neuronIdx = neuronIdx(neuronIdx > 0); % remove any not found in this stim + + gridSpikeRateSelected = gridSpikeRateSelected(:,:,neuronIdx,:,:); + gridShuffSelected = gridShuffSelected(:,:,neuronIdx,:,:,:); + + % Average over shuffles and reshape explicitly — no squeeze + gridShuffMean = mean(gridShuffSelected, 4); % [nGrid nGrid nN 1 nSize nLum] + + % Get dimensions explicitly + nN = size(gridSpikeRateSelected, 3); + nSize = size(gridSpikeRateSelected, 5); + nLum = size(gridSpikeRateSelected, 6); + + % Reshape both to clean [nGrid nGrid nN nSize nLum] + gridSpikeRateSelected = reshape(gridSpikeRateSelected, [nGrid nGrid nN nSize nLum]); + gridShuffMean = reshape(gridShuffMean, [nGrid nGrid nN nSize nLum]); + + nCells = nGrid * nGrid; + maxDist = sqrt(2) * (nGrid - 1); + + % Average over shuffles + + + % ---------------------------------------------------------- + % Compute indices + % ---------------------------------------------------------- + + fprintf('gridSpikeRate size: %s\n', num2str(size(gridSpikeRate))); + fprintf('gridSpikeRateShuff size: %s\n', num2str(size(gridSpikeRateShuff))); + fprintf('gridShuffMean size: %s\n', num2str(size(gridShuffMean))); + + for si = 1:nSize + for li = 1:nLum + + rateFlat = reshape(gridSpikeRateSelected(:,:,:,si,li), [nCells, nN]); + rateFlatShuff = reshape(gridShuffMean(:,:,:,si,li), [nCells, nN]); + + L_amplitude = zeros(nN, 1); + L_geometric = zeros(nN, 1); + L_combined = zeros(nN, 1); + + for u = 1:nN + + rateVec = rateFlat(:, u); + rateVecShuff = rateFlatShuff(:, u); + + % Top 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); + + % Amplitude + meanTop = mean(rateVec(topIdx)); + meanRest = mean(rateVec(restIdx)); + meanAll = mean(rateVec); + meanTopShuff = mean(rateVecShuff(topIdxShuff)); + meanRestShuff = mean(rateVecShuff(restIdxShuff)); + meanAllShuff = mean(rateVecShuff); + + if meanAll == 0, meanAll = eps; end + if meanAllShuff == 0, meanAllShuff = eps; end + + L_amplitude(u) = ... + (meanTop - meanRest) / meanAll - ... + (meanTopShuff - meanRestShuff) / meanAllShuff; + + % Geometric + [rowIdx, colIdx] = ind2sub([nGrid nGrid], topIdx); + [rowIdxShuff, colIdxShuff] = ind2sub([nGrid nGrid], topIdxShuff); + + 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 + + L_geometric(u) = (1 - D) - (1 - DShuff); + L_combined(u) = L_amplitude(u) * L_geometric(u); + + end + + % Build rows for this condition + rows = table(); + rows.L_amplitude = L_amplitude; + rows.L_geometric = L_geometric; + rows.L_combined = L_combined; + rows.stimulus = categorical(repmat({char(stimType)}, nN, 1)); + rows.insertion = categorical(repmat(ex, nN, 1)); + rows.animal = categorical(repmat({animalName}, nN, 1)); + rows.NeurID = (1:nN)'; + rows.onOff = repmat(params.onOff, nN, 1); % params.onOff for rectGrid, meaningless but consistent for movingBall + rows.sizeIdx = repmat(si, nN, 1); + rows.lumIdx = repmat(li, nN, 1); + + tbl = [tbl; rows]; + + end + end + + fprintf(' [%s] Indices computed. %d neurons.\n', stimType, nN); + + end % stim loop + end % exp loop + + % Clean categories + tbl.stimulus = removecats(tbl.stimulus); + tbl.animal = removecats(tbl.animal); + tbl.insertion = removecats(tbl.insertion); + + % Save + 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; + +% ========================================================================= +% PLOT +% ========================================================================= +if params.plot + + % Filter table to requested condition + idx = tbl.onOff == params.onOff & ... + tbl.sizeIdx == params.sizeIdx & ... + tbl.lumIdx == params.lumIdx; + + tblPlot = tbl(idx, :); + tblPlot.value = tblPlot.(params.indexType); % select which index to plot + + % ---------------------------------------------------------- + % Compute p-values using hierBoot + % ---------------------------------------------------------- + ps = []; + + pairs = {char(params.stimTypes(1)), char(params.stimTypes(2))}; + + + ps = zeros(size(pairs, 1), 1); + j = 1; + + for i = 1:size(pairs, 1) + 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]; + 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 + + + % ---------------------------------------------------------- + % Plot + % ---------------------------------------------------------- + V1max = max(tblPlot.value, [], 'omitnan'); + + [fig, ~] = plotSwarmBootstrapWithComparisons(tblPlot, pairs, ps, {'value'}, ... + yLegend = params.yLegend, ... + yMaxVis = max(params.yMaxVis, V1max), ... + diff = false, ... + Alpha = params.Alpha, ... + plotMeanSem = true); + + title(sprintf('%s — %s (onOff=%d, size=%d, lum=%d)', ... + params.indexType, strjoin(params.stimTypes, '/'), ... + params.onOff, 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 + + results.fig = fig; + results.ps = ps; + +end + +end \ No newline at end of file diff --git a/visualStimulationAnalysis/SpatialTuningIndexV1.m b/visualStimulationAnalysis/SpatialTuningIndexV1.m new file mode 100644 index 0000000..68a48d4 --- /dev/null +++ b/visualStimulationAnalysis/SpatialTuningIndexV1.m @@ -0,0 +1,246 @@ +function results = SpatialTuningIndex(exList, params) + +arguments + exList double + params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] + params.topPercent double = 10 + params.overwrite logical = false + params.statType string = "BootstrapPerNeuron" + params.speed double = 1 +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']; +if ~exist([p '\Combined_lizard_analysis'], 'dir') + cd(p) + mkdir Combined_lizard_analysis +end +saveDir = [p '\Combined_lizard_analysis']; + +stimLabel = strjoin(params.stimTypes, '-'); +nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s.mat', ... + exList(1), exList(end), stimLabel); + +% ------------------------------------------------------------------------- +% Decide whether to compute or load +% ------------------------------------------------------------------------- +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]); + results = S; + return + else + fprintf('Experiment list mismatch — recomputing.\n'); + end +end + +% ------------------------------------------------------------------------- +% EXPERIMENT LOOP +% ------------------------------------------------------------------------- +nExp = numel(exList); +nStim = numel(params.stimTypes); + +% Will grow as we discover dimensions from first valid experiment +L_amplitude_all = cell(nStim, nExp); +L_geometric_all = cell(nStim, nExp); +L_combined_all = cell(nStim, nExp); + +for ei = 1:nExp + + ex = exList(ei); + fprintf('\n=== Experiment %d ===\n', ex); + + try + NP = loadNPclassFromTable(ex); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + continue + end + + for s = 1:nStim + + stimType = params.stimTypes(s); + + % Build 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 + + % ---------------------------------------------------------- + % Check for responsive neurons + % ---------------------------------------------------------- + try + if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; + else + Stats = obj.ShufflingAnalysis; + end + + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + label = string(p_sort.label'); + goodU = p_sort.ic(:, label == 'good'); + + % Resolve field name depending on stim type + 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 + + respU = find(pvals < 0.05); + + catch ME + warning('Could not load stats for %s exp %d: %s', stimType, ex, ME.message); + L_amplitude_all{s, ei} = []; + L_geometric_all{s, ei} = []; + L_combined_all{s, ei} = []; + continue + end + + if isempty(respU) + fprintf(' [%s] No responsive neurons in exp %d — skipping.\n', stimType, ex); + L_amplitude_all{s, ei} = []; + L_geometric_all{s, ei} = []; + L_combined_all{s, ei} = []; + continue + end + + fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', stimType, ex, numel(respU)); + + % Load grid results + + S_rf = obj.CalculateReceptiveFields; + + gridSpikeRate = S_rf.gridSpikeRate; % [nGrid x nGrid x nN x onOff x nSize x nLum] + gridSpikeRateShuff = S_rf.gridSpikeRateShuff; % [nGrid x nGrid x nN x nShuffle x nSize x nLum] + + [nGrid, ~, nN, nOnOff, nSize, nLum] = size(gridSpikeRate); + nShuffle = size(gridSpikeRateShuff, 4); + nCells = nGrid * nGrid; + + % Average over shuffles + gridShuffMean = mean(gridSpikeRateShuff, 4); % [nGrid x nGrid x nN x nSize x nLum] + + L_amplitude = zeros(nN, nOnOff, nSize, nLum); + L_geometric = zeros(nN, nOnOff, nSize, nLum); + L_combined = zeros(nN, nOnOff, nSize, nLum); + + maxDist = sqrt(2) * (nGrid - 1); + + for oi = 1:nOnOff + for si = 1:nSize + for li = 1:nLum + + rateFlat = reshape(gridSpikeRate(:,:,:,oi,si,li), [nCells, nN]); + rateFlatShuff = reshape(gridShuffMean(:,:,:,si,li), [nCells, nN]); + + for u = 1:nN + + rateVec = rateFlat(:, u); + rateVecShuff = rateFlatShuff(:, u); + + %% ---- Shared: top 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); + + %% ---- 1. Amplitude index ---- + meanTop = mean(rateVec(topIdx)); + meanRest = mean(rateVec(restIdx)); + meanAll = mean(rateVec); + + meanTopShuff = mean(rateVecShuff(topIdxShuff)); + meanRestShuff = mean(rateVecShuff(restIdxShuff)); + meanAllShuff = mean(rateVecShuff); + + if meanAll == 0, meanAll = eps; end + if meanAllShuff == 0, meanAllShuff = eps; end + + L_amplitude(u, oi, si, li) = ... + (meanTop - meanRest) / meanAll - ... + (meanTopShuff - meanRestShuff) / meanAllShuff; + + %% ---- 2. Geometric index ---- + [rowIdx, colIdx] = ind2sub([nGrid nGrid], topIdx); + [rowIdxShuff, colIdxShuff] = ind2sub([nGrid nGrid], topIdxShuff); + + 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 + + L_geometric(u, oi, si, li) = (1 - D) - (1 - DShuff); + + %% ---- 3. Combined index ---- + L_combined(u, oi, si, li) = L_amplitude(u, oi, si, li) * L_geometric(u, oi, si, li); + + end + end + end + end + + L_amplitude_all{s, ei} = L_amplitude; % [nN x nOnOff x nSize x nLum] + L_geometric_all{s, ei} = L_geometric; + L_combined_all{s, ei} = L_combined; + + fprintf(' [%s] Done. %d neurons.\n', stimType, nN); + + end % stim loop +end % experiment loop + +% ------------------------------------------------------------------------- +% Save +% ------------------------------------------------------------------------- +S.expList = exList; +S.L_amplitude_all = L_amplitude_all; % {nStim x nExp} cell, each [nN x nOnOff x nSize x nLum] +S.L_geometric_all = L_geometric_all; +S.L_combined_all = L_combined_all; +S.params = params; + +save([saveDir nameOfFile], '-struct', 'S'); +fprintf('\nSaved SpatialTuningIndex to:\n %s\n', [saveDir nameOfFile]); + +results = S; + +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..24e42dc --- /dev/null +++ b/visualStimulationAnalysis/plotPSTH_MultiExp.m @@ -0,0 +1,463 @@ +function plotPSTH_MultiExp(exList, params) + +arguments + exList double + params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] + params.bin double = 30 + params.binWidth double = 10 + params.statType string = "BootstrapPerNeuron" + params.speed string = "max" + params.alpha double = 0.05 + params.shadeSTD logical = true + params.postStim double = 500 % ms after stim onset to include + params.preBase double = 200 % ms of baseline before stim onset + params.overwrite logical = false % force recompute even if file exists + params.TakeTopPercentTrials double = 0.3 %Percentage of highest spiking rate trials to take to calculate PSTHs + params.zScore logical = false % normalize firing rate to z-score using baseline + params.PaperFig logical = false %Is this going to be used in the paper? +end + +% ------------------------------------------------------------------------- +% Build save path using first experiment to get the analysis folder +% This mirrors the convention used in PlotZScoreComparison +% ------------------------------------------------------------------------- + +% Load first experiment just to get the folder path +NP_first = loadNPclassFromTable(exList(1)); +vs_first = linearlyMovingBallAnalysis(NP_first); % used only for path + +% Build the save directory path +p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); +p = [p 'lizards']; +if ~exist([p '\Combined_lizard_analysis'], 'dir') + cd(p) + mkdir Combined_lizard_analysis +end +saveDir = [p '\Combined_lizard_analysis']; + +% Build filename — includes stim types so different comparisons don't clash +stimLabel = strjoin(params.stimTypes, '-'); % e.g. "rectGrid-linearlyMovingBall" +nameOfFile = sprintf('\\Ex_%d-%d_Combined_PSTHs_%s.mat', ... + exList(1), exList(end), stimLabel); + +% ------------------------------------------------------------------------- +% Decide whether to run the experiment loop or load from disk +% forloop = true → compute PSTHs from scratch +% forloop = false → load saved struct and skip to plotting +% ------------------------------------------------------------------------- +if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite + % File exists and overwrite is off — check if expList matches + S = load([saveDir nameOfFile]); + if isequal(S.expList, exList) + fprintf('Loading saved PSTHs from:\n %s\n', [saveDir nameOfFile]); + forloop = false; % skip computation, go straight to plot + else + fprintf('Experiment list mismatch — recomputing.\n'); + forloop = true; % expList changed, recompute + end +else + forloop = true; % file doesn't exist or overwrite requested +end + +% ========================================================================= +% EXPERIMENT LOOP — only runs if forloop is true +% ========================================================================= +if forloop + + nStim = numel(params.stimTypes); + nExp = numel(exList); + + % One cell per stim type, grows one row per experiment + psthAll = cell(1, nStim); + for s = 1:nStim + psthAll{s} = []; + end + + % Locked time window — set from first valid experiment + lockedPreBase = []; + lockedNBins = []; + lockedEdges = []; + + % ------------------------------------------------------------------ + % LOOP OVER EXPERIMENTS + % ------------------------------------------------------------------ + for ei = 1:nExp + + ex = exList(ei); + fprintf('\n=== Experiment %d ===\n', ex); + + % Load NP data for this experiment + try + NP = loadNPclassFromTable(ex); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + % Add NaN placeholder row if window is already locked + for s = 1:nStim + if ~isempty(psthAll{s}) + psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; + end + end + continue + end + + % -------------------------------------------------------------- + % LOOP OVER STIMULUS TYPES + % -------------------------------------------------------------- + for s = 1:nStim + + stimType = params.stimTypes(s); + + % Build analysis object for this stim type + try + switch stimType + case "rectGrid" + obj = rectGridAnalysis(NP); + case "linearlyMovingBall" + obj = linearlyMovingBallAnalysis(NP); + case 'StaticGrating' + obj = StaticDriftingGratingAnalysis(NP); + case 'MovingGrating' + obj = StaticDriftingGratingAnalysis(NP); + otherwise + error('Unknown stimType: %s', stimType); + end + catch ME + warning('Could not build %s for exp %d: %s', stimType, ex, ME.message); + if ~isempty(psthAll{s}) + psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; + end + continue + end + + % ---------------------------------------------------------- + % Extract data structures + % ---------------------------------------------------------- + + % ResponseWindow holds trial timing and spike data + NeuronResp = obj.ResponseWindow; + + % Stats struct for p-values + if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; + else + Stats = obj.ShufflingAnalysis; + end + + % Resolve speed field name + if params.speed ~= "max" && isequal(obj.stimName,'linearlyMovingBall') + fieldName = 'Speed2'; + startStim = 0; + elseif isequal(obj.stimName,'linearlyMovingBall') + fieldName = 'Speed1'; + startStim = 0; + elseif isequal(params.stimTypes,'StaticGrating') + fieldName = 'Static'; + startStim = 0; + + elseif isequal(params.stimTypes,'MovingGrating') + startStim = obj.VST.static_time*1000; + fieldName = 'Moving'; + else + startStim = 0; + end + + % Spike trains of somatic (good) units + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + label = string(p_sort.label'); + goodU = p_sort.ic(:, label == 'good'); + + % P-values for each unit + try + pvals = Stats.(fieldName).pvalsResponse; + catch + pvals = Stats.pvalsResponse; + end + + % Trial onset times in ms + try + C = NeuronResp.(fieldName).C; + catch + C = NeuronResp.C; + end + directimesSorted = C(:, 1)' + startStim; + + % Use params.preBase directly — no formula needed + preBase = params.preBase; + + % Total trial window = baseline + post-stim period + windowTotal = preBase + params.postStim; + + % Lock in time window from first valid experiment + if isempty(lockedPreBase) + lockedPreBase = preBase; + lockedEdges = 0 : params.binWidth : windowTotal; + lockedNBins = numel(lockedEdges) - 1; + tAxis = lockedEdges(1:end-1); + fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... + lockedPreBase, params.postStim, lockedNBins); + end + + % ---------------------------------------------------------- + % Find responsive neurons + % ---------------------------------------------------------- + eNeurons = find(pvals < params.alpha); + + if isempty(eNeurons) + fprintf(' [%s] No responsive neurons in exp %d.\n', stimType, ex); + if ~isempty(psthAll{s}) + psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; + end + continue + end + + fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', ... + stimType, ex, numel(eNeurons)); + + % ---------------------------------------------------------- + % Build PSTH for each responsive neuron + % BuildBurstMatrix returns nTrials x 1 x nTimeBins + % Window: from (trialOnset - preBase) for windowTotal ms + % ---------------------------------------------------------- + psthRateNeurons = zeros(numel(eNeurons), lockedNBins); + + for ni = 1:numel(eNeurons) + u = eNeurons(ni); + + % Spike matrix: rows = trials, cols = time bins (1ms each) + MRhist = BuildBurstMatrix( ... + goodU(:, u), ... + round(p_sort.t), ... + round(directimesSorted - lockedPreBase), ... + round(windowTotal)); + + + + % Remove singleton dimensions → nTrials x nTimeBins + MRhist = squeeze(MRhist); + + if ~isempty(params.TakeTopPercentTrials) + MeanTrial = mean(MRhist,2); + [~, ind] = sort(MeanTrial,'descend'); + + takeTrials = ind(1:round(numel(MeanTrial)*params.TakeTopPercentTrials)); + + MRhist = MRhist(takeTrials,:); + + end + nTrials = size(MRhist, 1); + + % Convert to spike times in ms + spikeTimes = repmat((1:size(MRhist, 2)), nTrials, 1); + spikeTimes = spikeTimes(logical(MRhist)); + + % Bin into locked edges and convert to spk/s + counts = histcounts(spikeTimes, lockedEdges); + psthRateNeurons(ni, :) = (counts / (params.binWidth * nTrials)) * 1000; + end + + % Average across responsive neurons → 1 x lockedNBins + psthExp = mean(psthRateNeurons, 1, 'omitnan'); + + if params.zScore + baselineBins = tAxis < lockedPreBase; + baselineMean = mean(psthExp(baselineBins)); + baselineStd = std(psthExp(baselineBins)); + if baselineStd > 0 + psthExp = (psthExp - baselineMean) / baselineStd; + else + warning(' [%s] Baseline std is zero for exp %d — skipping experiment.', stimType, ex); + if ~isempty(psthAll{s}) + psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; + end + continue % skip to next experiment, do not append raw rates + end + end + + % Append as new row — guaranteed lockedNBins wide + psthAll{s} = [psthAll{s}; psthExp(:)']; + + end % end stim loop + end % end experiment loop + + % ------------------------------------------------------------------ + % Save results to struct + % ------------------------------------------------------------------ + S.expList = exList; % experiment list for future matching + S.lockedEdges = lockedEdges; % bin edges used (ms from trial start) + S.lockedPreBase = lockedPreBase; % baseline duration in ms + S.params = params; % all parameters used + + % Save one field per stim type, named by stim e.g. S.rectGrid + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); % safe field name + S.(stimField) = psthAll{s}; % nExp x nBins PSTH matrix + end + + save([saveDir nameOfFile], '-struct', 'S'); + fprintf('\nSaved PSTHs to:\n %s\n', [saveDir nameOfFile]); + +else + % ------------------------------------------------------------------ + % Load psthAll from saved struct + % ------------------------------------------------------------------ + lockedEdges = S.lockedEdges; + lockedPreBase = S.lockedPreBase; + + psthAll = cell(1, numel(params.stimTypes)); + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + if isfield(S, stimField) + psthAll{s} = S.(stimField); % load the nExp x nBins matrix + else + % Stim type not found in saved file — warn and leave empty + warning('Stim type "%s" not found in saved file.', params.stimTypes(s)); + psthAll{s} = []; + end + end + +end % end forloop + +% ========================================================================= +% PLOT +% ========================================================================= + +tAxis = lockedEdges(1:end-1); +tAxisPlot = tAxis - lockedPreBase; + +colors = lines(numel(params.stimTypes)); + +fig = figure; +set(fig, 'Units', 'centimeters', 'Position', [5 5 9 10]); % single axis now + +% ------------------------------------------------------------------ +% Map stimulus type names to short legend labels +% ------------------------------------------------------------------ +stimLegendMap = containers.Map(... + {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... + {'MB', 'SB', 'MG', 'SG'}); + +% ------------------------------------------------------------------ +% First pass: compute mean/sem for all stim types and find global ylim +% ------------------------------------------------------------------ +meanAll = cell(1, numel(params.stimTypes)); +semAll = cell(1, numel(params.stimTypes)); +yMax = 0; +yMin = inf; + +for s = 1:numel(params.stimTypes) + data = psthAll{s}; + if isempty(data) + continue + end + validRows = ~all(isnan(data), 2); + data = data(validRows, :); + if isempty(data) + continue + end + meanAll{s} = mean(data, 1, 'omitnan'); + semAll{s} = std(data, 0, 1, 'omitnan') / sqrt(sum(~isnan(data(:,1)))); + yMax = max(yMax, max(meanAll{s} + semAll{s})); + yMin = min(yMin, min(meanAll{s} - semAll{s})); +end + +% Y limits with 10% padding +yPad = (yMax - yMin) * 0.1; +if params.zScore + yLims = [yMin - yPad, yMax + yPad]; +else + yLims = [max(0, yMin - yPad), yMax + yPad]; +end + +% ------------------------------------------------------------------ +% Single axis plot — all stim types overlaid +% ------------------------------------------------------------------ +ax = axes(fig); +hold(ax, 'on'); + +legendHandles = gobjects(numel(params.stimTypes), 1); % store line handles for legend + +for s = 1:numel(params.stimTypes) + + data = psthAll{s}; + if isempty(data) + continue + end + validRows = ~all(isnan(data), 2); + data = data(validRows, :); + if isempty(data) + continue + end + + meanPSTH = meanAll{s}; + semPSTH = semAll{s}; + + % Get short legend label for this stim type + stimKey = char(params.stimTypes(s)); + if isKey(stimLegendMap, stimKey) + legendLabel = stimLegendMap(stimKey); + else + legendLabel = stimKey; % fallback to full name if not in map + end + + % Shade ±SEM band + if params.shadeSTD && size(data, 1) > 1 + upper = meanPSTH + semPSTH; + lower = meanPSTH - semPSTH; + xFill = [tAxisPlot(:)', fliplr(tAxisPlot(:)')]; + yFill = [upper(:)', fliplr(lower(:)') ]; + fill(ax, xFill, yFill, colors(s,:), 'FaceAlpha', 0.2, 'EdgeColor', 'none'); + end + + % Mean PSTH line — store handle for legend + legendHandles(s) = plot(ax, tAxisPlot(:)', meanPSTH(:)', ... + 'Color', colors(s,:), 'LineWidth', 1.5, 'DisplayName', legendLabel); + + % Number of contributing experiments as text + nValid = sum(validRows); + fprintf(' [%s] n=%d experiments in plot.\n', legendLabel, nValid); + +end + +% Stim onset and end of post-stim window +xline(ax, 0, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); +xline(ax, params.postStim, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); + +% Y label +if params.zScore + yLabel = 'Z-score'; +else + yLabel = '[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 — only show valid handles (skip stim types with no data) +validHandles = legendHandles(isgraphics(legendHandles)); +legend(validHandles, 'Location', 'northeast', 'FontName', 'helvetica', 'FontSize', 8); + +ax.FontName = 'helvetica'; +ax.FontSize = 8; +hold(ax, 'off'); + +sgtitle(sprintf('N = %d', numel(exList)), 'FontName', 'helvetica', 'FontSize', 11); + +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 5 6]); + +if params.PaperFig + vs_first.printFig(fig, sprintf('PSTH-comparison-%s-%s', ... + params.stimTypes(1), params.stimTypes(2)), PaperFig = params.PaperFig) +end + +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 From da2415431b3a4a1c95428d1a932f73004ee347ed Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Fri, 20 Mar 2026 00:53:19 +0200 Subject: [PATCH 05/19] details spatial ytuning --- .../RunAnalysisClass.asv | 210 --------- visualStimulationAnalysis/RunAnalysisClass.m | 2 +- .../SpatialTuningIndex.asv | 408 ------------------ .../SpatialTuningIndex.m | 79 ++-- 4 files changed, 51 insertions(+), 648 deletions(-) delete mode 100644 visualStimulationAnalysis/RunAnalysisClass.asv delete mode 100644 visualStimulationAnalysis/SpatialTuningIndex.asv diff --git a/visualStimulationAnalysis/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv deleted file mode 100644 index 9f7d3fd..0000000 --- a/visualStimulationAnalysis/RunAnalysisClass.asv +++ /dev/null @@ -1,210 +0,0 @@ -cd('\\sil3\data\Large_scale_mapping_NP') -excelFile = 'Experiment_Excel.xlsx'; - -data = readtable(excelFile); - -%% -%% Rect Grid -for ex = [49:54,64:97] %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 - % close all;vsRe.plotRaster(MergeNtrials=1,overwrite=true,exNeurons=18, selectedLum=255,oneTrial = true,PaperFig = true) %43 - vsRe.CalculateReceptiveFields('overwrite',true) - %[colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=18,allStimParamsCombined=true,PaperFig=true,overwrite=true); - %result = vsRe.BootstrapPerNeuron('overwrite',true); - -end -% vsRe.CalculateReceptiveFields -% vsRe.PlotReceptiveFields("meanAllNeurons",true) - -%% Moving ball - -for ex = [84:97]%97 74:84 (Neurons, 96_74, ) - NP = loadNPclassFromTable(ex); %73 81 - vs = linearlyMovingBallAnalysis(NP,Session=1); - % 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',82,'overwrite',true,'MergeNtrials',1,'OneDirection','up','OneLuminosity','white','PaperFig',true) - % % %vs.plotCorrSpikePattern - % vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'OneDirection','up','OneLuminosity','white','MergeNtrials',1,'PaperFig',true) - - %vs.plotRaster('exNeurons',9,'AllResponsiveNeurons',false,'overwrite',true,'MergeNtrials',3,MaxVal_1=false) - vs.CalculateReceptiveFields('overwrite',true,testConvolution=false); - % colorbarLims=vs.PlotReceptiveFields('exNeurons',82,'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]; -end - -%% PlotZScoreComparison -%[49:54 57:81] MBR all experiments 'NV','NI' -%[44:56,64:88] All experiments -%[28:32,44,45,47,48,56,98] All SA experiments -%Check triggers 45, SA82 44,45,47:54,56,64:88 -% All stim: 'FFF','SDG','MBR','MB','RG','NI','NV' -%[49:54,64:97] %All PV good experiments -% %%[89,90,92,93,95,96,97] %Al NV and NI experiments -%[49:54,84:90,92:96] %All SDG experiments -%solve MBR -%bootsrapRespBase -VStimAnalysis.PlotZScoreComparison([49:54,64:97] ,{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... - overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR -%% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false); - -%% Calculate spatial tuning -SpatialTuningIndex([52:54,64:97]) - -%% Gratings - -for ex = [54 84:90] - 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); -end - -%% movie - -for ex = [89,90,92,93,95: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); - vs.plotRaster('AllResponsiveNeurons',true,MergeNtrials=1,overwrite=true) -end - - -%% image - -for ex = [89,90,92,93,95: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('AllResponsiveNeurons',true,MergeNtrials=1,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 11c74e7..9332153 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -70,7 +70,7 @@ plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false); %% Calculate spatial tuning -SpatialTuningIndex([49:54,64:97], overwrite=true) +SpatialTuningIndex([49:54,64:97], indexType = "L_geometric",overwrite=true) %% Gratings diff --git a/visualStimulationAnalysis/SpatialTuningIndex.asv b/visualStimulationAnalysis/SpatialTuningIndex.asv deleted file mode 100644 index 0f6d98a..0000000 --- a/visualStimulationAnalysis/SpatialTuningIndex.asv +++ /dev/null @@ -1,408 +0,0 @@ -function results = SpatialTuningIndex(exList, params) - -arguments - exList double - params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] - params.topPercent double = 10 - params.overwrite logical = false - params.statType string = "BootstrapPerNeuron" - params.speed double = 1 - params.plot logical = true - 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.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']; -if ~exist([p '\Combined_lizard_analysis'], 'dir') - cd(p) - mkdir Combined_lizard_analysis -end -saveDir = [p '\Combined_lizard_analysis']; - -stimLabel = strjoin(params.stimTypes, '-'); -nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s.mat', ... - exList(1), exList(end), stimLabel); - -% ------------------------------------------------------------------------- -% Decide whether to compute or load -% ------------------------------------------------------------------------- -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]); - % Jump straight to table building - 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); - - tbl = table(); - - for ei = 1:nExp - - ex = exList(ei); - fprintf('\n=== Experiment %d ===\n', ex); - - try - NP = loadNPclassFromTable(ex); - catch ME - warning('Could not load experiment %d: %s', ex, ME.message); - continue - end - - nameParts = split(NP.recordingName, '_'); - animalName = nameParts{1}; - - % ---------------------------------------------------------- - % Find union of responsive neurons across ALL stim types - % ---------------------------------------------------------- - % Get phy IDs and responsive units for each stim type - respPhyIDs_all = cell(1, nStim); - phyIDs_all = cell(1, nStim); - - p_s = obj_s.dataObj.convertPhySorting2tIc(obj_s.spikeSortingFolder); - phy_IDg = p_s.phy_ID(string(p_s.label') == 'good'); - - - for s = 1:nStim - stimType = params.stimTypes(s); - try - switch stimType - case "rectGrid" - obj_s = rectGridAnalysis(NP); - case "linearlyMovingBall" - obj_s = linearlyMovingBallAnalysis(NP); - end - - if params.statType == "BootstrapPerNeuron" - Stats = obj_s.BootstrapPerNeuron; - else - Stats = obj_s.ShufflingAnalysis; - end - - - 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 - - respU = find(pvals < 0.05); - phyIDs_all{s} = phy_IDg; % all good unit phy IDs for this stim - respPhyIDs_all{s} = phy_IDg(respU); % only responsive ones - 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); - phyIDs_all{s} = []; - respPhyIDs_all{s} = []; - end - end - - % Union of responsive phy IDs across stim types - sharedPhyIDs = respPhyIDs_all{1}; - for s = 2:nStim - sharedPhyIDs = union(sharedPhyIDs, respPhyIDs_all{s}); - end - - if isempty(sharedPhyIDs) - fprintf(' No responsive neurons in exp %d — skipping.\n', ex); - continue - end - - fprintf(' %d neuron(s) responsive to at least one stim type in exp %d.\n', numel(sharedPhyIDs), ex); - - - for s = 1:nStim - - stimType = params.stimTypes(s); - - % Build 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 grid results - % ---------------------------------------------------------- - S_rf = obj.CalculateReceptiveFields; - - gridSpikeRate = S_rf.gridSpikeRate; - gridSpikeRateShuff = S_rf.gridSpikeRateShuff; - - switch stimType - case "rectGrid" - % Select onOff from both - gridSpikeRateSelected = gridSpikeRate(:,:,:,params.onOff,:,:); % [nGrid nGrid nN nSize nLum] -- but with singleton onOff removed - gridShuffSelected = gridSpikeRateShuff(:,:,:,:,params.onOff,:,:); % [nGrid nGrid nN nShuffle nSize nLum] - case "linearlyMovingBall" - gridSpikeRateSelected = gridSpikeRate; % [nGrid nGrid nN nSize nLum] - gridShuffSelected = gridSpikeRateShuff; % [nGrid nGrid nN nShuffle nSize nLum] - end - - % Find indices in this stim's good units that match sharedPhyIDs - [~, neuronIdx] = ismember(sharedPhyIDs, phyIDs_all{s}); - neuronIdx = neuronIdx(neuronIdx > 0); % remove any not found in this stim - - gridSpikeRateSelected = gridSpikeRateSelected(:,:,neuronIdx,:,:); - gridShuffSelected = gridShuffSelected(:,:,neuronIdx,:,:,:); - - % Average over shuffles and reshape explicitly — no squeeze - gridShuffMean = mean(gridShuffSelected, 4); % [nGrid nGrid nN 1 nSize nLum] - - % Get dimensions explicitly - nN = size(gridSpikeRateSelected, 3); - nSize = size(gridSpikeRateSelected, 5); - nLum = size(gridSpikeRateSelected, 6); - - % Reshape both to clean [nGrid nGrid nN nSize nLum] - gridSpikeRateSelected = reshape(gridSpikeRateSelected, [nGrid nGrid nN nSize nLum]); - gridShuffMean = reshape(gridShuffMean, [nGrid nGrid nN nSize nLum]); - - nCells = nGrid * nGrid; - maxDist = sqrt(2) * (nGrid - 1); - - % Average over shuffles - - - % ---------------------------------------------------------- - % Compute indices - % ---------------------------------------------------------- - - fprintf('gridSpikeRate size: %s\n', num2str(size(gridSpikeRate))); - fprintf('gridSpikeRateShuff size: %s\n', num2str(size(gridSpikeRateShuff))); - fprintf('gridShuffMean size: %s\n', num2str(size(gridShuffMean))); - - for si = 1:nSize - for li = 1:nLum - - rateFlat = reshape(gridSpikeRateSelected(:,:,:,si,li), [nCells, nN]); - rateFlatShuff = reshape(gridShuffMean(:,:,:,si,li), [nCells, nN]); - - L_amplitude = zeros(nN, 1); - L_geometric = zeros(nN, 1); - L_combined = zeros(nN, 1); - - for u = 1:nN - - rateVec = rateFlat(:, u); - rateVecShuff = rateFlatShuff(:, u); - - % Top 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); - - % Amplitude - meanTop = mean(rateVec(topIdx)); - meanRest = mean(rateVec(restIdx)); - meanAll = mean(rateVec); - meanTopShuff = mean(rateVecShuff(topIdxShuff)); - meanRestShuff = mean(rateVecShuff(restIdxShuff)); - meanAllShuff = mean(rateVecShuff); - - if meanAll == 0, meanAll = eps; end - if meanAllShuff == 0, meanAllShuff = eps; end - - L_amplitude(u) = ... - (meanTop - meanRest) / meanAll - ... - (meanTopShuff - meanRestShuff) / meanAllShuff; - - % Geometric - [rowIdx, colIdx] = ind2sub([nGrid nGrid], topIdx); - [rowIdxShuff, colIdxShuff] = ind2sub([nGrid nGrid], topIdxShuff); - - 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 - - L_geometric(u) = (1 - D) - (1 - DShuff); - L_combined(u) = L_amplitude(u) * L_geometric(u); - - end - - % Build rows for this condition - rows = table(); - rows.L_amplitude = L_amplitude; - rows.L_geometric = L_geometric; - rows.L_combined = L_combined; - rows.stimulus = categorical(repmat({char(stimType)}, nN, 1)); - rows.insertion = categorical(repmat(ex, nN, 1)); - rows.animal = categorical(repmat({animalName}, nN, 1)); - rows.NeurID = (1:nN)'; - rows.onOff = repmat(params.onOff, nN, 1); % params.onOff for rectGrid, meaningless but consistent for movingBall - rows.sizeIdx = repmat(si, nN, 1); - rows.lumIdx = repmat(li, nN, 1); - - tbl = [tbl; rows]; - - end - end - - fprintf(' [%s] Indices computed. %d neurons.\n', stimType, nN); - - end % stim loop - end % exp loop - - % Clean categories - tbl.stimulus = removecats(tbl.stimulus); - tbl.animal = removecats(tbl.animal); - tbl.insertion = removecats(tbl.insertion); - - % Save - 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; - -% ========================================================================= -% PLOT -% ========================================================================= -if params.plot - - % Filter table to requested condition - idx = tbl.onOff == params.onOff & ... - tbl.sizeIdx == params.sizeIdx & ... - tbl.lumIdx == params.lumIdx; - - tblPlot = tbl(idx, :); - tblPlot.value = tblPlot.(params.indexType); % select which index to plot - - % ---------------------------------------------------------- - % Compute p-values using hierBoot - % ---------------------------------------------------------- - ps = []; - - pairs = {char(params.stimTypes(1)), char(params.stimTypes(2))}; - - - ps = zeros(size(pairs, 1), 1); - j = 1; - - for i = 1:size(pairs, 1) - 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]; - 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 - - - % ---------------------------------------------------------- - % Plot - % ---------------------------------------------------------- - V1max = max(tblPlot.value, [], 'omitnan'); - - [fig, ~] = plotSwarmBootstrapWithComparisons(tblPlot, pairs, ps, {'value'}, ... - yLegend = params.yLegend, ... - yMaxVis = max(params.yMaxVis, V1max), ... - diff = false, ... - Alpha = params.Alpha, ... - plotMeanSem = true); - - title(sprintf('%s — %s (onOff=%d, size=%d, lum=%d)', ... - params.indexType, strjoin(params.stimTypes, '/'), ... - params.onOff, 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 - - results.fig = fig; - results.ps = ps; - -end - -end \ No newline at end of file diff --git a/visualStimulationAnalysis/SpatialTuningIndex.m b/visualStimulationAnalysis/SpatialTuningIndex.m index 0f6d98a..98bf534 100644 --- a/visualStimulationAnalysis/SpatialTuningIndex.m +++ b/visualStimulationAnalysis/SpatialTuningIndex.m @@ -8,7 +8,7 @@ params.statType string = "BootstrapPerNeuron" params.speed double = 1 params.plot logical = true - params.indexType string = "L_combined" % L_amplitude, L_geometric, L_combined + params.indexType string = "L_amplitude" % L_amplitude, L_geometric, L_combined params.onOff double = 1 % 1=on, 2=off (rectGrid only) params.sizeIdx double = 1 params.lumIdx double = 1 @@ -83,19 +83,21 @@ continue end + obj_s = linearlyMovingBallAnalysis(NP); + nameParts = split(NP.recordingName, '_'); animalName = nameParts{1}; % ---------------------------------------------------------- % Find union of responsive neurons across ALL stim types % ---------------------------------------------------------- - % Get phy IDs and responsive units for each stim type - respPhyIDs_all = cell(1, nStim); - phyIDs_all = cell(1, nStim); - p_s = obj_s.dataObj.convertPhySorting2tIc(obj_s.spikeSortingFolder); - phy_IDg = p_s.phy_ID(string(p_s.label') == 'good'); + % Get phy IDs once — same for all stim types + p_s = NP.convertPhySorting2tIc(obj_s.spikeSortingFolder); + phy_IDg = p_s.phy_ID(string(p_s.label') == 'good'); + respPhyIDs_all = cell(1, nStim); + respU_all = cell(1, nStim); % ADD — stores respU indices per stim for s = 1:nStim stimType = params.stimTypes(s); @@ -113,7 +115,6 @@ Stats = obj_s.ShufflingAnalysis; end - try switch stimType case "linearlyMovingBall" @@ -126,30 +127,30 @@ pvals = Stats.pvalsResponse; end - respU = find(pvals < 0.05); - phyIDs_all{s} = phy_IDg; % all good unit phy IDs for this stim - respPhyIDs_all{s} = phy_IDg(respU); % only responsive ones + respU = find(pvals < 0.05); + respU_all{s} = respU; % ADD — index into gridSpikeRate dim 3 + respPhyIDs_all{s} = phy_IDg(respU); % phy IDs of responsive neurons 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); - phyIDs_all{s} = []; + respU_all{s} = []; respPhyIDs_all{s} = []; end end - % Union of responsive phy IDs across stim types + % Intersection of responsive phy IDs across stim types sharedPhyIDs = respPhyIDs_all{1}; for s = 2:nStim - sharedPhyIDs = union(sharedPhyIDs, respPhyIDs_all{s}); + sharedPhyIDs = intersect(sharedPhyIDs, respPhyIDs_all{s}); end if isempty(sharedPhyIDs) - fprintf(' No responsive neurons in exp %d — skipping.\n', ex); + fprintf(' No neurons responsive to all stim types in exp %d — skipping.\n', ex); continue end - fprintf(' %d neuron(s) responsive to at least one stim type in exp %d.\n', numel(sharedPhyIDs), ex); + fprintf(' %d neuron(s) responsive to all stim types in exp %d.\n', numel(sharedPhyIDs), ex); for s = 1:nStim @@ -182,17 +183,27 @@ switch stimType case "rectGrid" - % Select onOff from both - gridSpikeRateSelected = gridSpikeRate(:,:,:,params.onOff,:,:); % [nGrid nGrid nN nSize nLum] -- but with singleton onOff removed - gridShuffSelected = gridSpikeRateShuff(:,:,:,:,params.onOff,:,:); % [nGrid nGrid nN nShuffle nSize nLum] + gridSpikeRateSelected = gridSpikeRate(:,:,:,params.onOff,:,:); + gridShuffSelected = gridSpikeRateShuff(:,:,:,:,params.onOff,:,:); + + % Remove onOff singleton at dim 4 for rate: [9 9 nN 1 nSize nLum] -> [9 9 nN nSize nLum] + gridSpikeRateSelected = reshape(gridSpikeRateSelected, ... + [size(gridSpikeRateSelected,1), size(gridSpikeRateSelected,2), ... + size(gridSpikeRateSelected,3), size(gridSpikeRateSelected,5), ... + size(gridSpikeRateSelected,6)]); + + % Remove onOff singleton at dim 5 for shuff: [9 9 nN nShuffle 1 nSize nLum] -> [9 9 nN nShuffle nSize nLum] + 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; % [nGrid nGrid nN nSize nLum] gridShuffSelected = gridSpikeRateShuff; % [nGrid nGrid nN nShuffle nSize nLum] end - % Find indices in this stim's good units that match sharedPhyIDs - [~, neuronIdx] = ismember(sharedPhyIDs, phyIDs_all{s}); - neuronIdx = neuronIdx(neuronIdx > 0); % remove any not found in this stim + % Find which indices of THIS stim's gridSpikeRate correspond to sharedPhyIDs + [~, neuronIdx] = ismember(sharedPhyIDs, phy_IDg(respU_all{s})); gridSpikeRateSelected = gridSpikeRateSelected(:,:,neuronIdx,:,:); gridShuffSelected = gridShuffSelected(:,:,neuronIdx,:,:,:); @@ -202,8 +213,12 @@ % Get dimensions explicitly nN = size(gridSpikeRateSelected, 3); - nSize = size(gridSpikeRateSelected, 5); - nLum = size(gridSpikeRateSelected, 6); + nSize = size(gridSpikeRateSelected, 4); + nLum = size(gridSpikeRateSelected, 5); + nGrid = size(gridSpikeRateSelected, 1); + + fprintf('gridSpikeRateSelected size before reshape: %s\n', num2str(size(gridSpikeRateSelected))); + fprintf('Expected: [%d %d %d %d %d]\n', nGrid, nGrid, nN, nSize, nLum); % Reshape both to clean [nGrid nGrid nN nSize nLum] gridSpikeRateSelected = reshape(gridSpikeRateSelected, [nGrid nGrid nN nSize nLum]); @@ -214,7 +229,6 @@ % Average over shuffles - % ---------------------------------------------------------- % Compute indices % ---------------------------------------------------------- @@ -229,7 +243,8 @@ rateFlat = reshape(gridSpikeRateSelected(:,:,:,si,li), [nCells, nN]); rateFlatShuff = reshape(gridShuffMean(:,:,:,si,li), [nCells, nN]); - L_amplitude = zeros(nN, 1); + L_amplitude_diff = zeros(nN, 1); + L_amplitude_ratio = zeros(nN, 1); L_geometric = zeros(nN, 1); L_combined = zeros(nN, 1); @@ -258,10 +273,15 @@ if meanAll == 0, meanAll = eps; end if meanAllShuff == 0, meanAllShuff = eps; end - L_amplitude(u) = ... + L_amplitude_diff(u) = ... (meanTop - meanRest) / meanAll - ... (meanTopShuff - meanRestShuff) / meanAllShuff; + shuffleNorm = (meanTopShuff - meanRestShuff) / meanAllShuff; + if shuffleNorm == 0, shuffleNorm = eps; end + + L_amplitude_ratio(u) = ((meanTop - meanRest) / meanAll) / shuffleNorm; + % Geometric [rowIdx, colIdx] = ind2sub([nGrid nGrid], topIdx); [rowIdxShuff, colIdxShuff] = ind2sub([nGrid nGrid], topIdxShuff); @@ -278,13 +298,14 @@ end L_geometric(u) = (1 - D) - (1 - DShuff); - L_combined(u) = L_amplitude(u) * L_geometric(u); + L_combined(u) = L_amplitude_diff(u) * L_geometric(u); end % Build rows for this condition rows = table(); - rows.L_amplitude = L_amplitude; + 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)); @@ -385,7 +406,7 @@ [fig, ~] = plotSwarmBootstrapWithComparisons(tblPlot, pairs, ps, {'value'}, ... yLegend = params.yLegend, ... yMaxVis = max(params.yMaxVis, V1max), ... - diff = false, ... + diff = true, ... Alpha = params.Alpha, ... plotMeanSem = true); From 14e9f4524ff03b153f2c22b610c915cb9ed25db3 Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Wed, 25 Mar 2026 00:42:59 +0200 Subject: [PATCH 06/19] Added option to plot by depth, and function to get depth of all exps --- .../CalculateReceptiveFields.m | 1 + .../RunAnalysisClass.asv | 213 ++++++++ visualStimulationAnalysis/RunAnalysisClass.m | 7 +- .../SpatialTuningIndex.m | 2 +- visualStimulationAnalysis/getNeuronDepths.m | 107 ++++ visualStimulationAnalysis/plotPSTH_MultiExp.m | 439 ++++++++--------- .../plotPSTH_MultiExpV1.m | 463 ++++++++++++++++++ 7 files changed, 1007 insertions(+), 225 deletions(-) create mode 100644 visualStimulationAnalysis/RunAnalysisClass.asv create mode 100644 visualStimulationAnalysis/getNeuronDepths.m create mode 100644 visualStimulationAnalysis/plotPSTH_MultiExpV1.m diff --git a/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m b/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m index 7465c3e..7080102 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m @@ -284,6 +284,7 @@ trialCount = zeros(nGrid, nGrid, nSize, nLums); jj = 1; + for i = 1:trialDiv:nT xBin = discretize(XcStore(jj), xEdges); diff --git a/visualStimulationAnalysis/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv new file mode 100644 index 0000000..e674375 --- /dev/null +++ b/visualStimulationAnalysis/RunAnalysisClass.asv @@ -0,0 +1,213 @@ +cd('\\sil3\data\Large_scale_mapping_NP') +excelFile = 'Experiment_Excel.xlsx'; + +data = readtable(excelFile); + +%% +%% Rect Grid +for ex = 52 %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 + % close all;vsRe.plotRaster(MergeNtrials=1,overwrite=true,exNeurons=18, selectedLum=255,oneTrial = true,PaperFig = true) %43 + vsRe.CalculateReceptiveFields('overwrite',true) + %[colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=18,allStimParamsCombined=true,PaperFig=true,overwrite=true); + %result = vsRe.BootstrapPerNeuron('overwrite',true); + +end +% vsRe.CalculateReceptiveFields +% vsRe.PlotReceptiveFields("meanAllNeurons",true) + +%% Moving ball + +for ex = [84:97]%97 74:84 (Neurons, 96_74, ) + ex = 84 + NP = loadNPclassFromTable(ex); %73 81 + vs = linearlyMovingBallAnalysis(NP,Session=1); + % 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',82,'overwrite',true,'MergeNtrials',1,'OneDirection','up','OneLuminosity','white','PaperFig',true) + % % %vs.plotCorrSpikePattern + % vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'OneDirection','up','OneLuminosity','white','MergeNtrials',1,'PaperFig',true) + + %vs.plotRaster('exNeurons',9,'AllResponsiveNeurons',false,'overwrite',true,'MergeNtrials',3,MaxVal_1=false) + vs.CalculateReceptiveFields('overwrite',true,testConvolution=false); + % colorbarLims=vs.PlotReceptiveFields('exNeurons',82,'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]; +end + +%% PlotZScoreComparison +%[49:54 57:81] MBR all experiments 'NV','NI' +%[44:56,64:88] All experiments +%[28:32,44,45,47,48,56,98] All SA experiments +%Check triggers 45, SA82 44,45,47:54,56,64:88 +% All stim: 'FFF','SDG','MBR','MB','RG','NI','NV' +%[49:54,64:97] %All PV good experiments +% %%[89,90,92,93,95,96,97] %Al NV and NI experiments +%[49:54,84:90,92:96] %All SDG experiments +%solve MBR +%bootsrapRespBase +VStimAnalysis.PlotZScoreComparison([49:54,64:97] ,{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR +%% PSTH for all experiments +plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false); + +%% Calculate spatial tuning +SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_ratio" ,overwrite=true, topPercent = 20) + +%% Get neuron depths +getNeuronDepths([49:54,64:72,84:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates +%% Gratings + +for ex = [54 84:90] + 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); +end + +%% movie + +for ex = [89,90,92,93,95: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); + vs.plotRaster('AllResponsiveNeurons',true,MergeNtrials=1,overwrite=true) +end + + +%% image + +for ex = [89,90,92,93,95: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('AllResponsiveNeurons',true,MergeNtrials=1,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 9332153..020bac3 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -29,6 +29,7 @@ %% Moving ball for ex = [84:97]%97 74:84 (Neurons, 96_74, ) + ex = 84 NP = loadNPclassFromTable(ex); %73 81 vs = linearlyMovingBallAnalysis(NP,Session=1); % vs.getSessionTime("overwrite",true); @@ -67,11 +68,13 @@ VStimAnalysis.PlotZScoreComparison([49:54,64:97] ,{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR %% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false); +plotPSTH_MultiExp([49:54,64:72,84:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false, byDepth=true); %% Calculate spatial tuning -SpatialTuningIndex([49:54,64:97], indexType = "L_geometric",overwrite=true) +SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_ratio" ,overwrite=true, topPercent = 20) +%% Get neuron depths +getNeuronDepths([49:54,64:72,84:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates %% Gratings for ex = [54 84:90] diff --git a/visualStimulationAnalysis/SpatialTuningIndex.m b/visualStimulationAnalysis/SpatialTuningIndex.m index 98bf534..5a80b33 100644 --- a/visualStimulationAnalysis/SpatialTuningIndex.m +++ b/visualStimulationAnalysis/SpatialTuningIndex.m @@ -8,7 +8,7 @@ params.statType string = "BootstrapPerNeuron" params.speed double = 1 params.plot logical = true - params.indexType string = "L_amplitude" % L_amplitude, L_geometric, L_combined + params.indexType string = "L_amplitude" % L_amplitude_diff,L_amplitude_ratio, L_geometric, L_combined params.onOff double = 1 % 1=on, 2=off (rectGrid only) params.sizeIdx double = 1 params.lumIdx double = 1 diff --git a/visualStimulationAnalysis/getNeuronDepths.m b/visualStimulationAnalysis/getNeuronDepths.m new file mode 100644 index 0000000..35f98bd --- /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); % 1 x nGoodUnits + neuronDepths = coor_Z - yPos; % 1 x nGoodUnits, in um + + % Accumulate table columns + expCol = [expCol, repmat(ex, 1, nGood)]; + unitCol = [unitCol, 1:nGood ]; + depthCol = [depthCol, neuronDepths ]; + + % Store per-experiment data + result.perExp(ei).exNum = ex; + result.perExp(ei).goodU = goodU; + result.perExp(ei).p_sort = p_sort; + + fprintf(' coor_Z = %.0f um | Good units: %d | Depth range: %.0f - %.0f um\n', ... + coor_Z, nGood, min(neuronDepths), max(neuronDepths)); + +end + +% ------------------------------------------------------------------ +% Build table +% ------------------------------------------------------------------ +result.depthTable = table(expCol(:), unitCol(:), depthCol(:), ... + 'VariableNames', {'Experiment', 'Unit', 'Depth_um'}); + +% ------------------------------------------------------------------ +% Global depth bins +% ------------------------------------------------------------------ +dMin = min(depthCol); +dMax = max(depthCol); +step = (dMax - dMin) / 3; + +result.depthBinEdges = [dMin, dMin+step, dMin+2*step, dMax]; + +fprintf('\nGlobal depth range: %.0f - %.0f um\n', dMin, dMax); +fprintf('Depth bins:\n'); +fprintf(' Bin 1 (shallow) : %.0f - %.0f um\n', result.depthBinEdges(1), result.depthBinEdges(2)); +fprintf(' Bin 2 (middle) : %.0f - %.0f um\n', result.depthBinEdges(2), result.depthBinEdges(3)); +fprintf(' Bin 3 (deep) : %.0f - %.0f um\n', result.depthBinEdges(3), result.depthBinEdges(4)); + +% ------------------------------------------------------------------ +% Save to disk +% ------------------------------------------------------------------ + +n = extractBefore(obj.getAnalysisFileName,'lizards'); +saveName = [n 'lizards' filesep 'Combined_lizard_analysis' filesep 'NeuronDepths.mat']; +save(saveName, '-struct', 'result'); +fprintf('\nSaved to: %s\n', saveName); +end \ No newline at end of file diff --git a/visualStimulationAnalysis/plotPSTH_MultiExp.m b/visualStimulationAnalysis/plotPSTH_MultiExp.m index 24e42dc..ec92d0d 100644 --- a/visualStimulationAnalysis/plotPSTH_MultiExp.m +++ b/visualStimulationAnalysis/plotPSTH_MultiExp.m @@ -9,24 +9,41 @@ function plotPSTH_MultiExp(exList, params) params.speed string = "max" params.alpha double = 0.05 params.shadeSTD logical = true - params.postStim double = 500 % ms after stim onset to include - params.preBase double = 200 % ms of baseline before stim onset - params.overwrite logical = false % force recompute even if file exists - params.TakeTopPercentTrials double = 0.3 %Percentage of highest spiking rate trials to take to calculate PSTHs - params.zScore logical = false % normalize firing rate to z-score using baseline - params.PaperFig logical = false %Is this going to be used in the paper? + params.postStim double = 500 + params.preBase double = 200 + params.overwrite logical = false + params.TakeTopPercentTrials double = 0.3 + params.zScore logical = false + params.PaperFig logical = false + params.byDepth logical = false % plot 3 depth bins per stim type end % ------------------------------------------------------------------------- -% Build save path using first experiment to get the analysis folder -% This mirrors the convention used in PlotZScoreComparison +% Load depth info from saved file (only 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 um\n', depthBinEdges(1), depthBinEdges(2)); + fprintf(' Bin 2 (middle) : %.0f - %.0f um\n', depthBinEdges(2), depthBinEdges(3)); + fprintf(' Bin 3 (deep) : %.0f - %.0f um\n', depthBinEdges(3), depthBinEdges(4)); +else + nDepthBins = 1; +end -% Load first experiment just to get the folder path +% ------------------------------------------------------------------------- +% Build save path +% ------------------------------------------------------------------------- NP_first = loadNPclassFromTable(exList(1)); -vs_first = linearlyMovingBallAnalysis(NP_first); % used only for path +vs_first = linearlyMovingBallAnalysis(NP_first); -% Build the save directory path p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); p = [p 'lizards']; if ~exist([p '\Combined_lizard_analysis'], 'dir') @@ -35,79 +52,66 @@ function plotPSTH_MultiExp(exList, params) end saveDir = [p '\Combined_lizard_analysis']; -% Build filename — includes stim types so different comparisons don't clash -stimLabel = strjoin(params.stimTypes, '-'); % e.g. "rectGrid-linearlyMovingBall" -nameOfFile = sprintf('\\Ex_%d-%d_Combined_PSTHs_%s.mat', ... - exList(1), exList(end), stimLabel); +stimLabel = strjoin(params.stimTypes, '-'); +depthSuffix = ''; +if params.byDepth; depthSuffix = '_byDepth'; end +nameOfFile = sprintf('\\Ex_%d-%d_Combined_PSTHs_%s%s.mat', ... + exList(1), exList(end), stimLabel, depthSuffix); % ------------------------------------------------------------------------- -% Decide whether to run the experiment loop or load from disk -% forloop = true → compute PSTHs from scratch -% forloop = false → load saved struct and skip to plotting +% Decide whether to recompute or load % ------------------------------------------------------------------------- if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite - % File exists and overwrite is off — check if expList matches S = load([saveDir nameOfFile]); if isequal(S.expList, exList) fprintf('Loading saved PSTHs from:\n %s\n', [saveDir nameOfFile]); - forloop = false; % skip computation, go straight to plot + forloop = false; else fprintf('Experiment list mismatch — recomputing.\n'); - forloop = true; % expList changed, recompute + forloop = true; end else - forloop = true; % file doesn't exist or overwrite requested + forloop = true; end % ========================================================================= -% EXPERIMENT LOOP — only runs if forloop is true +% EXPERIMENT LOOP % ========================================================================= if forloop nStim = numel(params.stimTypes); nExp = numel(exList); - % One cell per stim type, grows one row per experiment - psthAll = cell(1, nStim); - for s = 1:nStim - psthAll{s} = []; - end + % psthAll{s,b} — s = stim type, b = depth bin (1 if byDepth is off) + psthAll = cell(nStim, nDepthBins); - % Locked time window — set from first valid experiment - lockedPreBase = []; - lockedNBins = []; - lockedEdges = []; + lockedPreBase = []; + lockedNBins = []; + lockedEdges = []; - % ------------------------------------------------------------------ - % LOOP OVER EXPERIMENTS - % ------------------------------------------------------------------ for ei = 1:nExp ex = exList(ei); fprintf('\n=== Experiment %d ===\n', ex); - % Load NP data for this experiment try NP = loadNPclassFromTable(ex); catch ME warning('Could not load experiment %d: %s', ex, ME.message); - % Add NaN placeholder row if window is already locked for s = 1:nStim - if ~isempty(psthAll{s}) - psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; + for b = 1:nDepthBins + if ~isempty(psthAll{s,b}) + psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; + end end end continue end - % -------------------------------------------------------------- - % LOOP OVER STIMULUS TYPES - % -------------------------------------------------------------- for s = 1:nStim stimType = params.stimTypes(s); - % Build analysis object for this stim type try switch stimType case "rectGrid" @@ -123,71 +127,54 @@ function plotPSTH_MultiExp(exList, params) end catch ME warning('Could not build %s for exp %d: %s', stimType, ex, ME.message); - if ~isempty(psthAll{s}) - psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; + for b = 1:nDepthBins + if ~isempty(psthAll{s,b}) + psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; + end end continue end - % ---------------------------------------------------------- - % Extract data structures - % ---------------------------------------------------------- - - % ResponseWindow holds trial timing and spike data NeuronResp = obj.ResponseWindow; - % Stats struct for p-values if params.statType == "BootstrapPerNeuron" Stats = obj.BootstrapPerNeuron; else Stats = obj.ShufflingAnalysis; end - % Resolve speed field name if params.speed ~= "max" && isequal(obj.stimName,'linearlyMovingBall') - fieldName = 'Speed2'; - startStim = 0; + fieldName = 'Speed2'; startStim = 0; elseif isequal(obj.stimName,'linearlyMovingBall') - fieldName = 'Speed1'; - startStim = 0; + fieldName = 'Speed1'; startStim = 0; elseif isequal(params.stimTypes,'StaticGrating') - fieldName = 'Static'; - startStim = 0; - + fieldName = 'Static'; startStim = 0; elseif isequal(params.stimTypes,'MovingGrating') - startStim = obj.VST.static_time*1000; - fieldName = 'Moving'; + startStim = obj.VST.static_time*1000; fieldName = 'Moving'; else startStim = 0; end - % Spike trains of somatic (good) units p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); label = string(p_sort.label'); goodU = p_sort.ic(:, label == 'good'); - % P-values for each unit try - pvals = Stats.(fieldName).pvalsResponse; + pvals = Stats.(fieldName).pvalsResponse; catch - pvals = Stats.pvalsResponse; + pvals = Stats.pvalsResponse; end - % Trial onset times in ms try - C = NeuronResp.(fieldName).C; + C = NeuronResp.(fieldName).C; catch C = NeuronResp.C; end directimesSorted = C(:, 1)' + startStim; - % Use params.preBase directly — no formula needed - preBase = params.preBase; - - % Total trial window = baseline + post-stim period + preBase = params.preBase; windowTotal = preBase + params.postStim; - % Lock in time window from first valid experiment if isempty(lockedPreBase) lockedPreBase = preBase; lockedEdges = 0 : params.binWidth : windowTotal; @@ -197,125 +184,143 @@ function plotPSTH_MultiExp(exList, params) lockedPreBase, params.postStim, lockedNBins); end - % ---------------------------------------------------------- - % Find responsive neurons - % ---------------------------------------------------------- eNeurons = find(pvals < params.alpha); if isempty(eNeurons) fprintf(' [%s] No responsive neurons in exp %d.\n', stimType, ex); - if ~isempty(psthAll{s}) - psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; + for b = 1:nDepthBins + if ~isempty(psthAll{s,b}) + psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; + end end continue end - fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', ... - stimType, ex, numel(eNeurons)); + fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', stimType, ex, numel(eNeurons)); % ---------------------------------------------------------- - % Build PSTH for each responsive neuron - % BuildBurstMatrix returns nTrials x 1 x nTimeBins - % Window: from (trialOnset - preBase) for windowTotal ms + % Build PSTH per neuron % ---------------------------------------------------------- psthRateNeurons = zeros(numel(eNeurons), lockedNBins); + neuronBinIdx = zeros(numel(eNeurons), 1); for ni = 1:numel(eNeurons) u = eNeurons(ni); - % Spike matrix: rows = trials, cols = time bins (1ms each) + % Assign depth bin + if params.byDepth + depthRow = depthTable.Experiment == ex & depthTable.Unit == u; + if ~any(depthRow) + neuronBinIdx(ni) = 0; % unknown depth — skip + 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; % all neurons in single bin + end + MRhist = BuildBurstMatrix( ... goodU(:, u), ... round(p_sort.t), ... round(directimesSorted - lockedPreBase), ... round(windowTotal)); + MRhist = squeeze(MRhist); - - - % Remove singleton dimensions → nTrials x nTimeBins - MRhist = squeeze(MRhist); - - if ~isempty(params.TakeTopPercentTrials) - MeanTrial = mean(MRhist,2); - [~, ind] = sort(MeanTrial,'descend'); - - takeTrials = ind(1:round(numel(MeanTrial)*params.TakeTopPercentTrials)); - - MRhist = MRhist(takeTrials,:); - + if ~isempty(params.TakeTopPercentTrials) + MeanTrial = mean(MRhist, 2); + [~, ind] = sort(MeanTrial, 'descend'); + takeTrials = ind(1:round(numel(MeanTrial)*params.TakeTopPercentTrials)); + MRhist = MRhist(takeTrials, :); end - nTrials = size(MRhist, 1); - % Convert to spike times in ms - spikeTimes = repmat((1:size(MRhist, 2)), nTrials, 1); + nTrials = size(MRhist, 1); + spikeTimes = repmat((1:size(MRhist,2)), nTrials, 1); spikeTimes = spikeTimes(logical(MRhist)); - - % Bin into locked edges and convert to spk/s - counts = histcounts(spikeTimes, lockedEdges); + counts = histcounts(spikeTimes, lockedEdges); psthRateNeurons(ni, :) = (counts / (params.binWidth * nTrials)) * 1000; end - % Average across responsive neurons → 1 x lockedNBins - psthExp = mean(psthRateNeurons, 1, 'omitnan'); + % ---------------------------------------------------------- + % Average per depth bin and append + % ---------------------------------------------------------- + for b = 1:nDepthBins + binNeurons = neuronBinIdx == b; + if ~any(binNeurons) + fprintf(' [%s] No neurons in depth bin %d for exp %d.\n', stimType, b, ex); + if ~isempty(psthAll{s,b}) + psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; + end + continue + end - if params.zScore - baselineBins = tAxis < lockedPreBase; - baselineMean = mean(psthExp(baselineBins)); - baselineStd = std(psthExp(baselineBins)); - if baselineStd > 0 - psthExp = (psthExp - baselineMean) / baselineStd; - else - warning(' [%s] Baseline std is zero for exp %d — skipping experiment.', stimType, ex); - if ~isempty(psthAll{s}) - psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; + psthExp = mean(psthRateNeurons(binNeurons, :), 1, 'omitnan'); + + if params.zScore + baselineBins = tAxis < lockedPreBase; + baselineMean = mean(psthExp(baselineBins)); + baselineStd = std(psthExp(baselineBins)); + if baselineStd > 0 + psthExp = (psthExp - baselineMean) / baselineStd; + else + warning(' [%s] Bin %d: baseline std is zero for exp %d.', stimType, b, ex); + if ~isempty(psthAll{s,b}) + psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; + end + continue end - continue % skip to next experiment, do not append raw rates end - end - % Append as new row — guaranteed lockedNBins wide - psthAll{s} = [psthAll{s}; psthExp(:)']; + psthAll{s,b} = [psthAll{s,b}; psthExp(:)']; + fprintf(' [%s] Bin %d: %d neuron(s) in exp %d.\n', stimType, b, sum(binNeurons), ex); + end - end % end stim loop - end % end experiment loop + end % stim loop + end % experiment loop % ------------------------------------------------------------------ - % Save results to struct + % Save % ------------------------------------------------------------------ - S.expList = exList; % experiment list for future matching - S.lockedEdges = lockedEdges; % bin edges used (ms from trial start) - S.lockedPreBase = lockedPreBase; % baseline duration in ms - S.params = params; % all parameters used + S.expList = exList; + S.lockedEdges = lockedEdges; + S.lockedPreBase = lockedPreBase; + S.params = params; - % Save one field per stim type, named by stim e.g. S.rectGrid for s = 1:numel(params.stimTypes) - stimField = matlab.lang.makeValidName(params.stimTypes(s)); % safe field name - S.(stimField) = psthAll{s}; % nExp x nBins PSTH matrix + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + for b = 1:nDepthBins + S.(sprintf('%s_bin%d', stimField, b)) = psthAll{s,b}; + end end save([saveDir nameOfFile], '-struct', 'S'); fprintf('\nSaved PSTHs to:\n %s\n', [saveDir nameOfFile]); else - % ------------------------------------------------------------------ - % Load psthAll from saved struct - % ------------------------------------------------------------------ + % Load psthAll from disk lockedEdges = S.lockedEdges; lockedPreBase = S.lockedPreBase; - psthAll = cell(1, numel(params.stimTypes)); + psthAll = cell(numel(params.stimTypes), nDepthBins); for s = 1:numel(params.stimTypes) - stimField = matlab.lang.makeValidName(params.stimTypes(s)); - if isfield(S, stimField) - psthAll{s} = S.(stimField); % load the nExp x nBins matrix - else - % Stim type not found in saved file — warn and leave empty - warning('Stim type "%s" not found in saved file.', params.stimTypes(s)); - psthAll{s} = []; + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + for b = 1:nDepthBins + fieldKey = sprintf('%s_bin%d', stimField, b); + if isfield(S, fieldKey) + psthAll{s,b} = S.(fieldKey); + else + warning('Field "%s" not found in saved file.', fieldKey); + psthAll{s,b} = []; + end end end - -end % end forloop +end % ========================================================================= % PLOT @@ -324,43 +329,37 @@ function plotPSTH_MultiExp(exList, params) tAxis = lockedEdges(1:end-1); tAxisPlot = tAxis - lockedPreBase; -colors = lines(numel(params.stimTypes)); - -fig = figure; -set(fig, 'Units', 'centimeters', 'Position', [5 5 9 10]); % single axis now +baseColors = lines(numel(params.stimTypes)); +depthShades = [0.6, 0.35, 0.1]; % light → dark for shallow → deep +binLabels = {'shallow', 'middle', 'deep'}; -% ------------------------------------------------------------------ -% Map stimulus type names to short legend labels -% ------------------------------------------------------------------ stimLegendMap = containers.Map(... {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... {'MB', 'SB', 'MG', 'SG'}); % ------------------------------------------------------------------ -% First pass: compute mean/sem for all stim types and find global ylim +% First pass: global ylim % ------------------------------------------------------------------ -meanAll = cell(1, numel(params.stimTypes)); -semAll = cell(1, numel(params.stimTypes)); -yMax = 0; -yMin = inf; +yMax = 0; +yMin = inf; + +meanAll = cell(numel(params.stimTypes), nDepthBins); +semAll = cell(numel(params.stimTypes), nDepthBins); for s = 1:numel(params.stimTypes) - data = psthAll{s}; - if isempty(data) - continue + for b = 1:nDepthBins + data = psthAll{s,b}; + if isempty(data); continue; end + validRows = ~all(isnan(data), 2); + data = data(validRows, :); + if isempty(data); continue; end + meanAll{s,b} = mean(data, 1, 'omitnan'); + semAll{s,b} = std(data, 0, 1, 'omitnan') / sqrt(sum(~isnan(data(:,1)))); + yMax = max(yMax, max(meanAll{s,b} + semAll{s,b})); + yMin = min(yMin, min(meanAll{s,b} - semAll{s,b})); end - validRows = ~all(isnan(data), 2); - data = data(validRows, :); - if isempty(data) - continue - end - meanAll{s} = mean(data, 1, 'omitnan'); - semAll{s} = std(data, 0, 1, 'omitnan') / sqrt(sum(~isnan(data(:,1)))); - yMax = max(yMax, max(meanAll{s} + semAll{s})); - yMin = min(yMin, min(meanAll{s} - semAll{s})); end -% Y limits with 10% padding yPad = (yMax - yMin) * 0.1; if params.zScore yLims = [yMin - yPad, yMax + yPad]; @@ -369,94 +368,90 @@ function plotPSTH_MultiExp(exList, params) end % ------------------------------------------------------------------ -% Single axis plot — all stim types overlaid +% Plot % ------------------------------------------------------------------ +fig = figure; +set(fig, 'Units', 'centimeters', 'Position', [5 5 9 10]); ax = axes(fig); hold(ax, 'on'); -legendHandles = gobjects(numel(params.stimTypes), 1); % store line handles for legend +legendHandles = []; +legendLabels = {}; for s = 1:numel(params.stimTypes) - data = psthAll{s}; - if isempty(data) - continue - end - validRows = ~all(isnan(data), 2); - data = data(validRows, :); - if isempty(data) - continue - end - - meanPSTH = meanAll{s}; - semPSTH = semAll{s}; - - % Get short legend label for this stim type stimKey = char(params.stimTypes(s)); if isKey(stimLegendMap, stimKey) - legendLabel = stimLegendMap(stimKey); + shortName = stimLegendMap(stimKey); else - legendLabel = stimKey; % fallback to full name if not in map + shortName = stimKey; end - % Shade ±SEM band - if params.shadeSTD && size(data, 1) > 1 - upper = meanPSTH + semPSTH; - lower = meanPSTH - semPSTH; - xFill = [tAxisPlot(:)', fliplr(tAxisPlot(:)')]; - yFill = [upper(:)', fliplr(lower(:)') ]; - fill(ax, xFill, yFill, colors(s,:), 'FaceAlpha', 0.2, 'EdgeColor', 'none'); - end + for b = 1:nDepthBins + + data = psthAll{s,b}; + if isempty(data); continue; end + validRows = ~all(isnan(data), 2); + data = data(validRows, :); + if isempty(data); continue; end + + meanPSTH = meanAll{s,b}; + semPSTH = semAll{s,b}; + + % Color and label depend on mode + if params.byDepth + lineColor = baseColors(s,:) * (1 - depthShades(b)); + legendLabel = sprintf('%s %s (%.0f-%.0f um)', ... + shortName, binLabels{b}, depthBinEdges(b), depthBinEdges(b+1)); + else + lineColor = baseColors(s,:); + legendLabel = shortName; + end + + % SEM shading + if params.shadeSTD && size(data,1) > 1 + upper = meanPSTH + semPSTH; + lower = meanPSTH - semPSTH; + xFill = [tAxisPlot(:)', fliplr(tAxisPlot(:)')]; + yFill = [upper(:)', fliplr(lower(:)') ]; + fill(ax, xFill, yFill, lineColor, 'FaceAlpha', 0.15, 'EdgeColor', 'none'); + end - % Mean PSTH line — store handle for legend - legendHandles(s) = plot(ax, tAxisPlot(:)', meanPSTH(:)', ... - 'Color', colors(s,:), 'LineWidth', 1.5, 'DisplayName', legendLabel); + % Mean line + h = plot(ax, tAxisPlot(:)', meanPSTH(:)', ... + 'Color', lineColor, 'LineWidth', 1.5); - % Number of contributing experiments as text - nValid = sum(validRows); - fprintf(' [%s] n=%d experiments in plot.\n', legendLabel, nValid); + legendHandles(end+1) = h; %#ok + legendLabels{end+1} = legendLabel; %#ok + fprintf(' [%s] n=%d experiments in plot.\n', legendLabel, sum(validRows)); + end end -% Stim onset and end of post-stim window xline(ax, 0, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); xline(ax, params.postStim, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); -% Y label -if params.zScore - yLabel = 'Z-score'; -else - yLabel = '[spk/s]'; -end +if params.zScore; yLabel = 'Z-score'; else; yLabel = '[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 — only show valid handles (skip stim types with no data) -validHandles = legendHandles(isgraphics(legendHandles)); -legend(validHandles, 'Location', 'northeast', 'FontName', 'helvetica', 'FontSize', 8); +legend(legendHandles, legendLabels, 'Location', 'northeast', ... + 'FontName', 'helvetica', 'FontSize', 7); -ax.FontName = 'helvetica'; -ax.FontSize = 8; -hold(ax, 'off'); - -sgtitle(sprintf('N = %d', numel(exList)), 'FontName', 'helvetica', 'FontSize', 11); - -ax = gca; +ax.FontName = 'helvetica'; +ax.FontSize = 8; ax.YAxis.FontSize = 8; -ax.YAxis.FontName = 'helvetica'; - -ax = gca; ax.XAxis.FontSize = 8; -ax.XAxis.FontName = 'helvetica'; +hold(ax, 'off'); -set(fig, 'Units', 'centimeters'); -set(fig, 'Position', [20 20 5 6]); +sgtitle(sprintf('N = %d', numel(exList)), 'FontName', 'helvetica', 'FontSize', 11); +set(fig, 'Units', 'centimeters', 'Position', [20 20 8 6]); if params.PaperFig - vs_first.printFig(fig, sprintf('PSTH-comparison-%s-%s', ... + vs_first.printFig(fig, sprintf('PSTH-depth-%s-%s', ... params.stimTypes(1), params.stimTypes(2)), PaperFig = params.PaperFig) end diff --git a/visualStimulationAnalysis/plotPSTH_MultiExpV1.m b/visualStimulationAnalysis/plotPSTH_MultiExpV1.m new file mode 100644 index 0000000..f9a404d --- /dev/null +++ b/visualStimulationAnalysis/plotPSTH_MultiExpV1.m @@ -0,0 +1,463 @@ +function plotPSTH_MultiExpV1(exList, params) + +arguments + exList double + params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] + params.bin double = 30 + params.binWidth double = 10 + params.statType string = "BootstrapPerNeuron" + params.speed string = "max" + params.alpha double = 0.05 + params.shadeSTD logical = true + params.postStim double = 500 % ms after stim onset to include + params.preBase double = 200 % ms of baseline before stim onset + params.overwrite logical = false % force recompute even if file exists + params.TakeTopPercentTrials double = 0.3 %Percentage of highest spiking rate trials to take to calculate PSTHs + params.zScore logical = false % normalize firing rate to z-score using baseline + params.PaperFig logical = false %Is this going to be used in the paper? +end + +% ------------------------------------------------------------------------- +% Build save path using first experiment to get the analysis folder +% This mirrors the convention used in PlotZScoreComparison +% ------------------------------------------------------------------------- + +% Load first experiment just to get the folder path +NP_first = loadNPclassFromTable(exList(1)); +vs_first = linearlyMovingBallAnalysis(NP_first); % used only for path + +% Build the save directory path +p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); +p = [p 'lizards']; +if ~exist([p '\Combined_lizard_analysis'], 'dir') + cd(p) + mkdir Combined_lizard_analysis +end +saveDir = [p '\Combined_lizard_analysis']; + +% Build filename — includes stim types so different comparisons don't clash +stimLabel = strjoin(params.stimTypes, '-'); % e.g. "rectGrid-linearlyMovingBall" +nameOfFile = sprintf('\\Ex_%d-%d_Combined_PSTHs_%s.mat', ... + exList(1), exList(end), stimLabel); + +% ------------------------------------------------------------------------- +% Decide whether to run the experiment loop or load from disk +% forloop = true → compute PSTHs from scratch +% forloop = false → load saved struct and skip to plotting +% ------------------------------------------------------------------------- +if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite + % File exists and overwrite is off — check if expList matches + S = load([saveDir nameOfFile]); + if isequal(S.expList, exList) + fprintf('Loading saved PSTHs from:\n %s\n', [saveDir nameOfFile]); + forloop = false; % skip computation, go straight to plot + else + fprintf('Experiment list mismatch — recomputing.\n'); + forloop = true; % expList changed, recompute + end +else + forloop = true; % file doesn't exist or overwrite requested +end + +% ========================================================================= +% EXPERIMENT LOOP — only runs if forloop is true +% ========================================================================= +if forloop + + nStim = numel(params.stimTypes); + nExp = numel(exList); + + % One cell per stim type, grows one row per experiment + psthAll = cell(1, nStim); + for s = 1:nStim + psthAll{s} = []; + end + + % Locked time window — set from first valid experiment + lockedPreBase = []; + lockedNBins = []; + lockedEdges = []; + + % ------------------------------------------------------------------ + % LOOP OVER EXPERIMENTS + % ------------------------------------------------------------------ + for ei = 1:nExp + + ex = exList(ei); + fprintf('\n=== Experiment %d ===\n', ex); + + % Load NP data for this experiment + try + NP = loadNPclassFromTable(ex); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + % Add NaN placeholder row if window is already locked + for s = 1:nStim + if ~isempty(psthAll{s}) + psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; + end + end + continue + end + + % -------------------------------------------------------------- + % LOOP OVER STIMULUS TYPES + % -------------------------------------------------------------- + for s = 1:nStim + + stimType = params.stimTypes(s); + + % Build analysis object for this stim type + try + switch stimType + case "rectGrid" + obj = rectGridAnalysis(NP); + case "linearlyMovingBall" + obj = linearlyMovingBallAnalysis(NP); + case 'StaticGrating' + obj = StaticDriftingGratingAnalysis(NP); + case 'MovingGrating' + obj = StaticDriftingGratingAnalysis(NP); + otherwise + error('Unknown stimType: %s', stimType); + end + catch ME + warning('Could not build %s for exp %d: %s', stimType, ex, ME.message); + if ~isempty(psthAll{s}) + psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; + end + continue + end + + % ---------------------------------------------------------- + % Extract data structures + % ---------------------------------------------------------- + + % ResponseWindow holds trial timing and spike data + NeuronResp = obj.ResponseWindow; + + % Stats struct for p-values + if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; + else + Stats = obj.ShufflingAnalysis; + end + + % Resolve speed field name + if params.speed ~= "max" && isequal(obj.stimName,'linearlyMovingBall') + fieldName = 'Speed2'; + startStim = 0; + elseif isequal(obj.stimName,'linearlyMovingBall') + fieldName = 'Speed1'; + startStim = 0; + elseif isequal(params.stimTypes,'StaticGrating') + fieldName = 'Static'; + startStim = 0; + + elseif isequal(params.stimTypes,'MovingGrating') + startStim = obj.VST.static_time*1000; + fieldName = 'Moving'; + else + startStim = 0; + end + + % Spike trains of somatic (good) units + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + label = string(p_sort.label'); + goodU = p_sort.ic(:, label == 'good'); + + % P-values for each unit + try + pvals = Stats.(fieldName).pvalsResponse; + catch + pvals = Stats.pvalsResponse; + end + + % Trial onset times in ms + try + C = NeuronResp.(fieldName).C; + catch + C = NeuronResp.C; + end + directimesSorted = C(:, 1)' + startStim; + + % Use params.preBase directly — no formula needed + preBase = params.preBase; + + % Total trial window = baseline + post-stim period + windowTotal = preBase + params.postStim; + + % Lock in time window from first valid experiment + if isempty(lockedPreBase) + lockedPreBase = preBase; + lockedEdges = 0 : params.binWidth : windowTotal; + lockedNBins = numel(lockedEdges) - 1; + tAxis = lockedEdges(1:end-1); + fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... + lockedPreBase, params.postStim, lockedNBins); + end + + % ---------------------------------------------------------- + % Find responsive neurons + % ---------------------------------------------------------- + eNeurons = find(pvals < params.alpha); + + if isempty(eNeurons) + fprintf(' [%s] No responsive neurons in exp %d.\n', stimType, ex); + if ~isempty(psthAll{s}) + psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; + end + continue + end + + fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', ... + stimType, ex, numel(eNeurons)); + + % ---------------------------------------------------------- + % Build PSTH for each responsive neuron + % BuildBurstMatrix returns nTrials x 1 x nTimeBins + % Window: from (trialOnset - preBase) for windowTotal ms + % ---------------------------------------------------------- + psthRateNeurons = zeros(numel(eNeurons), lockedNBins); + + for ni = 1:numel(eNeurons) + u = eNeurons(ni); + + % Spike matrix: rows = trials, cols = time bins (1ms each) + MRhist = BuildBurstMatrix( ... + goodU(:, u), ... + round(p_sort.t), ... + round(directimesSorted - lockedPreBase), ... + round(windowTotal)); + + + + % Remove singleton dimensions → nTrials x nTimeBins + MRhist = squeeze(MRhist); + + if ~isempty(params.TakeTopPercentTrials) + MeanTrial = mean(MRhist,2); + [~, ind] = sort(MeanTrial,'descend'); + + takeTrials = ind(1:round(numel(MeanTrial)*params.TakeTopPercentTrials)); + + MRhist = MRhist(takeTrials,:); + + end + nTrials = size(MRhist, 1); + + % Convert to spike times in ms + spikeTimes = repmat((1:size(MRhist, 2)), nTrials, 1); + spikeTimes = spikeTimes(logical(MRhist)); + + % Bin into locked edges and convert to spk/s + counts = histcounts(spikeTimes, lockedEdges); + psthRateNeurons(ni, :) = (counts / (params.binWidth * nTrials)) * 1000; + end + + % Average across responsive neurons → 1 x lockedNBins + psthExp = mean(psthRateNeurons, 1, 'omitnan'); + + if params.zScore + baselineBins = tAxis < lockedPreBase; + baselineMean = mean(psthExp(baselineBins)); + baselineStd = std(psthExp(baselineBins)); + if baselineStd > 0 + psthExp = (psthExp - baselineMean) / baselineStd; + else + warning(' [%s] Baseline std is zero for exp %d — skipping experiment.', stimType, ex); + if ~isempty(psthAll{s}) + psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; + end + continue % skip to next experiment, do not append raw rates + end + end + + % Append as new row — guaranteed lockedNBins wide + psthAll{s} = [psthAll{s}; psthExp(:)']; + + end % end stim loop + end % end experiment loop + + % ------------------------------------------------------------------ + % Save results to struct + % ------------------------------------------------------------------ + S.expList = exList; % experiment list for future matching + S.lockedEdges = lockedEdges; % bin edges used (ms from trial start) + S.lockedPreBase = lockedPreBase; % baseline duration in ms + S.params = params; % all parameters used + + % Save one field per stim type, named by stim e.g. S.rectGrid + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); % safe field name + S.(stimField) = psthAll{s}; % nExp x nBins PSTH matrix + end + + save([saveDir nameOfFile], '-struct', 'S'); + fprintf('\nSaved PSTHs to:\n %s\n', [saveDir nameOfFile]); + +else + % ------------------------------------------------------------------ + % Load psthAll from saved struct + % ------------------------------------------------------------------ + lockedEdges = S.lockedEdges; + lockedPreBase = S.lockedPreBase; + + psthAll = cell(1, numel(params.stimTypes)); + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + if isfield(S, stimField) + psthAll{s} = S.(stimField); % load the nExp x nBins matrix + else + % Stim type not found in saved file — warn and leave empty + warning('Stim type "%s" not found in saved file.', params.stimTypes(s)); + psthAll{s} = []; + end + end + +end % end forloop + +% ========================================================================= +% PLOT +% ========================================================================= + +tAxis = lockedEdges(1:end-1); +tAxisPlot = tAxis - lockedPreBase; + +colors = lines(numel(params.stimTypes)); + +fig = figure; +set(fig, 'Units', 'centimeters', 'Position', [5 5 9 10]); % single axis now + +% ------------------------------------------------------------------ +% Map stimulus type names to short legend labels +% ------------------------------------------------------------------ +stimLegendMap = containers.Map(... + {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... + {'MB', 'SB', 'MG', 'SG'}); + +% ------------------------------------------------------------------ +% First pass: compute mean/sem for all stim types and find global ylim +% ------------------------------------------------------------------ +meanAll = cell(1, numel(params.stimTypes)); +semAll = cell(1, numel(params.stimTypes)); +yMax = 0; +yMin = inf; + +for s = 1:numel(params.stimTypes) + data = psthAll{s}; + if isempty(data) + continue + end + validRows = ~all(isnan(data), 2); + data = data(validRows, :); + if isempty(data) + continue + end + meanAll{s} = mean(data, 1, 'omitnan'); + semAll{s} = std(data, 0, 1, 'omitnan') / sqrt(sum(~isnan(data(:,1)))); + yMax = max(yMax, max(meanAll{s} + semAll{s})); + yMin = min(yMin, min(meanAll{s} - semAll{s})); +end + +% Y limits with 10% padding +yPad = (yMax - yMin) * 0.1; +if params.zScore + yLims = [yMin - yPad, yMax + yPad]; +else + yLims = [max(0, yMin - yPad), yMax + yPad]; +end + +% ------------------------------------------------------------------ +% Single axis plot — all stim types overlaid +% ------------------------------------------------------------------ +ax = axes(fig); +hold(ax, 'on'); + +legendHandles = gobjects(numel(params.stimTypes), 1); % store line handles for legend + +for s = 1:numel(params.stimTypes) + + data = psthAll{s}; + if isempty(data) + continue + end + validRows = ~all(isnan(data), 2); + data = data(validRows, :); + if isempty(data) + continue + end + + meanPSTH = meanAll{s}; + semPSTH = semAll{s}; + + % Get short legend label for this stim type + stimKey = char(params.stimTypes(s)); + if isKey(stimLegendMap, stimKey) + legendLabel = stimLegendMap(stimKey); + else + legendLabel = stimKey; % fallback to full name if not in map + end + + % Shade ±SEM band + if params.shadeSTD && size(data, 1) > 1 + upper = meanPSTH + semPSTH; + lower = meanPSTH - semPSTH; + xFill = [tAxisPlot(:)', fliplr(tAxisPlot(:)')]; + yFill = [upper(:)', fliplr(lower(:)') ]; + fill(ax, xFill, yFill, colors(s,:), 'FaceAlpha', 0.2, 'EdgeColor', 'none'); + end + + % Mean PSTH line — store handle for legend + legendHandles(s) = plot(ax, tAxisPlot(:)', meanPSTH(:)', ... + 'Color', colors(s,:), 'LineWidth', 1.5, 'DisplayName', legendLabel); + + % Number of contributing experiments as text + nValid = sum(validRows); + fprintf(' [%s] n=%d experiments in plot.\n', legendLabel, nValid); + +end + +% Stim onset and end of post-stim window +xline(ax, 0, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); +xline(ax, params.postStim, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); + +% Y label +if params.zScore + yLabel = 'Z-score'; +else + yLabel = '[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 — only show valid handles (skip stim types with no data) +validHandles = legendHandles(isgraphics(legendHandles)); +legend(validHandles, 'Location', 'northeast', 'FontName', 'helvetica', 'FontSize', 8); + +ax.FontName = 'helvetica'; +ax.FontSize = 8; +hold(ax, 'off'); + +sgtitle(sprintf('N = %d', numel(exList)), 'FontName', 'helvetica', 'FontSize', 11); + +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 5 6]); + +if params.PaperFig + vs_first.printFig(fig, sprintf('PSTH-comparison-%s-%s', ... + params.stimTypes(1), params.stimTypes(2)), PaperFig = params.PaperFig) +end + +end \ No newline at end of file From 807f862bec81bdeb352570606c0867499d052338 Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Wed, 25 Mar 2026 01:40:55 +0200 Subject: [PATCH 07/19] updates o PSTH --- .../RunAnalysisClass.asv | 5 +- visualStimulationAnalysis/RunAnalysisClass.m | 5 +- .../plotPSTH_MultiExp.asv | 463 ++++++++++++++++++ visualStimulationAnalysis/plotPSTH_MultiExp.m | 15 +- .../plotPSTH_MultiExpV1.m | 2 + 5 files changed, 484 insertions(+), 6 deletions(-) create mode 100644 visualStimulationAnalysis/plotPSTH_MultiExp.asv diff --git a/visualStimulationAnalysis/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv index e674375..f372c34 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.asv +++ b/visualStimulationAnalysis/RunAnalysisClass.asv @@ -68,7 +68,10 @@ end VStimAnalysis.PlotZScoreComparison([49:54,64:97] ,{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR %% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false); +plotPSTH_MultiExp([49:54,64:72,84:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false, byDepth=true, smooth=50, stimTypes=["linearlyMovingBall"]); + +%% +plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false, byDepth=false); %% Calculate spatial tuning SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_ratio" ,overwrite=true, topPercent = 20) diff --git a/visualStimulationAnalysis/RunAnalysisClass.m b/visualStimulationAnalysis/RunAnalysisClass.m index 020bac3..f372c34 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -68,7 +68,10 @@ VStimAnalysis.PlotZScoreComparison([49:54,64:97] ,{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR %% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:72,84:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false, byDepth=true); +plotPSTH_MultiExp([49:54,64:72,84:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false, byDepth=true, smooth=50, stimTypes=["linearlyMovingBall"]); + +%% +plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false, byDepth=false); %% Calculate spatial tuning SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_ratio" ,overwrite=true, topPercent = 20) diff --git a/visualStimulationAnalysis/plotPSTH_MultiExp.asv b/visualStimulationAnalysis/plotPSTH_MultiExp.asv new file mode 100644 index 0000000..58b8e5d --- /dev/null +++ b/visualStimulationAnalysis/plotPSTH_MultiExp.asv @@ -0,0 +1,463 @@ +function plotPSTH_MultiExp(exList, params) + +arguments + exList double + params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] + params.bin double = 30 + params.binWidth double = 10 + params.statType string = "BootstrapPerNeuron" + params.speed string = "max" + params.alpha double = 0.05 + params.shadeSTD logical = true + params.postStim double = 500 + params.preBase double = 200 + params.overwrite logical = false + params.TakeTopPercentTrials double = 0.3 + params.zScore logical = false + params.PaperFig logical = false + params.byDepth logical = false +end + +% ------------------------------------------------------------------------- +% Load depth info (only if byDepth 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 um\n', depthBinEdges(1), depthBinEdges(2)); + fprintf(' Bin 2 (middle) : %.0f - %.0f um\n', depthBinEdges(2), depthBinEdges(3)); + fprintf(' Bin 3 (deep) : %.0f - %.0f um\n', depthBinEdges(3), depthBinEdges(4)); +else + nDepthBins = 1; +end + +% ------------------------------------------------------------------------- +% Build save path +% ------------------------------------------------------------------------- +NP_first = loadNPclassFromTable(exList(1)); +vs_first = linearlyMovingBallAnalysis(NP_first); + +p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); +p = [p 'lizards']; +if ~exist([p '\Combined_lizard_analysis'], 'dir') + cd(p) + mkdir Combined_lizard_analysis +end +saveDir = [p '\Combined_lizard_analysis']; + +stimLabel = strjoin(params.stimTypes, '-'); +depthSuffix = ''; +if params.byDepth; depthSuffix = '_byDepth'; end +nameOfFile = sprintf('\\Ex_%d-%d_Combined_PSTHs_%s%s.mat', ... + exList(1), exList(end), stimLabel, depthSuffix); + +% ------------------------------------------------------------------------- +% Decide whether to recompute or load +% ------------------------------------------------------------------------- +if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite + S = load([saveDir nameOfFile]); + if isequal(S.expList, exList) + fprintf('Loading saved PSTHs from:\n %s\n', [saveDir nameOfFile]); + forloop = false; + else + fprintf('Experiment list mismatch — recomputing.\n'); + forloop = true; + end +else + forloop = true; +end + +% ========================================================================= +% EXPERIMENT LOOP +% ========================================================================= +if forloop + + nStim = numel(params.stimTypes); + nExp = numel(exList); + + psthAll = cell(nStim, nDepthBins); + + lockedPreBase = []; + lockedNBins = []; + lockedEdges = []; + tAxis = []; % FIX 3: initialise here so it is always defined + + for ei = 1:nExp + + ex = exList(ei); + fprintf('\n=== Experiment %d ===\n', ex); + + try + NP = loadNPclassFromTable(ex); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + % FIX 4: only append NaN rows if window is already locked + if ~isempty(lockedNBins) + for s = 1:nStim + for b = 1:nDepthBins + if ~isempty(psthAll{s,b}) + psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; + end + end + end + end + continue + end + + for s = 1:nStim + + stimType = params.stimTypes(s); + + try + switch stimType + case "rectGrid" + obj = rectGridAnalysis(NP); + case "linearlyMovingBall" + obj = linearlyMovingBallAnalysis(NP); + case "StaticGrating" + obj = StaticDriftingGratingAnalysis(NP); + case "MovingGrating" + obj = StaticDriftingGratingAnalysis(NP); + otherwise + error('Unknown stimType: %s', stimType); + end + catch ME + warning('Could not build %s for exp %d: %s', stimType, ex, ME.message); + if ~isempty(lockedNBins) + for b = 1:nDepthBins + if ~isempty(psthAll{s,b}) + psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; + end + end + end + continue + end + + NeuronResp = obj.ResponseWindow; + + if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; + else + Stats = obj.ShufflingAnalysis; + end + + % FIX 1+2: initialise fieldName and use stimType (loop var) not params.stimTypes + fieldName = ''; + startStim = 0; + if params.speed ~= "max" && isequal(obj.stimName, 'linearlyMovingBall') + fieldName = 'Speed2'; + elseif isequal(obj.stimName, 'linearlyMovingBall') + fieldName = 'Speed1'; + elseif isequal(stimType, 'StaticGrating') % FIX 2 + fieldName = 'Static'; + elseif isequal(stimType, 'MovingGrating') % FIX 2 + fieldName = 'Moving'; + startStim = obj.VST.static_time * 1000; + end + + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + label = string(p_sort.label'); + goodU = p_sort.ic(:, label == 'good'); + + % Use fieldName if set, otherwise fall back to top-level fields + try + pvals = Stats.(fieldName).pvalsResponse; + catch + pvals = Stats.pvalsResponse; + end + + try + C = NeuronResp.(fieldName).C; + catch + C = NeuronResp.C; + end + directimesSorted = C(:, 1)' + startStim; + + preBase = params.preBase; + windowTotal = preBase + params.postStim; + + if isempty(lockedPreBase) + lockedPreBase = preBase; + lockedEdges = 0 : params.binWidth : windowTotal; + lockedNBins = numel(lockedEdges) - 1; + tAxis = lockedEdges(1:end-1); % FIX 3: set alongside lockedEdges + fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... + lockedPreBase, params.postStim, lockedNBins); + end + + eNeurons = find(pvals < params.alpha); + + if isempty(eNeurons) + fprintf(' [%s] No responsive neurons in exp %d.\n', stimType, ex); + for b = 1:nDepthBins + if ~isempty(psthAll{s,b}) + psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; + end + end + continue + end + + fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', stimType, numel(eNeurons), ex); + + % ---------------------------------------------------------- + % Build PSTH per neuron + % ---------------------------------------------------------- + psthRateNeurons = zeros(numel(eNeurons), lockedNBins); + 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; % unknown — will be skipped + 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 + + MRhist = BuildBurstMatrix( ... + goodU(:, u), ... + round(p_sort.t), ... + round(directimesSorted - lockedPreBase), ... + round(windowTotal)); + MRhist = squeeze(MRhist); + + if ~isempty(params.TakeTopPercentTrials) + MeanTrial = mean(MRhist, 2); + [~, ind] = sort(MeanTrial, 'descend'); + takeTrials = ind(1:round(numel(MeanTrial)*params.TakeTopPercentTrials)); + MRhist = MRhist(takeTrials, :); + end + + nTrials = size(MRhist, 1); + spikeTimes = repmat((1:size(MRhist,2)), nTrials, 1); + spikeTimes = spikeTimes(logical(MRhist)); + counts = histcounts(spikeTimes, lockedEdges); + psthRateNeurons(ni, :) = (counts / (params.binWidth * nTrials)) * 1000; + end + + % ---------------------------------------------------------- + % Average per depth bin and append + % ---------------------------------------------------------- + for b = 1:nDepthBins + binNeurons = neuronBinIdx == b; + if ~any(binNeurons) + fprintf(' [%s] No neurons in depth bin %d for exp %d.\n', stimType, b, ex); + if ~isempty(psthAll{s,b}) + psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; + end + continue + end + + psthExp = mean(psthRateNeurons(binNeurons, :), 1, 'omitnan'); + + if params.zScore + baselineBins = tAxis < lockedPreBase; + baselineMean = mean(psthExp(baselineBins)); + baselineStd = std(psthExp(baselineBins)); + if baselineStd > 0 + psthExp = (psthExp - baselineMean) / baselineStd; + else + warning(' [%s] Bin %d: baseline std is zero for exp %d.', stimType, b, ex); + if ~isempty(psthAll{s,b}) + psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; + end + continue + end + end + + psthAll{s,b} = [psthAll{s,b}; psthExp(:)']; + fprintf(' [%s] Bin %d: %d neuron(s) in exp %d.\n', stimType, b, sum(binNeurons), ex); + end + + end % stim loop + end % experiment loop + + % ------------------------------------------------------------------ + % Save + % ------------------------------------------------------------------ + S.expList = exList; + S.lockedEdges = lockedEdges; + S.lockedPreBase = lockedPreBase; + S.params = params; + + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + for b = 1:nDepthBins + S.(sprintf('%s_bin%d', stimField, b)) = psthAll{s,b}; + end + end + + save([saveDir nameOfFile], '-struct', 'S'); + fprintf('\nSaved PSTHs to:\n %s\n', [saveDir nameOfFile]); + +else + % Load psthAll from disk + lockedEdges = S.lockedEdges; + lockedPreBase = S.lockedPreBase; + + psthAll = cell(numel(params.stimTypes), nDepthBins); + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + for b = 1:nDepthBins + fieldKey = sprintf('%s_bin%d', stimField, b); + if isfield(S, fieldKey) + psthAll{s,b} = S.(fieldKey); + else + warning('Field "%s" not found in saved file.', fieldKey); + psthAll{s,b} = []; + end + end + end +end + +% ========================================================================= +% PLOT +% ========================================================================= + +tAxis = lockedEdges(1:end-1); +tAxisPlot = tAxis - lockedPreBase; + +baseColors = lines(numel(params.stimTypes)); +depthShades = [0.6, 0.35, 0.1]; % light → dark for shallow → deep +binLabels = {'shallow', 'middle', 'deep'}; + +stimLegendMap = containers.Map(... + {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... + {'MB', 'SB', 'MG', 'SG'}); + +% ------------------------------------------------------------------ +% First pass: global ylim +% ------------------------------------------------------------------ +yMax = 0; +yMin = inf; + +meanAll = cell(numel(params.stimTypes), nDepthBins); +semAll = cell(numel(params.stimTypes), nDepthBins); + +for s = 1:numel(params.stimTypes) + for b = 1:nDepthBins + data = psthAll{s,b}; + if isempty(data); continue; end + validRows = ~all(isnan(data), 2); + data = data(validRows, :); + if isempty(data); continue; end + meanAll{s,b} = mean(data, 1, 'omitnan'); + semAll{s,b} = std(data, 0, 1, 'omitnan') / sqrt(sum(~isnan(data(:,1)))); + yMax = max(yMax, max(meanAll{s,b} + semAll{s,b})); + yMin = min(yMin, min(meanAll{s,b} - semAll{s,b})); + end +end + +yPad = (yMax - yMin) * 0.1; +if params.zScore + yLims = [yMin - yPad, yMax + yPad]; +else + yLims = [max(0, yMin - yPad), yMax + yPad]; +end + +% ------------------------------------------------------------------ +% Plot +% ------------------------------------------------------------------ +fig = figure; +set(fig, 'Units', 'centimeters', 'Position', [5 5 9 10]); +ax = axes(fig); +hold(ax, 'on'); + +legendHandles = []; +legendLabels = {}; + +for s = 1:numel(params.stimTypes) + + stimKey = char(params.stimTypes(s)); + if isKey(stimLegendMap, stimKey) + shortName = stimLegendMap(stimKey); + else + shortName = stimKey; + end + + for b = 1:nDepthBins + + data = psthAll{s,b}; + if isempty(data); continue; end + validRows = ~all(isnan(data), 2); + data = data(validRows, :); + if isempty(data); continue; end + + meanPSTH = meanAll{s,b}; + semPSTH = semAll{s,b}; + + if params.byDepth + lineColor = baseColors(s,:) * (1 - depthShades(b)); + legendLabel = sprintf('%s %s (%.0f-%.0f um)', ... + shortName, binLabels{b}, depthBinEdges(b), depthBinEdges(b+1)); + else + lineColor = baseColors(s,:); + legendLabel = shortName; + end + + if params.shadeSTD && size(data,1) > 1 + upper = meanPSTH + semPSTH; + lower = meanPSTH - semPSTH; + xFill = [tAxisPlot(:)', fliplr(tAxisPlot(:)')]; + yFill = [upper(:)', fliplr(lower(:)') ]; + fill(ax, xFill, yFill, lineColor, 'FaceAlpha', 0.15, 'EdgeColor', 'none'); + end + + h = plot(ax, tAxisPlot(:)', meanPSTH(:)', ... + 'Color', lineColor, 'LineWidth', 1.5); + + legendHandles(end+1) = h; %#ok + legendLabels{end+1} = legendLabel; %#ok + + fprintf(' [%s] n=%d experiments in plot.\n', legendLabel, sum(validRows)); + end +end + +xline(ax, 0, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); +xline(ax, params.postStim, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); + +if params.zScore; yLabel = 'Z-score'; else; yLabel = '[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', 'northeast', ... + 'FontName', 'helvetica', 'FontSize', 7); + +ax.FontName = 'helvetica'; +ax.FontSize = 8; +ax.YAxis.FontSize = 8; +ax.XAxis.FontSize = 8; +hold(ax, 'off'); + +sgtitle(sprintf('N = %d', numel(exList)), 'FontName', 'helvetica', 'FontSize', 11); +set(fig, 'Units', 'centimeters', 'Position', [20 20 8 6]); + +if params.PaperFig + vs_first.printFig(fig, sprintf('PSTH-depth-%s-%s', ... + params.stimTypes(1), params.stimTypes(2)), PaperFig = params.PaperFig) +end + +end \ No newline at end of file diff --git a/visualStimulationAnalysis/plotPSTH_MultiExp.m b/visualStimulationAnalysis/plotPSTH_MultiExp.m index ec92d0d..dc0c682 100644 --- a/visualStimulationAnalysis/plotPSTH_MultiExp.m +++ b/visualStimulationAnalysis/plotPSTH_MultiExp.m @@ -3,13 +3,13 @@ function plotPSTH_MultiExp(exList, params) arguments exList double params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] - params.bin double = 30 params.binWidth double = 10 + params.smooth double = 0 % smoothing window in ms (0 = no smoothing) params.statType string = "BootstrapPerNeuron" params.speed string = "max" params.alpha double = 0.05 params.shadeSTD logical = true - params.postStim double = 500 + params.postStim double = 2000 params.preBase double = 200 params.overwrite logical = false params.TakeTopPercentTrials double = 0.3 @@ -330,7 +330,7 @@ function plotPSTH_MultiExp(exList, params) tAxisPlot = tAxis - lockedPreBase; baseColors = lines(numel(params.stimTypes)); -depthShades = [0.6, 0.35, 0.1]; % light → dark for shallow → deep +depthShades = [0.05, 0.45, 0.78]; % light → dark for shallow → deep binLabels = {'shallow', 'middle', 'deep'}; stimLegendMap = containers.Map(... @@ -398,6 +398,13 @@ function plotPSTH_MultiExp(exList, params) meanPSTH = meanAll{s,b}; semPSTH = semAll{s,b}; + % Smooth if requested + if params.smooth > 0 + smoothBins = round(params.smooth / params.binWidth); % convert ms to bins + meanPSTH = smoothdata(meanPSTH, 'gaussian', smoothBins); + semPSTH = smoothdata(semPSTH, 'gaussian', smoothBins); + end + % Color and label depend on mode if params.byDepth lineColor = baseColors(s,:) * (1 - depthShades(b)); @@ -414,7 +421,7 @@ function plotPSTH_MultiExp(exList, params) lower = meanPSTH - semPSTH; xFill = [tAxisPlot(:)', fliplr(tAxisPlot(:)')]; yFill = [upper(:)', fliplr(lower(:)') ]; - fill(ax, xFill, yFill, lineColor, 'FaceAlpha', 0.15, 'EdgeColor', 'none'); + fill(ax, xFill, yFill, lineColor, 'FaceAlpha', 0.08, 'EdgeColor', 'none'); end % Mean line diff --git a/visualStimulationAnalysis/plotPSTH_MultiExpV1.m b/visualStimulationAnalysis/plotPSTH_MultiExpV1.m index f9a404d..e9270cd 100644 --- a/visualStimulationAnalysis/plotPSTH_MultiExpV1.m +++ b/visualStimulationAnalysis/plotPSTH_MultiExpV1.m @@ -5,6 +5,7 @@ function plotPSTH_MultiExpV1(exList, params) params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] params.bin double = 30 params.binWidth double = 10 + params.smooth double = 0 % smoothing window in ms (0 = no smoothing) params.statType string = "BootstrapPerNeuron" params.speed string = "max" params.alpha double = 0.05 @@ -391,6 +392,7 @@ function plotPSTH_MultiExpV1(exList, params) meanPSTH = meanAll{s}; semPSTH = semAll{s}; + % Get short legend label for this stim type stimKey = char(params.stimTypes(s)); if isKey(stimLegendMap, stimKey) From 68bbe596c2159486b4160c5b0e8f14edcad08d52 Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Thu, 26 Mar 2026 01:25:38 +0200 Subject: [PATCH 08/19] Adding general raster --- .../RunAnalysisClass.asv | 6 +- visualStimulationAnalysis/RunAnalysisClass.m | 6 +- visualStimulationAnalysis/getNeuronDepths.m | 2 +- .../plotPSTH_MultiExp.asv | 80 +-- visualStimulationAnalysis/plotPSTH_MultiExp.m | 2 +- .../plotRaster_MultiExp.asv | 448 +++++++++++++++++ .../plotRaster_MultiExp.m | 471 ++++++++++++++++++ 7 files changed, 968 insertions(+), 47 deletions(-) create mode 100644 visualStimulationAnalysis/plotRaster_MultiExp.asv create mode 100644 visualStimulationAnalysis/plotRaster_MultiExp.m diff --git a/visualStimulationAnalysis/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv index f372c34..10bb840 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.asv +++ b/visualStimulationAnalysis/RunAnalysisClass.asv @@ -68,16 +68,16 @@ end VStimAnalysis.PlotZScoreComparison([49:54,64:97] ,{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR %% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:72,84:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false, byDepth=true, smooth=50, stimTypes=["linearlyMovingBall"]); +plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=true, smooth=50); %stimTypes=["linearlyMovingBall"] %% -plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false, byDepth=false); +plotRaster_MultiExp([49:54,64:97], sortBy = "depth",overwrite=false,TakeTopPercentTrials=[]) %% Calculate spatial tuning SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_ratio" ,overwrite=true, topPercent = 20) %% Get neuron depths -getNeuronDepths([49:54,64:72,84:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates +getNeuronDepths([49:54,64:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates %% Gratings for ex = [54 84:90] diff --git a/visualStimulationAnalysis/RunAnalysisClass.m b/visualStimulationAnalysis/RunAnalysisClass.m index f372c34..8be8cca 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -68,16 +68,16 @@ VStimAnalysis.PlotZScoreComparison([49:54,64:97] ,{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR %% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:72,84:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false, byDepth=true, smooth=50, stimTypes=["linearlyMovingBall"]); +plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=true, smooth=50); %stimTypes=["linearlyMovingBall"] %% -plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=false, byDepth=false); +plotRaster_MultiExp([49:54,64:97], sortBy = "peak",overwrite=false,TakeTopPercentTrials=[]) %% Calculate spatial tuning SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_ratio" ,overwrite=true, topPercent = 20) %% Get neuron depths -getNeuronDepths([49:54,64:72,84:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates +getNeuronDepths([49:54,64:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates %% Gratings for ex = [54 84:90] diff --git a/visualStimulationAnalysis/getNeuronDepths.m b/visualStimulationAnalysis/getNeuronDepths.m index 35f98bd..b653851 100644 --- a/visualStimulationAnalysis/getNeuronDepths.m +++ b/visualStimulationAnalysis/getNeuronDepths.m @@ -57,7 +57,7 @@ % Channel IDs (0-based) → Y positions → real depths channelIDs = goodU(1, :); % 1 x nGoodUnits, 0-based - yPos = NP.chLayoutPositions(2, channelIDs + 1); % 1 x nGoodUnits + yPos = NP.chLayoutPositions(2, channelIDs); % 1 x nGoodUnits neuronDepths = coor_Z - yPos; % 1 x nGoodUnits, in um % Accumulate table columns diff --git a/visualStimulationAnalysis/plotPSTH_MultiExp.asv b/visualStimulationAnalysis/plotPSTH_MultiExp.asv index 58b8e5d..6b6bc02 100644 --- a/visualStimulationAnalysis/plotPSTH_MultiExp.asv +++ b/visualStimulationAnalysis/plotPSTH_MultiExp.asv @@ -3,8 +3,8 @@ function plotPSTH_MultiExp(exList, params) arguments exList double params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] - params.bin double = 30 params.binWidth double = 10 + params.smooth double = 0 % smoothing window in ms (0 = no smoothing) params.statType string = "BootstrapPerNeuron" params.speed string = "max" params.alpha double = 0.05 @@ -15,11 +15,11 @@ arguments params.TakeTopPercentTrials double = 0.3 params.zScore logical = false params.PaperFig logical = false - params.byDepth logical = false + params.byDepth logical = false % plot 3 depth bins per stim type end % ------------------------------------------------------------------------- -% Load depth info (only if byDepth requested) +% Load depth info from saved file (only if byDepth is requested) % ------------------------------------------------------------------------- if params.byDepth depthFile = 'W:\Large_scale_mapping_NP\lizards\Combined_lizard_analysis\NeuronDepths.mat'; @@ -82,12 +82,12 @@ if forloop nStim = numel(params.stimTypes); nExp = numel(exList); + % psthAll{s,b} — s = stim type, b = depth bin (1 if byDepth is off) psthAll = cell(nStim, nDepthBins); lockedPreBase = []; lockedNBins = []; lockedEdges = []; - tAxis = []; % FIX 3: initialise here so it is always defined for ei = 1:nExp @@ -98,13 +98,10 @@ if forloop NP = loadNPclassFromTable(ex); catch ME warning('Could not load experiment %d: %s', ex, ME.message); - % FIX 4: only append NaN rows if window is already locked - if ~isempty(lockedNBins) - for s = 1:nStim - for b = 1:nDepthBins - if ~isempty(psthAll{s,b}) - psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; - end + for s = 1:nStim + for b = 1:nDepthBins + if ~isempty(psthAll{s,b}) + psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; end end end @@ -121,20 +118,18 @@ if forloop obj = rectGridAnalysis(NP); case "linearlyMovingBall" obj = linearlyMovingBallAnalysis(NP); - case "StaticGrating" + case 'StaticGrating' obj = StaticDriftingGratingAnalysis(NP); - case "MovingGrating" + case 'MovingGrating' obj = StaticDriftingGratingAnalysis(NP); otherwise error('Unknown stimType: %s', stimType); end catch ME warning('Could not build %s for exp %d: %s', stimType, ex, ME.message); - if ~isempty(lockedNBins) - for b = 1:nDepthBins - if ~isempty(psthAll{s,b}) - psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; - end + for b = 1:nDepthBins + if ~isempty(psthAll{s,b}) + psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; end end continue @@ -148,25 +143,22 @@ if forloop Stats = obj.ShufflingAnalysis; end - % FIX 1+2: initialise fieldName and use stimType (loop var) not params.stimTypes - fieldName = ''; - startStim = 0; - if params.speed ~= "max" && isequal(obj.stimName, 'linearlyMovingBall') - fieldName = 'Speed2'; - elseif isequal(obj.stimName, 'linearlyMovingBall') - fieldName = 'Speed1'; - elseif isequal(stimType, 'StaticGrating') % FIX 2 - fieldName = 'Static'; - elseif isequal(stimType, 'MovingGrating') % FIX 2 - fieldName = 'Moving'; - startStim = obj.VST.static_time * 1000; + if params.speed ~= "max" && isequal(obj.stimName,'linearlyMovingBall') + fieldName = 'Speed2'; startStim = 0; + elseif isequal(obj.stimName,'linearlyMovingBall') + fieldName = 'Speed1'; startStim = 0; + elseif isequal(params.stimTypes,'StaticGrating') + fieldName = 'Static'; startStim = 0; + elseif isequal(params.stimTypes,'MovingGrating') + startStim = obj.VST.static_time*1000; fieldName = 'Moving'; + else + startStim = 0; end - p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder,1,1); label = string(p_sort.label'); goodU = p_sort.ic(:, label == 'good'); - % Use fieldName if set, otherwise fall back to top-level fields try pvals = Stats.(fieldName).pvalsResponse; catch @@ -187,7 +179,7 @@ if forloop lockedPreBase = preBase; lockedEdges = 0 : params.binWidth : windowTotal; lockedNBins = numel(lockedEdges) - 1; - tAxis = lockedEdges(1:end-1); % FIX 3: set alongside lockedEdges + tAxis = lockedEdges(1:end-1); fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... lockedPreBase, params.postStim, lockedNBins); end @@ -204,7 +196,7 @@ if forloop continue end - fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', stimType, numel(eNeurons), ex); + fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', stimType, ex, numel(eNeurons)); % ---------------------------------------------------------- % Build PSTH per neuron @@ -219,7 +211,7 @@ if forloop if params.byDepth depthRow = depthTable.Experiment == ex & depthTable.Unit == u; if ~any(depthRow) - neuronBinIdx(ni) = 0; % unknown — will be skipped + neuronBinIdx(ni) = 0; % unknown depth — skip continue end unitDepth = depthTable.Depth_um(depthRow); @@ -231,7 +223,7 @@ if forloop neuronBinIdx(ni) = 3; end else - neuronBinIdx(ni) = 1; + neuronBinIdx(ni) = 1; % all neurons in single bin end MRhist = BuildBurstMatrix( ... @@ -338,7 +330,7 @@ tAxis = lockedEdges(1:end-1); tAxisPlot = tAxis - lockedPreBase; baseColors = lines(numel(params.stimTypes)); -depthShades = [0.6, 0.35, 0.1]; % light → dark for shallow → deep +depthShades = [0.05, 0.45, 0.78]; % light → dark for shallow → deep binLabels = {'shallow', 'middle', 'deep'}; stimLegendMap = containers.Map(... @@ -406,6 +398,14 @@ for s = 1:numel(params.stimTypes) meanPSTH = meanAll{s,b}; semPSTH = semAll{s,b}; + % Smooth if requested + if params.smooth > 0 + smoothBins = round(params.smooth / params.binWidth); % convert ms to bins + meanPSTH = smoothdata(meanPSTH, 'gaussian', smoothBins); + semPSTH = smoothdata(semPSTH, 'gaussian', smoothBins); + end + + % Color and label depend on mode if params.byDepth lineColor = baseColors(s,:) * (1 - depthShades(b)); legendLabel = sprintf('%s %s (%.0f-%.0f um)', ... @@ -415,18 +415,20 @@ for s = 1:numel(params.stimTypes) legendLabel = shortName; end + % SEM shading if params.shadeSTD && size(data,1) > 1 upper = meanPSTH + semPSTH; lower = meanPSTH - semPSTH; xFill = [tAxisPlot(:)', fliplr(tAxisPlot(:)')]; yFill = [upper(:)', fliplr(lower(:)') ]; - fill(ax, xFill, yFill, lineColor, 'FaceAlpha', 0.15, 'EdgeColor', 'none'); + fill(ax, xFill, yFill, lineColor, 'FaceAlpha', 0.08, 'EdgeColor', 'none'); end + % Mean line h = plot(ax, tAxisPlot(:)', meanPSTH(:)', ... 'Color', lineColor, 'LineWidth', 1.5); - legendHandles(end+1) = h; %#ok + legendHandles(end+1) = h; %#ok legendLabels{end+1} = legendLabel; %#ok fprintf(' [%s] n=%d experiments in plot.\n', legendLabel, sum(validRows)); diff --git a/visualStimulationAnalysis/plotPSTH_MultiExp.m b/visualStimulationAnalysis/plotPSTH_MultiExp.m index dc0c682..e8ecb3a 100644 --- a/visualStimulationAnalysis/plotPSTH_MultiExp.m +++ b/visualStimulationAnalysis/plotPSTH_MultiExp.m @@ -9,7 +9,7 @@ function plotPSTH_MultiExp(exList, params) params.speed string = "max" params.alpha double = 0.05 params.shadeSTD logical = true - params.postStim double = 2000 + params.postStim double = 500 params.preBase double = 200 params.overwrite logical = false params.TakeTopPercentTrials double = 0.3 diff --git a/visualStimulationAnalysis/plotRaster_MultiExp.asv b/visualStimulationAnalysis/plotRaster_MultiExp.asv new file mode 100644 index 0000000..3623ccb --- /dev/null +++ b/visualStimulationAnalysis/plotRaster_MultiExp.asv @@ -0,0 +1,448 @@ +function plotRaster_MultiExp(exList, params) + +arguments + exList double + params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] + params.binWidth double = 10 + params.smooth double = 0 + params.statType string = "BootstrapPerNeuron" + params.speed string = "max" + params.alpha double = 0.05 + params.postStim double = 500 + params.preBase double = 200 + params.overwrite logical = false + params.TakeTopPercentTrials double = 0.3 + params.zScore logical = true % default true — more meaningful for raster + params.sortBy string = "peak" % "peak" = sort by peak response time, "depth" = sort by depth + params.PaperFig logical = false +end + +% ------------------------------------------------------------------------- +% Load depth info if sorting by depth +% ------------------------------------------------------------------------- +if params.sortBy == "depth" + 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; +end + +% ------------------------------------------------------------------------- +% Build save path +% ------------------------------------------------------------------------- +NP_first = loadNPclassFromTable(exList(1)); +vs_first = linearlyMovingBallAnalysis(NP_first); + +p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); +p = [p 'lizards']; +if ~exist([p '\Combined_lizard_analysis'], 'dir') + cd(p) + mkdir Combined_lizard_analysis +end +saveDir = [p '\Combined_lizard_analysis']; + +stimLabel = strjoin(params.stimTypes, '-'); +nameOfFile = sprintf('\\Ex_%d-%d_Raster_%s.mat', exList(1), exList(end), stimLabel); + +% ------------------------------------------------------------------------- +% Decide whether to recompute or load +% ------------------------------------------------------------------------- +if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite + S = load([saveDir nameOfFile]); + if isequal(S.expList, exList) + fprintf('Loading saved raster data from:\n %s\n', [saveDir nameOfFile]); + forloop = false; + else + fprintf('Experiment list mismatch — recomputing.\n'); + forloop = true; + end +else + forloop = true; +end + +% ========================================================================= +% EXPERIMENT LOOP +% ========================================================================= +if forloop + + nStim = numel(params.stimTypes); + nExp = numel(exList); + + % rasterAll{s} grows one row per responsive neuron across all experiments + % each row = mean PSTH of one neuron in spk/s (or z-score) + rasterAll = cell(1, nStim); % nNeurons x nBins + depthAll = cell(1, nStim); % nNeurons x 1 — depth of each neuron + expAll = cell(1, nStim); % nNeurons x 1 — which experiment each neuron came from + + for s = 1:nStim + rasterAll{s} = []; + depthAll{s} = []; + expAll{s} = []; + end + + lockedPreBase = []; + lockedNBins = []; + lockedEdges = []; + tAxis = []; + + for ei = 1:nExp + + ex = exList(ei); + fprintf('\n=== Experiment %d ===\n', ex); + + try + NP = loadNPclassFromTable(ex); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + continue + end + + for s = 1:nStim + + stimType = params.stimTypes(s); + + try + switch stimType + case "rectGrid" + obj = rectGridAnalysis(NP); + case "linearlyMovingBall" + obj = linearlyMovingBallAnalysis(NP); + case "StaticGrating" + obj = StaticDriftingGratingAnalysis(NP); + case "MovingGrating" + obj = StaticDriftingGratingAnalysis(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 + + NeuronResp = obj.ResponseWindow; + + if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; + else + Stats = obj.ShufflingAnalysis; + end + + % Resolve field name and stim start + fieldName = ''; + startStim = 0; + if params.speed ~= "max" && isequal(obj.stimName, 'linearlyMovingBall') + fieldName = 'Speed2'; + elseif isequal(obj.stimName, 'linearlyMovingBall') + fieldName = 'Speed1'; + elseif isequal(stimType, 'StaticGrating') + fieldName = 'Static'; + elseif isequal(stimType, 'MovingGrating') + fieldName = 'Moving'; + startStim = obj.VST.static_time * 1000; + end + + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + label = string(p_sort.label'); + goodU = p_sort.ic(:, label == 'good'); + + try + pvals = Stats.(fieldName).pvalsResponse; + catch + pvals = Stats.pvalsResponse; + end + + try + C = NeuronResp.(fieldName).C; + catch + C = NeuronResp.C; + end + directimesSorted = C(:, 1)' + startStim; + + preBase = params.preBase; + windowTotal = preBase + params.postStim; + + if isempty(lockedPreBase) + lockedPreBase = preBase; + lockedEdges = 0 : params.binWidth : windowTotal; + lockedNBins = numel(lockedEdges) - 1; + tAxis = lockedEdges(1:end-1); + fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... + lockedPreBase, params.postStim, lockedNBins); + end + + eNeurons = find(pvals < params.alpha); + + 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); + + % ---------------------------------------------------------- + % Build per-neuron PSTH + % ---------------------------------------------------------- + for ni = 1:numel(eNeurons) + u = eNeurons(ni); + + MRhist = BuildBurstMatrix( ... + goodU(:, u), ... + round(p_sort.t), ... + round(directimesSorted - lockedPreBase), ... + round(windowTotal)); + MRhist = squeeze(MRhist); + + if ~isempty(params.TakeTopPercentTrials) + MeanTrial = mean(MRhist, 2); + [~, ind] = sort(MeanTrial, 'descend'); + takeTrials = ind(1:round(numel(MeanTrial)*params.TakeTopPercentTrials)); + MRhist = MRhist(takeTrials, :); + end + + nTrials = size(MRhist, 1); + spikeTimes = repmat((1:size(MRhist,2)), nTrials, 1); + spikeTimes = spikeTimes(logical(MRhist)); + counts = histcounts(spikeTimes, lockedEdges); + neuronPSTH = (counts / (params.binWidth * nTrials)) * 1000; % spk/s + + % Z-score using baseline + if params.zScore + baselineBins = tAxis < lockedPreBase; + bMean = mean(neuronPSTH(baselineBins)); + bStd = std(neuronPSTH(baselineBins)); + if bStd > 0 + neuronPSTH = (neuronPSTH - bMean) / bStd; + else + continue % skip neuron if baseline std is zero + end + end + + % Smooth if requested + if params.smooth > 0 + smoothBins = round(params.smooth / params.binWidth); + neuronPSTH = smoothdata(neuronPSTH, 'gaussian', smoothBins); + end + + % Append neuron row + rasterAll{s} = [rasterAll{s}; neuronPSTH]; + + % Get depth for this neuron + if params.sortBy == "depth" + depthRow = depthTable.Experiment == ex & depthTable.Unit == u; + if any(depthRow) + depthAll{s}(end+1) = depthTable.Depth_um(depthRow); + else + depthAll{s}(end+1) = NaN; + end + else + depthAll{s}(end+1) = NaN; + end + + expAll{s}(end+1) = ex; + end + + end % stim loop + end % experiment loop + + % ------------------------------------------------------------------ + % Save + % ------------------------------------------------------------------ + S.expList = exList; + S.lockedEdges = lockedEdges; + S.lockedPreBase = lockedPreBase; + S.params = params; + + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + S.(sprintf('%s_raster', stimField)) = rasterAll{s}; + S.(sprintf('%s_depth', stimField)) = depthAll{s}; + S.(sprintf('%s_exp', stimField)) = expAll{s}; + end + + save([saveDir nameOfFile], '-struct', 'S'); + fprintf('\nSaved raster data to:\n %s\n', [saveDir nameOfFile]); + +else + % Load from disk + lockedEdges = S.lockedEdges; + lockedPreBase = S.lockedPreBase; + + rasterAll = cell(1, numel(params.stimTypes)); + depthAll = cell(1, numel(params.stimTypes)); + expAll = cell(1, numel(params.stimTypes)); + + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + rasterAll{s} = S.(sprintf('%s_raster', stimField)); + depthAll{s} = S.(sprintf('%s_depth', stimField)); + expAll{s} = S.(sprintf('%s_exp', stimField)); + end +end + +% ========================================================================= +% SORT NEURONS +% ========================================================================= +for s = 1:numel(params.stimTypes) + data = rasterAll{s}; + if isempty(data); continue; end + + if params.sortBy == "peak" + % Sort by time of peak response in the post-stimulus window + postStimBins = tAxis >= lockedPreBase; + [~, peakBin] = max(data(:, postStimBins), [], 2); + [~, sortIdx] = sort(peakBin); + elseif params.sortBy == "depth" + % Sort by depth (shallow to deep) + [~, sortIdx] = sort(depthAll{s}, 'ascend'); + else + sortIdx = 1:size(data, 1); % no sorting + end + + rasterAll{s} = data(sortIdx, :); + depthAll{s} = depthAll{s}(sortIdx); + expAll{s} = expAll{s}(sortIdx); +end + +% ========================================================================= +% PLOT +% ========================================================================= + +tAxis = lockedEdges(1:end-1); +tAxisPlot = tAxis - lockedPreBase; + +stimLegendMap = containers.Map(... + {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... + {'MB', 'SB', 'MG', 'SG'}); + +nStim = numel(params.stimTypes); + +% ------------------------------------------------------------------ +% Global color limits across all stim types +% ------------------------------------------------------------------ +allValues = []; +for s = 1:nStim + if ~isempty(rasterAll{s}) + allValues = [allValues, rasterAll{s}(:)']; %#ok + end +end +cLimMax = prctile(abs(allValues), 98); % robust limit — ignore extreme outliers +if params.zScore + cLims = [-cLimMax, cLimMax]; % symmetric around zero for z-score +else + cLims = [0, cLimMax]; +end + +% ------------------------------------------------------------------ +% Figure and tiled layout +% ------------------------------------------------------------------ +fig = figure; +set(fig, 'Units', 'centimeters', 'Position', [5 5 5*nStim + 2, 10]); + +tl = tiledlayout(fig, 1, nStim, 'TileSpacing', 'compact', 'Padding', 'compact'); + +axAll = gobjects(1, nStim); + +for s = 1:nStim + + data = rasterAll{s}; + stimKey = char(params.stimTypes(s)); + if isKey(stimLegendMap, stimKey) + shortName = stimLegendMap(stimKey); + else + shortName = stimKey; + end + + axAll(s) = nexttile(tl); + ax = axAll(s); + + if isempty(data) + title(ax, shortName, 'FontName', 'helvetica', 'FontSize', 8); + axis(ax, 'off'); + continue + end + + % imagesc: x = time, y = neuron index + imagesc(ax, tAxisPlot, 1:size(data,1), data); + clim(ax, cLims); + colormap(ax, flipud(gray)); % white = low, black = high + + % ------------------------------------------------------------------ + % Depth bin boundary lines (only when sorted by depth) + % ------------------------------------------------------------------ + if params.sortBy == "depth" && ~isempty(depthAll{s}) + + % Load bin edges + depthFile = 'W:\Large_scale_mapping_NP\lizards\Combined_lizard_analysis\NeuronDepths.mat'; + D = load(depthFile); + depthBinEdges = D.depthBinEdges; + + 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))}; + + % Find the last neuron index belonging to each bin boundary + for edge = 2:3 % edges 2 and 3 are the internal boundaries + %lastInBin = find(depthAll{s} <= depthBinEdges(edge), 1, 'last'); + %lastInBin = find(~isnan(depthAll{s}) & depthAll{s} <= depthBinEdges(edge), 1, 'last'); + depthCombined = depthAll{s}; + depthCombined = depthCombined(); + if ~isempty(lastInBin) && lastInBin < size(data,1) + yline(ax, lastInBin + 0.5, 'r-', 'LineWidth', 1.2); + % Label on the right side showing the bin range + text(ax, tAxisPlot(end), lastInBin - size(data,1)*0.02, ... + binLabelsDepth{edge-1}, ... + 'Color', 'r', 'FontSize', 6, 'FontName', 'helvetica', ... + 'HorizontalAlignment', 'right', 'VerticalAlignment', 'top'); + end + end + % Label for the deepest bin + text(ax, tAxisPlot(end), size(data,1), ... + binLabelsDepth{3}, ... + 'Color', 'r', 'FontSize', 6, 'FontName', 'helvetica', ... + 'HorizontalAlignment', 'right', 'VerticalAlignment', 'top'); + end + + % Stim onset and offset lines + xline(ax, 0, 'w--', 'LineWidth', 1.0); + xline(ax, params.postStim, 'w--', 'LineWidth', 1.0); + + xlim(ax, [tAxisPlot(1), tAxisPlot(end)]); + ylim(ax, [0.5, size(data,1)+0.5]); + + xlabel(ax, 'Time re. stim onset [ms]', 'FontName', 'helvetica', 'FontSize', 8); + if s == 1 + ylabel(ax, 'Neuron #', 'FontName', 'helvetica', 'FontSize', 8); + end + title(ax, sprintf('%s (n=%d)', shortName, size(data,1)), ... + 'FontName', 'helvetica', 'FontSize', 8); + + ax.FontName = 'helvetica'; + ax.FontSize = 8; + ax.YDir = 'normal'; % neuron 1 at bottom + +end + +% ------------------------------------------------------------------ +% Single colorbar for the whole layout +% ------------------------------------------------------------------ +cb = colorbar(axAll(end)); +if params.zScore + cb.Label.String = 'Z-score'; +else + cb.Label.String = 'Firing rate [spk/s]'; +end +cb.Label.FontName = 'helvetica'; +cb.Label.FontSize = 8; +cb.FontName = 'helvetica'; +cb.FontSize = 8; + +sgtitle(sprintf('N = %d experiments', numel(exList)), ... + 'FontName', 'helvetica', 'FontSize', 10); + +if params.PaperFig + vs_first.printFig(fig, sprintf('Raster-%s', stimLabel), PaperFig=params.PaperFig); +end + +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..b31a0f9 --- /dev/null +++ b/visualStimulationAnalysis/plotRaster_MultiExp.m @@ -0,0 +1,471 @@ +function plotRaster_MultiExp(exList, params) + +arguments + exList double + params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] + params.binWidth double = 10 + params.smooth double = 0 + params.statType string = "BootstrapPerNeuron" + params.speed string = "max" + params.alpha double = 0.05 + params.postStim double = 500 + params.preBase double = 200 + params.overwrite logical = false + params.TakeTopPercentTrials double = 0.3 + params.zScore logical = true % default true — more meaningful for raster + params.sortBy string = "peak" % "peak" = sort by peak response time, "depth" = sort by depth + params.PaperFig logical = false + params.climPrctile double = 90 % percentile for color limit — lower = more contrast + params.climNeg double = 0 % fixed negative z-score limit (absolute value) +end + +% ------------------------------------------------------------------------- +% Load depth info if sorting by depth +% ------------------------------------------------------------------------- +if params.sortBy == "depth" + 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; +end + +% ------------------------------------------------------------------------- +% Build save path +% ------------------------------------------------------------------------- +NP_first = loadNPclassFromTable(exList(1)); +vs_first = linearlyMovingBallAnalysis(NP_first); + +p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); +p = [p 'lizards']; +if ~exist([p '\Combined_lizard_analysis'], 'dir') + cd(p) + mkdir Combined_lizard_analysis +end +saveDir = [p '\Combined_lizard_analysis']; + +stimLabel = strjoin(params.stimTypes, '-'); +nameOfFile = sprintf('\\Ex_%d-%d_Raster_%s.mat', exList(1), exList(end), stimLabel); + +% ------------------------------------------------------------------------- +% Decide whether to recompute or load +% ------------------------------------------------------------------------- +if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite + S = load([saveDir nameOfFile]); + if isequal(S.expList, exList) + fprintf('Loading saved raster data from:\n %s\n', [saveDir nameOfFile]); + forloop = false; + else + fprintf('Experiment list mismatch — recomputing.\n'); + forloop = true; + end +else + forloop = true; +end + +% ========================================================================= +% EXPERIMENT LOOP +% ========================================================================= +if forloop + + nStim = numel(params.stimTypes); + nExp = numel(exList); + + % rasterAll{s} grows one row per responsive neuron across all experiments + % each row = mean PSTH of one neuron in spk/s (or z-score) + rasterAll = cell(1, nStim); % nNeurons x nBins + depthAll = cell(1, nStim); % nNeurons x 1 — depth of each neuron + expAll = cell(1, nStim); % nNeurons x 1 — which experiment each neuron came from + + for s = 1:nStim + rasterAll{s} = []; + depthAll{s} = []; + expAll{s} = []; + end + + lockedPreBase = []; + lockedNBins = []; + lockedEdges = []; + tAxis = []; + + for ei = 1:nExp + + ex = exList(ei); + fprintf('\n=== Experiment %d ===\n', ex); + + try + NP = loadNPclassFromTable(ex); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + continue + end + + for s = 1:nStim + + stimType = params.stimTypes(s); + + try + switch stimType + case "rectGrid" + obj = rectGridAnalysis(NP); + case "linearlyMovingBall" + obj = linearlyMovingBallAnalysis(NP); + case "StaticGrating" + obj = StaticDriftingGratingAnalysis(NP); + case "MovingGrating" + obj = StaticDriftingGratingAnalysis(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 + + NeuronResp = obj.ResponseWindow; + + if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; + else + Stats = obj.ShufflingAnalysis; + end + + % Resolve field name and stim start + fieldName = ''; + startStim = 0; + if params.speed ~= "max" && isequal(obj.stimName, 'linearlyMovingBall') + fieldName = 'Speed2'; + elseif isequal(obj.stimName, 'linearlyMovingBall') + fieldName = 'Speed1'; + elseif isequal(stimType, 'StaticGrating') + fieldName = 'Static'; + elseif isequal(stimType, 'MovingGrating') + fieldName = 'Moving'; + startStim = obj.VST.static_time * 1000; + end + + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + label = string(p_sort.label'); + goodU = p_sort.ic(:, label == 'good'); + + try + pvals = Stats.(fieldName).pvalsResponse; + catch + pvals = Stats.pvalsResponse; + end + + try + C = NeuronResp.(fieldName).C; + catch + C = NeuronResp.C; + end + directimesSorted = C(:, 1)' + startStim; + + preBase = params.preBase; + windowTotal = preBase + params.postStim; + + if isempty(lockedPreBase) + lockedPreBase = preBase; + lockedEdges = 0 : params.binWidth : windowTotal; + lockedNBins = numel(lockedEdges) - 1; + tAxis = lockedEdges(1:end-1); + fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... + lockedPreBase, params.postStim, lockedNBins); + end + + eNeurons = find(pvals < params.alpha); + + 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); + + % ---------------------------------------------------------- + % Build per-neuron PSTH + % ---------------------------------------------------------- + for ni = 1:numel(eNeurons) + u = eNeurons(ni); + + MRhist = BuildBurstMatrix( ... + goodU(:, u), ... + round(p_sort.t), ... + round(directimesSorted - lockedPreBase), ... + round(windowTotal)); + MRhist = squeeze(MRhist); + + if ~isempty(params.TakeTopPercentTrials) + MeanTrial = mean(MRhist, 2); + [~, ind] = sort(MeanTrial, 'descend'); + takeTrials = ind(1:round(numel(MeanTrial)*params.TakeTopPercentTrials)); + MRhist = MRhist(takeTrials, :); + end + + nTrials = size(MRhist, 1); + spikeTimes = repmat((1:size(MRhist,2)), nTrials, 1); + spikeTimes = spikeTimes(logical(MRhist)); + counts = histcounts(spikeTimes, lockedEdges); + neuronPSTH = (counts / (params.binWidth * nTrials)) * 1000; % spk/s + + % Z-score using baseline + if params.zScore + baselineBins = tAxis < lockedPreBase; + bMean = mean(neuronPSTH(baselineBins)); + bStd = std(neuronPSTH(baselineBins)); + if bStd > 0 + neuronPSTH = (neuronPSTH - bMean) / bStd; + else + continue % skip neuron if baseline std is zero + end + end + + % Smooth if requested + if params.smooth > 0 + smoothBins = round(params.smooth / params.binWidth); + neuronPSTH = smoothdata(neuronPSTH, 'gaussian', smoothBins); + end + + % Append neuron row + rasterAll{s} = [rasterAll{s}; neuronPSTH]; + + % Get depth for this neuron + if params.sortBy == "depth" + depthRow = depthTable.Experiment == ex & depthTable.Unit == u; + if any(depthRow) + depthAll{s}(end+1) = depthTable.Depth_um(depthRow); + else + depthAll{s}(end+1) = NaN; + end + else + depthAll{s}(end+1) = NaN; + end + + expAll{s}(end+1) = ex; + end + + end % stim loop + end % experiment loop + + % ------------------------------------------------------------------ + % Save + % ------------------------------------------------------------------ + S.expList = exList; + S.lockedEdges = lockedEdges; + S.lockedPreBase = lockedPreBase; + S.params = params; + + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + S.(sprintf('%s_raster', stimField)) = rasterAll{s}; + S.(sprintf('%s_depth', stimField)) = depthAll{s}; + S.(sprintf('%s_exp', stimField)) = expAll{s}; + end + + save([saveDir nameOfFile], '-struct', 'S'); + fprintf('\nSaved raster data to:\n %s\n', [saveDir nameOfFile]); + +else + % Load from disk + lockedEdges = S.lockedEdges; + lockedPreBase = S.lockedPreBase; + + rasterAll = cell(1, numel(params.stimTypes)); + depthAll = cell(1, numel(params.stimTypes)); + expAll = cell(1, numel(params.stimTypes)); + + for s = 1:numel(params.stimTypes) + stimField = matlab.lang.makeValidName(params.stimTypes(s)); + rasterAll{s} = S.(sprintf('%s_raster', stimField)); + depthAll{s} = S.(sprintf('%s_depth', stimField)); + expAll{s} = S.(sprintf('%s_exp', stimField)); + end +end + +tAxis = lockedEdges(1:end-1); +tAxisPlot = tAxis - lockedPreBase; + +% ========================================================================= +% SORT NEURONS +% ========================================================================= +for s = 1:numel(params.stimTypes) + data = rasterAll{s}; + if isempty(data); continue; end + + if params.sortBy == "peak" + % Sort by time of peak response in the post-stimulus window + postStimBins = tAxis >= lockedPreBase; + [~, peakBin] = max(data(:, postStimBins), [], 2); + [~, sortIdx] = sort(peakBin); + elseif params.sortBy == "depth" + % Sort by depth (shallow to deep) + [~, sortIdx] = sort(depthAll{s}, 'ascend'); + else + sortIdx = 1:size(data, 1); % no sorting + end + + rasterAll{s} = data(sortIdx, :); + depthAll{s} = depthAll{s}(sortIdx); + expAll{s} = expAll{s}(sortIdx); +end + +% ========================================================================= +% PLOT +% ========================================================================= + + +stimLegendMap = containers.Map(... + {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... + {'MB', 'SB', 'MG', 'SG'}); + +nStim = numel(params.stimTypes); + +% ------------------------------------------------------------------ +% ------------------------------------------------------------------ +% Global color limits — use lower percentile for better contrast +allValues = []; +for s = 1:nStim + if ~isempty(rasterAll{s}) + allValues = [allValues, rasterAll{s}(:)']; %#ok + end +end + +if params.zScore + cLimPos = prctile(allValues, params.climPrctile); % data-driven positive limit + cLims = [-params.climNeg, cLimPos]; % asymmetric: fixed neg, data-driven pos +else + cLims = [prctile(allValues, 2), prctile(allValues, params.climPrctile)]; +end + +% ------------------------------------------------------------------ +% Figure and tiled layout +% ------------------------------------------------------------------ +fig = figure; +set(fig, 'Units', 'centimeters', 'Position', [5 5 5*nStim + 2, 10]); + +tl = tiledlayout(fig, 1, nStim, 'TileSpacing', 'compact', 'Padding', 'compact'); + +axAll = gobjects(1, nStim); + +for s = 1:nStim + + data = rasterAll{s}; + stimKey = char(params.stimTypes(s)); + if isKey(stimLegendMap, stimKey) + shortName = stimLegendMap(stimKey); + else + shortName = stimKey; + end + + axAll(s) = nexttile(tl); + ax = axAll(s); + + if isempty(data) + title(ax, shortName, 'FontName', 'helvetica', 'FontSize', 8); + axis(ax, 'off'); + continue + end + + % imagesc: x = time, y = neuron index + imagesc(ax, tAxisPlot, 1:size(data,1), data); + clim(ax, cLims); + %colormap(ax, flipud(gray)); % white = low, black = high + if params.zScore + cLimPos = prctile(allValues, params.climPrctile); + cLims = [-params.climNeg, cLimPos]; + + % Proportion of colors for each side — white stays at zero + nColors = 256; + nNeg = round(nColors * params.climNeg / (params.climNeg + cLimPos)); + nPos = nColors - nNeg; + + blueHalf = [linspace(0.1, 1, nNeg)', linspace(0.2, 1, nNeg)', linspace(0.8, 1, nNeg)']; + redHalf = [linspace(1, 0.9, nPos)', linspace(1, 0.2, nPos)', linspace(1, 0.05, nPos)']; + colormap(ax, [blueHalf; redHalf]); + else + cLims = [prctile(allValues, 2), prctile(allValues, params.climPrctile)]; + colormap(ax, flipud(gray)); + end + + % ------------------------------------------------------------------ + % Depth bin boundary lines (only when sorted by depth) + % ------------------------------------------------------------------ + if params.sortBy == "depth" && ~isempty(depthAll{s}) + + % Load bin edges + depthFile = 'W:\Large_scale_mapping_NP\lizards\Combined_lizard_analysis\NeuronDepths.mat'; + D = load(depthFile); + depthBinEdges = D.depthBinEdges; + + 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))}; + + % Find the last neuron index belonging to each bin boundary + for edge = 2:3 % edges 2 and 3 are the internal boundaries + %lastInBin = find(depthAll{s} <= depthBinEdges(edge), 1, 'last'); + %lastInBin = find(~isnan(depthAll{s}) & depthAll{s} <= depthBinEdges(edge), 1, 'last'); + depthCombined = depthAll{s}; + depthCombined = depthCombined(~isnan(depthCombined)); + lastInBin = find(depthCombined <= depthBinEdges(edge), 1, 'last'); + if ~isempty(lastInBin) && lastInBin < size(data,1) + yline(ax, lastInBin + 0.5, 'k-', 'LineWidth', 1.2); + % Label on the right side showing the bin range + text(ax, tAxisPlot(5), lastInBin - size(data,1)*0.02, ... + binLabelsDepth{edge-1}, ... + 'Color', 'w', 'FontSize', 6, 'FontName', 'helvetica', ... + 'HorizontalAlignment', 'left', 'VerticalAlignment', 'top'); + end + end + % Label for the deepest bin + text(ax, tAxisPlot(5), size(data,1), ... + binLabelsDepth{3}, ... + 'Color', 'w', 'FontSize', 6, 'FontName', 'helvetica', ... + 'HorizontalAlignment', 'left', 'VerticalAlignment', 'top'); + end + + % Stim onset and offset lines + xline(ax, 0, 'k--', 'LineWidth', 1.0); + xline(ax, params.postStim, 'k--', 'LineWidth', 1.0); + + xlim(ax, [tAxisPlot(1), tAxisPlot(end)]); + ylim(ax, [0.5, size(data,1)+0.5]); + xticks(ax, -params.preBase : 100 : params.postStim); + + xlabel(ax, 'Time re. stim onset [ms]', 'FontName', 'helvetica', 'FontSize', 8); + if s == 1 + ylabel(ax, 'Neuron #', 'FontName', 'helvetica', 'FontSize', 8); + end + title(ax, sprintf('%s (n=%d)', shortName, size(data,1)), ... + 'FontName', 'helvetica', 'FontSize', 8); + + ax.FontName = 'helvetica'; + ax.FontSize = 8; + ax.YDir = 'normal'; % neuron 1 at bottom + + +end + +% ------------------------------------------------------------------ +% Single colorbar for the whole layout +% ------------------------------------------------------------------ +cb = colorbar(axAll(end)); +if params.zScore + cb.Label.String = 'Z-score'; +else + cb.Label.String = 'Firing rate [spk/s]'; +end +cb.Label.FontName = 'helvetica'; +cb.Label.FontSize = 8; +cb.FontName = 'helvetica'; +cb.FontSize = 8; + +sgtitle(sprintf('N = %d experiments', numel(exList)), ... + 'FontName', 'helvetica', 'FontSize', 10); + +if params.PaperFig + vs_first.printFig(fig, sprintf('Raster-%s', stimLabel), PaperFig=params.PaperFig); +end + +end \ No newline at end of file From 0e507cc4568f98d58bfc539e8bfaf34d817cace7 Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Tue, 7 Apr 2026 05:11:55 +0300 Subject: [PATCH 09/19] Addin stats per neuron and changes to spatial tunning (selected direction) --- .../plotSwarmBootstrapWithComparisons.m | 6 + .../@VStimAnalysis/BootstrapPerNeuron.m | 33 +- .../@VStimAnalysis/PlotZScoreComparison.asv | 1524 +++++++++++++++++ .../@VStimAnalysis/PlotZScoreComparison.m | 62 +- .../@VStimAnalysis/StatisticsPerNeuron.m | 409 +++++ .../@linearlyMovingBallAnalysis/plotRaster.m | 15 + .../CalculateReceptiveFields.m | 459 ++--- .../@rectGridAnalysis/plotRaster.m | 25 +- .../RunAnalysisClass.asv | 21 +- visualStimulationAnalysis/RunAnalysisClass.m | 27 +- .../SpatialTuningIndex.m | 639 +++++-- .../SpatialTuningIndexV2.m | 433 +++++ .../plotPSTH_MultiExp.asv | 465 ----- .../plotRaster_MultiExp.asv | 448 ----- .../plotRaster_MultiExp.m | 5 +- 15 files changed, 3239 insertions(+), 1332 deletions(-) create mode 100644 visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.asv create mode 100644 visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m create mode 100644 visualStimulationAnalysis/SpatialTuningIndexV2.m delete mode 100644 visualStimulationAnalysis/plotPSTH_MultiExp.asv delete mode 100644 visualStimulationAnalysis/plotRaster_MultiExp.asv diff --git a/general functions/plotSwarmBootstrapWithComparisons.m b/general functions/plotSwarmBootstrapWithComparisons.m index 6ae4703..51ea88d 100644 --- a/general functions/plotSwarmBootstrapWithComparisons.m +++ b/general functions/plotSwarmBootstrapWithComparisons.m @@ -334,6 +334,12 @@ % Draw significance stars + fprintf('size(x1): %s\n', num2str(size(x1))); + fprintf('size(yText): %s\n', num2str(size(yText))); + fprintf('size(maxVisible): %s\n', num2str(size(maxVisible))); + fprintf('size(bracketPad): %s\n', num2str(size(bracketPad))); + fprintf('size(vals): %s\n', num2str(size(vals))); + fprintf('size(yMaxVis): %s\n', num2str(size(yMaxVis))); if pValues(1) < 1e-3 txt = '***'; if pValues(1) == 0 diff --git a/visualStimulationAnalysis/@VStimAnalysis/BootstrapPerNeuron.m b/visualStimulationAnalysis/@VStimAnalysis/BootstrapPerNeuron.m index f216e72..e1b97d1 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/BootstrapPerNeuron.m +++ b/visualStimulationAnalysis/@VStimAnalysis/BootstrapPerNeuron.m @@ -140,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 @@ -158,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") @@ -228,3 +232,4 @@ % p_sh = mean(D_sh <= 0); end +%% diff --git a/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.asv b/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.asv new file mode 100644 index 0000000..513ee1e --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.asv @@ -0,0 +1,1524 @@ +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); + elseif isequal(params.StatMethod,'bootsrapRespBase') + vs.BootstrapPerNeuron('overwrite',params.overwriteStats); + elseif isequal(params.StatMethod,'maxPermuteTest') + vs.StatisticsPerNeuron('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); + 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) + 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 + 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 + 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 + 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 + 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.6); + +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 5 6]); + + +if params.PaperFig + vs.printFig(fig,sprintf('ResponsiveUnits-comparison-%s-%s',params.ComparePairs{1},... + params.ComparePairs{2}),PaperFig = params.PaperFig) +end + +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..835f489 --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m @@ -0,0 +1,409 @@ +function results = StatisticsPerNeuron(obj, params) +% StatisticsPerNeuron - Computes per-neuron response statistics vs baseline. +% +% For each neuron this function outputs: +% pVal : 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 : Leave-one-out (LOO) cross-validated z-score at the preferred +% stimulus category. On each LOO fold, the preferred category is +% identified on all-but-one trials, and the held-out trial contributes +% to the z-score estimate. This prevents winner's curse inflation that +% would otherwise scale with nCats, making z-scores non-comparable +% across stimuli with different category counts (e.g. rectGrid with +% 81 positions vs moving ball with 4 directions). +% +% prefCat : Consensus preferred category — the category most frequently +% selected as preferred across all LOO folds. +% +% validCats: [nCats × nNeurons] logical mask. False where a category has +% >= EmptyTrialPerc fraction of zero-spike trials for a given neuron. +% +% Usage: +% results = obj.StatisticsPerNeuron() +% results = obj.StatisticsPerNeuron(nBoot=5000, 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 = true % 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) +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 + +% ------------------------------------------------------------------------- +% 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 → 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, not unique speeds — caused loop to run hundreds of times + nSpeeds = numel(unique(obj.VST.speed)); % number of distinct speed values + + Times.Speed1 = responseParams.Speed1.C(:,1)'; % trial onset times [1 × nTrials] + Durations.Speed1 = responseParams.Speed1.stimDur; % stimulus duration in ms + trialsCats.Speed1 = round(numel(Times.Speed1) / size(responseParams.Speed1.NeuronVals, 2)); % trials per category + + 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)); + end + + x = nSpeeds; % number of loop iterations + +elseif isequal(obj.stimName, 'StaticDriftingGrating') + % Moving phase onset is shifted by static_time relative to trial onset + 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)); + + Times.Static = responseParams.C(:,1)'; + Durations.Static = responseParams.Static.stimDur; + trialsCats.Static = round(numel(Times.Static) / size(responseParams.Static.NeuronVals, 2)); + + FieldNames = {'Static', 'Moving'}; % loop will index these in order + x = 2; + +else + % Single-condition stimuli (rectGrid, etc.) + directimesSorted = responseParams.C(:,1)'; + stimDur = responseParams.stimDur; + trialsCat = round(numel(directimesSorted) / size(responseParams.NeuronVals, 2)); + x = 1; +end + +% ========================================================================= +% Main loop over stimulus conditions +% ========================================================================= +for s = 1:x + + % --- Assign condition-specific timing variables --- + if isfield(responseParams, "Speed1") + fieldName = sprintf('Speed%d', s); % 'Speed1' or 'Speed2' + directimesSorted = Times.(fieldName); + stimDur = Durations.(fieldName); + trialsCat = trialsCats.(fieldName); + end + + if isequal(obj.stimName, 'StaticDriftingGrating') + fieldName = FieldNames{s}; % 'Static' or 'Moving' + directimesSorted = Times.(fieldName); + stimDur = Durations.(fieldName); + trialsCat = trialsCats.(fieldName); + end + + % --- Build spike count matrices --- + % Mr: spike counts in the response window (stimulus duration) + Mr = BuildBurstMatrix(goodU, ... + round(p.t), ... + round(directimesSorted), ... + round(stimDur)); + + % Mb: spike counts in the baseline window (75% of inter-trial interval + % before each trial onset — conservative buffer to avoid overlap with + % the preceding stimulus) + Mb = BuildBurstMatrix(goodU, ... + round(p.t), ... + round(directimesSorted - 0.75 * obj.VST.interTrialDelay * 1000), ... + round(0.75 * obj.VST.interTrialDelay * 1000)); + + responses = mean(Mr, 3); % mean spike count over time bins: [nTrials × nNeurons] + baselines = mean(Mb, 3); % mean spike count over baseline bins: [nTrials × nNeurons] + Diff = responses - baselines; % per-trial response minus baseline: [nTrials × nNeurons] + + nNeurons = size(goodU, 2); % number of good units + nCats = round(size(Diff,1) / trialsCat); % number of stimulus categories + + % Sanity check: total trials must equal nCats × trialsCat + assert(size(Diff,1) == nCats * trialsCat, ... + 'Trial count (%d) is not evenly divisible by trialsCat (%d). Check responseParams.', ... + size(Diff,1), trialsCat); + + % ------------------------------------------------------------------------- + % Category-level empty-trial filtering + % Mark a category as invalid for a given neuron if the fraction of trials + % with zero spikes meets or exceeds EmptyTrialPerc. + % Invalid categories are excluded (not zeroed) to avoid biasing Diff toward 0. + % ------------------------------------------------------------------------- + validCats = true(nCats, nNeurons); % [nCats × nNeurons]; true = include in analysis + + if params.FilterEmptyResponses + % Reshape responses to [trialsCat × nCats × nNeurons] for category indexing + responsesReshaped = reshape(responses, trialsCat, nCats, nNeurons); + + for c = 1:nCats + for u = 1:nNeurons + emptyTrials = responsesReshaped(:, c, u) == 0; % logical: zero-spike trials + perc = sum(emptyTrials) / trialsCat; % fraction of empty trials + if perc >= params.EmptyTrialPerc + validCats(c, u) = false; % exclude category c for neuron u + end + end + end + end + + % Neurons where ALL categories are invalid — statistics are undefined + noValidCat = all(~validCats, 1); % [1 × nNeurons] + + % ------------------------------------------------------------------------- + % Reshape Diff into category structure + % DiffReshaped: [trialsCat × nCats × nNeurons] + % ------------------------------------------------------------------------- + DiffReshaped = reshape(Diff, trialsCat, nCats, nNeurons); + + % ------------------------------------------------------------------------- + % Observed max-statistic + % For each neuron: maximum mean response-minus-baseline across valid categories. + % Invalid categories are set to -Inf so they cannot contribute to the max. + % ------------------------------------------------------------------------- + catMeans = squeeze(mean(DiffReshaped, 1)); % [nCats × nNeurons] + catMeansMasked = catMeans; + catMeansMasked(~validCats) = -Inf; % exclude invalid categories + ObsStat = max(catMeansMasked, [], 1); % [1 × nNeurons] + + % ------------------------------------------------------------------------- + % Max-statistic sign-flip permutation test + % + % H0: for each trial the sign of (response - baseline) is random, + % meaning response and baseline are drawn from the same distribution. + % + % Under H0, randomly flipping the sign of each trial's difference is + % equivalent to randomly reassigning which window is "response" and which + % is "baseline". Repeating this nBoot times builds a null distribution of + % the max category mean under H0. + % + % Taking the MAX across categories at each permutation automatically + % controls family-wise error rate (FWER) across categories without + % requiring Bonferroni correction (Nichols & Holmes 2002). + % + % Fully vectorised using pagemtimes for efficiency — no loop over nBoot. + % ------------------------------------------------------------------------- + + % Generate all sign vectors at once: [nTrials × nBoot], values ±1 + signs = 2 * randi(2, size(Diff,1), params.nBoot) - 3; + + % Reshape signs to match category structure: [trialsCat × nCats × nBoot] + % This maps each trial's sign flip to its correct category slot + signsR = reshape(signs, trialsCat, nCats, params.nBoot); + + % Permute for pagemtimes — pages correspond to categories: + % DiffRp : [nNeurons × trialsCat × nCats] + % signsRp : [trialsCat × nBoot × nCats] + DiffRp = permute(DiffReshaped, [3 1 2]); % [nNeurons × trialsCat × nCats] + signsRp = permute(signsR, [1 3 2]); % [trialsCat × nBoot × nCats] + + % Batched matrix multiply over category pages: + % for each category: [nNeurons × trialsCat] × [trialsCat × nBoot] = [nNeurons × nBoot] + % result: [nNeurons × nBoot × nCats] + catMeansAll = pagemtimes(DiffRp, signsRp) / trialsCat; + + % Permute to [nCats × nNeurons × nBoot] for masking and max operation + catMeansAll = permute(catMeansAll, [3 1 2]); + + % Exclude invalid categories from null distribution (same mask as observed stat) + validCats3D = repmat(validCats, 1, 1, params.nBoot); % [nCats × nNeurons × nBoot] + catMeansAll(~validCats3D) = -Inf; + + % Max across categories for each permutation and neuron: [nBoot × nNeurons] + nullMax = squeeze(max(catMeansAll, [], 1))'; % squeeze: [nNeurons × nBoot], then transpose + + % p-value: proportion of null max-statistics >= observed max-statistic + % One-tailed test for excitatory responses + pVal = mean(nullMax >= ObsStat, 1); % [1 × nNeurons] + pVal(noValidCat) = NaN; % undefined for neurons with no valid categories + + % ------------------------------------------------------------------------- + % Leave-one-out (LOO) cross-validated z-score + % + % With only ~10 trials per category, split-half would leave only 5 trials + % for each half — too few for stable estimates. LOO maximises data usage: + % - Selection set: all (trialsCat - 1) trials → identify preferred category + % - Test set: the single held-out trial → contributes to z-score + % + % The preferred category is selected independently of the test trial on + % every fold, preventing winner's curse inflation that would otherwise + % differ across stimuli with different numbers of categories. + % + % Implementation is vectorised over categories and neurons. + % The only loop runs trialsCat times (e.g., 10) — negligible cost. + % ------------------------------------------------------------------------- + + % Pre-compute sum across trials once: [1 × nCats × nNeurons] + % Subtracting one trial from the total is cheaper than recomputing the mean + totalSum = sum(DiffReshaped, 1); + + % Pre-allocate accumulators + z_loo_acc = zeros(1, nNeurons); % accumulates held-out diff at preferred category + prefCatCount = zeros(nCats, nNeurons); % tallies how often each category is preferred per fold + + for k = 1:trialsCat + % Category mean on all trials except trial k: [nCats × nNeurons] + % Subtracting trial k from pre-computed total avoids recomputing the full mean + looMean = squeeze((totalSum - DiffReshaped(k,:,:)) / (trialsCat - 1)); + + % Exclude invalid categories from selection + looMeanMasked = looMean; + looMeanMasked(~validCats) = -Inf; + + % Preferred category index on the selection data: [1 × nNeurons] + [~, prefCatLOO] = max(looMeanMasked, [], 1); + + % Accumulate preferred category tally (used later for consensus prefCat) + idx = sub2ind([nCats, nNeurons], prefCatLOO, 1:nNeurons); + prefCatCount(idx) = prefCatCount(idx) + 1; % increment tally for this fold's choice + + % Held-out trial k: diff values at all categories: [nCats × nNeurons] + testVals = squeeze(DiffReshaped(k,:,:)); + + % Accumulate the held-out diff at the fold's preferred category + z_loo_acc = z_loo_acc + testVals(idx); % [1 × nNeurons] + end + + % Average LOO diff across all held-out trials: [1 × nNeurons] + z_loo_mean = z_loo_acc / trialsCat; + + % Consensus preferred category: most frequently selected across LOO folds + % Used to extract baseline SD at a stable preferred location + [~, prefCat] = max(prefCatCount, [], 1); % [1 × nNeurons] + + % Baseline SD pooled across all trials and categories per neuron + % More stable than per-category SD, especially with few trials per category. + % Justified because baseline periods are pre-stimulus and should not vary + % systematically across stimulus categories. + sdBasePref = std(baselines, 0, 1); % [1 × nNeurons] — std over all nTrials rows + + % Final z-score: LOO mean diff normalised by baseline SD + z = z_loo_mean ./ sdBasePref; % [1 × nNeurons] + z(sdBasePref == 0) = 0; % silent baseline (no variability): set to 0 + z(noValidCat) = NaN; % no valid categories: undefined + + % ------------------------------------------------------------------------- + % Store results for this condition + % ------------------------------------------------------------------------- + if isfield(responseParams, "Speed1") || isequal(obj.stimName, 'StaticDriftingGrating') + % Named sub-struct for multi-condition stimuli + S.(fieldName).pvalsResponse = pVal; % [1 × nNeurons] permutation p-values + S.(fieldName).ZScoreU = z; % [1 × nNeurons] LOO cross-validated z-scores + S.(fieldName).ObsDiff = Diff; % [nTrials × nNeurons] raw trial differences + S.(fieldName).ObsResponse = responses; % [nTrials × nNeurons] response spike counts + S.(fieldName).ObsBaseline = baselines; % [nTrials × nNeurons] baseline spike counts + S.(fieldName).prefCat = prefCat; % [1 × nNeurons] consensus preferred category index + S.(fieldName).validCats = validCats; % [nCats × nNeurons] category validity mask + else + % Flat struct for single-condition stimuli + S.pvalsResponse = pVal; + S.ZScoreU = z; + S.ObsDiff = Diff; + S.ObsResponse = responses; + S.ObsBaseline = baselines; + S.prefCat = prefCat; + S.validCats = validCats; + end + + S.params = params; % store analysis parameters 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 an empty results struct with correct field names. +% Ensures downstream code receives a consistent struct regardless of neuron count. + + emptyFields = {'pvalsResponse','ZScoreU','ObsDiff','ObsResponse', ... + 'ObsBaseline','prefCat','validCats'}; + + if isequal(obj.stimName, 'linearlyMovingBall') + for f = emptyFields + S.Speed1.(f{1}) = []; % empty Speed1 fields + end + if isfield(responseParams, "Speed2") + for f = emptyFields + S.Speed2.(f{1}) = []; % empty Speed2 fields if second speed exists + end + end + + elseif isequal(obj.stimName, 'StaticDriftingGrating') + for cond = {'Static', 'Moving'} + for f = emptyFields + S.(cond{1}).(f{1}) = []; % empty fields for each grating condition + end + end + + else + for f = emptyFields + S.(f{1}) = []; % flat empty struct for single-condition stimuli + end + end +end \ No newline at end of file diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m index 2575e8f..47dabd9 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m @@ -8,6 +8,7 @@ function plotRaster(obj,params) 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 @@ -63,6 +64,19 @@ function plotRaster(obj,params) 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" @@ -129,6 +143,7 @@ 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)); if params.Gaussian diff --git a/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m b/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m index 7080102..28c86ed 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m @@ -12,281 +12,229 @@ 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 = "BootstrapPerNeuron" 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 struct for p-values +% Select statistics struct for p-values based on statType parameter if params.statType == "BootstrapPerNeuron" Stats = obj.BootstrapPerNeuron; else Stats = obj.ShufflingAnalysis; 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'); +% 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 -pvals = Stats.pvalsResponse; -C = NeuronResp.C; -stimDur = NeuronResp.stimDur; +% 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 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); + +% Number of trial repetitions per unique condition (pos x size x lum) +trialDiv = length(seqMatrix) / length(unique(seqMatrix)) / nSize / nLums; -trialDiv = length(seqMatrix)/length(unique(seqMatrix))/nSize/nLums; -directimesSorted = C(:,1)'; +% 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 +% Use the shorter of on/off windows to keep matrix sizes consistent durationMin = min([params.duration params.durationOff]); -[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)); - -% 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); - +% Build spike count matrices for on-response (Mr) and off-response (Mro) +[Mr] = BuildBurstMatrix(goodU, round(p.t / params.bin), ... + round((directimesSorted + params.offsetR) / params.bin), ... + round(durationMin / params.bin)); +[Mro] = BuildBurstMatrix(goodU, round(p.t / params.bin), ... + round((directimesSorted + stimDur) / params.bin), ... + round(durationMin / params.bin)); + +[nT, nN, NB] = size(Mr); % nT=trials, nN=neurons, NB=time bins + +% ------------------------------------------------------------------------- +% Build shuffle distributions for both on and off responses +% Each shuffle randomly permutes trial order (dim 1) and time bin order +% (dim 3) independently, breaking stimulus-response associations +% ------------------------------------------------------------------------- +nShuffle = params.nShuffle; + +% On-response raster in spike/s, used as base for on-shuffles +RasterOn = Mr .* (1000 / params.bin); % [nT, nN, NB] +% Off-response raster in spike/s, used as base for off-shuffles +RasterOff = Mro .* (1000 / params.bin); % [nT, nN, NB] + +% Pre-allocate shuffle arrays: [nT, nN, NB, nShuffle] +shuffledDataOn = zeros(nT, nN, NB, nShuffle); +shuffledDataOff = zeros(nT, nN, NB, nShuffle); + +for i = 1:nShuffle + idx1 = randperm(nT); % shuffle trial order + idx3 = randperm(NB); % shuffle time bin order within trial + + shuffledDataOn( :,:,:,i) = RasterOn( idx1, :, idx3); + shuffledDataOff(:,:,:,i) = RasterOff(idx1, :, idx3); end -if params.noEyeMoves - % EyePositionAnalysis - % Create spike Sums with NaNs when the eye is not present. - % - % - % file = dir (NP.recordingDir); - % filenames = {file.name}; - % files= filenames(contains(filenames,"timeSnipsNoMov-31")); - % cd(NP.recordingDir) - % %Run eyePosition Analysis to find no movement timeSnips - % timeSnips = load(files{1}).timeSnips; - % timeSnipsMode = timeSnips(:,timeSnips(3,:) == mode(timeSnips(3,:))); - % - % selecInd = []; - % for i = 1:size(timeSnipsMode,2) - % - % %Find stimOns and offs that are between each timeSnip - % selecInd = [selecInd find(directimesSorted>=timeSnipsMode(1,i) & directimesSorted<(timeSnipsMode(2,i)-stimDur))]; - % end - % - % %MrC = nan(round(nT/trialDiv),nN, NB+NBo); - % - % MrC = nan(round(nT/trialDiv),nN, NB); - % - % - % %%Create summary of identical trials - % - % MrMean = nan(round(nT/trialDiv),nN); - % - % for u = 1:length(goodU) - % j=1; - % - % for i = 1:trialDiv:nT - % - % indexVal = selecInd(selecInd>=i & selecInd<=i+trialDiv-1); - % - % if ~isempty(indexVal) - % - % - % meanRon = reshape(mean(Mr(indexVal,u,:),1),[1,size(Mr,3)]); - % - % meanRoff = reshape(mean(Mro(indexVal,u,:),1),[1,size(Mro,3)]); - % - % %meanBase = reshape(mean(Mb1(indexVal,u,:),1),[1,size(Mb1,3)]); - % - % %MrC(j,u,:) = [meanRon-meanBase meanRoff-meanBase]; %Combine on and off response and substract to each the mean baseline - % - % MrC(j,u,:) = [meanRon]; - % MrMean(j,u) = mean(MrC(j,u,:),3);%-Nbase; - % - % else - % 2+2 - % end - % - % - % j = j+1; - % - % end - % end - % - % 2+2 +% ------------------------------------------------------------------------- +% NOTE: Original code used shuffledData (on only). We now use +% shuffledDataOn / shuffledDataOff to keep on and off shuffles separate +% and symmetric. gridSpikeRateShuff still uses shuffledDataOn for +% backward compatibility (on response only). +% ------------------------------------------------------------------------- +if params.noEyeMoves + % Eye movement exclusion path — not implemented (see commented code above) else - MrC = zeros(2,nLums,nSize,round(nT/trialDiv),nN, NB); - - MRtotal = zeros(2,size(Mr,1),size(Mr,2),size(Mr,3)); %includes on and off response - - MRtotal(1,:,:,:) = Mr; + % Build per-condition mean spike rate: [2, nLums, nSize, nPos, nN, NB] + % dim 1: on(1) / off(2) response + % NOTE: dim order here is [onOff, nLums, nSize, ...] — not [onOff, nSize, nLums] + % (matches MrC indexing: MrC(o, uLums==..., uSize==..., j, u, :)) + MrC = zeros(2, nLums, nSize, round(nT / trialDiv), nN, NB); - MRtotal(2,:,:,:) = Mro; - - %%Create summary of identical trials - - for u = 1:size(goodU,2) - + % 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); - -screenSide = obj.VST.rect; %Same as moving ball - -screenRed = screenSide(4)/params.reduceFactor; -[x, y] = meshgrid(1:screenRed, 1:screenRed); - -pxyScreen = zeros(screenRed,screenRed); - -VideoScreen = zeros(screenRed,screenRed,size(C,1)/trialDiv); +pxyScreen = zeros(screenRed, screenRed); % cumulative position coverage +VideoScreen = zeros(screenRed, screenRed, size(C,1) / trialDiv); % per-position stimulus mask rectData = obj.VST.rectData; -% Before the loop: -XcStore = zeros(1, size(C,1)/trialDiv); -YcStore = zeros(1, size(C,1)/trialDiv); - -j=1; +% 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); +j = 1; for i = 1:trialDiv:length(C) + xyScreen = zeros(screenRed, screenRed)'; - xyScreen = zeros(screenRed,screenRed)'; %%Make calculations if sizes>1 and if experiment is new and the shape is a circle. + % 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; - % string(obj.VST.shape) == "circle" %%%Asumes that shape is circle + 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; - 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; - - 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; - - XcStore(j) = Xc; % still in pixel coords at this point + XcStore(j) = Xc; YcStore(j) = Yc; - 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; - - % Calculate the distance of each point from the center - distances = sqrt((x - Xc).^2 + (y - Yc).^2); + % 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; - % Set the values inside the circle to 1 (or any other value you prefer) + % Binary circle mask: 1 inside stimulus, 0 outside + distances = sqrt((x - Xc).^2 + (y - Yc).^2); xyScreen(distances <= r) = 1; - % - % 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); - - %figure;imagesc(xyScreen') - VideoScreen(:,:,j) = xyScreen'; - - pxyScreen = pxyScreen+xyScreen; - - j = j+1; - + pxyScreen = pxyScreen + xyScreen; + j = j + 1; end -%%%%%%%%%% Spike rate grid map -nGrid = params.nGrid; -xEdges = linspace(0, screenSide(3)/params.reduceFactor, nGrid+1); % reduced pixel coords -yEdges = linspace(0, screenSide(4)/params.reduceFactor, nGrid+1); +% ------------------------------------------------------------------------- +% Spike rate grid map: bin trials into nGrid x nGrid spatial grid +% ------------------------------------------------------------------------- +nGrid = params.nGrid; + +% Grid edges in reduced pixel coordinates +xEdges = linspace(0, screenSide(3) / params.reduceFactor, nGrid + 1); +yEdges = linspace(0, screenSide(4) / params.reduceFactor, nGrid + 1); +% [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); jj = 1; - for i = 1:trialDiv:nT + % Bin stimulus centre into grid cell xBin = discretize(XcStore(jj), xEdges); yBin = discretize(YcStore(jj), yEdges); @@ -300,28 +248,31 @@ trialCount(yBin, xBin, sizeIdx, lumIdx) = trialCount(yBin, xBin, sizeIdx, lumIdx) + 1; - % On and off response + % 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]); 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; + % Accumulate shuffle spike rates (on response only, for grid) for s = 1:nShuffle - shuffRate = reshape(mean(mean(shuffledData(i:i+trialDiv-1,:,:,s), 1), 3), [1 1 nN]); - gridSpikeRateShuff(yBin, xBin, :, s, sizeIdx, lumIdx) = ... - gridSpikeRateShuff(yBin, xBin, :, s, sizeIdx, lumIdx) + shuffRate; + 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 -% Normalize by trial count +% Normalize grid maps by trial count per cell for si = 1:nSize for li = 1:nLums - tc = max(trialCount(:,:,si,li), 1); % [nGrid x nGrid] + tc = max(trialCount(:,:,si,li), 1); % [nGrid, nGrid] — avoid divide-by-zero for s = 1:nShuffle - gridSpikeRateShuff(:,:,:,s,si,li) = gridSpikeRateShuff(:,:,:,s,si,li) ./ tc; + 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; @@ -329,55 +280,115 @@ end end -%%%%%% Convolution -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]); - +% ------------------------------------------------------------------------- +% 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]); +% 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 -end -% figure;imagesc(squeeze(RFu(2,:,:,:,:,83))); -S.RFu = RFu; + % Set NaNs to zero before multiplication (same as real RF path) + MrMeanShuff_sh(isnan(MrMeanShuff_sh)) = 0; -S.RFuFilt = RFuFilt; + % Reshape to align with VD: [2, nLums, nSize, 1, 1, nPos, nN] + ResSh = reshape(MrMeanShuff_sh, [2, nLums, nSize, 1, 1, nPos, nN]); -S.shuffledData = shuffledData; + % Weighted average across positions: [2, nLums, nSize, screenRed, screenRed, nN] + RFuShuffAll(:,:,:,:,:,:,sh) = reshape(mean(VD .* ResSh, 6), ... + [2, nLums, nSize, screenRed, screenRed, nN]); -S.gridSpikeRateShuff = gridSpikeRateShuff; +end + +% Average shuffle RFs across shuffles: [2, nLums, nSize, screenRed, screenRed, nN] +% This is the shuffle baseline, directly comparable to RFu +RFuShuffMean = mean(RFuShuffAll, 7); + +% ------------------------------------------------------------------------- +% 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.gridSpikeRate = gridSpikeRate; +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/plotRaster.m b/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m index a21684b..f7436c4 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m @@ -7,7 +7,8 @@ function plotRaster(obj,params) params.inputParams = false params.preBase = 200 params.bin = 15 - params.exNeurons = [] + params.exNeurons double = [] + params.exNeuronsPhyID double = [] % alternative to exNeurons: specify neurons by phy cluster ID params.AllSomaticNeurons = false params.AllResponsiveNeurons = true params.fixedWindow = true @@ -51,6 +52,18 @@ function plotRaster(obj,params) 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; @@ -310,9 +323,13 @@ function plotRaster(obj,params) maxRespIn = maxRespIn-1; - + % trialsPerCath = length(directimesSorted)/(length(unique(seqMatrix))); trials = maxRespIn*trialsPerCath+1:maxRespIn*trialsPerCath + trialsPerCath; + bin3 = 1; + trialM = BuildBurstMatrix(goodU(:,u),round(p.t/bin3),round((directimesSorted+start)/bin3),round((window)/bin3)); + TrialM = squeeze(trialM(trials,:,:))'; + chan = goodU(1,u); @@ -331,8 +348,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) diff --git a/visualStimulationAnalysis/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv index 10bb840..1f1ee97 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.asv +++ b/visualStimulationAnalysis/RunAnalysisClass.asv @@ -5,7 +5,7 @@ data = readtable(excelFile); %% %% Rect Grid -for ex = 52 %84:91 +for ex = 69 %84:91 NP = loadNPclassFromTable(ex); %73 81 vsRe = rectGridAnalysis(NP); % vsRe.getSessionTime("overwrite",true); @@ -16,20 +16,20 @@ for ex = 52 %84:91 % % 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 - % close all;vsRe.plotRaster(MergeNtrials=1,overwrite=true,exNeurons=18, selectedLum=255,oneTrial = true,PaperFig = true) %43 + %vsRe.plotRaster(MergeNtrials=1,overwrite=true,AllResponsiveNeurons = true, selectedLum=[],oneTrial = true,PaperFig = true) %43 + vsRe.plotRaster(MergeNtrials=1,overwrite=true,exNeuronsPhyID=137, selectedLum=255,oneTrial = true,PaperFig = true) %43 vsRe.CalculateReceptiveFields('overwrite',true) %[colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=18,allStimParamsCombined=true,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 = [84:97]%97 74:84 (Neurons, 96_74, ) - ex = 84 +for ex = [81]%97 74:84 (Neurons, 96_74, ) NP = loadNPclassFromTable(ex); %73 81 vs = linearlyMovingBallAnalysis(NP,Session=1); % vs.getSessionTime("overwrite",true); @@ -42,16 +42,17 @@ for ex = [84:97]%97 74:84 (Neurons, 96_74, ) % % 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',82,'overwrite',true,'MergeNtrials',1,'OneDirection','up','OneLuminosity','white','PaperFig',true) + vs.plotRaster('exNeuronsPhyID',288,'overwrite',true,'MergeNtrials',3,'PaperFig',true) % % %vs.plotCorrSpikePattern % vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'OneDirection','up','OneLuminosity','white','MergeNtrials',1,'PaperFig',true) %vs.plotRaster('exNeurons',9,'AllResponsiveNeurons',false,'overwrite',true,'MergeNtrials',3,MaxVal_1=false) - vs.CalculateReceptiveFields('overwrite',true,testConvolution=false); + %vs.CalculateReceptiveFields('overwrite',true,testConvolution=false); % colorbarLims=vs.PlotReceptiveFields('exNeurons',82,'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); end %% PlotZScoreComparison @@ -68,13 +69,13 @@ end VStimAnalysis.PlotZScoreComparison([49:54,64:97] ,{'MB','RG'},StatMethod='bootsrapRespBase', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR %% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=true, smooth=50); %stimTypes=["linearlyMovingBall"] +plotPSTH_MultiExp([49:54], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] %% plotRaster_MultiExp([49:54,64:97], sortBy = "depth",overwrite=false,TakeTopPercentTrials=[]) %% Calculate spatial tuning -SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_ratio" ,overwrite=true, topPercent = 20) +results= SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_diff" ,overwrite=false, topPercent = 20,useRF=true,onOff=2); %% Get neuron depths getNeuronDepths([49:54,64:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates diff --git a/visualStimulationAnalysis/RunAnalysisClass.m b/visualStimulationAnalysis/RunAnalysisClass.m index 8be8cca..cb91ccc 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -5,7 +5,7 @@ %% %% Rect Grid -for ex = 52 %84:91 +for ex = 69 %84:91 NP = loadNPclassFromTable(ex); %73 81 vsRe = rectGridAnalysis(NP); % vsRe.getSessionTime("overwrite",true); @@ -16,20 +16,20 @@ % % 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 - % close all;vsRe.plotRaster(MergeNtrials=1,overwrite=true,exNeurons=18, selectedLum=255,oneTrial = true,PaperFig = true) %43 + %vsRe.plotRaster(MergeNtrials=1,overwrite=true,AllResponsiveNeurons = true, selectedLum=[],oneTrial = true,PaperFig = true) %43 + vsRe.plotRaster(MergeNtrials=1,overwrite=true,exNeuronsPhyID=137, selectedLum=255,oneTrial = true,PaperFig = true) %43 vsRe.CalculateReceptiveFields('overwrite',true) %[colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=18,allStimParamsCombined=true,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 = [84:97]%97 74:84 (Neurons, 96_74, ) - ex = 84 +for ex = [[49:54,64:97]]%97 74:84 (Neurons, 96_74, ) NP = loadNPclassFromTable(ex); %73 81 vs = linearlyMovingBallAnalysis(NP,Session=1); % vs.getSessionTime("overwrite",true); @@ -42,16 +42,17 @@ % % 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',82,'overwrite',true,'MergeNtrials',1,'OneDirection','up','OneLuminosity','white','PaperFig',true) + %vs.plotRaster('exNeuronsPhyID',288,'overwrite',true,'MergeNtrials',3,'PaperFig',true) % % %vs.plotCorrSpikePattern % vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'OneDirection','up','OneLuminosity','white','MergeNtrials',1,'PaperFig',true) %vs.plotRaster('exNeurons',9,'AllResponsiveNeurons',false,'overwrite',true,'MergeNtrials',3,MaxVal_1=false) - vs.CalculateReceptiveFields('overwrite',true,testConvolution=false); + %vs.CalculateReceptiveFields('overwrite',true,testConvolution=false); % colorbarLims=vs.PlotReceptiveFields('exNeurons',82,'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); end %% PlotZScoreComparison @@ -65,16 +66,16 @@ %[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=true,... - overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR +VStimAnalysis.PlotZScoreComparison([49:54,64:97] ,{'MB','RG'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true)%[49:54,57:91] %%Check why I have different array dimensions in MBR %% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=true, smooth=50); %stimTypes=["linearlyMovingBall"] +plotPSTH_MultiExp([49:54], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] %% -plotRaster_MultiExp([49:54,64:97], sortBy = "peak",overwrite=false,TakeTopPercentTrials=[]) +plotRaster_MultiExp([49:54,64:97], sortBy = "depth",overwrite=false,TakeTopPercentTrials=[]) %% Calculate spatial tuning -SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_ratio" ,overwrite=true, topPercent = 20) +results= SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_diff" ,overwrite=false, topPercent = 20,useRF=true,onOff=2); %% Get neuron depths getNeuronDepths([49:54,64:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates diff --git a/visualStimulationAnalysis/SpatialTuningIndex.m b/visualStimulationAnalysis/SpatialTuningIndex.m index 5a80b33..d815400 100644 --- a/visualStimulationAnalysis/SpatialTuningIndex.m +++ b/visualStimulationAnalysis/SpatialTuningIndex.m @@ -2,14 +2,14 @@ arguments exList double - params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] + params.stimTypes (1,:) string = ["linearlyMovingBall", "rectGrid"] params.topPercent double = 10 params.overwrite logical = false params.statType string = "BootstrapPerNeuron" params.speed double = 1 params.plot logical = true - params.indexType string = "L_amplitude" % L_amplitude_diff,L_amplitude_ratio, L_geometric, L_combined - params.onOff double = 1 % 1=on, 2=off (rectGrid only) + 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 @@ -17,13 +17,30 @@ 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. +end + +% Guard: prefDir requires useRF — it operates on RFuSTDirSizeLum which is +% only available in 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 % ------------------------------------------------------------------------- % Build save path % ------------------------------------------------------------------------- -NP_first = loadNPclassFromTable(exList(1)); +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); @@ -31,27 +48,35 @@ 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 experiment range, stim types, RF mode, and prefDir mode +% so that different parameter combinations never share a cache file stimLabel = strjoin(params.stimTypes, '-'); -nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s.mat', ... - exList(1), exList(end), stimLabel); +rfLabel = ''; +if params.useRF, rfLabel = '_RF'; end +prefLabel = ''; +if params.prefDir, prefLabel = '_prefDir'; end +nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s%s%s.mat', ... + exList(1), exList(end), stimLabel, rfLabel, prefLabel); % ------------------------------------------------------------------------- -% Decide whether to compute or load +% 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]); - % Jump straight to table building - tbl = S.tbl; + tbl = S.tbl; goto_plot = true; else fprintf('Experiment list mismatch — recomputing.\n'); @@ -69,13 +94,34 @@ nExp = numel(exList); nStim = numel(params.stimTypes); - tbl = table(); + % Guard: useRF must apply to all stim types — mixed inputs are not allowed + % because RF (convolution-based) and gridSpikeRate (trial-binned) measures + % are not directly comparable and must not be mixed across stim types + 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 @@ -83,22 +129,26 @@ 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}; % ---------------------------------------------------------- - % Find union of responsive neurons across ALL stim types + % Get phy IDs for all good units (same spike sorting for all stim types) % ---------------------------------------------------------- - - % Get phy IDs once — same for all stim types p_s = NP.convertPhySorting2tIc(obj_s.spikeSortingFolder); - phy_IDg = p_s.phy_ID(string(p_s.label') == 'good'); + 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); % ADD — stores respU indices per stim + respU_all = cell(1, nStim); + % ---------------------------------------------------------- + % Find responsive neurons for each stim type + % ---------------------------------------------------------- for s = 1:nStim stimType = params.stimTypes(s); try @@ -109,12 +159,14 @@ obj_s = linearlyMovingBallAnalysis(NP); end + % Select statistical test output if params.statType == "BootstrapPerNeuron" Stats = obj_s.BootstrapPerNeuron; else Stats = obj_s.ShufflingAnalysis; end + % Extract p-values (linearlyMovingBall has per-speed fields) try switch stimType case "linearlyMovingBall" @@ -127,9 +179,10 @@ pvals = Stats.pvalsResponse; end + % Indices of significantly responsive neurons (into phy_IDg) respU = find(pvals < 0.05); - respU_all{s} = respU; % ADD — index into gridSpikeRate dim 3 - respPhyIDs_all{s} = phy_IDg(respU); % phy IDs of responsive neurons + respU_all{s} = respU; + respPhyIDs_all{s} = phy_IDg(respU); fprintf(' [%s] %d responsive neuron(s).\n', stimType, numel(respU)); catch ME @@ -139,7 +192,9 @@ end end - % Intersection of responsive phy IDs across stim types + % ---------------------------------------------------------- + % Keep only neurons responsive to ALL stim types (intersection) + % ---------------------------------------------------------- sharedPhyIDs = respPhyIDs_all{1}; for s = 2:nStim sharedPhyIDs = intersect(sharedPhyIDs, respPhyIDs_all{s}); @@ -152,12 +207,18 @@ fprintf(' %d neuron(s) responsive to all stim types in exp %d.\n', numel(sharedPhyIDs), ex); - + % ---------------------------------------------------------- + % Loop over stim types and compute spatial tuning index + % ---------------------------------------------------------- for s = 1:nStim stimType = params.stimTypes(s); - % Build analysis object + % 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" @@ -172,124 +233,359 @@ continue end + % Load pre-computed receptive field results from file + S_rf = obj.CalculateReceptiveFields; % ---------------------------------------------------------- - % Load grid results + % Build gridSpikeRateSelected and gridShuffMean + % Two paths: RF-based (convolution) or grid-binned (gridSpikeRate) % ---------------------------------------------------------- - S_rf = obj.CalculateReceptiveFields; - gridSpikeRate = S_rf.gridSpikeRate; - gridSpikeRateShuff = S_rf.gridSpikeRateShuff; - - switch stimType - case "rectGrid" - gridSpikeRateSelected = gridSpikeRate(:,:,:,params.onOff,:,:); - gridShuffSelected = gridSpikeRateShuff(:,:,:,:,params.onOff,:,:); - - % Remove onOff singleton at dim 4 for rate: [9 9 nN 1 nSize nLum] -> [9 9 nN nSize nLum] - gridSpikeRateSelected = reshape(gridSpikeRateSelected, ... - [size(gridSpikeRateSelected,1), size(gridSpikeRateSelected,2), ... - size(gridSpikeRateSelected,3), size(gridSpikeRateSelected,5), ... - size(gridSpikeRateSelected,6)]); - - % Remove onOff singleton at dim 5 for shuff: [9 9 nN nShuffle 1 nSize nLum] -> [9 9 nN nShuffle nSize nLum] - 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; % [nGrid nGrid nN nSize nLum] - gridShuffSelected = gridSpikeRateShuff; % [nGrid nGrid nN nShuffle nSize nLum] - end + if params.useRF - % Find which indices of THIS stim's gridSpikeRate correspond to sharedPhyIDs - [~, neuronIdx] = ismember(sharedPhyIDs, phy_IDg(respU_all{s})); + switch stimType - gridSpikeRateSelected = gridSpikeRateSelected(:,:,neuronIdx,:,:); - gridShuffSelected = gridShuffSelected(:,:,neuronIdx,:,:,:); - - % Average over shuffles and reshape explicitly — no squeeze - gridShuffMean = mean(gridShuffSelected, 4); % [nGrid nGrid nN 1 nSize nLum] + 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); + 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, + % so we need to map via respU_all to get responsive ones. + % + % 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) + % Logical mask: conditions with this direction + 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 onto responsive neurons of this stim type + prefDirIdxResp = prefDirIdxAllGood(respU_all{s}); % [nRespUnits, 1] + + % Map onto shared neurons + [~, neuronIdx] = ismember(sharedPhyIDs, phy_IDg(respU_all{s})); + prefDirIdxShared = prefDirIdxResp(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: select same responsive+shared neuron subset + rfShuff = S_rf.RFuShuffST; % [rfY, rfX, nRespUnits] — already responsive-only + rfShuff = rfShuff(:,:, neuronIdx); % [rfY, rfX, nShared] — select shared neurons + + % neuronIdx already applied above — skip post-switch indexing + alreadyIndexed = true; - % Get dimensions explicitly + 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, :, :, :, :, :); % [1, nLums, nSize, screenRed, screenRed, nN] + rfShuffFull = S_rf.RFuShuffMean(params.onOff, :, :, :, :, :); % same + + 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 + % Row and column pixel indices for this spatial block + rr = (bi-1)*blockSize + (1:blockSize); + cc = (bj-1)*blockSize + (1:blockSize); + + % Average over spatial dims 3 and 4 + % [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); + + % Reshape and permute to [nN, nSize, nLums] + % 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(respU_all{s})); + gridSpikeRateSelected = gridSpikeRateSelected(:,:,neuronIdx,:,:); + gridShuffMean = gridShuffMean(:,:,neuronIdx,:,:); + end + + else + % ---------------------------------------------------------- + % Standard path: use gridSpikeRate / gridSpikeRateShuff + % Note: linearlyMovingBall may have structural zeros due to + % Y-offset bug in CalculateReceptiveFields — use useRF=true + % ---------------------------------------------------------- + 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" + % gridSpikeRate: [nGrid, nGrid, nN, 2, nSize, nLum] + % gridSpikeRateShuff: [nGrid, nGrid, nN, nShuffle, 2, nSize, nLum] + gridSpikeRateSelected = gridSpikeRate(:,:,:,params.onOff,:,:); + gridShuffSelected = gridSpikeRateShuff(:,:,:,:,params.onOff,:,:); + + % Remove onOff singleton: [9,9,nN,1,nSize,nLum] -> [9,9,nN,nSize,nLum] + gridSpikeRateSelected = reshape(gridSpikeRateSelected, ... + [size(gridSpikeRateSelected,1), size(gridSpikeRateSelected,2), ... + size(gridSpikeRateSelected,3), size(gridSpikeRateSelected,5), ... + size(gridSpikeRateSelected,6)]); + + % Remove onOff singleton: [9,9,nN,nShuffle,1,nSize,nLum] -> [9,9,nN,nShuffle,nSize,nLum] + 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; % [nGrid, nGrid, nN, nSize, nLum] + gridShuffSelected = gridSpikeRateShuff; % [nGrid, nGrid, nN, nShuffle, nSize, nLum] + end + + % Map sharedPhyIDs onto indices of this stim's responsive neurons + [~, 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); % [nGrid, nGrid, nN, 1, nSize, nLum] + + 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 before reshape: %s\n', num2str(size(gridSpikeRateSelected))); - fprintf('Expected: [%d %d %d %d %d]\n', nGrid, nGrid, nN, nSize, nLum); - - % Reshape both to clean [nGrid nGrid nN nSize nLum] - gridSpikeRateSelected = reshape(gridSpikeRateSelected, [nGrid nGrid nN nSize nLum]); - gridShuffMean = reshape(gridShuffMean, [nGrid nGrid nN nSize nLum]); + fprintf('gridSpikeRateSelected size: %s\n', num2str(size(gridSpikeRateSelected))); - nCells = nGrid * nGrid; - maxDist = sqrt(2) * (nGrid - 1); + gridSpikeRateSelected = reshape(gridSpikeRateSelected, [nGrid, nGrid, nN, nSize, nLum]); + gridShuffMean = reshape(gridShuffMean, [nGrid, nGrid, nN, nSize, nLum]); - % Average over shuffles + nCells = nGrid * nGrid; + maxDist = sqrt(2) * (nGrid - 1); % maximum possible distance between two grid cells % ---------------------------------------------------------- - % Compute indices + % Compute spatial tuning indices per neuron, size, and lum % ---------------------------------------------------------- - - fprintf('gridSpikeRate size: %s\n', num2str(size(gridSpikeRate))); - fprintf('gridSpikeRateShuff size: %s\n', num2str(size(gridSpikeRateShuff))); - fprintf('gridShuffMean size: %s\n', num2str(size(gridShuffMean))); - 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_diff = zeros(nN, 1); L_amplitude_ratio = zeros(nN, 1); - L_geometric = zeros(nN, 1); - L_combined = zeros(nN, 1); + L_geometric = zeros(nN, 1); + L_combined = zeros(nN, 1); for u = 1:nN - rateVec = rateFlat(:, u); - rateVecShuff = rateFlatShuff(:, u); + rateVec = rateFlat(:, u); % spike rate at each grid cell + rateVecShuff = rateFlatShuff(:, u); % shuffle baseline at each grid cell - % Top cells + % 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); + % Indices of top and rest cells for real and shuffle maps + topIdx = find(rateVec >= threshold); + topIdxShuff = find(rateVecShuff >= thresholdShuff); restIdx = setdiff(1:nCells, topIdx); restIdxShuff = setdiff(1:nCells, topIdxShuff); - % Amplitude - meanTop = mean(rateVec(topIdx)); - meanRest = mean(rateVec(restIdx)); - meanAll = mean(rateVec); - meanTopShuff = mean(rateVecShuff(topIdxShuff)); - meanRestShuff = mean(rateVecShuff(restIdxShuff)); - meanAllShuff = mean(rateVecShuff); + % Mean rates in top and rest regions for real and shuffle. + % Guard against empty restIdx: occurs when topPercent is large + % enough that all cells exceed the threshold (all tied at zero). + % In that case there is no spatial contrast — set meanRest = 0. + 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; - % Geometric + % L_geometric: clustering of top cells (low spread = high tuning) + % Convert linear indices to [row, col] grid coordinates [rowIdx, colIdx] = ind2sub([nGrid nGrid], topIdx); [rowIdxShuff, colIdxShuff] = ind2sub([nGrid nGrid], topIdxShuff); + % Mean pairwise distance among top cells, normalised by max possible distance if size(rowIdx, 1) > 1 D = mean(pdist([rowIdx, colIdx], 'euclidean')) / maxDist; else - D = 0; + D = 0; % single top cell: perfectly localised by definition end if size(rowIdxShuff, 1) > 1 DShuff = mean(pdist([rowIdxShuff, colIdxShuff], 'euclidean')) / maxDist; @@ -297,41 +593,55 @@ DShuff = 0; end + % Shuffle-corrected geometric index: positive = more clustered than chance 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 their source 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 rows for this condition - rows = table(); - rows.L_amplitude_diff = L_amplitude_diff; + % 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.insertion = categorical(repmat(ex, nN, 1)); - rows.animal = categorical(repmat({animalName}, nN, 1)); - rows.NeurID = (1:nN)'; - rows.onOff = repmat(params.onOff, nN, 1); % params.onOff for rectGrid, meaningless but consistent for movingBall - rows.sizeIdx = repmat(si, nN, 1); - rows.lumIdx = repmat(li, nN, 1); - - tbl = [tbl; rows]; - - end - end + 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)'; + % Store actual phy cluster ID for each neuron. + % After neuronIdx selection, neuron u in dim 3 corresponds to sharedPhyIDs(u). + rows.phyID = sharedPhyIDs(:); + rows.onOff = repmat(params.onOff, nN, 1); % meaningful for rectGrid; stored for consistency + rows.sizeIdx = repmat(si, nN, 1); + rows.lumIdx = repmat(li, nN, 1); + + 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 - % Clean categories - tbl.stimulus = removecats(tbl.stimulus); - tbl.animal = removecats(tbl.animal); - tbl.insertion = removecats(tbl.insertion); + % Remove unused categorical levels introduced by partial data + tbl.stimulus = removecats(tbl.stimulus); + tbl.animal = removecats(tbl.animal); + tbl.experimentNum = removecats(tbl.experimentNum); - % Save + % Cache results to disk S.expList = exList; S.tbl = tbl; S.params = params; @@ -342,26 +652,83 @@ 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). +% ========================================================================= + +% Filter to the requested condition — same as plot filter +idxCond = tbl.onOff == params.onOff & ... + tbl.sizeIdx == params.sizeIdx & ... + tbl.lumIdx == params.lumIdx; + +tblCond = tbl(idxCond, :); +tblCond.value = tblCond.(params.indexType); % column to rank on + +% Identify the stim label for SB (rectGrid) and MB (linearlyMovingBall) +sbLabel = 'rectGrid'; +mbLabel = 'linearlyMovingBall'; + +% Build one top-unit table per stim type +for tt = 1:2 + + if tt == 1 + stimLabel = sbLabel; + outField = 'topUnitsSB'; + else + stimLabel = mbLabel; + outField = 'topUnitsMB'; + end + + % Check this stim type was actually computed + if ~any(tblCond.stimulus == stimLabel) + fprintf(' No data for %s — skipping top unit table.\n', stimLabel); + results.(outField) = table(); + continue + end + + % Subset to this stim type + tblStim = tblCond(tblCond.stimulus == stimLabel, :); + + % Global threshold: top 20% across all animals and insertions + globalThreshold = prctile(tblStim.value, 80); + + % Select top units and sort descending by index value + topMask = tblStim.value >= globalThreshold; + tblTop = sortrows(tblStim(topMask, :), 'value', 'descend'); + + % Build clean output table with one row per top unit + outTbl = table(); + outTbl.animal = tblTop.animal; + outTbl.experimentNum = tblTop.experimentNum; + outTbl.phyID = tblTop.phyID; % phy cluster ID (Kilosort/phy) + outTbl.indexValue = tblTop.value; % spatial tuning index 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 requested condition + % Filter table to the requested on/off, size, and lum condition idx = tbl.onOff == params.onOff & ... - tbl.sizeIdx == params.sizeIdx & ... - tbl.lumIdx == params.lumIdx; + tbl.sizeIdx == params.sizeIdx & ... + tbl.lumIdx == params.lumIdx; - tblPlot = tbl(idx, :); + tblPlot = tbl(idx, :); tblPlot.value = tblPlot.(params.indexType); % select which index to plot % ---------------------------------------------------------- - % Compute p-values using hierBoot + % Compute hierarchical bootstrap p-value for the comparison pair % ---------------------------------------------------------- - ps = []; - - pairs = {char(params.stimTypes(1)), char(params.stimTypes(2))}; - + pairs = {char(params.stimTypes(1)), char(params.stimTypes(2))}; % 1x2 cell ps = zeros(size(pairs, 1), 1); j = 1; @@ -371,9 +738,10 @@ 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}; + % Compute per-neuron differences within each insertion + for ins = unique(tblPlot.experimentNum)' + idx1 = tblPlot.experimentNum == categorical(ins) & tblPlot.stimulus == pairs{i,1}; + idx2 = tblPlot.experimentNum == categorical(ins) & tblPlot.stimulus == pairs{i,2}; V1 = tblPlot.value(idx1); V2 = tblPlot.value(idx2); @@ -383,41 +751,48 @@ end animal = unique(tblPlot.animal(idx1)); - diffs = [diffs; V1 - V2]; - insers = [insers; double(repmat(ins, size(V1,1), 1))]; - animals = [animals; double(repmat(animal, size(V1,1), 1))]; + 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 + % Hierarchical bootstrap: respects nested structure + % (neurons within insertions within animals) bootDiff = hierBoot(diffs, params.nBoot, insers, animals); - ps(j) = mean(bootDiff <= 0); + ps(j) = mean(bootDiff <= 0); % one-tailed p: P(stim1 <= stim2) end j = j + 1; end - % ---------------------------------------------------------- - % Plot + % Plot swarm with bootstrap confidence intervals % ---------------------------------------------------------- - V1max = max(tblPlot.value, [], 'omitnan'); + V1max = max(tblPlot.value, [], 'omitnan'); % data max for y-axis scaling + + fprintf('Length of ps: %d\n', numel(ps)); + fprintf('Size of pairs: %s\n', num2str(size(pairs))); + + tblPlot.insertion = tblPlot.experimentNum; % rename for plotting compatibility [fig, ~] = plotSwarmBootstrapWithComparisons(tblPlot, pairs, ps, {'value'}, ... yLegend = params.yLegend, ... yMaxVis = max(params.yMaxVis, V1max), ... - diff = true, ... + diff = false, ... Alpha = params.Alpha, ... plotMeanSem = true); - title(sprintf('%s — %s (onOff=%d, size=%d, lum=%d)', ... + title(sprintf('%s — %s (onOff=%d, size=%d, lum=%d, RF=%d, prefDir=%d)', ... params.indexType, strjoin(params.stimTypes, '/'), ... - params.onOff, params.sizeIdx, params.lumIdx), ... + params.onOff, params.sizeIdx, params.lumIdx, params.useRF, params.prefDir), ... 'FontSize', 9); + % Save publication-quality figure if requested if params.PaperFig - vs_first.printFig(fig, sprintf('SpatialTuningIndex-%s-%s', ... - params.indexType, strjoin(params.stimTypes, '-')), ... + vs_first.printFig(fig, sprintf('SpatialTuningIndex-%s-%s-RF%d-prefDir%d', ... + params.indexType, strjoin(params.stimTypes, '-'), params.useRF, params.prefDir), ... PaperFig = params.PaperFig); end diff --git a/visualStimulationAnalysis/SpatialTuningIndexV2.m b/visualStimulationAnalysis/SpatialTuningIndexV2.m new file mode 100644 index 0000000..208f8e8 --- /dev/null +++ b/visualStimulationAnalysis/SpatialTuningIndexV2.m @@ -0,0 +1,433 @@ +function results = SpatialTuningIndex(exList, params) + +arguments + exList double + params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] + params.topPercent double = 10 + params.overwrite logical = false + params.statType string = "BootstrapPerNeuron" + params.speed double = 1 + params.plot logical = true + params.indexType string = "L_amplitude" % L_amplitude_diff,L_amplitude_ratio, 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.yLegend char = 'Spatial Tuning Index' + params.yMaxVis double = 1 + params.Alpha double = 0.4 + params.PaperFig logical = false + params.useRF 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']; +if ~exist([p '\Combined_lizard_analysis'], 'dir') + cd(p) + mkdir Combined_lizard_analysis +end +saveDir = [p '\Combined_lizard_analysis']; + +stimLabel = strjoin(params.stimTypes, '-'); +nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s.mat', ... + exList(1), exList(end), stimLabel); + +% ------------------------------------------------------------------------- +% Decide whether to compute or load +% ------------------------------------------------------------------------- +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]); + % Jump straight to table building + 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); + + tbl = table(); + + for ei = 1:nExp + + ex = exList(ei); + fprintf('\n=== Experiment %d ===\n', ex); + + try + NP = loadNPclassFromTable(ex); + catch ME + warning('Could not load experiment %d: %s', ex, ME.message); + continue + end + + obj_s = linearlyMovingBallAnalysis(NP); + + nameParts = split(NP.recordingName, '_'); + animalName = nameParts{1}; + + % ---------------------------------------------------------- + % Find union of responsive neurons across ALL stim types + % ---------------------------------------------------------- + + % Get phy IDs once — same for all stim types + p_s = NP.convertPhySorting2tIc(obj_s.spikeSortingFolder); + phy_IDg = p_s.phy_ID(string(p_s.label') == 'good'); + + respPhyIDs_all = cell(1, nStim); + respU_all = cell(1, nStim); % ADD — stores respU indices per stim + + for s = 1:nStim + stimType = params.stimTypes(s); + try + switch stimType + case "rectGrid" + obj_s = rectGridAnalysis(NP); + case "linearlyMovingBall" + obj_s = linearlyMovingBallAnalysis(NP); + end + + if params.statType == "BootstrapPerNeuron" + Stats = obj_s.BootstrapPerNeuron; + else + Stats = obj_s.ShufflingAnalysis; + end + + 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 + + respU = find(pvals < 0.05); + respU_all{s} = respU; % ADD — index into gridSpikeRate dim 3 + respPhyIDs_all{s} = phy_IDg(respU); % phy IDs of responsive neurons + 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 + + % Intersection of responsive phy IDs 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); + + + for s = 1:nStim + + stimType = params.stimTypes(s); + + % Build 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 grid results + % ---------------------------------------------------------- + S_rf = obj.CalculateReceptiveFields; + + gridSpikeRate = S_rf.gridSpikeRate; + gridSpikeRateShuff = S_rf.gridSpikeRateShuff; + + switch stimType + case "rectGrid" + gridSpikeRateSelected = gridSpikeRate(:,:,:,params.onOff,:,:); + gridShuffSelected = gridSpikeRateShuff(:,:,:,:,params.onOff,:,:); + + % Remove onOff singleton at dim 4 for rate: [9 9 nN 1 nSize nLum] -> [9 9 nN nSize nLum] + gridSpikeRateSelected = reshape(gridSpikeRateSelected, ... + [size(gridSpikeRateSelected,1), size(gridSpikeRateSelected,2), ... + size(gridSpikeRateSelected,3), size(gridSpikeRateSelected,5), ... + size(gridSpikeRateSelected,6)]); + + % Remove onOff singleton at dim 5 for shuff: [9 9 nN nShuffle 1 nSize nLum] -> [9 9 nN nShuffle nSize nLum] + 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; % [nGrid nGrid nN nSize nLum] + gridShuffSelected = gridSpikeRateShuff; % [nGrid nGrid nN nShuffle nSize nLum] + end + + % Find which indices of THIS stim's gridSpikeRate correspond to sharedPhyIDs + [~, neuronIdx] = ismember(sharedPhyIDs, phy_IDg(respU_all{s})); + + gridSpikeRateSelected = gridSpikeRateSelected(:,:,neuronIdx,:,:); + gridShuffSelected = gridShuffSelected(:,:,neuronIdx,:,:,:); + + % Average over shuffles and reshape explicitly — no squeeze + gridShuffMean = mean(gridShuffSelected, 4); % [nGrid nGrid nN 1 nSize nLum] + + % Get dimensions explicitly + nN = size(gridSpikeRateSelected, 3); + nSize = size(gridSpikeRateSelected, 4); + nLum = size(gridSpikeRateSelected, 5); + nGrid = size(gridSpikeRateSelected, 1); + + fprintf('gridSpikeRateSelected size before reshape: %s\n', num2str(size(gridSpikeRateSelected))); + fprintf('Expected: [%d %d %d %d %d]\n', nGrid, nGrid, nN, nSize, nLum); + + % Reshape both to clean [nGrid nGrid nN nSize nLum] + gridSpikeRateSelected = reshape(gridSpikeRateSelected, [nGrid nGrid nN nSize nLum]); + gridShuffMean = reshape(gridShuffMean, [nGrid nGrid nN nSize nLum]); + + nCells = nGrid * nGrid; + maxDist = sqrt(2) * (nGrid - 1); + + % Average over shuffles + + % ---------------------------------------------------------- + % Compute indices + % ---------------------------------------------------------- + + fprintf('gridSpikeRate size: %s\n', num2str(size(gridSpikeRate))); + fprintf('gridSpikeRateShuff size: %s\n', num2str(size(gridSpikeRateShuff))); + fprintf('gridShuffMean size: %s\n', num2str(size(gridShuffMean))); + + for si = 1:nSize + for li = 1:nLum + + 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); + + % Top 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); + + % Amplitude + meanTop = mean(rateVec(topIdx)); + meanRest = mean(rateVec(restIdx)); + meanAll = mean(rateVec); + meanTopShuff = mean(rateVecShuff(topIdxShuff)); + meanRestShuff = mean(rateVecShuff(restIdxShuff)); + meanAllShuff = mean(rateVecShuff); + + if meanAll == 0, meanAll = eps; end + if meanAllShuff == 0, meanAllShuff = eps; end + + L_amplitude_diff(u) = ... + (meanTop - meanRest) / meanAll - ... + (meanTopShuff - meanRestShuff) / meanAllShuff; + + shuffleNorm = (meanTopShuff - meanRestShuff) / meanAllShuff; + if shuffleNorm == 0, shuffleNorm = eps; end + + L_amplitude_ratio(u) = ((meanTop - meanRest) / meanAll) / shuffleNorm; + + % Geometric + [rowIdx, colIdx] = ind2sub([nGrid nGrid], topIdx); + [rowIdxShuff, colIdxShuff] = ind2sub([nGrid nGrid], topIdxShuff); + + 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 + + L_geometric(u) = (1 - D) - (1 - DShuff); + L_combined(u) = L_amplitude_diff(u) * L_geometric(u); + + end + + % Build rows 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.insertion = categorical(repmat(ex, nN, 1)); + rows.animal = categorical(repmat({animalName}, nN, 1)); + rows.NeurID = (1:nN)'; + rows.onOff = repmat(params.onOff, nN, 1); % params.onOff for rectGrid, meaningless but consistent for movingBall + rows.sizeIdx = repmat(si, nN, 1); + rows.lumIdx = repmat(li, nN, 1); + + tbl = [tbl; rows]; + + end + end + + fprintf(' [%s] Indices computed. %d neurons.\n', stimType, nN); + + end % stim loop + end % exp loop + + % Clean categories + tbl.stimulus = removecats(tbl.stimulus); + tbl.animal = removecats(tbl.animal); + tbl.insertion = removecats(tbl.insertion); + + % Save + 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; + +% ========================================================================= +% PLOT +% ========================================================================= +if params.plot + + % Filter table to requested condition + idx = tbl.onOff == params.onOff & ... + tbl.sizeIdx == params.sizeIdx & ... + tbl.lumIdx == params.lumIdx; + + tblPlot = tbl(idx, :); + tblPlot.value = tblPlot.(params.indexType); % select which index to plot + + % ---------------------------------------------------------- + % Compute p-values using hierBoot + % ---------------------------------------------------------- + ps = []; + + pairs = {char(params.stimTypes(1)), char(params.stimTypes(2))}; + + + ps = zeros(size(pairs, 1), 1); + j = 1; + + for i = 1:size(pairs, 1) + 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]; + 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 + + + % ---------------------------------------------------------- + % Plot + % ---------------------------------------------------------- + V1max = max(tblPlot.value, [], 'omitnan'); + + fprintf('Length of ps: %d\n', numel(ps)); + fprintf('Size of pairs: %s\n', num2str(size(pairs))); + + [fig, ~] = plotSwarmBootstrapWithComparisons(tblPlot, pairs, ps, {'value'}, ... + yLegend = params.yLegend, ... + yMaxVis = max(params.yMaxVis, V1max), ... + diff = true, ... + Alpha = params.Alpha, ... + plotMeanSem = true); + + title(sprintf('%s — %s (onOff=%d, size=%d, lum=%d)', ... + params.indexType, strjoin(params.stimTypes, '/'), ... + params.onOff, 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 + + results.fig = fig; + results.ps = ps; + +end + +end \ No newline at end of file diff --git a/visualStimulationAnalysis/plotPSTH_MultiExp.asv b/visualStimulationAnalysis/plotPSTH_MultiExp.asv deleted file mode 100644 index 6b6bc02..0000000 --- a/visualStimulationAnalysis/plotPSTH_MultiExp.asv +++ /dev/null @@ -1,465 +0,0 @@ -function plotPSTH_MultiExp(exList, params) - -arguments - exList double - params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] - params.binWidth double = 10 - params.smooth double = 0 % smoothing window in ms (0 = no smoothing) - params.statType string = "BootstrapPerNeuron" - params.speed string = "max" - params.alpha double = 0.05 - params.shadeSTD logical = true - params.postStim double = 500 - params.preBase double = 200 - params.overwrite logical = false - params.TakeTopPercentTrials double = 0.3 - params.zScore logical = false - params.PaperFig logical = false - params.byDepth logical = false % plot 3 depth bins per stim type -end - -% ------------------------------------------------------------------------- -% Load depth info from saved file (only 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 um\n', depthBinEdges(1), depthBinEdges(2)); - fprintf(' Bin 2 (middle) : %.0f - %.0f um\n', depthBinEdges(2), depthBinEdges(3)); - fprintf(' Bin 3 (deep) : %.0f - %.0f um\n', depthBinEdges(3), depthBinEdges(4)); -else - nDepthBins = 1; -end - -% ------------------------------------------------------------------------- -% Build save path -% ------------------------------------------------------------------------- -NP_first = loadNPclassFromTable(exList(1)); -vs_first = linearlyMovingBallAnalysis(NP_first); - -p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); -p = [p 'lizards']; -if ~exist([p '\Combined_lizard_analysis'], 'dir') - cd(p) - mkdir Combined_lizard_analysis -end -saveDir = [p '\Combined_lizard_analysis']; - -stimLabel = strjoin(params.stimTypes, '-'); -depthSuffix = ''; -if params.byDepth; depthSuffix = '_byDepth'; end -nameOfFile = sprintf('\\Ex_%d-%d_Combined_PSTHs_%s%s.mat', ... - exList(1), exList(end), stimLabel, depthSuffix); - -% ------------------------------------------------------------------------- -% Decide whether to recompute or load -% ------------------------------------------------------------------------- -if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite - S = load([saveDir nameOfFile]); - if isequal(S.expList, exList) - fprintf('Loading saved PSTHs from:\n %s\n', [saveDir nameOfFile]); - forloop = false; - else - fprintf('Experiment list mismatch — recomputing.\n'); - forloop = true; - end -else - forloop = true; -end - -% ========================================================================= -% EXPERIMENT LOOP -% ========================================================================= -if forloop - - nStim = numel(params.stimTypes); - nExp = numel(exList); - - % psthAll{s,b} — s = stim type, b = depth bin (1 if byDepth is off) - psthAll = cell(nStim, nDepthBins); - - lockedPreBase = []; - lockedNBins = []; - lockedEdges = []; - - for ei = 1:nExp - - ex = exList(ei); - fprintf('\n=== Experiment %d ===\n', ex); - - try - NP = loadNPclassFromTable(ex); - catch ME - warning('Could not load experiment %d: %s', ex, ME.message); - for s = 1:nStim - for b = 1:nDepthBins - if ~isempty(psthAll{s,b}) - psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; - end - end - end - continue - end - - for s = 1:nStim - - stimType = params.stimTypes(s); - - try - switch stimType - case "rectGrid" - obj = rectGridAnalysis(NP); - case "linearlyMovingBall" - obj = linearlyMovingBallAnalysis(NP); - case 'StaticGrating' - obj = StaticDriftingGratingAnalysis(NP); - case 'MovingGrating' - obj = StaticDriftingGratingAnalysis(NP); - otherwise - error('Unknown stimType: %s', stimType); - end - catch ME - warning('Could not build %s for exp %d: %s', stimType, ex, ME.message); - for b = 1:nDepthBins - if ~isempty(psthAll{s,b}) - psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; - end - end - continue - end - - NeuronResp = obj.ResponseWindow; - - if params.statType == "BootstrapPerNeuron" - Stats = obj.BootstrapPerNeuron; - else - Stats = obj.ShufflingAnalysis; - end - - if params.speed ~= "max" && isequal(obj.stimName,'linearlyMovingBall') - fieldName = 'Speed2'; startStim = 0; - elseif isequal(obj.stimName,'linearlyMovingBall') - fieldName = 'Speed1'; startStim = 0; - elseif isequal(params.stimTypes,'StaticGrating') - fieldName = 'Static'; startStim = 0; - elseif isequal(params.stimTypes,'MovingGrating') - startStim = obj.VST.static_time*1000; fieldName = 'Moving'; - else - startStim = 0; - end - - p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder,1,1); - label = string(p_sort.label'); - goodU = p_sort.ic(:, label == 'good'); - - try - pvals = Stats.(fieldName).pvalsResponse; - catch - pvals = Stats.pvalsResponse; - end - - try - C = NeuronResp.(fieldName).C; - catch - C = NeuronResp.C; - end - directimesSorted = C(:, 1)' + startStim; - - preBase = params.preBase; - windowTotal = preBase + params.postStim; - - if isempty(lockedPreBase) - lockedPreBase = preBase; - lockedEdges = 0 : params.binWidth : windowTotal; - lockedNBins = numel(lockedEdges) - 1; - tAxis = lockedEdges(1:end-1); - fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... - lockedPreBase, params.postStim, lockedNBins); - end - - eNeurons = find(pvals < params.alpha); - - if isempty(eNeurons) - fprintf(' [%s] No responsive neurons in exp %d.\n', stimType, ex); - for b = 1:nDepthBins - if ~isempty(psthAll{s,b}) - psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; - end - end - continue - end - - fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', stimType, ex, numel(eNeurons)); - - % ---------------------------------------------------------- - % Build PSTH per neuron - % ---------------------------------------------------------- - psthRateNeurons = zeros(numel(eNeurons), lockedNBins); - 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; % unknown depth — skip - 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; % all neurons in single bin - end - - MRhist = BuildBurstMatrix( ... - goodU(:, u), ... - round(p_sort.t), ... - round(directimesSorted - lockedPreBase), ... - round(windowTotal)); - MRhist = squeeze(MRhist); - - if ~isempty(params.TakeTopPercentTrials) - MeanTrial = mean(MRhist, 2); - [~, ind] = sort(MeanTrial, 'descend'); - takeTrials = ind(1:round(numel(MeanTrial)*params.TakeTopPercentTrials)); - MRhist = MRhist(takeTrials, :); - end - - nTrials = size(MRhist, 1); - spikeTimes = repmat((1:size(MRhist,2)), nTrials, 1); - spikeTimes = spikeTimes(logical(MRhist)); - counts = histcounts(spikeTimes, lockedEdges); - psthRateNeurons(ni, :) = (counts / (params.binWidth * nTrials)) * 1000; - end - - % ---------------------------------------------------------- - % Average per depth bin and append - % ---------------------------------------------------------- - for b = 1:nDepthBins - binNeurons = neuronBinIdx == b; - if ~any(binNeurons) - fprintf(' [%s] No neurons in depth bin %d for exp %d.\n', stimType, b, ex); - if ~isempty(psthAll{s,b}) - psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; - end - continue - end - - psthExp = mean(psthRateNeurons(binNeurons, :), 1, 'omitnan'); - - if params.zScore - baselineBins = tAxis < lockedPreBase; - baselineMean = mean(psthExp(baselineBins)); - baselineStd = std(psthExp(baselineBins)); - if baselineStd > 0 - psthExp = (psthExp - baselineMean) / baselineStd; - else - warning(' [%s] Bin %d: baseline std is zero for exp %d.', stimType, b, ex); - if ~isempty(psthAll{s,b}) - psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; - end - continue - end - end - - psthAll{s,b} = [psthAll{s,b}; psthExp(:)']; - fprintf(' [%s] Bin %d: %d neuron(s) in exp %d.\n', stimType, b, sum(binNeurons), ex); - end - - end % stim loop - end % experiment loop - - % ------------------------------------------------------------------ - % Save - % ------------------------------------------------------------------ - S.expList = exList; - S.lockedEdges = lockedEdges; - S.lockedPreBase = lockedPreBase; - S.params = params; - - for s = 1:numel(params.stimTypes) - stimField = matlab.lang.makeValidName(params.stimTypes(s)); - for b = 1:nDepthBins - S.(sprintf('%s_bin%d', stimField, b)) = psthAll{s,b}; - end - end - - save([saveDir nameOfFile], '-struct', 'S'); - fprintf('\nSaved PSTHs to:\n %s\n', [saveDir nameOfFile]); - -else - % Load psthAll from disk - lockedEdges = S.lockedEdges; - lockedPreBase = S.lockedPreBase; - - psthAll = cell(numel(params.stimTypes), nDepthBins); - for s = 1:numel(params.stimTypes) - stimField = matlab.lang.makeValidName(params.stimTypes(s)); - for b = 1:nDepthBins - fieldKey = sprintf('%s_bin%d', stimField, b); - if isfield(S, fieldKey) - psthAll{s,b} = S.(fieldKey); - else - warning('Field "%s" not found in saved file.', fieldKey); - psthAll{s,b} = []; - end - end - end -end - -% ========================================================================= -% PLOT -% ========================================================================= - -tAxis = lockedEdges(1:end-1); -tAxisPlot = tAxis - lockedPreBase; - -baseColors = lines(numel(params.stimTypes)); -depthShades = [0.05, 0.45, 0.78]; % light → dark for shallow → deep -binLabels = {'shallow', 'middle', 'deep'}; - -stimLegendMap = containers.Map(... - {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... - {'MB', 'SB', 'MG', 'SG'}); - -% ------------------------------------------------------------------ -% First pass: global ylim -% ------------------------------------------------------------------ -yMax = 0; -yMin = inf; - -meanAll = cell(numel(params.stimTypes), nDepthBins); -semAll = cell(numel(params.stimTypes), nDepthBins); - -for s = 1:numel(params.stimTypes) - for b = 1:nDepthBins - data = psthAll{s,b}; - if isempty(data); continue; end - validRows = ~all(isnan(data), 2); - data = data(validRows, :); - if isempty(data); continue; end - meanAll{s,b} = mean(data, 1, 'omitnan'); - semAll{s,b} = std(data, 0, 1, 'omitnan') / sqrt(sum(~isnan(data(:,1)))); - yMax = max(yMax, max(meanAll{s,b} + semAll{s,b})); - yMin = min(yMin, min(meanAll{s,b} - semAll{s,b})); - end -end - -yPad = (yMax - yMin) * 0.1; -if params.zScore - yLims = [yMin - yPad, yMax + yPad]; -else - yLims = [max(0, yMin - yPad), yMax + yPad]; -end - -% ------------------------------------------------------------------ -% Plot -% ------------------------------------------------------------------ -fig = figure; -set(fig, 'Units', 'centimeters', 'Position', [5 5 9 10]); -ax = axes(fig); -hold(ax, 'on'); - -legendHandles = []; -legendLabels = {}; - -for s = 1:numel(params.stimTypes) - - stimKey = char(params.stimTypes(s)); - if isKey(stimLegendMap, stimKey) - shortName = stimLegendMap(stimKey); - else - shortName = stimKey; - end - - for b = 1:nDepthBins - - data = psthAll{s,b}; - if isempty(data); continue; end - validRows = ~all(isnan(data), 2); - data = data(validRows, :); - if isempty(data); continue; end - - meanPSTH = meanAll{s,b}; - semPSTH = semAll{s,b}; - - % Smooth if requested - if params.smooth > 0 - smoothBins = round(params.smooth / params.binWidth); % convert ms to bins - meanPSTH = smoothdata(meanPSTH, 'gaussian', smoothBins); - semPSTH = smoothdata(semPSTH, 'gaussian', smoothBins); - end - - % Color and label depend on mode - if params.byDepth - lineColor = baseColors(s,:) * (1 - depthShades(b)); - legendLabel = sprintf('%s %s (%.0f-%.0f um)', ... - shortName, binLabels{b}, depthBinEdges(b), depthBinEdges(b+1)); - else - lineColor = baseColors(s,:); - legendLabel = shortName; - end - - % SEM shading - if params.shadeSTD && size(data,1) > 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 line - h = plot(ax, tAxisPlot(:)', meanPSTH(:)', ... - 'Color', lineColor, 'LineWidth', 1.5); - - legendHandles(end+1) = h; %#ok - legendLabels{end+1} = legendLabel; %#ok - - fprintf(' [%s] n=%d experiments in plot.\n', legendLabel, sum(validRows)); - end -end - -xline(ax, 0, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); -xline(ax, params.postStim, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); - -if params.zScore; yLabel = 'Z-score'; else; yLabel = '[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', 'northeast', ... - 'FontName', 'helvetica', 'FontSize', 7); - -ax.FontName = 'helvetica'; -ax.FontSize = 8; -ax.YAxis.FontSize = 8; -ax.XAxis.FontSize = 8; -hold(ax, 'off'); - -sgtitle(sprintf('N = %d', numel(exList)), 'FontName', 'helvetica', 'FontSize', 11); -set(fig, 'Units', 'centimeters', 'Position', [20 20 8 6]); - -if params.PaperFig - vs_first.printFig(fig, sprintf('PSTH-depth-%s-%s', ... - params.stimTypes(1), params.stimTypes(2)), PaperFig = params.PaperFig) -end - -end \ No newline at end of file diff --git a/visualStimulationAnalysis/plotRaster_MultiExp.asv b/visualStimulationAnalysis/plotRaster_MultiExp.asv deleted file mode 100644 index 3623ccb..0000000 --- a/visualStimulationAnalysis/plotRaster_MultiExp.asv +++ /dev/null @@ -1,448 +0,0 @@ -function plotRaster_MultiExp(exList, params) - -arguments - exList double - params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] - params.binWidth double = 10 - params.smooth double = 0 - params.statType string = "BootstrapPerNeuron" - params.speed string = "max" - params.alpha double = 0.05 - params.postStim double = 500 - params.preBase double = 200 - params.overwrite logical = false - params.TakeTopPercentTrials double = 0.3 - params.zScore logical = true % default true — more meaningful for raster - params.sortBy string = "peak" % "peak" = sort by peak response time, "depth" = sort by depth - params.PaperFig logical = false -end - -% ------------------------------------------------------------------------- -% Load depth info if sorting by depth -% ------------------------------------------------------------------------- -if params.sortBy == "depth" - 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; -end - -% ------------------------------------------------------------------------- -% Build save path -% ------------------------------------------------------------------------- -NP_first = loadNPclassFromTable(exList(1)); -vs_first = linearlyMovingBallAnalysis(NP_first); - -p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); -p = [p 'lizards']; -if ~exist([p '\Combined_lizard_analysis'], 'dir') - cd(p) - mkdir Combined_lizard_analysis -end -saveDir = [p '\Combined_lizard_analysis']; - -stimLabel = strjoin(params.stimTypes, '-'); -nameOfFile = sprintf('\\Ex_%d-%d_Raster_%s.mat', exList(1), exList(end), stimLabel); - -% ------------------------------------------------------------------------- -% Decide whether to recompute or load -% ------------------------------------------------------------------------- -if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite - S = load([saveDir nameOfFile]); - if isequal(S.expList, exList) - fprintf('Loading saved raster data from:\n %s\n', [saveDir nameOfFile]); - forloop = false; - else - fprintf('Experiment list mismatch — recomputing.\n'); - forloop = true; - end -else - forloop = true; -end - -% ========================================================================= -% EXPERIMENT LOOP -% ========================================================================= -if forloop - - nStim = numel(params.stimTypes); - nExp = numel(exList); - - % rasterAll{s} grows one row per responsive neuron across all experiments - % each row = mean PSTH of one neuron in spk/s (or z-score) - rasterAll = cell(1, nStim); % nNeurons x nBins - depthAll = cell(1, nStim); % nNeurons x 1 — depth of each neuron - expAll = cell(1, nStim); % nNeurons x 1 — which experiment each neuron came from - - for s = 1:nStim - rasterAll{s} = []; - depthAll{s} = []; - expAll{s} = []; - end - - lockedPreBase = []; - lockedNBins = []; - lockedEdges = []; - tAxis = []; - - for ei = 1:nExp - - ex = exList(ei); - fprintf('\n=== Experiment %d ===\n', ex); - - try - NP = loadNPclassFromTable(ex); - catch ME - warning('Could not load experiment %d: %s', ex, ME.message); - continue - end - - for s = 1:nStim - - stimType = params.stimTypes(s); - - try - switch stimType - case "rectGrid" - obj = rectGridAnalysis(NP); - case "linearlyMovingBall" - obj = linearlyMovingBallAnalysis(NP); - case "StaticGrating" - obj = StaticDriftingGratingAnalysis(NP); - case "MovingGrating" - obj = StaticDriftingGratingAnalysis(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 - - NeuronResp = obj.ResponseWindow; - - if params.statType == "BootstrapPerNeuron" - Stats = obj.BootstrapPerNeuron; - else - Stats = obj.ShufflingAnalysis; - end - - % Resolve field name and stim start - fieldName = ''; - startStim = 0; - if params.speed ~= "max" && isequal(obj.stimName, 'linearlyMovingBall') - fieldName = 'Speed2'; - elseif isequal(obj.stimName, 'linearlyMovingBall') - fieldName = 'Speed1'; - elseif isequal(stimType, 'StaticGrating') - fieldName = 'Static'; - elseif isequal(stimType, 'MovingGrating') - fieldName = 'Moving'; - startStim = obj.VST.static_time * 1000; - end - - p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); - label = string(p_sort.label'); - goodU = p_sort.ic(:, label == 'good'); - - try - pvals = Stats.(fieldName).pvalsResponse; - catch - pvals = Stats.pvalsResponse; - end - - try - C = NeuronResp.(fieldName).C; - catch - C = NeuronResp.C; - end - directimesSorted = C(:, 1)' + startStim; - - preBase = params.preBase; - windowTotal = preBase + params.postStim; - - if isempty(lockedPreBase) - lockedPreBase = preBase; - lockedEdges = 0 : params.binWidth : windowTotal; - lockedNBins = numel(lockedEdges) - 1; - tAxis = lockedEdges(1:end-1); - fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... - lockedPreBase, params.postStim, lockedNBins); - end - - eNeurons = find(pvals < params.alpha); - - 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); - - % ---------------------------------------------------------- - % Build per-neuron PSTH - % ---------------------------------------------------------- - for ni = 1:numel(eNeurons) - u = eNeurons(ni); - - MRhist = BuildBurstMatrix( ... - goodU(:, u), ... - round(p_sort.t), ... - round(directimesSorted - lockedPreBase), ... - round(windowTotal)); - MRhist = squeeze(MRhist); - - if ~isempty(params.TakeTopPercentTrials) - MeanTrial = mean(MRhist, 2); - [~, ind] = sort(MeanTrial, 'descend'); - takeTrials = ind(1:round(numel(MeanTrial)*params.TakeTopPercentTrials)); - MRhist = MRhist(takeTrials, :); - end - - nTrials = size(MRhist, 1); - spikeTimes = repmat((1:size(MRhist,2)), nTrials, 1); - spikeTimes = spikeTimes(logical(MRhist)); - counts = histcounts(spikeTimes, lockedEdges); - neuronPSTH = (counts / (params.binWidth * nTrials)) * 1000; % spk/s - - % Z-score using baseline - if params.zScore - baselineBins = tAxis < lockedPreBase; - bMean = mean(neuronPSTH(baselineBins)); - bStd = std(neuronPSTH(baselineBins)); - if bStd > 0 - neuronPSTH = (neuronPSTH - bMean) / bStd; - else - continue % skip neuron if baseline std is zero - end - end - - % Smooth if requested - if params.smooth > 0 - smoothBins = round(params.smooth / params.binWidth); - neuronPSTH = smoothdata(neuronPSTH, 'gaussian', smoothBins); - end - - % Append neuron row - rasterAll{s} = [rasterAll{s}; neuronPSTH]; - - % Get depth for this neuron - if params.sortBy == "depth" - depthRow = depthTable.Experiment == ex & depthTable.Unit == u; - if any(depthRow) - depthAll{s}(end+1) = depthTable.Depth_um(depthRow); - else - depthAll{s}(end+1) = NaN; - end - else - depthAll{s}(end+1) = NaN; - end - - expAll{s}(end+1) = ex; - end - - end % stim loop - end % experiment loop - - % ------------------------------------------------------------------ - % Save - % ------------------------------------------------------------------ - S.expList = exList; - S.lockedEdges = lockedEdges; - S.lockedPreBase = lockedPreBase; - S.params = params; - - for s = 1:numel(params.stimTypes) - stimField = matlab.lang.makeValidName(params.stimTypes(s)); - S.(sprintf('%s_raster', stimField)) = rasterAll{s}; - S.(sprintf('%s_depth', stimField)) = depthAll{s}; - S.(sprintf('%s_exp', stimField)) = expAll{s}; - end - - save([saveDir nameOfFile], '-struct', 'S'); - fprintf('\nSaved raster data to:\n %s\n', [saveDir nameOfFile]); - -else - % Load from disk - lockedEdges = S.lockedEdges; - lockedPreBase = S.lockedPreBase; - - rasterAll = cell(1, numel(params.stimTypes)); - depthAll = cell(1, numel(params.stimTypes)); - expAll = cell(1, numel(params.stimTypes)); - - for s = 1:numel(params.stimTypes) - stimField = matlab.lang.makeValidName(params.stimTypes(s)); - rasterAll{s} = S.(sprintf('%s_raster', stimField)); - depthAll{s} = S.(sprintf('%s_depth', stimField)); - expAll{s} = S.(sprintf('%s_exp', stimField)); - end -end - -% ========================================================================= -% SORT NEURONS -% ========================================================================= -for s = 1:numel(params.stimTypes) - data = rasterAll{s}; - if isempty(data); continue; end - - if params.sortBy == "peak" - % Sort by time of peak response in the post-stimulus window - postStimBins = tAxis >= lockedPreBase; - [~, peakBin] = max(data(:, postStimBins), [], 2); - [~, sortIdx] = sort(peakBin); - elseif params.sortBy == "depth" - % Sort by depth (shallow to deep) - [~, sortIdx] = sort(depthAll{s}, 'ascend'); - else - sortIdx = 1:size(data, 1); % no sorting - end - - rasterAll{s} = data(sortIdx, :); - depthAll{s} = depthAll{s}(sortIdx); - expAll{s} = expAll{s}(sortIdx); -end - -% ========================================================================= -% PLOT -% ========================================================================= - -tAxis = lockedEdges(1:end-1); -tAxisPlot = tAxis - lockedPreBase; - -stimLegendMap = containers.Map(... - {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... - {'MB', 'SB', 'MG', 'SG'}); - -nStim = numel(params.stimTypes); - -% ------------------------------------------------------------------ -% Global color limits across all stim types -% ------------------------------------------------------------------ -allValues = []; -for s = 1:nStim - if ~isempty(rasterAll{s}) - allValues = [allValues, rasterAll{s}(:)']; %#ok - end -end -cLimMax = prctile(abs(allValues), 98); % robust limit — ignore extreme outliers -if params.zScore - cLims = [-cLimMax, cLimMax]; % symmetric around zero for z-score -else - cLims = [0, cLimMax]; -end - -% ------------------------------------------------------------------ -% Figure and tiled layout -% ------------------------------------------------------------------ -fig = figure; -set(fig, 'Units', 'centimeters', 'Position', [5 5 5*nStim + 2, 10]); - -tl = tiledlayout(fig, 1, nStim, 'TileSpacing', 'compact', 'Padding', 'compact'); - -axAll = gobjects(1, nStim); - -for s = 1:nStim - - data = rasterAll{s}; - stimKey = char(params.stimTypes(s)); - if isKey(stimLegendMap, stimKey) - shortName = stimLegendMap(stimKey); - else - shortName = stimKey; - end - - axAll(s) = nexttile(tl); - ax = axAll(s); - - if isempty(data) - title(ax, shortName, 'FontName', 'helvetica', 'FontSize', 8); - axis(ax, 'off'); - continue - end - - % imagesc: x = time, y = neuron index - imagesc(ax, tAxisPlot, 1:size(data,1), data); - clim(ax, cLims); - colormap(ax, flipud(gray)); % white = low, black = high - - % ------------------------------------------------------------------ - % Depth bin boundary lines (only when sorted by depth) - % ------------------------------------------------------------------ - if params.sortBy == "depth" && ~isempty(depthAll{s}) - - % Load bin edges - depthFile = 'W:\Large_scale_mapping_NP\lizards\Combined_lizard_analysis\NeuronDepths.mat'; - D = load(depthFile); - depthBinEdges = D.depthBinEdges; - - 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))}; - - % Find the last neuron index belonging to each bin boundary - for edge = 2:3 % edges 2 and 3 are the internal boundaries - %lastInBin = find(depthAll{s} <= depthBinEdges(edge), 1, 'last'); - %lastInBin = find(~isnan(depthAll{s}) & depthAll{s} <= depthBinEdges(edge), 1, 'last'); - depthCombined = depthAll{s}; - depthCombined = depthCombined(); - if ~isempty(lastInBin) && lastInBin < size(data,1) - yline(ax, lastInBin + 0.5, 'r-', 'LineWidth', 1.2); - % Label on the right side showing the bin range - text(ax, tAxisPlot(end), lastInBin - size(data,1)*0.02, ... - binLabelsDepth{edge-1}, ... - 'Color', 'r', 'FontSize', 6, 'FontName', 'helvetica', ... - 'HorizontalAlignment', 'right', 'VerticalAlignment', 'top'); - end - end - % Label for the deepest bin - text(ax, tAxisPlot(end), size(data,1), ... - binLabelsDepth{3}, ... - 'Color', 'r', 'FontSize', 6, 'FontName', 'helvetica', ... - 'HorizontalAlignment', 'right', 'VerticalAlignment', 'top'); - end - - % Stim onset and offset lines - xline(ax, 0, 'w--', 'LineWidth', 1.0); - xline(ax, params.postStim, 'w--', 'LineWidth', 1.0); - - xlim(ax, [tAxisPlot(1), tAxisPlot(end)]); - ylim(ax, [0.5, size(data,1)+0.5]); - - xlabel(ax, 'Time re. stim onset [ms]', 'FontName', 'helvetica', 'FontSize', 8); - if s == 1 - ylabel(ax, 'Neuron #', 'FontName', 'helvetica', 'FontSize', 8); - end - title(ax, sprintf('%s (n=%d)', shortName, size(data,1)), ... - 'FontName', 'helvetica', 'FontSize', 8); - - ax.FontName = 'helvetica'; - ax.FontSize = 8; - ax.YDir = 'normal'; % neuron 1 at bottom - -end - -% ------------------------------------------------------------------ -% Single colorbar for the whole layout -% ------------------------------------------------------------------ -cb = colorbar(axAll(end)); -if params.zScore - cb.Label.String = 'Z-score'; -else - cb.Label.String = 'Firing rate [spk/s]'; -end -cb.Label.FontName = 'helvetica'; -cb.Label.FontSize = 8; -cb.FontName = 'helvetica'; -cb.FontSize = 8; - -sgtitle(sprintf('N = %d experiments', numel(exList)), ... - 'FontName', 'helvetica', 'FontSize', 10); - -if params.PaperFig - vs_first.printFig(fig, sprintf('Raster-%s', stimLabel), PaperFig=params.PaperFig); -end - -end \ No newline at end of file diff --git a/visualStimulationAnalysis/plotRaster_MultiExp.m b/visualStimulationAnalysis/plotRaster_MultiExp.m index b31a0f9..aa5e93c 100644 --- a/visualStimulationAnalysis/plotRaster_MultiExp.m +++ b/visualStimulationAnalysis/plotRaster_MultiExp.m @@ -17,6 +17,7 @@ function plotRaster_MultiExp(exList, params) params.PaperFig logical = false params.climPrctile double = 90 % percentile for color limit — lower = more contrast params.climNeg double = 0 % fixed negative z-score limit (absolute value) + params.colormap string = "gray" end % ------------------------------------------------------------------------- @@ -370,8 +371,8 @@ function plotRaster_MultiExp(exList, params) % imagesc: x = time, y = neuron index imagesc(ax, tAxisPlot, 1:size(data,1), data); clim(ax, cLims); - %colormap(ax, flipud(gray)); % white = low, black = high - if params.zScore + colormap(ax, flipud(gray)); % white = low, black = high + if params.zScore && params.colormap ~= "gray" cLimPos = prctile(allValues, params.climPrctile); cLims = [-params.climNeg, cLimPos]; From 86be57562eb0b680fbd71808673d1bdd59360091 Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Tue, 14 Apr 2026 04:04:39 +0300 Subject: [PATCH 10/19] Adding new statistical changes --- .../plotSwarmBootstrapWithComparisons.m | 731 ++++--- .../StaticDriftingGratingAnalysis.m | 4 +- .../@VStimAnalysis/PlotZScoreComparison.asv | 1524 --------------- .../@VStimAnalysis/StatisticsPerNeuron.asv | 521 +++++ .../@VStimAnalysis/StatisticsPerNeuron.m | 486 +++-- .../@VStimAnalysis/VStimAnalysis.asv | 912 +++++++++ .../@VStimAnalysis/VStimAnalysis.m | 1 + .../fullFieldFlashAnalysis.m | 4 +- .../@imageAnalysis/imageAnalysis.m | 4 +- .../linearlyMovingBallAnalysis.m | 17 +- .../linearlyMovingBarAnalysis.m | 4 +- .../@movieAnalysis/movieAnalysis.m | 4 +- .../@rectGridAnalysis/rectGridAnalysis.m | 4 +- visualStimulationAnalysis/AllExpAnalysis.asv | 1688 +++++++++++++++++ visualStimulationAnalysis/AllExpAnalysis.m | 1686 ++++++++++++++++ visualStimulationAnalysis/AllExpAnalysisV2.m | 1037 ++++++++++ .../RunAnalysisClass.asv | 36 +- visualStimulationAnalysis/RunAnalysisClass.m | 36 +- .../SpatialTuningIndex.m | 4 +- .../SpatialTuningIndexV1.m | 246 --- .../SpatialTuningIndexV2.m | 433 ----- .../plotPSTH_MultiExpV1.m | 465 ----- 22 files changed, 6662 insertions(+), 3185 deletions(-) delete mode 100644 visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.asv create mode 100644 visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv create mode 100644 visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.asv create mode 100644 visualStimulationAnalysis/AllExpAnalysis.asv create mode 100644 visualStimulationAnalysis/AllExpAnalysis.m create mode 100644 visualStimulationAnalysis/AllExpAnalysisV2.m delete mode 100644 visualStimulationAnalysis/SpatialTuningIndexV1.m delete mode 100644 visualStimulationAnalysis/SpatialTuningIndexV2.m delete mode 100644 visualStimulationAnalysis/plotPSTH_MultiExpV1.m diff --git a/general functions/plotSwarmBootstrapWithComparisons.m b/general functions/plotSwarmBootstrapWithComparisons.m index 51ea88d..b52264f 100644 --- a/general functions/plotSwarmBootstrapWithComparisons.m +++ b/general functions/plotSwarmBootstrapWithComparisons.m @@ -1,391 +1,522 @@ -function [fig,randiColors] = plotSwarmBootstrapWithComparisons(tbl, pairs, pValues, valueField, params) +function [fig, randiColors] = plotSwarmBootstrapWithComparisons(tbl, pairs, pValues, valueField, params) 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 + 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 logical = true + params.Alpha = 0.2 + params.plotMeanSem logical = true + params.colorByZScore logical = false % color dots by zScore column in tbl instead of by animal + params.showBothAndDiff logical = true % show raw (both stim types) in left tile AND difference in right tile + params.drawLines logical = false %draw lines between points end -%% ----------------- PARAMETERS ----------------- -yMaxVis = params.yMaxVis; -bracketPad = yMaxVis * 0.05; -stackPad = yMaxVis * 0.05; -textPad = yMaxVis * 0.01; -semAlpha = 0.6; +% ------------------------------------------------------------------------- +% Validate Z-score coloring request +% ------------------------------------------------------------------------- +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 -%% ----------------- DIFF MODE ----------------- -if params.diff +% showBothAndDiff implicitly requires diff=false at the top level, because +% it manages the diff internally in the right tile +if params.showBothAndDiff && params.diff + warning('showBothAndDiff=true overrides params.diff — diff will be shown in the right tile only.'); + params.diff = false; +end - assert(~isempty(pairs) && size(pairs,1) >= 1, ... - 'params.diff=true requires at least one stimulus pair.'); +% ------------------------------------------------------------------------- +% Shared parameters derived from yMaxVis +% ------------------------------------------------------------------------- +yMaxVis = params.yMaxVis; +bracketPad = yMaxVis * 0.05; +stackPad = yMaxVis * 0.05; +textPad = yMaxVis * 0.01; +semAlpha = 0.6; + +% ------------------------------------------------------------------------- +% Pre-process tbl: rename stim labels and compute value column +% ------------------------------------------------------------------------- +tbl = renameStimulusLabels(tbl); % RG->SB, SDGs->SG, SDGm->MG +pairs = renamePairLabels(pairs); % same substitution in pairs + +if params.fraction + assert(numel(valueField) == 2, 'Fraction mode requires two valueField entries.'); + tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); +else + tbl.value = tbl.(valueField{1}); +end - stimA = pairs{1,1}; - stimB = pairs{1,2}; +tbl.stimulus = removecats(tbl.stimulus); +tbl.animal = removecats(tbl.animal); +tbl.insertion = removecats(tbl.insertion); - if params.fraction - assert(numel(valueField) == 2, ... - 'Fraction mode requires two valueField entries.'); - end +% ------------------------------------------------------------------------- +% Build figure: single axes OR tiledlayout(1,2) for showBothAndDiff +% ------------------------------------------------------------------------- +fig = figure; +set(fig, 'Color', 'w'); +if params.showBothAndDiff + % Two tiles: [raw both stim types | difference] + tl = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact'); - ins = categories(tbl.insertion); + axRaw = nexttile(tl, 1); % left tile: raw swarm + axDiff = nexttile(tl, 2); % right tile: difference swarm - diffVals = []; - animals = []; - insers = []; + % --- LEFT TILE: raw (both stim types) --- + randiColors = plotRawSwarm(axRaw, tbl, pairs, pValues, params, ... + yMaxVis, bracketPad, stackPad, textPad, semAlpha); - for i = 1:numel(ins) - idxA = tbl.insertion == ins{i} & tbl.stimulus == stimA; - idxB = tbl.insertion == ins{i} & tbl.stimulus == stimB; + % --- RIGHT TILE: difference --- + tblDiff = buildDiffTable(tbl, pairs, params); + plotDiffSwarm(axDiff, tblDiff, pairs, pValues, params, ... + yMaxVis, bracketPad, textPad); - if any(idxA) && any(idxB) +else + % Single axes mode + ax = axes(fig); %#ok + hold(ax, 'on'); + set(ax, 'Clipping', 'off'); + + if params.diff + % Difference mode only + tblDiff = buildDiffTable(tbl, pairs, params); + randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... + yMaxVis, bracketPad, textPad); + else + % Raw mode only + randiColors = plotRawSwarm(ax, tbl, pairs, pValues, params, ... + yMaxVis, bracketPad, stackPad, textPad, semAlpha); + end +end - 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 +end % main function - diffVals = [diffVals; vA - vB]; - animals = [animals; repmat(tbl.animal(find(idxA,1)),length(vA),1)]; - insers = [insers; repmat(i,length(vA),1)]; - end - end - valid = ~isnan(diffVals); +% ========================================================================= +% LOCAL FUNCTION: plotRawSwarm +% Plots raw values for all stim types with lines between paired neurons. +% Returns randiColors (permuted indices used for dot ordering). +% ========================================================================= +function randiColors = plotRawSwarm(ax, tbl, pairs, pValues, params, ... + yMaxVis, bracketPad, stackPad, textPad, semAlpha) - stimName = sprintf('%s-%s', stimA, stimB); +hold(ax, 'on'); +set(ax, 'Clipping', 'off'); - tblPlot = table(); - tblPlot.insertion = categorical(insers(valid)); - tblPlot.stimulus = categorical(repmat({stimName}, sum(valid), 1)); - tblPlot.animal = animals(valid); - tblPlot.value = diffVals(valid); +stimuli = categories(tbl.stimulus); +tblPlot = tbl; - plotLinesBetween = false; +% Randomize dot draw order so overlapping colors are visible +randiColors = randperm(height(tblPlot)); +% Determine dot color data: Z-score (diverging) or animal (categorical) +if params.colorByZScore + % Use Z-score values — colormap set to diverging RdBu below + colorData = tblPlot.zScore(randiColors); else - %% ----------------- RAW MODE ----------------- - if params.fraction - assert(numel(valueField) == 2, ... - 'Fraction mode requires two valueField entries.'); - tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); - else - tbl.value = tbl.(valueField{1}); - end + % Use animal labels — colormap set to lines() below + colorData = tblPlot.animal(randiColors); +end - tblPlot = tbl; - plotLinesBetween = true; +% 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; -%% ----------------- CHANGE STIM NAMES ----------------- -stimuli = unique(tblPlot.stimulus); +% Set colormap and colorbar +if params.colorByZScore + % Diverging red-blue colormap centred at 0 + colormap(ax, buildRdBuColormap(256)); + maxZ = max(abs(tblPlot.zScore), [], 'omitnan'); + if 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 -% tbl.stimulus = removecats(tbl.stimulus); -% tbl.animal = removecats(tbl.animal); -% tbl.insertion = removecats(tbl.insertion); -%Replace 'RG' with 'SB' +% Draw lines between paired neurons across stim types +if params.drawLines + cats = categories(tblPlot.stimulus); + xMap = containers.Map(cats, 1:numel(cats)); + xNum = arrayfun(@(i) xMap(char(tblPlot.stimulus(i))), 1:height(tblPlot)); -% 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"); + try + tblPlot.NeurID; + UI = 'NeurID'; + catch + UI = 'insertion'; + end + for i = 1:numel(unique(tblPlot.(UI))) + idx = double(tblPlot.(UI)) == i; + if sum(idx) < 2, continue; end + line(ax, xNum(idx), tblPlot.value(idx), ... + 'Color', [0 0 0 0.1], 'LineWidth', 0.1); + end +end -% Convert back to categorical -tblPlot.stimulus = categorical(s); +ylabel(ax, params.yLegend); +ax.Box = 'off'; +ax.Layer = 'top'; +% Bootstrap mean + SEM per stimulus +if params.plotMeanSem + plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha); +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'; - end - end +% Pairwise significance brackets +if ~isempty(pairs) && numel(pValues) == size(pairs,1) + plotBrackets(ax, tblPlot, stimuli, pairs, pValues, yMaxVis, bracketPad, stackPad, textPad); end +ylim(ax, [ax.YLim(1) yMaxVis]); -%% ----------------- CLEAN CATEGORIES ----------------- -tblPlot.stimulus = removecats(tblPlot.stimulus); -tblPlot.animal = removecats(tblPlot.animal); -tblPlot.insertion = removecats(tblPlot.insertion); +end % plotRawSwarm -stimuli = categories(tblPlot.stimulus); -insertions = categories(tblPlot.insertion); -%% ----------------- RANDOMIZED COLORS ----------------- -randiColors = randperm(size(tblPlot,1)); +% ========================================================================= +% LOCAL FUNCTION: plotDiffSwarm +% Plots the per-neuron difference (stimA - stimB) as a single swarm column. +% ========================================================================= +function randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... + yMaxVis, bracketPad, textPad) -%% ----------------- FIGURE ----------------- -fig = figure; -ax = axes; hold(ax, 'on'); -set(ax, 'Clipping', 'off'); % <-- ADD THIS LINE +set(ax, 'Clipping', 'off'); -% 1) Swarm first +randiColors = randperm(height(tblDiff)); + +% Determine dot color data +if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) + colorData = tblDiff.zScore(randiColors); +else + colorData = tblDiff.animal(randiColors); +end + +% Draw swarm 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)); +% Set colormap +if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) + colormap(ax, buildRdBuColormap(256)); + maxZ = max(abs(tblDiff.zScore), [], 'omitnan'); + if 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 - xNum = arrayfun(@(i) xMap(char(tblPlot.stimulus(i))), ... - 1:height(tblPlot)); +% Zero reference line +yline(ax, 0, 'LineWidth', 1, 'Alpha', 0.7); +ylabel(ax, params.yLegend); +ax.Box = 'off'; +ax.Layer = 'top'; - try - tblPlot.NeurID; - UI = 'NeurID'; - catch - UI = 'insertion'; +% Bootstrap mean + SEM +if params.plotMeanSem + stimuli = categories(tblDiff.stimulus); + plotMeanSemBars(ax, tblDiff, stimuli, params, 0.6); +end + +% Significance annotation for diff mode +ylims = ylim(ax); +if ~isempty(pValues) && numel(pValues) >= 1 + + fprintf('=== DIFF MODE SIGNIFICANCE ===\n'); + fprintf('p-value: %.4e\n', pValues(1)); + + vals = tblDiff.value; + if isempty(vals) + fprintf('No values to annotate.\n'); + ylim(ax, [ylims(1) yMaxVis]); + return end - % 3) Plot lines AFTER swarm - for i = 1:numel(unique(tblPlot.(UI))) - idx = double(tblPlot.(UI)) == i; - if sum(idx) < 2 - continue - end + % Guard against empty vals producing empty maxVisible + maxVisible = max(min(vals(:), yMaxVis(1))); + if isempty(maxVisible), maxVisible = yMaxVis; end + yText = maxVisible + bracketPad; - line(ax, ... - xNum(idx), tblPlot.value(idx), ... - 'Color', [0 0 0 0.1], ... - 'LineWidth', 0.1),'lin'; + if pValues(1) < 1e-3 + txt = '***'; + if pValues(1) == 0, txt = '****'; end + + text(ax, 1, yText, txt, ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); + + stimA = pairs{1,1}; + stimB = pairs{1,2}; + compText = sprintf('%s > %s', stimA, stimB); + yCompText = yText + textPad * 10; + + text(ax, 1, yCompText, compText, ... + 'HorizontalAlignment', 'center', 'FontSize', 10, 'Clipping', 'off'); + + requiredHeight = yCompText + textPad * 10; + if requiredHeight > yMaxVis + ylim(ax, [ylims(1) requiredHeight]); + else + ylim(ax, [ylims(1) yMaxVis]); + end + else + ylim(ax, [ylims(1) yMaxVis]); end 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) +fprintf('=== END DIFF MODE SIGNIFICANCE ===\n'); -ax = gca; -ax.Box = 'off'; -ax.Layer = 'top'; +end % plotDiffSwarm -%% ----------------- BOOTSTRAP MEAN + SEM ----------------- -if params.plotMeanSem +% ========================================================================= +% LOCAL FUNCTION: buildDiffTable +% Computes per-neuron difference (stimA - stimB) within each insertion. +% If colorByZScore, also computes mean zScore across the pair for each neuron. +% ========================================================================= +function tblDiff = buildDiffTable(tbl, pairs, params) - for i = 1:numel(stimuli) +assert(~isempty(pairs) && size(pairs,1) >= 1, ... + 'diff mode requires at least one stimulus pair.'); - idx = tblPlot.stimulus == stimuli{i}; +stimA = pairs{1,1}; +stimB = pairs{1,2}; - 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); - end +ins = categories(tbl.insertion); +diffVals = []; +animals = []; +insers = []; +zScores = []; % mean zScore across pair, used only if colorByZScore - if numel(vals) < 3 - fprintf('Number of values to bootstrap is less than 3\n') - continue - end +for i = 1:numel(ins) + idxA = tbl.insertion == ins{i} & tbl.stimulus == stimA; + idxB = tbl.insertion == ins{i} & tbl.stimulus == stimB; - if size(tblPlot,1) < 500 - bootMean = bootstrp(params.nBoot, @mean, vals); - mu = mean(bootMean); - sem = std(bootMean); - else + if ~any(idxA) || ~any(idxB), continue; end - mu = mean(vals); - sem = std(vals,'omitnan') / sqrt(numel(vals)); - end + if params.fraction + vA = tbl.(valueField{1})(idxA) ./ tbl.(valueField{2})(idxA); + vB = tbl.(valueField{1})(idxB) ./ tbl.(valueField{2})(idxB); + else + vA = tbl.value(idxA); + vB = tbl.value(idxB); + end - % SEM - line([i i], mu + [-1 1]*sem, ... - 'Color', [0 0 0 semAlpha], 'LineWidth', 2); + diffVals = [diffVals; vA - vB]; %#ok + animals = [animals; repmat(tbl.animal(find(idxA,1)), length(vA), 1)]; %#ok + insers = [insers; repmat(i, length(vA), 1)]; %#ok - 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); + % For Z-score coloring: average zScore across both stim types per neuron + if params.colorByZScore && ismember('zScore', tbl.Properties.VariableNames) + zA = tbl.zScore(idxA); + zB = tbl.zScore(idxB); + zScores = [zScores; (zA + zB) / 2]; %#ok + end +end +valid = ~isnan(diffVals); +stimName = sprintf('%s-%s', stimA, stimB); - % mean - dx = 0.15; - plot([i-dx i+dx], [mu mu], 'k-', 'LineWidth', 1.2); - end +tblDiff = table(); +tblDiff.insertion = categorical(insers(valid)); +tblDiff.stimulus = categorical(repmat({stimName}, sum(valid), 1)); +tblDiff.animal = animals(valid); +tblDiff.value = diffVals(valid); + +if params.colorByZScore && ~isempty(zScores) + 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); - - for k = 1:size(pairs,1) - - fprintf('\n--- Pair %d: %s vs %s ---\n', k, pairs{k,1}, pairs{k,2}); - - x1 = find(strcmp(stimuli, pairs{k,1})); - x2 = find(strcmp(stimuli, pairs{k,2})); - - fprintf('x1 index: %d, x2 index: %d\n', x1, x2); - - if isempty(x1) || isempty(x2) - fprintf('SKIPPING: One or both stimuli not found!\n'); - continue - end - vals1 = tblPlot.value(tblPlot.stimulus == pairs{k,1}); - vals2 = tblPlot.value(tblPlot.stimulus == pairs{k,2}); +% ========================================================================= +% LOCAL FUNCTION: plotMeanSemBars +% Draws bootstrapped mean ± SEM bars for each stimulus group. +% ========================================================================= +function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) - fprintf('vals1 count: %d, vals2 count: %d\n', numel(vals1), numel(vals2)); +for i = 1:numel(stimuli) + idx = tblPlot.stimulus == stimuli{i}; + if ~any(idx), continue; end - maxVisible = max(min([vals1; vals2], yMaxVis)); - yBase = maxVisible + bracketPad; + vals = tblPlot.value(idx); + if numel(vals) < 3 + fprintf('Number of values to bootstrap is less than 3\n'); + continue + end - y = yBase; - while any(abs(usedHeights(1:k-1) - y) < stackPad) - y = y + stackPad; - end - usedHeights(k) = y; - - fprintf('Bracket y position: %.3f\n', y); - fprintf('p-value: %.4e\n', pValues(k)); - fprintf('Will draw bracket: YES\n'); - - % bracket - line([x1 x2], [y y], 'Color','k', 'LineWidth',1.2, 'Clipping', 'off'); - line([x1 x1], [y-yMaxVis*0.01 y], 'Color','k', 'LineWidth',1.2, 'Clipping', 'off'); - line([x2 x2], [y-yMaxVis*0.01 y], 'Color','k', 'LineWidth',1.2, 'Clipping', 'off'); - - % significance - if pValues(k) < 1e-3 - txt = '***'; - if pValues(k) == 0 - txt = '****'; - end - fprintf('Drawing text: %s\n', txt); - text(mean([x1 x2]), y + textPad, txt, ... - 'HorizontalAlignment','center', ... - 'FontSize', 7, 'Clipping', 'off'); - else - fprintf('p-value not significant enough (>= 1e-3)\n'); - end + if height(tblPlot) < 500 + bootMean = bootstrp(params.nBoot, @mean, vals); + mu = mean(bootMean); + sem = std(bootMean); + else + mu = mean(vals, 'omitnan'); + sem = std(vals, 'omitnan') / sqrt(sum(~isnan(vals))); end - + + % SEM error bar + line(ax, [i i], mu + [-1 1]*sem, ... + 'Color', [0 0 0 semAlpha], 'LineWidth', 2); + + capW = 0.1; + line(ax, [i-capW i+capW], [mu+sem mu+sem], ... + 'Color', [0 0 0 semAlpha], 'LineWidth', 2); + line(ax, [i-capW i+capW], [mu-sem mu-sem], ... + '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 -%% ----------------- SIGNIFICANCE FOR DIFF MODE ----------------- -ylims = ylim; +end % plotMeanSemBars -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 - - fprintf('size(x1): %s\n', num2str(size(x1))); - fprintf('size(yText): %s\n', num2str(size(yText))); - fprintf('size(maxVisible): %s\n', num2str(size(maxVisible))); - fprintf('size(bracketPad): %s\n', num2str(size(bracketPad))); - fprintf('size(vals): %s\n', num2str(size(vals))); - fprintf('size(yMaxVis): %s\n', num2str(size(yMaxVis))); - if pValues(1) < 1e-3 + +% ========================================================================= +% LOCAL FUNCTION: plotBrackets +% Draws pairwise significance brackets above the swarm. +% ========================================================================= +function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... + yMaxVis, bracketPad, stackPad, textPad) + +fprintf('=== DEBUGGING BRACKETS ===\n'); +fprintf('Number of pairs: %d\n', size(pairs,1)); +fprintf('Number of pValues: %d\n', numel(pValues)); +fprintf('Stimuli in plot: %s\n', strjoin(cellstr(stimuli), ', ')); + +usedHeights = zeros(size(pairs,1), 1); + +for k = 1:size(pairs,1) + + fprintf('\n--- Pair %d: %s vs %s ---\n', k, pairs{k,1}, pairs{k,2}); + + x1 = find(strcmp(stimuli, pairs{k,1})); + x2 = find(strcmp(stimuli, pairs{k,2})); + + fprintf('x1 index: %d, x2 index: %d\n', x1, x2); + + if isempty(x1) || isempty(x2) + fprintf('SKIPPING: One or both stimuli not found!\n'); + continue + end + + vals1 = tblPlot.value(tblPlot.stimulus == pairs{k,1}); + vals2 = tblPlot.value(tblPlot.stimulus == pairs{k,2}); + + fprintf('vals1 count: %d, vals2 count: %d\n', numel(vals1), numel(vals2)); + + maxVisible = max(min([vals1; vals2], yMaxVis)); + yBase = maxVisible + bracketPad; + + 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)); + + % Bracket lines + 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'); + + % Significance stars + if pValues(k) < 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 + if pValues(k) == 0, txt = '****'; end + fprintf('Drawing text: %s\n', txt); + text(ax, mean([x1 x2]), y + textPad, txt, ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); else fprintf('p-value not significant enough (>= 1e-3)\n'); - ylim([ylims(1) yMaxVis]); end - - fprintf('=== END DIFF MODE SIGNIFICANCE ===\n'); -else - ylim([ylims(1) yMaxVis]); end +end % plotBrackets + + +% ========================================================================= +% LOCAL FUNCTION: renameStimulusLabels +% Replaces legacy stimulus abbreviations in tbl.stimulus. +% ========================================================================= +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 +% Applies same label substitutions to the pairs cell array. +% ========================================================================= +function pairs = renamePairLabels(pairs) +if isempty(pairs), return; end +for i = 1:numel(pairs) + if strcmp(pairs{i}, 'RG'), pairs{i} = 'SB'; end + if strcmp(pairs{i}, 'SDGm'), pairs{i} = 'MG'; end + if strcmp(pairs{i}, 'SDGs'), pairs{i} = 'SG'; end +end +end + + +% ========================================================================= +% LOCAL FUNCTION: buildRdBuColormap +% Returns an n-row diverging Red-Blue colormap centred at 0. +% Blue = negative (low Z), White = zero, Red = positive (high Z). +% ========================================================================= +function cmap = buildRdBuColormap(n) +half = floor(n/2); + +% Blue -> White (low to mid) +blueToWhite = [linspace(0.02, 1, half)', ... + linspace(0.44, 1, half)', ... + linspace(0.69, 1, half)']; + +% White -> Red (mid to high) +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..af86adc 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 diff --git a/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.asv b/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.asv deleted file mode 100644 index 513ee1e..0000000 --- a/visualStimulationAnalysis/@VStimAnalysis/PlotZScoreComparison.asv +++ /dev/null @@ -1,1524 +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); - elseif isequal(params.StatMethod,'bootsrapRespBase') - vs.BootstrapPerNeuron('overwrite',params.overwriteStats); - elseif isequal(params.StatMethod,'maxPermuteTest') - vs.StatisticsPerNeuron('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); - 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) - 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 - 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 - 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 - 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 - 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.6); - -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 5 6]); - - -if params.PaperFig - vs.printFig(fig,sprintf('ResponsiveUnits-comparison-%s-%s',params.ComparePairs{1},... - params.ComparePairs{2}),PaperFig = params.PaperFig) -end - -end \ No newline at end of file diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv new file mode 100644 index 0000000..14e1a32 --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv @@ -0,0 +1,521 @@ +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 : Z-score of neuronal response normalised by pooled baseline SD. +% If UseLOO=true (default): leave-one-out cross-validated z-score +% at the preferred category, preventing winner's curse inflation +% that scales with nCats (non-comparable across stimuli otherwise). +% If UseLOO=false: z-score computed directly from observed mean +% difference at the preferred category using all trials. Faster +% but potentially inflated when nCats is large. +% +% prefCat : Consensus preferred category index [1 × nNeurons]. +% If UseLOO=true: category most frequently selected across LOO folds. +% If UseLOO=false: category with highest mean Diff across all trials. +% +% validCats : [nCats × nNeurons] logical mask. False where a category has +% >= EmptyTrialPerc fraction of zero-spike trials for a given neuron. +% +% pValTTest : p-value from one-sample t-test against zero, pooled across all +% valid categories per neuron. Complements the permutation test. +% +% 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.MovingWindow = true % use moving window response as observed statistic for permutation test + params.UseLOO = true % if true: LOO cross-validated z-score (recommended, unbiased across stimuli) + % if false: direct z-score at preferred category (faster, potentially inflated) +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 + +% ------------------------------------------------------------------------- +% 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)); % number of distinct speed values + + 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)'; % moving window response [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; % moving phase onset + 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; + +else + % Single-condition stimuli (rectGrid, etc.) + directimesSorted = responseParams.C(:,1)'; + stimDur = responseParams.stimDur; + trialsCat = round(numel(directimesSorted) / size(responseParams.NeuronVals, 2)); + MW = responseParams.NeuronVals(:,:,4)'; % moving window [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); % moving window for this speed condition + end + + if isequal(obj.stimName, 'StaticDriftingGrating') + fieldName = FieldNames{s}; + directimesSorted = Times.(fieldName); + stimDur = Durations.(fieldName); + trialsCat = trialsCats.(fieldName); + MW = MWs.(fieldName); % moving window for this grating condition + end + + % --- Build spike count matrices --- + % Mr: spike counts in the response window (stimulus duration) + Mr = BuildBurstMatrix(goodU, ... + round(p.t), ... + round(directimesSorted), ... + round(stimDur)); + + % Mb: spike counts in the baseline window + % Uses 75% of inter-trial interval before each trial onset — + % conservative buffer to avoid overlap with the preceding stimulus + Mb = BuildBurstMatrix(goodU, ... + round(p.t), ... + round(directimesSorted - 0.75 * obj.VST.interTrialDelay * 1000), ... + round(0.75 * obj.VST.interTrialDelay * 1000)); + + % Always compute full-duration means — needed for empty-trial filtering + % regardless of MovingWindow setting + responses = mean(Mr, 3); % mean spikes/ms over full response window: [nTrials × nNeurons] + baselines = mean(Mb, 3); % mean spikes/ms over full baseline window: [nTrials × nNeurons] + + if params.MovingWindow + % Per-trial sliding window max — captures transient responses + % (e.g. moving ball crossing the receptive field at a specific moment) + % Applied identically to response and baseline so the permutation + % test null distribution uses the same operation as the observed stat + winSize = responseParams.params.durationWindow; + + + % movmean along dim 3 — no loop needed + % 'Endpoints','discard' removes partial windows at edges + mrMov = movmean(Mr, winSize, 3, 'Endpoints', 'discard'); % [nTrials × nNeurons × nSteps] + mbMov = movmean(Mb, winSize, 3, 'Endpoints', 'discard'); + + responsesMW = max(mrMov, [], 3); % [nTrials × nNeurons] max over time + baselinesMW = max(mbMov, [], 3); + + Diff = responsesMW - baselinesMW; % [nTrials × nNeurons] + + else + % Full stimulus duration mean — appropriate for spatially localized + % stimuli where response is sustained (rectGrid, gratings) + Diff = responses - baselines; % [nTrials × nNeurons] + end + + nNeurons = size(goodU, 2); + nCats = round(size(Diff,1) / trialsCat); + + assert(size(Diff,1) == nCats * trialsCat, ... + 'Trial count (%d) not evenly divisible by trialsCat (%d).', ... + size(Diff,1), trialsCat); + + + % ------------------------------------------------------------------------- + % Category-level empty-trial filtering + % Mark category as invalid for a neuron if fraction of zero-spike trials + % meets or exceeds EmptyTrialPerc. Invalid categories are excluded entirely + % (not zeroed) to avoid biasing Diff toward 0. + % ------------------------------------------------------------------------- + validCats = true(nCats, nNeurons); % [nCats × nNeurons]; true = include + + if params.FilterEmptyResponses + responsesReshaped = reshape(responses, trialsCat, nCats, nNeurons); % [trialsCat × nCats × nNeurons] + for c = 1:nCats + for u = 1:nNeurons + emptyTrials = responsesReshaped(:, c, u) == 0; % zero-spike trials in this category + perc = sum(emptyTrials) / trialsCat; % fraction of empty trials + if perc >= params.EmptyTrialPerc + validCats(c, u) = false; % exclude category c for neuron u + end + end + end + end + + % Neurons where ALL categories are invalid — statistics undefined + noValidCat = all(~validCats, 1); % [1 × nNeurons] + + % ------------------------------------------------------------------------- + % Observed max-statistic + % ------------------------------------------------------------------------- + + if params.MovingWindow + DiffReshaped = reshape(DiffMW, trialsCat, nCats, nNeurons); + else + DiffReshaped = reshape(Diff, trialsCat, nCats, nNeurons); % [trialsCat × nCats × nNeurons] + end + catMeans = reshape(mean(DiffReshaped, 1), nCats, nNeurons); % [nCats × nNeurons] + + catMeansMasked = catMeans; + catMeansMasked(~validCats) = -Inf; % invalid categories cannot be preferred + ObsStat = max(catMeansMasked, [], 1); % [1 × nNeurons] + + % ------------------------------------------------------------------------- + % Max-statistic sign-flip permutation test + % + % H0: sign of (response - baseline) is random for each trial, + % i.e. response and baseline drawn from the same distribution. + % Randomly flipping trial signs simulates H0 without parametric assumptions. + % Taking the MAX across categories at each permutation controls FWER + % across categories (Nichols & Holmes 2002). + % + % Note: permutation test always uses Diff (not moving window) because + % the null distribution must be generated from the same trial-level data. + % The observed stat may differ (MovingWindow=true) but the null is always + % sign-flip based — this is a conservative and valid combination. + % ------------------------------------------------------------------------- + + % Always reshape Diff for permutation test regardless of MovingWindow flag + DiffReshaped = reshape(Diff, trialsCat, nCats, nNeurons); % [trialsCat × nCats × nNeurons] + + % Generate all sign vectors at once: [nTrials × nBoot], values ±1 + signs = 2 * randi(2, size(Diff,1), params.nBoot) - 3; + + % Reshape signs to match category structure: [trialsCat × nCats × nBoot] + signsR = reshape(signs, trialsCat, nCats, params.nBoot); + + % Permute for pagemtimes — pages correspond to categories: + % DiffRp : [nNeurons × trialsCat × nCats] + % signsRp : [trialsCat × nBoot × nCats] + DiffRp = permute(DiffReshaped, [3 1 2]); + signsRp = permute(signsR, [1 3 2]); + + % Batched matrix multiply: result is [nNeurons × nBoot × nCats] + catMeansAll = pagemtimes(DiffRp, signsRp) / trialsCat; + + % Permute to [nCats × nNeurons × nBoot] for masking and max + catMeansAll = permute(catMeansAll, [3 1 2]); + + % Exclude invalid categories from null distribution + validCats3D = repmat(validCats, 1, 1, params.nBoot); % [nCats × nNeurons × nBoot] + catMeansAll(~validCats3D) = -Inf; + + % Max across categories → null distribution [nBoot × nNeurons] + % reshape used instead of squeeze to guarantee correct shape when nNeurons=1 or nCats=1 + nullMax = reshape(max(catMeansAll, [], 1), params.nBoot, nNeurons); + + % p-value: proportion of permuted max-statistics >= observed (one-tailed, excitatory) + pVal = mean(nullMax >= ObsStat, 1); % [1 × nNeurons] + pVal(noValidCat) = NaN; % undefined for fully invalid neurons + + % ------------------------------------------------------------------------- + % Z-score: two modes controlled by params.UseLOO + % + % MODE 1 (UseLOO=true, recommended): + % Leave-one-out cross-validated z-score at preferred category. + % Preferred category identified on n-1 trials, held-out trial contributes + % to z-score. Prevents winner's curse inflation that scales with nCats, + % ensuring z-scores are comparable across stimuli with different nCats. + % + % MODE 2 (UseLOO=false): + % Direct z-score at preferred category identified from all trials. + % Faster but subject to winner's curse — z-scores will be inflated + % relative to LOO, and inflation will differ across stimuli with + % different numbers of categories. Use only for exploration. + % + % In both modes: z normalised by pooled baseline SD across all trials, + % which is more stable than per-category SD with few trials per category. + % ------------------------------------------------------------------------- + + % Pooled baseline SD: more stable than per-category with small n + sdBase = std(baselines, 0, 1); % [1 × nNeurons] + + + if params.MovingWindow + % Extract MW value at preferred category per neuron + % Preferred category is simply the one with highest MW value + + % MW is [nCats × nNeurons] — max across categories (dim 1) + catMWMasked = MW; + catMWMasked(~validCats) = -Inf; % exclude invalid categories + [~, prefCat] = max(catMWMasked, [], 1); % [1 × nNeurons] + idx_pref = prefCat + (0:nNeurons-1) * nCats; % linear index [nCats × nNeurons] + mwPrefCat = MW(idx_pref); % [1 × nNeurons] MW at preferred cat + z_mean = mwPrefCat - mean(baselines, 1); % baseline correct + + else + if nCats == 1 + % Single category — LOO and direct z-score are identical + % No selection step needed: preferred category is trivially 1 + prefCat = ones(1, nNeurons); % only one category + z_mean = mean(Diff, 1); % mean over all trials [1 × nNeurons] + + elseif params.UseLOO + % --- LOO cross-validated z-score --- + % Pre-compute per-category trial sums for efficient LOO mean computation + totalSum = zeros(nCats, nNeurons); % [nCats × nNeurons] + for c = 1:nCats + rows = (c-1)*trialsCat + 1 : c*trialsCat; % row range for category c + totalSum(c,:) = sum(Diff(rows,:), 1); % sum over trials in category c + end + + z_loo_acc = zeros(1, nNeurons); % accumulates held-out Diff at preferred category + prefCatCount = zeros(nCats, nNeurons); % tallies preferred category selections across folds + + for k = 1:trialsCat + % LOO category mean: subtract trial k from total sum, divide by n-1 + % kth row of each category's block: row index is (c-1)*trialsCat + k + looMean = (totalSum - Diff((0:nCats-1)*trialsCat + k, :)) / (trialsCat - 1); + % looMean is [nCats × nNeurons] — no reshape needed since Diff rows + % are already one per category after the index (0:nCats-1)*trialsCat+k + + looMeanMasked = looMean; + looMeanMasked(~validCats) = -Inf; % mask invalid categories + + [~, prefCatLOO] = max(looMeanMasked, [], 1); % [1 × nNeurons] preferred cat this fold + + % Linear index into [nCats × nNeurons]: row=prefCatLOO, col=1:nNeurons + idx = prefCatLOO + (0:nNeurons-1) * nCats; + prefCatCount(idx) = prefCatCount(idx) + 1; % tally this fold's selection + + % Held-out trial k: one row per category block + testVals = Diff((0:nCats-1)*trialsCat + k, :); % [nCats × nNeurons] + z_loo_acc = z_loo_acc + testVals(idx); % accumulate test diff at preferred cat + end + + z_mean = z_loo_acc / trialsCat; % average held-out diff across folds [1 × nNeurons] + [~, prefCat] = max(prefCatCount, [], 1); % consensus preferred category [1 × nNeurons] + + else + % --- Direct z-score (no LOO) --- + % Preferred category from all trials — subject to winner's curse + % when nCats is large. Use for exploration only. + catMeansAll2 = reshape(mean(DiffReshaped, 1), nCats, nNeurons); % [nCats × nNeurons] + catMeansAll2(~validCats) = -Inf; % exclude invalid categories + [~, prefCat] = max(catMeansAll2, [], 1);% [1 × nNeurons] preferred category + + % Extract mean Diff at preferred category using linear indexing + idx = prefCat + (0:nNeurons-1) * nCats; % linear index into [nCats × nNeurons] + z_mean = catMeansAll2(idx); % [1 × nNeurons] mean diff at preferred cat + end + + end + + % Final z-score: mean diff normalised by pooled baseline SD + z = z_mean ./ sdBase; % [1 × nNeurons] + z(sdBase == 0) = 0; % silent baseline — z undefined, set to 0 + z(noValidCat) = NaN; % no valid categories — z undefined + + % ------------------------------------------------------------------------- + % One-sample t-test pooled across all valid categories + % + % Tests H0: mean(Diff) = 0 across all valid trials for each neuron. + % Pooling across categories maximises degrees of freedom and avoids + % cherry-picking the preferred category (which would inflate t). + % Complements the permutation test: permutation test is the primary + % significance criterion; t-test is a parametric secondary check. + % + % Caveat: normality assumption cannot be verified with ~10 trials per + % category. The permutation test remains the primary criterion. + % ------------------------------------------------------------------------- + pValTTest = zeros(1, nNeurons); % [1 × nNeurons] t-test p-values + tStat = zeros(1, nNeurons); % [1 × nNeurons] t-statistics + + for u = 1:nNeurons + if noValidCat(u) + % No valid categories — t-test undefined + pValTTest(u) = NaN; + tStat(u) = NaN; + continue + end + + % Build logical row mask for all valid categories for this neuron + validRows = false(size(Diff, 1), 1); % initialise as all false + for c = 1:nCats + if validCats(c, u) % only include valid categories + rows = (c-1)*trialsCat + 1 : c*trialsCat; + validRows(rows) = true; % mark trials for valid category c + end + end + + DiffValid = Diff(validRows, u); % all valid trials for neuron u + [~, pValTTest(u), ~, stats] = ttest(DiffValid); % one-sample t-test vs zero + tStat(u) = stats.tstat; % store t-statistic + end + + pValTTest(noValidCat) = NaN; % redundant safety guard — already set above + 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] z-scores (LOO or direct) + S.(fieldName).ObsDiff = Diff; % [nTrials × nNeurons] response minus baseline + S.(fieldName).ObsResponse = responses; % [nTrials × nNeurons] response spike counts + 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 moving window response + S.(fieldName).pValTTest = pValTTest; % [1 × nNeurons] t-test p-values + S.(fieldName).tStat = tStat; % [1 × nNeurons] t-statistics + else + S.pvalsResponse = pVal; + S.ZScoreU = z; + 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; + end + + S.params = params; % store parameters 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 an empty results struct with correct field names. +% Ensures downstream code receives consistent struct regardless of neuron count. + + emptyFields = {'pvalsResponse','ZScoreU','ObsDiff','ObsResponse', ... + 'ObsBaseline','prefCat','validCats','MaxMovWinResponse', ... + 'pValTTest','tStat'}; % includes t-test fields + + 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/StatisticsPerNeuron.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m index 835f489..e89b0fa 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m @@ -2,39 +2,63 @@ % StatisticsPerNeuron - Computes per-neuron response statistics vs baseline. % % For each neuron this function outputs: -% pVal : 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. +% 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 : Leave-one-out (LOO) cross-validated z-score at the preferred -% stimulus category. On each LOO fold, the preferred category is -% identified on all-but-one trials, and the held-out trial contributes -% to the z-score estimate. This prevents winner's curse inflation that -% would otherwise scale with nCats, making z-scores non-comparable -% across stimuli with different category counts (e.g. rectGrid with -% 81 positions vs moving ball with 4 directions). +% 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. % -% prefCat : Consensus preferred category — the category most frequently -% selected as preferred across all LOO folds. +% 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. % -% validCats: [nCats × nNeurons] logical mask. False where a category has -% >= EmptyTrialPerc fraction of zero-spike trials for a given neuron. +% 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, overwrite=true) +% 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 = true % 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.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 = true % if true: use per-trial sliding window max for pval + % if false: use full stimulus duration mean + params.UseLOO = true % 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 = true % 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. end % ------------------------------------------------------------------------- @@ -92,45 +116,48 @@ % ------------------------------------------------------------------------- % Parse stimulus timing per condition % Stimulus type determines loop structure: -% linearlyMovingBall → one or two speed conditions (Speed1, Speed2) -% StaticDriftingGrating → Static and Moving phases -% all others (rectGrid) → single condition +% 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, not unique speeds — caused loop to run hundreds of times - nSpeeds = numel(unique(obj.VST.speed)); % number of distinct speed values + % number of trials — corrected to numel(unique(...)) for distinct speeds + nSpeeds = numel(unique(obj.VST.speed)); - Times.Speed1 = responseParams.Speed1.C(:,1)'; % trial onset times [1 × nTrials] - Durations.Speed1 = responseParams.Speed1.stimDur; % stimulus duration in ms - trialsCats.Speed1 = round(numel(Times.Speed1) / size(responseParams.Speed1.NeuronVals, 2)); % trials per category + 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; % number of loop iterations + x = nSpeeds; elseif isequal(obj.stimName, 'StaticDriftingGrating') - % Moving phase onset is shifted by static_time relative to trial onset 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'}; % loop will index these in order + FieldNames = {'Static', 'Moving'}; x = 2; else - % Single-condition stimuli (rectGrid, etc.) directimesSorted = responseParams.C(:,1)'; stimDur = responseParams.stimDur; trialsCat = round(numel(directimesSorted) / size(responseParams.NeuronVals, 2)); + MW = responseParams.NeuronVals(:,:,4)'; % [nCats × nNeurons] x = 1; end @@ -139,64 +166,118 @@ % ========================================================================= for s = 1:x - % --- Assign condition-specific timing variables --- + % --- Assign condition-specific variables --- if isfield(responseParams, "Speed1") - fieldName = sprintf('Speed%d', s); % 'Speed1' or 'Speed2' + 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}; % 'Static' or 'Moving' + 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 only + else + effectiveStimDur = stimDur; % full duration — no capping needed end % --- Build spike count matrices --- - % Mr: spike counts in the response window (stimulus duration) + % 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(stimDur)); + round(effectiveStimDur)); % capped or full duration - % Mb: spike counts in the baseline window (75% of inter-trial interval - % before each trial onset — conservative buffer to avoid overlap with - % the preceding stimulus) + % 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 - 0.75 * obj.VST.interTrialDelay * 1000), ... round(0.75 * obj.VST.interTrialDelay * 1000)); - responses = mean(Mr, 3); % mean spike count over time bins: [nTrials × nNeurons] - baselines = mean(Mb, 3); % mean spike count over baseline bins: [nTrials × nNeurons] - Diff = responses - baselines; % per-trial response minus baseline: [nTrials × nNeurons] + % Always compute full-duration means — needed for empty-trial filtering + % and for the non-moving-window z-score path regardless of MovingWindow flag + responses = mean(Mr, 3); % mean spikes/ms over response window: [nTrials × nNeurons] + baselines = mean(Mb, 3); % mean spikes/ms over baseline window: [nTrials × nNeurons] + + % ------------------------------------------------------------------------- + % Compute Diff — method depends on MovingWindow flag + % MovingWindow=true : per-trial sliding window max, applied identically to + % Mr and Mb so null distribution uses the same operation + % MovingWindow=false: full duration mean, appropriate for sustained responses + % ------------------------------------------------------------------------- + if params.MovingWindowPval + winSize = responseParams.params.durationWindow; % sliding window size in ms/bins + + % Guard: both response and baseline must be at least as long as winSize + assert(size(Mr,3) >= winSize, ... + ['Response window (%d ms) shorter than durationWindow (%d ms). ' ... + 'Reduce durationWindow or increase MaxStimDuration.'], ... + size(Mr,3), winSize); + assert(size(Mb,3) >= winSize, ... + ['Baseline window (%d ms) shorter than durationWindow (%d ms). ' ... + 'Reduce durationWindow or increase interTrialDelay.'], ... + size(Mb,3), winSize); + + % movmean along dim 3 — vectorised sliding window mean, no loop needed + % 'Endpoints','discard' removes partial windows at array edges + mrMov = movmean(Mr, winSize, 3, 'Endpoints', 'discard'); % [nTrials × nNeurons × nStepsR] + mbMov = movmean(Mb, winSize, 3, 'Endpoints', 'discard'); % [nTrials × nNeurons × nStepsB] + + % Per-trial maximum over all valid window positions: [nTrials × nNeurons] + responsesMW = max(mrMov, [], 3); + baselinesMW = max(mbMov, [], 3); + + % Per-trial moving window difference — used for permutation test and z-score + Diff = responsesMW - baselinesMW; % [nTrials × nNeurons] - nNeurons = size(goodU, 2); % number of good units + else + % Full duration mean — appropriate for sustained/spatially localised responses + Diff = responses - baselines; % [nTrials × nNeurons] + end + + nNeurons = size(goodU, 2); % number of good units nCats = round(size(Diff,1) / trialsCat); % number of stimulus categories % Sanity check: total trials must equal nCats × trialsCat assert(size(Diff,1) == nCats * trialsCat, ... - 'Trial count (%d) is not evenly divisible by trialsCat (%d). Check responseParams.', ... + 'Trial count (%d) not evenly divisible by trialsCat (%d). Check responseParams.', ... size(Diff,1), trialsCat); % ------------------------------------------------------------------------- % Category-level empty-trial filtering - % Mark a category as invalid for a given neuron if the fraction of trials - % with zero spikes meets or exceeds EmptyTrialPerc. - % Invalid categories are excluded (not zeroed) to avoid biasing Diff toward 0. + % Uses full-duration responses (not moving window) for the zero-spike check + % because MW inflates apparent spike counts for silent trials % ------------------------------------------------------------------------- - validCats = true(nCats, nNeurons); % [nCats × nNeurons]; true = include in analysis + validCats = true(nCats, nNeurons); % [nCats × nNeurons]; true = include if params.FilterEmptyResponses - % Reshape responses to [trialsCat × nCats × nNeurons] for category indexing - responsesReshaped = reshape(responses, trialsCat, nCats, nNeurons); - + responsesReshaped = reshape(responses, trialsCat, nCats, nNeurons); % [trialsCat × nCats × nNeurons] for c = 1:nCats for u = 1:nNeurons - emptyTrials = responsesReshaped(:, c, u) == 0; % logical: zero-spike trials - perc = sum(emptyTrials) / trialsCat; % fraction of empty trials + emptyTrials = responsesReshaped(:, c, u) == 0; % zero-spike trials in this category + perc = sum(emptyTrials) / trialsCat; % fraction of empty trials if perc >= params.EmptyTrialPerc validCats(c, u) = false; % exclude category c for neuron u end @@ -204,165 +285,231 @@ end end - % Neurons where ALL categories are invalid — statistics are undefined + % Neurons where ALL categories are invalid — statistics undefined noValidCat = all(~validCats, 1); % [1 × nNeurons] - % ------------------------------------------------------------------------- - % Reshape Diff into category structure - % DiffReshaped: [trialsCat × nCats × nNeurons] - % ------------------------------------------------------------------------- - DiffReshaped = reshape(Diff, trialsCat, nCats, nNeurons); - % ------------------------------------------------------------------------- % Observed max-statistic - % For each neuron: maximum mean response-minus-baseline across valid categories. - % Invalid categories are set to -Inf so they cannot contribute to the max. + % Maximum mean Diff across valid categories per neuron [1 × nNeurons] % ------------------------------------------------------------------------- - catMeans = squeeze(mean(DiffReshaped, 1)); % [nCats × nNeurons] - catMeansMasked = catMeans; - catMeansMasked(~validCats) = -Inf; % exclude invalid categories - ObsStat = max(catMeansMasked, [], 1); % [1 × nNeurons] + DiffReshaped = reshape(Diff, trialsCat, nCats, nNeurons); % [trialsCat × nCats × nNeurons] + catMeans = reshape(mean(DiffReshaped, 1), nCats, nNeurons); % [nCats × nNeurons] + + catMeansMasked = catMeans; + catMeansMasked(~validCats) = -Inf; % invalid categories cannot be preferred + ObsStat = max(catMeansMasked, [], 1); % [1 × nNeurons] % ------------------------------------------------------------------------- % Max-statistic sign-flip permutation test % - % H0: for each trial the sign of (response - baseline) is random, - % meaning response and baseline are drawn from the same distribution. - % - % Under H0, randomly flipping the sign of each trial's difference is - % equivalent to randomly reassigning which window is "response" and which - % is "baseline". Repeating this nBoot times builds a null distribution of - % the max category mean under H0. - % - % Taking the MAX across categories at each permutation automatically - % controls family-wise error rate (FWER) across categories without - % requiring Bonferroni correction (Nichols & Holmes 2002). - % - % Fully vectorised using pagemtimes for efficiency — no loop over nBoot. + % H0: sign of Diff is random per trial (response = baseline distribution). + % Sign-flipping simulates H0 without parametric assumptions. + % Taking MAX across categories controls FWER (Nichols & Holmes 2002). + % Fully vectorised via pagemtimes — no loop over nBoot. % ------------------------------------------------------------------------- % Generate all sign vectors at once: [nTrials × nBoot], values ±1 - signs = 2 * randi(2, size(Diff,1), params.nBoot) - 3; + signs = 2 * randi(2, size(Diff,1), params.nBoot) - 3; % Reshape signs to match category structure: [trialsCat × nCats × nBoot] - % This maps each trial's sign flip to its correct category slot signsR = reshape(signs, trialsCat, nCats, params.nBoot); - % Permute for pagemtimes — pages correspond to categories: + % Permute for pagemtimes: % DiffRp : [nNeurons × trialsCat × nCats] % signsRp : [trialsCat × nBoot × nCats] - DiffRp = permute(DiffReshaped, [3 1 2]); % [nNeurons × trialsCat × nCats] - signsRp = permute(signsR, [1 3 2]); % [trialsCat × nBoot × nCats] + DiffRp = permute(DiffReshaped, [3 1 2]); + signsRp = permute(signsR, [1 3 2]); - % Batched matrix multiply over category pages: - % for each category: [nNeurons × trialsCat] × [trialsCat × nBoot] = [nNeurons × nBoot] - % result: [nNeurons × nBoot × nCats] + % Batched matrix multiply over category pages: [nNeurons × nBoot × nCats] catMeansAll = pagemtimes(DiffRp, signsRp) / trialsCat; - % Permute to [nCats × nNeurons × nBoot] for masking and max operation + % Permute to [nCats × nNeurons × nBoot] for masking and max catMeansAll = permute(catMeansAll, [3 1 2]); - % Exclude invalid categories from null distribution (same mask as observed stat) - validCats3D = repmat(validCats, 1, 1, params.nBoot); % [nCats × nNeurons × nBoot] + % Exclude invalid categories from null distribution + validCats3D = repmat(validCats, 1, 1, params.nBoot); % [nCats × nNeurons × nBoot] catMeansAll(~validCats3D) = -Inf; - % Max across categories for each permutation and neuron: [nBoot × nNeurons] - nullMax = squeeze(max(catMeansAll, [], 1))'; % squeeze: [nNeurons × nBoot], then transpose + % Null distribution: max across categories for each permutation [nBoot × nNeurons] + % reshape instead of squeeze — safe when nNeurons=1 or nCats=1 + nullMax = reshape(max(catMeansAll, [], 1), params.nBoot, nNeurons); - % p-value: proportion of null max-statistics >= observed max-statistic - % One-tailed test for excitatory responses + % p-value: proportion of null max-statistics >= observed (one-tailed, excitatory) pVal = mean(nullMax >= ObsStat, 1); % [1 × nNeurons] - pVal(noValidCat) = NaN; % undefined for neurons with no valid categories + pVal(noValidCat) = NaN; % ------------------------------------------------------------------------- - % Leave-one-out (LOO) cross-validated z-score + % 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 + + % ------------------------------------------------------------------------- + % 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. % - % With only ~10 trials per category, split-half would leave only 5 trials - % for each half — too few for stable estimates. LOO maximises data usage: - % - Selection set: all (trialsCat - 1) trials → identify preferred category - % - Test set: the single held-out trial → contributes to z-score + % 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. % - % The preferred category is selected independently of the test trial on - % every fold, preventing winner's curse inflation that would otherwise - % differ across stimuli with different numbers of categories. + % MovingWindow=false, UseLOO=false: + % Direct mean Diff at preferred category from all trials. + % Faster but inflated when nCats is large — exploration only. % - % Implementation is vectorised over categories and neurons. - % The only loop runs trialsCat times (e.g., 10) — negligible cost. + % All modes normalised by pooled baseline SD across all trials, + % more stable than per-category SD with few trials per category. % ------------------------------------------------------------------------- + sdBase = std(baselines, 0, 1); % [1 × nNeurons] pooled baseline SD + + % if params.MovingWindowZS + % % Preferred category = argmax of MW per neuron + % % MW is [nCats × nNeurons] — already the best window mean per category + % catMWMasked = MW; + % catMWMasked(~validCats) = -Inf; % exclude invalid categories + % [~, prefCat] = max(catMWMasked, [], 1); % [1 × nNeurons] + % + % % Extract MW at preferred category using linear indexing + % idx_pref = prefCat + (0:nNeurons-1) * nCats; % linear index into [nCats × nNeurons] + % mwPrefCat = MW(idx_pref); % [1 × nNeurons] MW at preferred cat + % + % % Baseline correct: both MW and mean(baselines) are in spikes/ms + % z_mean = mwPrefCat - mean(baselines, 1); % [1 × nNeurons] + % + % else + if nCats == 1 + % Single category — preferred is trivially category 1 + % LOO and direct z-score are identical with one category + prefCat = ones(1, nNeurons); % only one category + z_mean = mean(Diff, 1); % mean over all trials [1 × nNeurons] + + elseif params.UseLOO + % --- LOO cross-validated z-score --- + % Pre-compute per-category sums for efficient LOO mean: [nCats × nNeurons] + totalSum = zeros(nCats, nNeurons); + for c = 1:nCats + rows = (c-1)*trialsCat + 1 : c*trialsCat; % trial rows for category c + totalSum(c,:) = sum(Diff(rows,:), 1); % sum over trials + end - % Pre-compute sum across trials once: [1 × nCats × nNeurons] - % Subtracting one trial from the total is cheaper than recomputing the mean - totalSum = sum(DiffReshaped, 1); - - % Pre-allocate accumulators - z_loo_acc = zeros(1, nNeurons); % accumulates held-out diff at preferred category - prefCatCount = zeros(nCats, nNeurons); % tallies how often each category is preferred per fold + z_loo_acc = zeros(1, nNeurons); % accumulates held-out Diff at preferred cat + prefCatCount = zeros(nCats, nNeurons); % tallies preferred category per fold - for k = 1:trialsCat - % Category mean on all trials except trial k: [nCats × nNeurons] - % Subtracting trial k from pre-computed total avoids recomputing the full mean - looMean = squeeze((totalSum - DiffReshaped(k,:,:)) / (trialsCat - 1)); + for k = 1:trialsCat + % LOO mean: subtract held-out trial k from total, divide by n-1 + % Indexing (0:nCats-1)*trialsCat+k gives the kth trial of each category + looMean = (totalSum - Diff((0:nCats-1)*trialsCat + k, :)) / (trialsCat - 1); + looMeanMasked = looMean; + looMeanMasked(~validCats) = -Inf; % exclude invalid categories - % Exclude invalid categories from selection - looMeanMasked = looMean; - looMeanMasked(~validCats) = -Inf; + [~, prefCatLOO] = max(looMeanMasked, [], 1); % [1 × nNeurons] preferred cat this fold - % Preferred category index on the selection data: [1 × nNeurons] - [~, prefCatLOO] = max(looMeanMasked, [], 1); + % Linear index into [nCats × nNeurons] + idx = prefCatLOO + (0:nNeurons-1) * nCats; + prefCatCount(idx) = prefCatCount(idx) + 1; % tally this fold's choice - % Accumulate preferred category tally (used later for consensus prefCat) - idx = sub2ind([nCats, nNeurons], prefCatLOO, 1:nNeurons); - prefCatCount(idx) = prefCatCount(idx) + 1; % increment tally for this fold's choice + % Held-out trial contribution at preferred category + testVals = Diff((0:nCats-1)*trialsCat + k, :); % [nCats × nNeurons] + z_loo_acc = z_loo_acc + testVals(idx); % accumulate held-out diff + end - % Held-out trial k: diff values at all categories: [nCats × nNeurons] - testVals = squeeze(DiffReshaped(k,:,:)); + z_mean = z_loo_acc / trialsCat; % mean held-out diff [1 × nNeurons] + [~, prefCat] = max(prefCatCount, [], 1); % consensus preferred category - % Accumulate the held-out diff at the fold's preferred category - z_loo_acc = z_loo_acc + testVals(idx); % [1 × nNeurons] + else + % --- Direct z-score (no LOO) --- + % Subject to winner's curse — use for exploration only + catMeansDir = reshape(mean(DiffReshaped, 1), nCats, nNeurons); + catMeansDir(~validCats) = -Inf; + [~, prefCat] = max(catMeansDir, [], 1); % [1 × nNeurons] + idx = prefCat + (0:nNeurons-1) * nCats; + z_mean = catMeansDir(idx); % [1 × nNeurons] end + % end + + % Normalise by pooled baseline SD + z = z_mean ./ sdBase; % [1 × nNeurons] + z(sdBase == 0) = 0; % silent baseline — set to 0 + z(noValidCat) = NaN; % no valid categories — undefined - % Average LOO diff across all held-out trials: [1 × nNeurons] - z_loo_mean = z_loo_acc / trialsCat; + % ------------------------------------------------------------------------- + % 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 - % Consensus preferred category: most frequently selected across LOO folds - % Used to extract baseline SD at a stable preferred location - [~, prefCat] = max(prefCatCount, [], 1); % [1 × nNeurons] + % 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 - % Baseline SD pooled across all trials and categories per neuron - % More stable than per-category SD, especially with few trials per category. - % Justified because baseline periods are pre-stimulus and should not vary - % systematically across stimulus categories. - sdBasePref = std(baselines, 0, 1); % [1 × nNeurons] — std over all nTrials rows + 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 - % Final z-score: LOO mean diff normalised by baseline SD - z = z_loo_mean ./ sdBasePref; % [1 × nNeurons] - z(sdBasePref == 0) = 0; % silent baseline (no variability): set to 0 - z(noValidCat) = NaN; % no valid categories: undefined + pValTTest(noValidCat) = NaN; + tStat(noValidCat) = NaN; % ------------------------------------------------------------------------- % Store results for this condition % ------------------------------------------------------------------------- if isfield(responseParams, "Speed1") || isequal(obj.stimName, 'StaticDriftingGrating') - % Named sub-struct for multi-condition stimuli - S.(fieldName).pvalsResponse = pVal; % [1 × nNeurons] permutation p-values - S.(fieldName).ZScoreU = z; % [1 × nNeurons] LOO cross-validated z-scores - S.(fieldName).ObsDiff = Diff; % [nTrials × nNeurons] raw trial differences - S.(fieldName).ObsResponse = responses; % [nTrials × nNeurons] response spike counts - S.(fieldName).ObsBaseline = baselines; % [nTrials × nNeurons] baseline spike counts - S.(fieldName).prefCat = prefCat; % [1 × nNeurons] consensus preferred category index - S.(fieldName).validCats = validCats; % [nCats × nNeurons] category validity mask + 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 else - % Flat struct for single-condition stimuli - S.pvalsResponse = pVal; - S.ZScoreU = z; - S.ObsDiff = Diff; - S.ObsResponse = responses; - S.ObsBaseline = baselines; - S.prefCat = prefCat; - S.validCats = validCats; + 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; end - S.params = params; % store analysis parameters for reproducibility + S.params = params; % store parameters alongside results for reproducibility end % end condition loop @@ -378,32 +525,33 @@ %% Local helper: build empty output struct when no neurons are found % ========================================================================= function S = buildEmptyStruct(obj, responseParams) -% buildEmptyStruct - Returns an empty results struct with correct field names. +% buildEmptyStruct - Returns empty results struct with correct field names. % Ensures downstream code receives a consistent struct regardless of neuron count. - emptyFields = {'pvalsResponse','ZScoreU','ObsDiff','ObsResponse', ... - 'ObsBaseline','prefCat','validCats'}; + emptyFields = {'pvalsResponse','ZScoreU','ZScorePermutation','ObsDiff', ... + 'ObsResponse','ObsBaseline','prefCat','validCats', ... + 'MaxMovWinResponse','pValTTest','tStat'}; - if isequal(obj.stimName, 'linearlyMovingBall') + if isequal(obj.stimName, 'linearlyMovingBall') || isequal(obj.stimName, 'linearlyMovingBar') for f = emptyFields - S.Speed1.(f{1}) = []; % empty Speed1 fields + S.Speed1.(f{1}) = []; end if isfield(responseParams, "Speed2") for f = emptyFields - S.Speed2.(f{1}) = []; % empty Speed2 fields if second speed exists + S.Speed2.(f{1}) = []; end end elseif isequal(obj.stimName, 'StaticDriftingGrating') for cond = {'Static', 'Moving'} for f = emptyFields - S.(cond{1}).(f{1}) = []; % empty fields for each grating condition + S.(cond{1}).(f{1}) = []; end end else for f = emptyFields - S.(f{1}) = []; % flat empty struct for single-condition stimuli + S.(f{1}) = []; end end end \ No newline at end of file diff --git a/visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.asv b/visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.asv new file mode 100644 index 0000000..3d8af68 --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.asv @@ -0,0 +1,912 @@ +classdef (Abstract) VStimAnalysis < handle + + properties + %diodeUpCross % times [ms] of diode up crossing + %diodeDownCross % times [ms] of diode down crossing + startSessionTrigger = [1 2] % Trigger number for start and end of visual stimulation session + sessionOrderInRecording = [] % the sequential position of the relevant session in case a few sessions exist in the same recording + sessionStartTime % the start time [ms] of the stimulation session + sessionEndTime % the end time [ms] of the stimulation session + visualStimFolder %the folder with visual stimulation files + visualStimAnalysisFolder %the folder with results of visual stimulation analysis (under visualStimFolder) + visualStimPlotsFolder %the folder with results of visual stimulation analysis plots (under visualStimFolder) + spikeSortingFolder %the folder with spike sorting data + visualStimulationFile %the name of the visual stimulation mat file + stimName %the name of the visual stimulation - extracted by removing analysis from the class name + VST %all visual stimulation properties and values + end + + properties (SetObservable, AbortSet = true, SetAccess=public) + dataObj %data recording object + end + + properties (Constant, Abstract) + trialType % The type of trials in terms of flips 'imageTrials' have one flip per trial and 'videoTrials' have many flips per trial + end + + methods (Hidden) + %class constructor - gets name and adds listener to update initialization every time the dataRecording object is changed + %If + function obj=VStimAnalysis(dataObj, params) + arguments (Input) %ResponseWindow.mat + dataObj + params.Session = 1; + end + StimSession = params.Session; + obj.stimName=class(obj);obj.stimName=obj.stimName(1:end-8); % + addlistener(obj, 'dataObj', 'PostSet',@(src,evnt)obj.initialize(StimSession)); + if nargin==0 || isempty(dataObj) + fprintf('No dataRecording object entered as input!!! Most functions will not work!!!\n Please manually populate the dataObj property.\n'); + else + obj.dataObj=dataObj; + end + + + end + end + + methods + + function obj = initialize(obj, StimSession) + %Initialization - extraction of folders and visual parameters subclass name should match the visual stimulation + %extract the visual stimulation parameters from parameter file + obj.visualStimFolder=obj.findFolderInExperiment(obj.dataObj.recordingDir,'visualStimulation'); + obj=setVisualStimulationFile(obj,'Session',StimSession); + obj=getStimParams(obj); + obj.spikeSortingFolder=obj.findFolderInExperiment(obj.dataObj.recordingDir,'kilosort'); + end + + function printFig(obj,f,figName,params) + arguments (Input) + obj + f + figName + params.PaperFig logical = false %print fig in corresponding folder for paper figures + end + %Prints plots in the relevant folder + %f - figure handle + %figName - the name of the figure in the figure folder + set(f,'PaperPositionMode','auto'); + + if params.PaperFig + + parts = split(obj.visualStimPlotsFolder, filesep); + out = [fullfile(parts{1:2}), filesep, 'Paper_figs']; + + %disp(['Printing fig: ',obj.visualStimPlotsFolder,filesep,figName]); + print([out,filesep,figName],'-djpeg','-vector','-r300'); + else + disp(['Printing fig: ',obj.visualStimPlotsFolder,filesep,figName]); + print([obj.visualStimPlotsFolder,filesep,figName],'-djpeg','-vector','-r300'); + end + end + + function results = getStimLFP(obj,params) + %Extracts the LFP for all visual stimulation times from the row data. + arguments (Input) + obj + params.win = [500,500] % duration [1,2] [ms] (for on and off) for LFP analysis + params.channelSkip = 5 %includes every 5th channel + params.getWinFromStimDuration = false %if this option is used, only the on response is calculated + params.overwrite logical = false %if true overwrites results + params.analysisTime = datetime('now') %extract the time at which analysis was performed + params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method + end + if params.inputParams,disp(params),return,end + + %load previous results if analysis was previuosly performed and there is no need to overwrite otherwise continue + results = obj.isOutputAnalysis(obj.getAnalysisFileName,params.overwrite,nargout==1); + if ~isempty(results), return, end + + stimTimes=obj.getSyncedDiodeTriggers; + + %Design decimation Filter + F=filterData(obj.dataObj.samplingFrequencyAP(1)); + F.downSamplingFactor=obj.dataObj.samplingFrequencyAP(1)/250; + F=F.designDownSample; + F.padding=true; + samplingFreqLFP=F.filteredSamplingFrequency; + + %To add: for cases of low memory use a loop for calculating over groups of 10 trials and merge + if params.getWinFromStimDuration + params.win=[obj.VST.stimDuration*1000, 500]; + end + + [LFP_on,~]=obj.dataObj.getData(1:params.channelSkip:obj.dataObj.channelNumbers(end),stimTimes.stimOnFlipTimes,params.win(1)); + LFP_on=F.getFilteredData(LFP_on); + + [LFP_off,~]=obj.dataObj.getData(1:params.channelSkip:obj.dataObj.channelNumbers(end),stimTimes.stimOffFlipTimes,params.win(2)); + LFP_off=F.getFilteredData(LFP_off); + + fprintf('Saving results to file.\n'); + save(obj.getAnalysisFileName,'params','LFP_on','LFP_off','samplingFreqLFP'); + end + + function obj = getStimParams(obj) + %extract visual stimulation parameters from the file saved by VStim classes when running visualStimGUI + VSFile=[obj.visualStimFolder filesep obj.visualStimulationFile]; + disp(['Extracting information from: ' VSFile]); + VS=load(VSFile); + + %create structure + if isfield(VS,'VSMetaData') + for i=1:numel(VS.VSMetaData.allPropName) + obj.VST.(VS.VSMetaData.allPropName{i})=VS.VSMetaData.allPropVal{i}; + end + else % for compatibility with old versions + for i=1:size(VS.props,1) + obj.VST.(VS.props{i,1})=VS.props{i,2}; + end + end + if nargout>0 + VST=obj.VST; + end + + end + + function analysisFile = getAnalysisFileName(obj) + %extract currently running analysis method name and use it to create a unique file name for saving analysis results + db=dbstack;currentMethod=strsplit(db(2).name,'.'); + analysisFile=[obj.visualStimAnalysisFolder,filesep,currentMethod{end},'.mat']; + analysisFile=[obj.visualStimAnalysisFolder,filesep,currentMethod{end},'.mat']; + end + + %check if spike sorting data exists and converts to t,ic format if needed. + function [s]=getSpikeTIcData(obj) + %check that sorting exist + if isdir(obj.spikeSortingFolder) + if isfile([obj.spikeSortingFolder filesep 'sorting_tIc.mat']) + s=load([obj.spikeSortingFolder filesep 'sorting_tIc.mat']); + else + fprintf('Did not find sorting_tIc.mat (t,ic format) in spike sorting folder!\ntrying to convert from Phy format, please wait...\n'); + obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + s=load([obj.spikeSortingFolder filesep 'sorting_tIc.mat']); + end + else + fprintf('Did not find spike sorting folder, put phy results into a folder starting with kilosort and try again,\n'); + end + end + + function [results] = getCorrSpikePattern(obj,T,trialCat,params) + arguments + obj + T = []%the start times of for the trials included in the analysis and its categories + trialCat = []% the category of each of the trials. + params.win = 1000 %the window post stimTimes times + params.bin = 10 %[ms] - bins size for the generated rasters + params.gaussConvSamples = 5 % one standard deviation of the Gaussian for smoothing rasters in units of bin + params.overwrite logical = false %if true overwrites results + params.analysisTime = datetime('now') %extract the time at which analysis was performed + params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method + end + if params.inputParams,disp(params),return,end + + %load previous results if analysis was previuosly performed and there is no need to overwrite otherwise continue + results = obj.isOutputAnalysis(obj.getAnalysisFileName,params.overwrite,nargout==1); + if ~isempty(results), return, end + + s=obj.getSpikeTIcData; + + M=BuildBurstMatrix(s.ic,round(s.t/params.bin),round(T/params.bin),round(params.win/params.bin)); + M=ConvBurstMatrix(M,fspecial('gaussian',[1 params.gaussConvSamples*3],params.gaussConvSamples),'same'); + [nTrials,nNeu,nBins]=size(M); + + [CI]=CalcCorrAlfaB_tmp2(M); + + M=reshape(M,[nTrials,nNeu*nBins])'; + C=corrcoef(M); + + fprintf('Saving results to file.\n'); + save(obj.getAnalysisFileName,'params','C','CI','trialCat'); + end + + function plotCorrSpikePattern(obj,params) + arguments + obj + params.overwrite logical = true %if true overwrites the existing plots. If false only generates the plots without saving + params.analysisTime = datetime('now') %extract the time at which analysis was performed + params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method + end + if params.inputParams,disp(params),return,end + + res=obj.getCorrSpikePattern; + + nTrials=size(res.C,1); + uniqCat=unique(res.trialCat,'stable'); + nCat=numel(uniqCat); + trialsPerCat=nTrials/nCat; + [x,y]=meshgrid(0:trialsPerCat:nTrials); + + f1=figure; + imagesc(res.C); + hold on; + line(x,y,'Color','k');line(y,x,'Color','k'); + axis(f1.Children,'square'); + set(f1.Children,'XTick',trialsPerCat/2:trialsPerCat:nTrials,'YTick',trialsPerCat/2:trialsPerCat:nTrials,'XTickLabel',uniqCat,'YTickLabel',uniqCat) + if params.overwrite,obj.printFig(f1,'trialCorrMatrix'),end + + f2=figure; + imagesc(res.CI); + hold on; + line(x,y,'Color','k');line(y,x,'Color','k'); + axis(f2.Children,'square'); + set(f2.Children,'XTick',trialsPerCat/2:trialsPerCat:nTrials,'YTick',trialsPerCat/2:trialsPerCat:nTrials,'XTickLabel',uniqCat,'YTickLabel',uniqCat) + if params.overwrite,obj.printFig(f2,'timeInvariantTrialCorrMatrix'),end + + f3=figure; + [DC,order]=DendrogramMatrix(res.CI,'toPlotBinaryTree',1,'maxClusters',8,'figureHandle',f3); + if params.overwrite,obj.printFig(f3,'timeInvariantDendrogramedTrialCorrMatrix'),end + + f4=figure; + text(1:numel(order),order,res.trialCat,'FontSize',8);hold on;plot(order,'.','MarkerSize',10); + xlabel('Trial');ylabel('Reordered trial'); + if params.overwrite,obj.printFig(f4,'timeInvariantDendrogramedOrdering'),end + + f5=figure; + [DC,order]=DendrogramMatrix(res.C,'toPlotBinaryTree',1,'maxClusters',8,'figureHandle',f5); + if params.overwrite,obj.printFig(f5,'dendrogramedTrialCorrMatrix'),end + + f6=figure; + text(1:numel(order),order,res.trialCat,'FontSize',8);hold on;plot(order,'.','MarkerSize',10); + xlabel('Trial');ylabel('Reordered trial'); + if params.overwrite,obj.printFig(f6,'dendrogramedOrdering'),end + end + + function results = getSyncedDiodeTriggers(obj,params) + %Sychronize the times for each stimulation onet or frame between the diode analog data and the time stamps saved by the visual stimulation class used for presenting the stimulations + arguments + obj + params.minDiodeInterval = 0.5 %removes diode frames shorter than minDiodeInterval fraction of the inter frame interval + params.analyzeOnlyOnFlips = false %in some stimuli - the off flips exist but not analyzed. + params.ignoreNLastFlips = 0 %some stimuli have residual flips that do not need to be analyzed. + params.overwrite logical = false %if true overwrites results + params.analysisTime = datetime('now') %extract the time at which analysis was performed + params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method + end + if params.inputParams,disp(params),return,end + + %load previous results if analysis was previuosly performed and there is no need to overwrite otherwise continue + results = obj.isOutputAnalysis(obj.getAnalysisFileName,params.overwrite,nargout==1); + if ~isempty(results), return, end + + diode=obj.getDiodeTriggers; + + allDiodeFlips=sort([diode.diodeUpCross,diode.diodeDownCross]); + allDiodeFlips(1+find(diff(allDiodeFlips)All flip times in the visual stimulation meta data were NaNs!!! Please check that your vStim is valid\nTrying to use the start times of diode signals and expected frame rates\n'); + pTrialEnds=[find(diff(allDiodeFlips)>obj.VST.interTrialDelay*0.9*1000) numel(allDiodeFlips)]; + pTrialStarts=[1 1+find(diff(allDiodeFlips)>obj.VST.interTrialDelay*0.9*1000)]; + allFlips=bsxfun(@plus, (0:size(allFlips,1)-1)*obj.VST.ifi*1000,allDiodeFlips(pTrialStarts)'); + else + allFlips=allFlips(~isnan(allFlips))*1000; + end + + if isequal(obj.stimName,'StaticDriftingGrating') + params.ignoreNLastFlips =2; + end + + %ignore a few last flips - needed for some stimuli + if params.ignoreNLastFlips~=0 + allFlips(end-params.ignoreNLastFlips+1:end)=[]; + end + + expectedFlips=numel(allFlips); + fprintf('%d flips expected, %d found (diff=%d). Linking existing flip times with stimuli...\n',expectedFlips,measuredFlips,expectedFlips-measuredFlips); + if (expectedFlips-measuredFlips)>0.1*expectedFlips + fprintf('There are more than 10 percent mismatch in the number of diode and vStim expected flips. Cant continue!!! Please check diode extraction!\n'); + return; + end + switch obj.trialType + case 'videoTrials' + pTrialEnds=[find(diff(allDiodeFlips)>obj.VST.interTrialDelay*0.9*1000) numel(allDiodeFlips)]; + pTrialStarts=[1 1+find(diff(allDiodeFlips)>obj.VST.interTrialDelay*0.9*1000)]; + stimOnFlipTimes=allDiodeFlips(pTrialStarts); + stimOffFlipTimes=allDiodeFlips(pTrialEnds); + + if isequal(obj.stimName,'StaticDriftingGrating') %Change because static time could be equal to intertrial delat + + % Compute differences + dv_prev = [NaN diff(allDiodeFlips)]; % difference from previous element + dv_next = [diff(allDiodeFlips) NaN]; % difference from next element + pTrialStarts = [1 find(abs(dv_prev) >= obj.VST.static_time*0.7*1000 & abs(dv_next) >= obj.VST.interTrialDelay*0.7*1000)]; + pTrialEnds = [find(abs(dv_prev) <= (1/obj.VST.fps)*10*1000 & abs(dv_next) >= obj.VST.interTrialDelay*0.7*1000) numel(allDiodeFlips)]; + + stimOnFlipTimes=allDiodeFlips(pTrialStarts); + stimOffFlipTimes=allDiodeFlips(pTrialEnds); + end + + if numel(pTrialEnds)~=obj.VST.nTotTrials || numel(pTrialStarts)~=obj.VST.nTotTrials + disp('The total number of trials does not equal the number of inter trial delay gaps! Could not perform trial association'); + end + + trialDiodeFlips=cell(1,obj.VST.nTotTrials); + + try + diodeFrameFlipTimes=nan(size(obj.VST.flip)); + catch + obj.VST.flip = obj.VST.flipOnsetTimeStamp; + diodeFrameFlipTimes=nan(size(obj.VST.flip)); + end + + for i=1:obj.VST.nTotTrials + pFlips=~isnan(obj.VST.flip(i,:)); %not all trials have the same number of flips + currentDiodeFlipTimes=allDiodeFlips(pTrialStarts(i):pTrialEnds(i)); + currentPCFlipTimes=obj.VST.flip(i,pFlips)*1000; + %plot(allFlips-allFlips(1),ones(1,numel(allFlips)),'or');hold on;plot(allDiodeFlips-allDiodeFlips(1),ones(1,numel(allDiodeFlips)),'.k'); + %check for cases in which there was a missed frame in diode signal - this could be from a missed frame or a delayed frame + pDelayed=diff(currentDiodeFlipTimes)>obj.VST.ifi*1000*1.5; + frameMatch=numel(currentPCFlipTimes)-(numel(currentDiodeFlipTimes)+sum(pDelayed)); + if frameMatch>=0 %all frames that were not present were missed + [in,p]=ismembertol(currentPCFlipTimes-currentPCFlipTimes(1),currentDiodeFlipTimes-currentDiodeFlipTimes(1),obj.VST.ifi*1000/2,'DataScale',1); + elseif (frameMatch+sum(pDelayed))==0 && ~isequal(obj.stimName,'StaticDriftingGrating')%at least some of the frames were delayed in presentation and not just missed + %look for a delay in diode flips which may explain a consistent delay + tmpDiode=currentDiodeFlipTimes+cumsum([zeros(1,-frameMatch) pDelayed])*(-obj.VST.ifi*1000); + [in,p]=ismembertol(currentPCFlipTimes-currentPCFlipTimes(1),tmpDiode-tmpDiode(1),obj.VST.ifi*1000/2,'DataScale',1); + elseif frameMatch<0 && (numel(currentPCFlipTimes)-numel(currentDiodeFlipTimes))>=0 %identifies irregularities in timing + in=ones(1,numel(currentDiodeFlipTimes)); + p=1:numel(currentDiodeFlipTimes); + elseif frameMatch<0 %the match is negative and there was no compensation due the previous condition + currentDiodeFlipTimes=currentDiodeFlipTimes(1:numel(currentPCFlipTimes)); %remove excess frames at end and match one to one + in=ones(1,numel(currentPCFlipTimes)); + p=1:numel(currentPCFlipTimes); + else + error('The case which there are more diode flips than stimulated frames was not addressed in the algorithm! Please add to code.') + end + diodeFrameFlipTimes(i,in)=currentDiodeFlipTimes(p(p~=0)); + + %{ + plot(currentPCFlipTimes-currentPCFlipTimes(1),ones(1,numel(currentPCFlipTimes)),'.k');hold on; + plot(currentDiodeFlipTimes-currentDiodeFlipTimes(1),ones(1,numel(currentDiodeFlipTimes)),'or');legend({'trig','diode'}) + %} + end + fprintf('Saving results to file.\n'); + save(obj.getAnalysisFileName,'params','diodeFrameFlipTimes','stimOnFlipTimes','stimOffFlipTimes'); + results = load(obj.getAnalysisFileName); + case 'imageTrials' + if expectedFlips==measuredFlips + if params.analyzeOnlyOnFlips + stimOnFlipTimes=allDiodeFlips; + stimOffFlipTimes=[]; + else + stimOnFlipTimes=allDiodeFlips(1:2:end); + stimOffFlipTimes=allDiodeFlips(2:2:end); + end + else + error('This case of unequal triggers for image type trials was not addressed in the code!'); + return; + end + + fprintf('Saving results to file.\n'); + save(obj.getAnalysisFileName,'params','stimOnFlipTimes','stimOffFlipTimes'); + results=load(obj.getAnalysisFileName); + end + end + + function copyFilesFromRecordingFolder(obj) + %searches visual stimulation files and copies them to a dedicated visual stimulation folder + numberOfParentFolders=2; %How many parent folders to go in looking for the visual stimulation files + + tmpFolder=obj.dataObj.recordingDir; + filesFound=false; + for f=1:numberOfParentFolders + matFiles=dir([tmpFolder,filesep,'*.mat']); + if ~isempty(matFiles) + for i=1:numel(matFiles) + tmpVars=whos('-file',[tmpFolder filesep matFiles(i).name]); + if strcmp(tmpVars.name,'VSMetaData') + copyfile([tmpFolder filesep matFiles(i).name], obj.visualStimFolder); + fprintf('%s was copied to the visual stimulation folder: %s\n',matFiles(i).name,obj.visualStimFolder); + filesFound=true; + end + end + end + if ~filesFound + if tmpFolder(end)==filesep + [tmpFolder, ~, ~] = fileparts(tmpFolder(1:end-1)); + else + [tmpFolder, ~, ~] = fileparts(tmpFolder(1:end)); + end + end + end + + if ~filesFound + error('No visual stimulation mat files found!!! Please copy stimulation files to the visual stimulation folder') + end + end + + function obj=setVisualStimulationFile(obj,params) + %find visual stimulation file according to recording file names and the name of the visual stimulation analysis class + arguments (Input) %ResponseWindow.mat + obj + params.visualStimulationfile = []; + params.Session = 1; + end + + if isempty(params.visualStimulationfile) + VSFiles=dir([obj.visualStimFolder filesep '*.mat']); + if isempty(VSFiles) + obj.copyFilesFromRecordingFolder; + VSFiles=dir([obj.visualStimFolder filesep '*.mat']); + end + + try + dateTime=datetime({VSFiles.date},'InputFormat','dd-MMM-yyyy HH:mm:ss'); + catch + %In case pc regional setting is Israel and hebrew + dateTime=datetime({VSFiles.date},'InputFormat','dd-MMM-yyyy HH:mm:ss','Locale', 'he_IL'); + end + [~,pDate]=sort(dateTime); + VSFiles={VSFiles.name}; %do not switch with line above + VSFiles = VSFiles(~contains(lower(VSFiles), 'metadata')); %exclude metadata + recordingsFound=0; + tmpDateTime = datetime.empty(0,numel(VSFiles)); + pSession = []; + for i=1:numel(VSFiles) + if contains(VSFiles{i},obj.stimName,'IgnoreCase',true) + recordingsFound=recordingsFound+1; + pSession=[pSession i]; + end + try + vStimIdentifiers=split(VSFiles{i},["_","."]); + tmpDateTime(i)=datetime(join(vStimIdentifiers(2:8), "-"),'InputFormat','yyyy-MM-dd-HH-mm-ss-SSS'); + catch + fprintf('!!!!Important!!!!!\nUnable to extract date and time from a visual stimulation file name!!!!\nPlease correct the format or remove the file and run again!!!!\n') + end + end + + if recordingsFound<1 + fprintf('No matchings visual stimulation files found!!!\n Please check the names of visual stimulation files or run setVisualStimulationFile(file) with the filename as input.\n'); + return; + else + obj.visualStimulationFile=VSFiles{pSession(params.Session)}; + [~,order]=sort(tmpDateTime); + obj.sessionOrderInRecording=find(order==pSession(params.Session)); + end + else + obj.visualStimulationFile=visualStimulationfile; + end + + %populate properties and create folders for analysis if needed + [~,fileWithoutExtension]=fileparts(obj.visualStimulationFile); + obj.visualStimAnalysisFolder=[obj.visualStimFolder filesep fileWithoutExtension '_Analysis']; + if ~isfolder(obj.visualStimAnalysisFolder) + mkdir(obj.visualStimAnalysisFolder); + fprintf('Visual stimulation Analysis folders created:\n%s\n',obj.visualStimAnalysisFolder); + end + obj.visualStimPlotsFolder=[obj.visualStimFolder filesep fileWithoutExtension '_Plots']; + if ~isfolder(obj.visualStimPlotsFolder) + mkdir(obj.visualStimPlotsFolder); + fprintf('Visual stimulation analysis Plots folders created:\n%s\n',obj.visualStimAnalysisFolder); + end + end + + function results=getSessionTime(obj,params) + %obj=getSessionTime(obj,params) - Gets the start times of each visual stimulation session from digital triggers in the recoring + arguments (Input) + obj + params.startEndChannel = [] %[1,2] - The digital triger channel for stim onset and offset + params.analysisTime = datetime('now') %extract the time at which analysis was performed + params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method + params.overwrite logical = false %if true overwrites results %if true overwrites results + end + if params.inputParams,disp(params),return,end + + %In future versions remove these properties and dont use them for analysis results + %load previous results if analysis was previuosly performed and there is no need to overwrite + if isfile(obj.getAnalysisFileName) && ~params.overwrite + fprintf('Analysis already exists (use overwrite option to recalculate).\n'); + results=load(obj.getAnalysisFileName); + obj.sessionStartTime=results.sessionStartTime; + obj.sessionEndTime=results.sessionEndTime; + obj.startSessionTrigger=results.startSessionTrigger; + return; + end + + %load previous results if analysis was previuosly performed and there is no need to overwrite otherwise continue + results = obj.isOutputAnalysis(obj.getAnalysisFileName,params.overwrite,nargout==1); + if ~isempty(results), return, end + + if nargin == 2 + obj.startSessionTrigger=params.startEndChannel; + end + T=obj.dataObj.getTrigger; + if isscalar(T{obj.startSessionTrigger(1)}) && isscalar(T{obj.startSessionTrigger(2)}) %only 1 visual stimulation session in the recording + obj.sessionStartTime=T{obj.startSessionTrigger(1)}; + obj.sessionEndTime=T{obj.startSessionTrigger(2)}; + else + fprintf('There are %d session in the recording. Analysis indicated session # %d. If not please modify the sessionOrderInRecording property\n',numel(T{obj.startSessionTrigger(1)}),obj.sessionOrderInRecording); + obj.sessionStartTime=T{obj.startSessionTrigger(1)}(obj.sessionOrderInRecording); + obj.sessionEndTime=T{obj.startSessionTrigger(2)}(obj.sessionOrderInRecording); + end + + sessionStartTime=obj.sessionStartTime; + sessionEndTime=obj.sessionEndTime; + startSessionTrigger=obj.startSessionTrigger; + + %save results in the right file + fprintf('Saving results to file.\n'); + save(obj.getAnalysisFileName,'sessionStartTime','sessionEndTime','startSessionTrigger'); + end + + %Extract the frame flips from the diode signal + function results=getDiodeTriggers(obj,params) + arguments (Input) + obj + %extractionMethod (1,1) string {mustBeMember(extractionMethod,{'diodeThreshold','digitalTriggerDiode'})} = 'diodeThreshold'; + params.extractionMethod string = 'diodeThreshold' %the method used to extract frame flipes-{'diodeThreshold','digitalTriggerDiode'} + params.analogDataCh = 1 + params.overwrite logical = false %if true overwrites results + params.analysisTime = datetime('now') %extract the time at which analysis was performed + params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method + end + if params.inputParams,disp(params),return,end + + %load previous results if analysis was previuosly performed and there is no need to overwrite otherwise continue + results = obj.isOutputAnalysis(obj.getAnalysisFileName,params.overwrite,nargout==1); + if ~isempty(results), return, end + + fprintf('Extracting diode signal from analog channel #%d\n',params.analogDataCh); + switch params.extractionMethod + case "diodeThreshold" + + if ~any(isempty([obj.sessionStartTime,obj.sessionEndTime])) + [A,t_ms]=obj.dataObj.getAnalogData(params.analogDataCh,obj.sessionStartTime,obj.sessionEndTime-obj.sessionStartTime); %extract diode data for entire recording + + Th=mean(A(1:100:end)); + diodeUpCross=t_ms(A(1:end-1)=Th)+obj.sessionStartTime; + diodeDownCross=t_ms(A(1:end-1)>Th & A(2:end)<=Th)+obj.sessionStartTime; + else + disp('Missing start and end times!!! Please run getSessionTime before extracting triggers'); + return; + end + case "digitalTriggerDiode" + + switch obj.trialType + + case 'videoTrials' + + if ~any(isempty([obj.sessionStartTime,obj.sessionEndTime])) + + if all(obj.trialType == 'videoTrials') && (isequal(obj.stimName,'linearlyMovingBall')... + || isequal(obj.stimName, 'linearlyMovingBar')) + expectedFlipsperTrial = unique(obj.VST.nFrames); + speeds = obj.VST.speeds; + framesNspeed = zeros(2,length(speeds)); + framesNspeed(1,:) = speeds; + %Works for two speeds + framesNspeed(2,speeds == min(speeds)) = max(expectedFlipsperTrial); + framesNspeed(2,speeds == max(speeds)) = min(expectedFlipsperTrial); + elseif isequal(obj.stimName,'StaticDriftingGrating') || isequal(obj.stimName,'movie') + framesNspeed = ones(2,1); + framesNspeed(2,1) = round(obj.VST.actualStimDuration*obj.VST.fps); + + if isequal(obj.stimName,'movie') + framesNspeed(2,1) = round(obj.VST.movFrameCount); + framesNspeed = repmat(framesNspeed,1,numel(obj.VST.movieSequence)); + end + + if isequal(obj.stimName,'StaticDriftingGrating') + framesNspeed(2,1) = framesNspeed(2,1)+1;%adding static time. + framesNspeed = repmat(framesNspeed,1,numel(obj.VST.angleSequence)); + end + + else + framesNspeed = ones(2,1); + end + + t = obj.dataObj.getTrigger; + trialOn = t{3}(t{3} > obj.sessionStartTime & t{3} < obj.sessionEndTime); + trialOff = t{4}(t{4} > obj.sessionStartTime & t{4} < obj.sessionEndTime); + interDelayMs = obj.VST.interTrialDelay*1000; + + %[A,t_ms]=obj.dataObj.getAnalogData(params.analogDataCh,trialOn(1)-interDelayMs/2,trialOff(end)-trialOn(1)+interDelayMs); %extract diode data for entire recording + [A,t_ms]=obj.dataObj.getAnalogData(params.analogDataCh,trialOn(1),trialOff(end)-trialOn(1)+interDelayMs); %extract diode data for entire recording + + DiodeCrosses = cell(2,numel(trialOn)); + moreCross =0; + trialMostcross=inf; + intTrials =[]; + iMC =0; + intrialsNum = 0; + trialFail =0; + failedTrials =[]; + for i =1:length(trialOff) + + startSnip = round((trialOn(i)-trialOn(1))*(obj.dataObj.samplingFrequencyNI/1000))+1; + endSnip = round((trialOff(i)-trialOn(1)+100)*(obj.dataObj.samplingFrequencyNI/1000)); + + if endSnip>length(A) + signal = squeeze(A(startSnip:end)); + t_msS = t_ms(startSnip:end); + else + signal =squeeze(A(startSnip:endSnip)); + t_msS = t_ms(startSnip:endSnip); + end + fDat=medfilt1(signal,15); + Th=mean(fDat(1:100:end)); + stdS = std(fDat(1:100:end)); + sdK = 0.1; + upTimes=t_msS(fDat(1:end-1)=Th-sdK*stdS)+trialOn(1);%+interDelayMs/2; %get real recording times + downTimes=t_msS(fDat(1:end-1)>Th-sdK*stdS & fDat(2:end)<=Th-sdK*stdS )+trialOn(1);%+interDelayMs/2; + + % Filter crossings: Remove those too close together (e.g., < 50 ms) + minISI = 2*floor(1000/obj.VST.fps); % ms + filterISI = @(x) x([true, diff(x) > minISI]); + + try + DiodeCrosses{1,i} = filterISI(upTimes); + DiodeCrosses{2,i} = filterISI(downTimes); + catch + DiodeCrosses{1,i} = upTimes; + DiodeCrosses{2,i} = downTimes; + end + + if (length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i}))*1.1 < framesNspeed(2,i) && ~isequal(obj.stimName,'StaticDriftingGrating') + %if the number of calculated frames is less than 10% + %then perform an interpolation with the + %first and last cross + + if (length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i})) < framesNspeed(2,i)*0.5 + %%Diode failure. Use digital triggers + %%and interpolate. + diodeAll = zeros(1,2); + diodeAll(1) = trialOn(i); + diodeAll(2) = trialOff(i); + ind = 2; + trialFail = trialFail +1; + failedTrials = [failedTrials i]; + else + [~, ind]=min([DiodeCrosses{1,i}(1) DiodeCrosses{2,i}(1)]); %check if trial starts with up or down cross + diodeAll = sort([DiodeCrosses{1,i} DiodeCrosses{2,i}]); + end + + %DiodeInterp = linspace(diodeAll(1),diodeAll(end),framesNspeed(2,i)); + DiodeInterp = diodeAll(1):1000/obj.VST.fps: min([diodeAll(1) + (framesNspeed(2,i)-1)*(1000/obj.VST.fps), trialOff(i)]); + if ind == 2 %Trial starts with down cross + DiodeCrosses{2,i} = DiodeInterp(1:2:end); + DiodeCrosses{1,i} = DiodeInterp(2:2:end); + else + DiodeCrosses{2,i} = DiodeInterp(2:2:end); + DiodeCrosses{1,i} = DiodeInterp(1:2:end); + end + + intTrials = [intTrials i]; + intrialsNum = intrialsNum+1; + + end + + if (length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i}))>framesNspeed(2,i) + %if there are more crosses than there + %should be + moreCross = moreCross+1; + if trialMostcross>(length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i})) - framesNspeed(2,i) + trialMostcross = (length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i})) - framesNspeed(2,i); + iMC = i; + end + end + + if isequal(obj.stimName,'StaticDriftingGrating') %Make sure there is only one start frame. + + [firstCross, idx]= min([DiodeCrosses{1,i}(1),DiodeCrosses{2,i}(1)]); + + if firstCross > t_msS(1) + trialOn(1) + (obj.VST.static_time*1000)/2 %Add first frame that might not be read because of no diode change + if idx ==1 + DiodeCrosses{2,i} = [50+trialOn(i) DiodeCrosses{2,i}]; + else + DiodeCrosses{1,i} = [50+trialOn(i) DiodeCrosses{1,i}]; + end + [firstCross, idx]= min([DiodeCrosses{1,i}(1),DiodeCrosses{2,i}(1)]); + end + + ups = DiodeCrosses{1,i}; + downs = DiodeCrosses{2,i}; + ups = ups(ups == firstCross | ups>firstCross+obj.VST.static_time*1000*0.9); + downs = downs(downs == firstCross | downs>firstCross+obj.VST.static_time*1000*0.9); + + DiodeCrosses{1,i} = ups; + DiodeCrosses{2,i} = downs; + + if (length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i}))*1.1 < framesNspeed(2,i) + [~, ind]=min([DiodeCrosses{1,i}(1) DiodeCrosses{2,i}(1)]); + diodeAll = sort([DiodeCrosses{1,i} DiodeCrosses{2,i}]); + + %DiodeInterp = linspace(diodeAll(1),diodeAll(end),framesNspeed(2,i)); + DiodeInterp = [diodeAll(1) diodeAll(2):1000/obj.VST.fps: diodeAll(2) + (framesNspeed(2,i)-1)*(1000/obj.VST.fps)]; + if ind == 2 %Trial starts with down cross + DiodeCrosses{2,i} = DiodeInterp(1:2:end); + DiodeCrosses{1,i} = DiodeInterp(2:2:end); + else + DiodeCrosses{2,i} = DiodeInterp(2:2:end); + DiodeCrosses{1,i} = DiodeInterp(1:2:end); + end + end + + end %end especial case "if" for static and drifting gratings. + + end %End for loop through trials + + diodeUpCross=cell2mat(DiodeCrosses(1,:)); + diodeDownCross=cell2mat(DiodeCrosses(2,:)); + + fprintf('%d trials out of %d have little or no diode signal, assuming diode failure but correct fliping in trials:',trialFail,length(trialOff)) + fprintf('%.0f ', failedTrials); + fprintf('\n'); + fprintf('%d trials have excess crossings out of %d; trial %d has the most excess crossings: %d',moreCross,length(trialOff),iMC,trialMostcross) + fprintf('\n'); + fprintf('%d Interpolated trials (out of %d) with more than 10%% of crosses missing: ',intrialsNum,length(trialOff)); + fprintf('%.0f ', intTrials); + fprintf('\n'); + %Test + figure;plot(squeeze(fDat)); + hold on;xline((DiodeCrosses{1,i} - trialOn(i))*(obj.dataObj.samplingFrequencyNI/1000)) + xline((DiodeCrosses{2,i} - trialOn(i))*(obj.dataObj.samplingFrequencyNI/1000),'r') + yline(Th) + hold on;xline((upTimes-trialOn(i))*(obj.dataObj.samplingFrequencyNI/1000));xline((50)*(obj.dataObj.samplingFrequencyNI/1000)) + % %xline((trialOff(i)-trialOn(1))*(obj.dataObj.samplingFrequencyNI/1000),'b') + % figure;plot(1:length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i})-1,diff(sort([DiodeCrosses{1,i} DiodeCrosses{2,i}]))) + else + disp('Missing start and end times!!! Please run getSessionTime before extracting triggers'); + end + + case 'imageTrials' + + t = obj.dataObj.getTrigger; + trialOn = t{3}(t{3} > obj.sessionStartTime & t{3} < obj.sessionEndTime); + trialOff = t{4}(t{4} > obj.sessionStartTime & t{4} < obj.sessionEndTime); + interDelayMs = obj.VST.interTrialDelay*1000; + + %[A,t_ms]=obj.dataObj.getAnalogData(params.analogDataCh,trialOn(1)-interDelayMs/2,trialOff(end)-trialOn(1)+interDelayMs); %extract diode data for entire recording + [A,t_ms]=obj.dataObj.getAnalogData(params.analogDataCh,trialOn(1),trialOff(end)-trialOn(1)+interDelayMs); %extract diode data for entire recording + + DiodeCrosses = cell(2,numel(trialOn)); + moreCross =0; + trialMostcross=inf; + intTrials =[]; + iMC =0; + intrialsNum = 0; + trialFail =0; + failedTrials =[]; + for i =1:length(trialOff) + + startSnip = round((trialOn(i)-trialOn(1))*(obj.dataObj.samplingFrequencyNI/1000))+1; + endSnip = round((trialOff(i)-trialOn(1)+interDelayMs/2)*(obj.dataObj.samplingFrequencyNI/1000)); + + if endSnip>length(A) + signal = squeeze(A(startSnip:end)); + t_msS = t_ms(startSnip:end); + elseif startSnip<1 + signal =squeeze(A(1:endSnip)); + t_msS = t_ms(1:endSnip); + else + signal =squeeze(A(startSnip:endSnip)); + t_msS = t_ms(startSnip:endSnip); + end + + fDat=-1*medfilt1(signal,(obj.VST.stimDuration/4)*1000); + Th=mean(fDat(1:100:end)); + stdS = std(fDat(1:100:end)); + sdK = 0; + upTimes=t_msS(fDat(1:end-1)=Th-sdK*stdS)+trialOn(1);%+interDelayMs/2; %get real recording times + downTimes=t_msS(fDat(1:end-1)>Th-sdK*stdS & fDat(2:end)<=Th-sdK*stdS )+trialOn(1);%+interDelayMs/2; + + if length(upTimes) >1 || length(downTimes)>1 + upTimes=upTimes(1); + downTimes = downTimes(2); + end + + if length(upTimes) <1 || length(downTimes)<1 + + upTimes = trialOn(i)+10; + downTimes = trialOff(i)+10; + end + + DiodeCrosses{1,i} = upTimes; + DiodeCrosses{2,i} = downTimes; + + + end + + figure;plot(squeeze(fDat)); + hold on;xline((DiodeCrosses{1,i} - trialOn(i))*(obj.dataObj.samplingFrequencyNI/1000)) + xline((DiodeCrosses{2,i} - trialOn(i))*(obj.dataObj.samplingFrequencyNI/1000),'r') + + diodeUpCross=cell2mat(DiodeCrosses(1,:)); + diodeDownCross=cell2mat(DiodeCrosses(2,:)); + + end + end + results.diodeUpCross = diodeUpCross; + results.diodeDownCross = diodeDownCross; + + %save results in the right file + fprintf('Saving results to file.\n'); + save(obj.getAnalysisFileName,'params','diodeUpCross','diodeDownCross','Th'); + end + + function f=plotDiodeTriggers(obj) + %Plots the position of detected diode triggers + if isfile([obj.visualStimAnalysisFolder filesep 'getDiodeTriggers.mat']) + D=load([obj.visualStimAnalysisFolder filesep 'getDiodeTriggers.mat']); + else + fprintf('Missing analysis: Running getDiodeTriggers is required!');return; + end + if isfile([obj.visualStimAnalysisFolder filesep 'getSessionTime.mat']) + S=load([obj.visualStimAnalysisFolder filesep 'getSessionTime.mat']); + else + fprintf('Missing analysis: Running getSessionTime is required!');return; + end + + if ~any(isempty([D.diodeUpCross,D.diodeDownCross,obj.sessionStartTime])) + f=figure('Position',[100,100,1200,300]); + h1=plot(D.diodeUpCross,ones(1,numel(D.diodeUpCross)),'^k');hold on; + h2=plot(D.diodeDownCross,ones(1,numel(D.diodeDownCross)),'vk'); + h3=line([S.sessionStartTime;S.sessionEndTime]',[1.01;1.01]','linewidth',3);hold on; + ylim([0.99 1.02]); + l=legend([h1, h2, h3],{'diode up crossings','diode down crossings','stimulation session duration'}); + else + disp('plotting triggers requires missing variables: run getDiodeTriggers, getSessionStartTime again'); + end + end + + end + + methods (Static) + %find a specific folder in the experiment + %folderLocation=findFolderInExperiment(rootFolder,folderNamePart,params) + folderLocation=findFolderInExperiment() + Fig = PlotZScoreComparison() + + function results=isOutputAnalysis(analysisFileName,overwrite,isOutput) + %load previous results if analysis was previuosly performed and there is no need to overwrite + results=[]; + if ~overwrite + if isOutput + if isfile(analysisFileName) + fprintf('Loading saved results from file.\n'); + results=load(analysisFileName); + else + fprintf('No results for this analyis!!! Running analysis first but will not be able to load and output the results.\n'); + end + else + if isfile(analysisFileName) + fprintf('Analysis already exists (use overwrite option to recalculate).\n'); + results=false; + end + end + else + if isOutput + fprintf('Cant not calculate and return results!\n Please run without output argument first and then run again to load the results.\n'); + results=false; + end + end + end + + end +end \ 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/@fullFieldFlashAnalysis/fullFieldFlashAnalysis.m b/visualStimulationAnalysis/@fullFieldFlashAnalysis/fullFieldFlashAnalysis.m index 3a4639a..40f3592 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 diff --git a/visualStimulationAnalysis/@imageAnalysis/imageAnalysis.m b/visualStimulationAnalysis/@imageAnalysis/imageAnalysis.m index 86aa899..d28040c 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 diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/linearlyMovingBallAnalysis.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/linearlyMovingBallAnalysis.m index f083f52..9e6d6eb 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/linearlyMovingBallAnalysis.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/linearlyMovingBallAnalysis.m @@ -13,14 +13,29 @@ function [obj] = linearlyMovingBallAnalysis(dataObj,params) arguments (Input) %ResponseWindow.mat dataObj - params.Session = 1; + params.Session double = 1 + params.MultipleOffsets logical = true end if nargin==0 dataObj=[]; end + % Call superclass constructor obj@VStimAnalysis(dataObj,'Session',params.Session); obj.Session = params.Session; + + 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 end end 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..b3f9aad 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 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..2b5c58a --- /dev/null +++ b/visualStimulationAnalysis/AllExpAnalysis.asv @@ -0,0 +1,1688 @@ +function fig = 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 +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} = '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); % 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; + spkR_MB = max(rwMB.Speed1.NeuronVals(:,:,4), [], 2); % max across directions, col 4 = resp. rate + 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; + spkR_MB = max(rwMB.Speed2.NeuronVals(:,:,4), [], 2); + 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; + spkR_RG = max(rwRG.NeuronVals(:,:,4), [], 2); + 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) + 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'}); + + 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=false, 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 4 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")(S.TableStimComp.stimulus == pairs{1}); + pair2 = S.TableStimComp.("Z-score")(S.TableStimComp.stimulus == pairs{2}); + colorAnimal = S.TableStimComp.animal(S.TableStimComp.stimulus == pairs{1}); + + % Scatter with animal-coded colour, using subsampled indices + scatter(pair1, pair2, 7, colorAnimal, ... + "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(lines(numel(categories(S.TableStimComp.animal)))) + + 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=false, 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 4 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(S.TableStimComp.stimulus == pairs{1}); + pair2 = S.TableStimComp.SpkR(S.TableStimComp.stimulus == pairs{2}); + colorAnimal = S.TableStimComp.animal(S.TableStimComp.stimulus == pairs{1}); + + scatter(pair1(randiColors), pair2(randiColors), 7, colorAnimal(randiColors), ... + "filled", "MarkerFaceAlpha", 0.4) + 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(lines(numel(categories(S.TableStimComp.animal)))) + + 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, yLegend='Responsive/total units', ... + diff=false, filled=false, Xjitter='none', Alpha=0.6); + +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]); + +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/AllExpAnalysis.m b/visualStimulationAnalysis/AllExpAnalysis.m new file mode 100644 index 0000000..b69f009 --- /dev/null +++ b/visualStimulationAnalysis/AllExpAnalysis.m @@ -0,0 +1,1686 @@ +function fig = 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 +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} = '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); % 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; + spkR_MB = max(rwMB.Speed1.NeuronVals(:,:,4), [], 2); % max across directions, col 4 = resp. rate + 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; + spkR_MB = max(rwMB.Speed2.NeuronVals(:,:,4), [], 2); + 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; + spkR_RG = max(rwRG.NeuronVals(:,:,4), [], 2); + 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) + 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'}); + + 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=false, 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 4 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=false, 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 4 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); + +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]); + +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/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv index 1f1ee97..ed14b9c 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.asv +++ b/visualStimulationAnalysis/RunAnalysisClass.asv @@ -14,7 +14,7 @@ for ex = 69 %84:91 % vsRe.getSyncedDiodeTriggers("overwrite",true); % % vsRe.plotSpatialTuningSpikes; % % vsRe.plotSpatialTuningLFP; - % vsRe.ResponseWindow('overwrite',true) + 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,exNeuronsPhyID=137, selectedLum=255,oneTrial = true,PaperFig = true) %43 @@ -29,21 +29,21 @@ vsRe.PlotReceptiveFields("exNeurons",18) %% Moving ball -for ex = [81]%97 74:84 (Neurons, 96_74, ) +for ex = [87]%97 74:84 (Neurons, 96_74, ) NP = loadNPclassFromTable(ex); %73 81 - vs = linearlyMovingBallAnalysis(NP,Session=1); + vs = linearlyMovingBallAnalysis(NP,Session=2); % 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('exNeuronsPhyID',288,'overwrite',true,'MergeNtrials',3,'PaperFig',true) - % % %vs.plotCorrSpikePattern + 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('exNeuronsPhyID',288,'overwrite',true,'MergeNtrials',3,'PaperFig',true) + % % % %vs.plotCorrSpikePattern % vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'OneDirection','up','OneLuminosity','white','MergeNtrials',1,'PaperFig',true) %vs.plotRaster('exNeurons',9,'AllResponsiveNeurons',false,'overwrite',true,'MergeNtrials',3,MaxVal_1=false) @@ -55,7 +55,8 @@ for ex = [81]%97 74:84 (Neurons, 96_74, ) result = vs.StatisticsPerNeuron('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 @@ -66,8 +67,9 @@ end %[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=true,... - overwriteResponse=false,overwriteStats=false)%[49:54,57:91] %%Check why I have different array dimensions in MBR +AllExpAnalysis([49:54,64:85 87:97] ,{'MB','RG'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true)%[49:54,57:91] %%Check why I have different array dimensions in MBR%% + %% PSTH for all experiments plotPSTH_MultiExp([49:54], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] @@ -75,7 +77,7 @@ plotPSTH_MultiExp([49:54], overwrite=true, zScore=true,TakeTopPercentTrials=[], plotRaster_MultiExp([49:54,64:97], sortBy = "depth",overwrite=false,TakeTopPercentTrials=[]) %% Calculate spatial tuning -results= SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_diff" ,overwrite=false, topPercent = 20,useRF=true,onOff=2); +results= SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_diff" ,overwrite=true, topPercent = 20,useRF=true,onOff=1); %% Get neuron depths getNeuronDepths([49:54,64:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates @@ -96,7 +98,7 @@ end %% movie -for ex = [89,90,92,93,95:97] +for ex = [90] NP = loadNPclassFromTable(ex); %73 81 vs = movieAnalysis(NP); % vs.getSessionTime("overwrite",true); @@ -104,9 +106,9 @@ for ex = [89,90,92,93,95:97] % 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); end diff --git a/visualStimulationAnalysis/RunAnalysisClass.m b/visualStimulationAnalysis/RunAnalysisClass.m index cb91ccc..e70e859 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -14,7 +14,7 @@ % vsRe.getSyncedDiodeTriggers("overwrite",true); % % vsRe.plotSpatialTuningSpikes; % % vsRe.plotSpatialTuningLFP; - % vsRe.ResponseWindow('overwrite',true) + 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,exNeuronsPhyID=137, selectedLum=255,oneTrial = true,PaperFig = true) %43 @@ -29,7 +29,7 @@ %% Moving ball -for ex = [[49:54,64:97]]%97 74:84 (Neurons, 96_74, ) +for ex = [96]%97 74:84 (Neurons, 96_74, ) NP = loadNPclassFromTable(ex); %73 81 vs = linearlyMovingBallAnalysis(NP,Session=1); % vs.getSessionTime("overwrite",true); @@ -37,13 +37,13 @@ % % %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('exNeuronsPhyID',288,'overwrite',true,'MergeNtrials',3,'PaperFig',true) - % % %vs.plotCorrSpikePattern + 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('exNeuronsPhyID',288,'overwrite',true,'MergeNtrials',3,'PaperFig',true) + % % % %vs.plotCorrSpikePattern % vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'OneDirection','up','OneLuminosity','white','MergeNtrials',1,'PaperFig',true) %vs.plotRaster('exNeurons',9,'AllResponsiveNeurons',false,'overwrite',true,'MergeNtrials',3,MaxVal_1=false) @@ -55,19 +55,21 @@ result = vs.StatisticsPerNeuron('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='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... - overwriteResponse=false,overwriteStats=true)%[49:54,57:91] %%Check why I have different array dimensions in MBR +AllExpAnalysis([49:54,64:85 87:97] ,{'MB','RG'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true)%[49:54,57:91] %%Check why I have different array dimensions in MBR%% + %% PSTH for all experiments plotPSTH_MultiExp([49:54], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] @@ -75,7 +77,7 @@ plotRaster_MultiExp([49:54,64:97], sortBy = "depth",overwrite=false,TakeTopPercentTrials=[]) %% Calculate spatial tuning -results= SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_diff" ,overwrite=false, topPercent = 20,useRF=true,onOff=2); +results= SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_diff" ,overwrite=true, topPercent = 20,useRF=true,onOff=1); %% Get neuron depths getNeuronDepths([49:54,64:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates @@ -96,7 +98,7 @@ %% movie -for ex = [89,90,92,93,95:97] +for ex = [90] NP = loadNPclassFromTable(ex); %73 81 vs = movieAnalysis(NP); % vs.getSessionTime("overwrite",true); @@ -104,9 +106,9 @@ % 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); end diff --git a/visualStimulationAnalysis/SpatialTuningIndex.m b/visualStimulationAnalysis/SpatialTuningIndex.m index d815400..08673a7 100644 --- a/visualStimulationAnalysis/SpatialTuningIndex.m +++ b/visualStimulationAnalysis/SpatialTuningIndex.m @@ -5,7 +5,7 @@ params.stimTypes (1,:) string = ["linearlyMovingBall", "rectGrid"] params.topPercent double = 10 params.overwrite logical = false - params.statType string = "BootstrapPerNeuron" + 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 @@ -162,6 +162,8 @@ % 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 diff --git a/visualStimulationAnalysis/SpatialTuningIndexV1.m b/visualStimulationAnalysis/SpatialTuningIndexV1.m deleted file mode 100644 index 68a48d4..0000000 --- a/visualStimulationAnalysis/SpatialTuningIndexV1.m +++ /dev/null @@ -1,246 +0,0 @@ -function results = SpatialTuningIndex(exList, params) - -arguments - exList double - params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] - params.topPercent double = 10 - params.overwrite logical = false - params.statType string = "BootstrapPerNeuron" - params.speed double = 1 -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']; -if ~exist([p '\Combined_lizard_analysis'], 'dir') - cd(p) - mkdir Combined_lizard_analysis -end -saveDir = [p '\Combined_lizard_analysis']; - -stimLabel = strjoin(params.stimTypes, '-'); -nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s.mat', ... - exList(1), exList(end), stimLabel); - -% ------------------------------------------------------------------------- -% Decide whether to compute or load -% ------------------------------------------------------------------------- -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]); - results = S; - return - else - fprintf('Experiment list mismatch — recomputing.\n'); - end -end - -% ------------------------------------------------------------------------- -% EXPERIMENT LOOP -% ------------------------------------------------------------------------- -nExp = numel(exList); -nStim = numel(params.stimTypes); - -% Will grow as we discover dimensions from first valid experiment -L_amplitude_all = cell(nStim, nExp); -L_geometric_all = cell(nStim, nExp); -L_combined_all = cell(nStim, nExp); - -for ei = 1:nExp - - ex = exList(ei); - fprintf('\n=== Experiment %d ===\n', ex); - - try - NP = loadNPclassFromTable(ex); - catch ME - warning('Could not load experiment %d: %s', ex, ME.message); - continue - end - - for s = 1:nStim - - stimType = params.stimTypes(s); - - % Build 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 - - % ---------------------------------------------------------- - % Check for responsive neurons - % ---------------------------------------------------------- - try - if params.statType == "BootstrapPerNeuron" - Stats = obj.BootstrapPerNeuron; - else - Stats = obj.ShufflingAnalysis; - end - - p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); - label = string(p_sort.label'); - goodU = p_sort.ic(:, label == 'good'); - - % Resolve field name depending on stim type - 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 - - respU = find(pvals < 0.05); - - catch ME - warning('Could not load stats for %s exp %d: %s', stimType, ex, ME.message); - L_amplitude_all{s, ei} = []; - L_geometric_all{s, ei} = []; - L_combined_all{s, ei} = []; - continue - end - - if isempty(respU) - fprintf(' [%s] No responsive neurons in exp %d — skipping.\n', stimType, ex); - L_amplitude_all{s, ei} = []; - L_geometric_all{s, ei} = []; - L_combined_all{s, ei} = []; - continue - end - - fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', stimType, ex, numel(respU)); - - % Load grid results - - S_rf = obj.CalculateReceptiveFields; - - gridSpikeRate = S_rf.gridSpikeRate; % [nGrid x nGrid x nN x onOff x nSize x nLum] - gridSpikeRateShuff = S_rf.gridSpikeRateShuff; % [nGrid x nGrid x nN x nShuffle x nSize x nLum] - - [nGrid, ~, nN, nOnOff, nSize, nLum] = size(gridSpikeRate); - nShuffle = size(gridSpikeRateShuff, 4); - nCells = nGrid * nGrid; - - % Average over shuffles - gridShuffMean = mean(gridSpikeRateShuff, 4); % [nGrid x nGrid x nN x nSize x nLum] - - L_amplitude = zeros(nN, nOnOff, nSize, nLum); - L_geometric = zeros(nN, nOnOff, nSize, nLum); - L_combined = zeros(nN, nOnOff, nSize, nLum); - - maxDist = sqrt(2) * (nGrid - 1); - - for oi = 1:nOnOff - for si = 1:nSize - for li = 1:nLum - - rateFlat = reshape(gridSpikeRate(:,:,:,oi,si,li), [nCells, nN]); - rateFlatShuff = reshape(gridShuffMean(:,:,:,si,li), [nCells, nN]); - - for u = 1:nN - - rateVec = rateFlat(:, u); - rateVecShuff = rateFlatShuff(:, u); - - %% ---- Shared: top 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); - - %% ---- 1. Amplitude index ---- - meanTop = mean(rateVec(topIdx)); - meanRest = mean(rateVec(restIdx)); - meanAll = mean(rateVec); - - meanTopShuff = mean(rateVecShuff(topIdxShuff)); - meanRestShuff = mean(rateVecShuff(restIdxShuff)); - meanAllShuff = mean(rateVecShuff); - - if meanAll == 0, meanAll = eps; end - if meanAllShuff == 0, meanAllShuff = eps; end - - L_amplitude(u, oi, si, li) = ... - (meanTop - meanRest) / meanAll - ... - (meanTopShuff - meanRestShuff) / meanAllShuff; - - %% ---- 2. Geometric index ---- - [rowIdx, colIdx] = ind2sub([nGrid nGrid], topIdx); - [rowIdxShuff, colIdxShuff] = ind2sub([nGrid nGrid], topIdxShuff); - - 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 - - L_geometric(u, oi, si, li) = (1 - D) - (1 - DShuff); - - %% ---- 3. Combined index ---- - L_combined(u, oi, si, li) = L_amplitude(u, oi, si, li) * L_geometric(u, oi, si, li); - - end - end - end - end - - L_amplitude_all{s, ei} = L_amplitude; % [nN x nOnOff x nSize x nLum] - L_geometric_all{s, ei} = L_geometric; - L_combined_all{s, ei} = L_combined; - - fprintf(' [%s] Done. %d neurons.\n', stimType, nN); - - end % stim loop -end % experiment loop - -% ------------------------------------------------------------------------- -% Save -% ------------------------------------------------------------------------- -S.expList = exList; -S.L_amplitude_all = L_amplitude_all; % {nStim x nExp} cell, each [nN x nOnOff x nSize x nLum] -S.L_geometric_all = L_geometric_all; -S.L_combined_all = L_combined_all; -S.params = params; - -save([saveDir nameOfFile], '-struct', 'S'); -fprintf('\nSaved SpatialTuningIndex to:\n %s\n', [saveDir nameOfFile]); - -results = S; - -end \ No newline at end of file diff --git a/visualStimulationAnalysis/SpatialTuningIndexV2.m b/visualStimulationAnalysis/SpatialTuningIndexV2.m deleted file mode 100644 index 208f8e8..0000000 --- a/visualStimulationAnalysis/SpatialTuningIndexV2.m +++ /dev/null @@ -1,433 +0,0 @@ -function results = SpatialTuningIndex(exList, params) - -arguments - exList double - params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] - params.topPercent double = 10 - params.overwrite logical = false - params.statType string = "BootstrapPerNeuron" - params.speed double = 1 - params.plot logical = true - params.indexType string = "L_amplitude" % L_amplitude_diff,L_amplitude_ratio, 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.yLegend char = 'Spatial Tuning Index' - params.yMaxVis double = 1 - params.Alpha double = 0.4 - params.PaperFig logical = false - params.useRF 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']; -if ~exist([p '\Combined_lizard_analysis'], 'dir') - cd(p) - mkdir Combined_lizard_analysis -end -saveDir = [p '\Combined_lizard_analysis']; - -stimLabel = strjoin(params.stimTypes, '-'); -nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s.mat', ... - exList(1), exList(end), stimLabel); - -% ------------------------------------------------------------------------- -% Decide whether to compute or load -% ------------------------------------------------------------------------- -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]); - % Jump straight to table building - 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); - - tbl = table(); - - for ei = 1:nExp - - ex = exList(ei); - fprintf('\n=== Experiment %d ===\n', ex); - - try - NP = loadNPclassFromTable(ex); - catch ME - warning('Could not load experiment %d: %s', ex, ME.message); - continue - end - - obj_s = linearlyMovingBallAnalysis(NP); - - nameParts = split(NP.recordingName, '_'); - animalName = nameParts{1}; - - % ---------------------------------------------------------- - % Find union of responsive neurons across ALL stim types - % ---------------------------------------------------------- - - % Get phy IDs once — same for all stim types - p_s = NP.convertPhySorting2tIc(obj_s.spikeSortingFolder); - phy_IDg = p_s.phy_ID(string(p_s.label') == 'good'); - - respPhyIDs_all = cell(1, nStim); - respU_all = cell(1, nStim); % ADD — stores respU indices per stim - - for s = 1:nStim - stimType = params.stimTypes(s); - try - switch stimType - case "rectGrid" - obj_s = rectGridAnalysis(NP); - case "linearlyMovingBall" - obj_s = linearlyMovingBallAnalysis(NP); - end - - if params.statType == "BootstrapPerNeuron" - Stats = obj_s.BootstrapPerNeuron; - else - Stats = obj_s.ShufflingAnalysis; - end - - 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 - - respU = find(pvals < 0.05); - respU_all{s} = respU; % ADD — index into gridSpikeRate dim 3 - respPhyIDs_all{s} = phy_IDg(respU); % phy IDs of responsive neurons - 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 - - % Intersection of responsive phy IDs 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); - - - for s = 1:nStim - - stimType = params.stimTypes(s); - - % Build 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 grid results - % ---------------------------------------------------------- - S_rf = obj.CalculateReceptiveFields; - - gridSpikeRate = S_rf.gridSpikeRate; - gridSpikeRateShuff = S_rf.gridSpikeRateShuff; - - switch stimType - case "rectGrid" - gridSpikeRateSelected = gridSpikeRate(:,:,:,params.onOff,:,:); - gridShuffSelected = gridSpikeRateShuff(:,:,:,:,params.onOff,:,:); - - % Remove onOff singleton at dim 4 for rate: [9 9 nN 1 nSize nLum] -> [9 9 nN nSize nLum] - gridSpikeRateSelected = reshape(gridSpikeRateSelected, ... - [size(gridSpikeRateSelected,1), size(gridSpikeRateSelected,2), ... - size(gridSpikeRateSelected,3), size(gridSpikeRateSelected,5), ... - size(gridSpikeRateSelected,6)]); - - % Remove onOff singleton at dim 5 for shuff: [9 9 nN nShuffle 1 nSize nLum] -> [9 9 nN nShuffle nSize nLum] - 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; % [nGrid nGrid nN nSize nLum] - gridShuffSelected = gridSpikeRateShuff; % [nGrid nGrid nN nShuffle nSize nLum] - end - - % Find which indices of THIS stim's gridSpikeRate correspond to sharedPhyIDs - [~, neuronIdx] = ismember(sharedPhyIDs, phy_IDg(respU_all{s})); - - gridSpikeRateSelected = gridSpikeRateSelected(:,:,neuronIdx,:,:); - gridShuffSelected = gridShuffSelected(:,:,neuronIdx,:,:,:); - - % Average over shuffles and reshape explicitly — no squeeze - gridShuffMean = mean(gridShuffSelected, 4); % [nGrid nGrid nN 1 nSize nLum] - - % Get dimensions explicitly - nN = size(gridSpikeRateSelected, 3); - nSize = size(gridSpikeRateSelected, 4); - nLum = size(gridSpikeRateSelected, 5); - nGrid = size(gridSpikeRateSelected, 1); - - fprintf('gridSpikeRateSelected size before reshape: %s\n', num2str(size(gridSpikeRateSelected))); - fprintf('Expected: [%d %d %d %d %d]\n', nGrid, nGrid, nN, nSize, nLum); - - % Reshape both to clean [nGrid nGrid nN nSize nLum] - gridSpikeRateSelected = reshape(gridSpikeRateSelected, [nGrid nGrid nN nSize nLum]); - gridShuffMean = reshape(gridShuffMean, [nGrid nGrid nN nSize nLum]); - - nCells = nGrid * nGrid; - maxDist = sqrt(2) * (nGrid - 1); - - % Average over shuffles - - % ---------------------------------------------------------- - % Compute indices - % ---------------------------------------------------------- - - fprintf('gridSpikeRate size: %s\n', num2str(size(gridSpikeRate))); - fprintf('gridSpikeRateShuff size: %s\n', num2str(size(gridSpikeRateShuff))); - fprintf('gridShuffMean size: %s\n', num2str(size(gridShuffMean))); - - for si = 1:nSize - for li = 1:nLum - - 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); - - % Top 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); - - % Amplitude - meanTop = mean(rateVec(topIdx)); - meanRest = mean(rateVec(restIdx)); - meanAll = mean(rateVec); - meanTopShuff = mean(rateVecShuff(topIdxShuff)); - meanRestShuff = mean(rateVecShuff(restIdxShuff)); - meanAllShuff = mean(rateVecShuff); - - if meanAll == 0, meanAll = eps; end - if meanAllShuff == 0, meanAllShuff = eps; end - - L_amplitude_diff(u) = ... - (meanTop - meanRest) / meanAll - ... - (meanTopShuff - meanRestShuff) / meanAllShuff; - - shuffleNorm = (meanTopShuff - meanRestShuff) / meanAllShuff; - if shuffleNorm == 0, shuffleNorm = eps; end - - L_amplitude_ratio(u) = ((meanTop - meanRest) / meanAll) / shuffleNorm; - - % Geometric - [rowIdx, colIdx] = ind2sub([nGrid nGrid], topIdx); - [rowIdxShuff, colIdxShuff] = ind2sub([nGrid nGrid], topIdxShuff); - - 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 - - L_geometric(u) = (1 - D) - (1 - DShuff); - L_combined(u) = L_amplitude_diff(u) * L_geometric(u); - - end - - % Build rows 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.insertion = categorical(repmat(ex, nN, 1)); - rows.animal = categorical(repmat({animalName}, nN, 1)); - rows.NeurID = (1:nN)'; - rows.onOff = repmat(params.onOff, nN, 1); % params.onOff for rectGrid, meaningless but consistent for movingBall - rows.sizeIdx = repmat(si, nN, 1); - rows.lumIdx = repmat(li, nN, 1); - - tbl = [tbl; rows]; - - end - end - - fprintf(' [%s] Indices computed. %d neurons.\n', stimType, nN); - - end % stim loop - end % exp loop - - % Clean categories - tbl.stimulus = removecats(tbl.stimulus); - tbl.animal = removecats(tbl.animal); - tbl.insertion = removecats(tbl.insertion); - - % Save - 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; - -% ========================================================================= -% PLOT -% ========================================================================= -if params.plot - - % Filter table to requested condition - idx = tbl.onOff == params.onOff & ... - tbl.sizeIdx == params.sizeIdx & ... - tbl.lumIdx == params.lumIdx; - - tblPlot = tbl(idx, :); - tblPlot.value = tblPlot.(params.indexType); % select which index to plot - - % ---------------------------------------------------------- - % Compute p-values using hierBoot - % ---------------------------------------------------------- - ps = []; - - pairs = {char(params.stimTypes(1)), char(params.stimTypes(2))}; - - - ps = zeros(size(pairs, 1), 1); - j = 1; - - for i = 1:size(pairs, 1) - 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]; - 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 - - - % ---------------------------------------------------------- - % Plot - % ---------------------------------------------------------- - V1max = max(tblPlot.value, [], 'omitnan'); - - fprintf('Length of ps: %d\n', numel(ps)); - fprintf('Size of pairs: %s\n', num2str(size(pairs))); - - [fig, ~] = plotSwarmBootstrapWithComparisons(tblPlot, pairs, ps, {'value'}, ... - yLegend = params.yLegend, ... - yMaxVis = max(params.yMaxVis, V1max), ... - diff = true, ... - Alpha = params.Alpha, ... - plotMeanSem = true); - - title(sprintf('%s — %s (onOff=%d, size=%d, lum=%d)', ... - params.indexType, strjoin(params.stimTypes, '/'), ... - params.onOff, 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 - - results.fig = fig; - results.ps = ps; - -end - -end \ No newline at end of file diff --git a/visualStimulationAnalysis/plotPSTH_MultiExpV1.m b/visualStimulationAnalysis/plotPSTH_MultiExpV1.m deleted file mode 100644 index e9270cd..0000000 --- a/visualStimulationAnalysis/plotPSTH_MultiExpV1.m +++ /dev/null @@ -1,465 +0,0 @@ -function plotPSTH_MultiExpV1(exList, params) - -arguments - exList double - params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] - params.bin double = 30 - params.binWidth double = 10 - params.smooth double = 0 % smoothing window in ms (0 = no smoothing) - params.statType string = "BootstrapPerNeuron" - params.speed string = "max" - params.alpha double = 0.05 - params.shadeSTD logical = true - params.postStim double = 500 % ms after stim onset to include - params.preBase double = 200 % ms of baseline before stim onset - params.overwrite logical = false % force recompute even if file exists - params.TakeTopPercentTrials double = 0.3 %Percentage of highest spiking rate trials to take to calculate PSTHs - params.zScore logical = false % normalize firing rate to z-score using baseline - params.PaperFig logical = false %Is this going to be used in the paper? -end - -% ------------------------------------------------------------------------- -% Build save path using first experiment to get the analysis folder -% This mirrors the convention used in PlotZScoreComparison -% ------------------------------------------------------------------------- - -% Load first experiment just to get the folder path -NP_first = loadNPclassFromTable(exList(1)); -vs_first = linearlyMovingBallAnalysis(NP_first); % used only for path - -% Build the save directory path -p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); -p = [p 'lizards']; -if ~exist([p '\Combined_lizard_analysis'], 'dir') - cd(p) - mkdir Combined_lizard_analysis -end -saveDir = [p '\Combined_lizard_analysis']; - -% Build filename — includes stim types so different comparisons don't clash -stimLabel = strjoin(params.stimTypes, '-'); % e.g. "rectGrid-linearlyMovingBall" -nameOfFile = sprintf('\\Ex_%d-%d_Combined_PSTHs_%s.mat', ... - exList(1), exList(end), stimLabel); - -% ------------------------------------------------------------------------- -% Decide whether to run the experiment loop or load from disk -% forloop = true → compute PSTHs from scratch -% forloop = false → load saved struct and skip to plotting -% ------------------------------------------------------------------------- -if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite - % File exists and overwrite is off — check if expList matches - S = load([saveDir nameOfFile]); - if isequal(S.expList, exList) - fprintf('Loading saved PSTHs from:\n %s\n', [saveDir nameOfFile]); - forloop = false; % skip computation, go straight to plot - else - fprintf('Experiment list mismatch — recomputing.\n'); - forloop = true; % expList changed, recompute - end -else - forloop = true; % file doesn't exist or overwrite requested -end - -% ========================================================================= -% EXPERIMENT LOOP — only runs if forloop is true -% ========================================================================= -if forloop - - nStim = numel(params.stimTypes); - nExp = numel(exList); - - % One cell per stim type, grows one row per experiment - psthAll = cell(1, nStim); - for s = 1:nStim - psthAll{s} = []; - end - - % Locked time window — set from first valid experiment - lockedPreBase = []; - lockedNBins = []; - lockedEdges = []; - - % ------------------------------------------------------------------ - % LOOP OVER EXPERIMENTS - % ------------------------------------------------------------------ - for ei = 1:nExp - - ex = exList(ei); - fprintf('\n=== Experiment %d ===\n', ex); - - % Load NP data for this experiment - try - NP = loadNPclassFromTable(ex); - catch ME - warning('Could not load experiment %d: %s', ex, ME.message); - % Add NaN placeholder row if window is already locked - for s = 1:nStim - if ~isempty(psthAll{s}) - psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; - end - end - continue - end - - % -------------------------------------------------------------- - % LOOP OVER STIMULUS TYPES - % -------------------------------------------------------------- - for s = 1:nStim - - stimType = params.stimTypes(s); - - % Build analysis object for this stim type - try - switch stimType - case "rectGrid" - obj = rectGridAnalysis(NP); - case "linearlyMovingBall" - obj = linearlyMovingBallAnalysis(NP); - case 'StaticGrating' - obj = StaticDriftingGratingAnalysis(NP); - case 'MovingGrating' - obj = StaticDriftingGratingAnalysis(NP); - otherwise - error('Unknown stimType: %s', stimType); - end - catch ME - warning('Could not build %s for exp %d: %s', stimType, ex, ME.message); - if ~isempty(psthAll{s}) - psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; - end - continue - end - - % ---------------------------------------------------------- - % Extract data structures - % ---------------------------------------------------------- - - % ResponseWindow holds trial timing and spike data - NeuronResp = obj.ResponseWindow; - - % Stats struct for p-values - if params.statType == "BootstrapPerNeuron" - Stats = obj.BootstrapPerNeuron; - else - Stats = obj.ShufflingAnalysis; - end - - % Resolve speed field name - if params.speed ~= "max" && isequal(obj.stimName,'linearlyMovingBall') - fieldName = 'Speed2'; - startStim = 0; - elseif isequal(obj.stimName,'linearlyMovingBall') - fieldName = 'Speed1'; - startStim = 0; - elseif isequal(params.stimTypes,'StaticGrating') - fieldName = 'Static'; - startStim = 0; - - elseif isequal(params.stimTypes,'MovingGrating') - startStim = obj.VST.static_time*1000; - fieldName = 'Moving'; - else - startStim = 0; - end - - % Spike trains of somatic (good) units - p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); - label = string(p_sort.label'); - goodU = p_sort.ic(:, label == 'good'); - - % P-values for each unit - try - pvals = Stats.(fieldName).pvalsResponse; - catch - pvals = Stats.pvalsResponse; - end - - % Trial onset times in ms - try - C = NeuronResp.(fieldName).C; - catch - C = NeuronResp.C; - end - directimesSorted = C(:, 1)' + startStim; - - % Use params.preBase directly — no formula needed - preBase = params.preBase; - - % Total trial window = baseline + post-stim period - windowTotal = preBase + params.postStim; - - % Lock in time window from first valid experiment - if isempty(lockedPreBase) - lockedPreBase = preBase; - lockedEdges = 0 : params.binWidth : windowTotal; - lockedNBins = numel(lockedEdges) - 1; - tAxis = lockedEdges(1:end-1); - fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... - lockedPreBase, params.postStim, lockedNBins); - end - - % ---------------------------------------------------------- - % Find responsive neurons - % ---------------------------------------------------------- - eNeurons = find(pvals < params.alpha); - - if isempty(eNeurons) - fprintf(' [%s] No responsive neurons in exp %d.\n', stimType, ex); - if ~isempty(psthAll{s}) - psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; - end - continue - end - - fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', ... - stimType, ex, numel(eNeurons)); - - % ---------------------------------------------------------- - % Build PSTH for each responsive neuron - % BuildBurstMatrix returns nTrials x 1 x nTimeBins - % Window: from (trialOnset - preBase) for windowTotal ms - % ---------------------------------------------------------- - psthRateNeurons = zeros(numel(eNeurons), lockedNBins); - - for ni = 1:numel(eNeurons) - u = eNeurons(ni); - - % Spike matrix: rows = trials, cols = time bins (1ms each) - MRhist = BuildBurstMatrix( ... - goodU(:, u), ... - round(p_sort.t), ... - round(directimesSorted - lockedPreBase), ... - round(windowTotal)); - - - - % Remove singleton dimensions → nTrials x nTimeBins - MRhist = squeeze(MRhist); - - if ~isempty(params.TakeTopPercentTrials) - MeanTrial = mean(MRhist,2); - [~, ind] = sort(MeanTrial,'descend'); - - takeTrials = ind(1:round(numel(MeanTrial)*params.TakeTopPercentTrials)); - - MRhist = MRhist(takeTrials,:); - - end - nTrials = size(MRhist, 1); - - % Convert to spike times in ms - spikeTimes = repmat((1:size(MRhist, 2)), nTrials, 1); - spikeTimes = spikeTimes(logical(MRhist)); - - % Bin into locked edges and convert to spk/s - counts = histcounts(spikeTimes, lockedEdges); - psthRateNeurons(ni, :) = (counts / (params.binWidth * nTrials)) * 1000; - end - - % Average across responsive neurons → 1 x lockedNBins - psthExp = mean(psthRateNeurons, 1, 'omitnan'); - - if params.zScore - baselineBins = tAxis < lockedPreBase; - baselineMean = mean(psthExp(baselineBins)); - baselineStd = std(psthExp(baselineBins)); - if baselineStd > 0 - psthExp = (psthExp - baselineMean) / baselineStd; - else - warning(' [%s] Baseline std is zero for exp %d — skipping experiment.', stimType, ex); - if ~isempty(psthAll{s}) - psthAll{s} = [psthAll{s}; NaN(1, lockedNBins)]; - end - continue % skip to next experiment, do not append raw rates - end - end - - % Append as new row — guaranteed lockedNBins wide - psthAll{s} = [psthAll{s}; psthExp(:)']; - - end % end stim loop - end % end experiment loop - - % ------------------------------------------------------------------ - % Save results to struct - % ------------------------------------------------------------------ - S.expList = exList; % experiment list for future matching - S.lockedEdges = lockedEdges; % bin edges used (ms from trial start) - S.lockedPreBase = lockedPreBase; % baseline duration in ms - S.params = params; % all parameters used - - % Save one field per stim type, named by stim e.g. S.rectGrid - for s = 1:numel(params.stimTypes) - stimField = matlab.lang.makeValidName(params.stimTypes(s)); % safe field name - S.(stimField) = psthAll{s}; % nExp x nBins PSTH matrix - end - - save([saveDir nameOfFile], '-struct', 'S'); - fprintf('\nSaved PSTHs to:\n %s\n', [saveDir nameOfFile]); - -else - % ------------------------------------------------------------------ - % Load psthAll from saved struct - % ------------------------------------------------------------------ - lockedEdges = S.lockedEdges; - lockedPreBase = S.lockedPreBase; - - psthAll = cell(1, numel(params.stimTypes)); - for s = 1:numel(params.stimTypes) - stimField = matlab.lang.makeValidName(params.stimTypes(s)); - if isfield(S, stimField) - psthAll{s} = S.(stimField); % load the nExp x nBins matrix - else - % Stim type not found in saved file — warn and leave empty - warning('Stim type "%s" not found in saved file.', params.stimTypes(s)); - psthAll{s} = []; - end - end - -end % end forloop - -% ========================================================================= -% PLOT -% ========================================================================= - -tAxis = lockedEdges(1:end-1); -tAxisPlot = tAxis - lockedPreBase; - -colors = lines(numel(params.stimTypes)); - -fig = figure; -set(fig, 'Units', 'centimeters', 'Position', [5 5 9 10]); % single axis now - -% ------------------------------------------------------------------ -% Map stimulus type names to short legend labels -% ------------------------------------------------------------------ -stimLegendMap = containers.Map(... - {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... - {'MB', 'SB', 'MG', 'SG'}); - -% ------------------------------------------------------------------ -% First pass: compute mean/sem for all stim types and find global ylim -% ------------------------------------------------------------------ -meanAll = cell(1, numel(params.stimTypes)); -semAll = cell(1, numel(params.stimTypes)); -yMax = 0; -yMin = inf; - -for s = 1:numel(params.stimTypes) - data = psthAll{s}; - if isempty(data) - continue - end - validRows = ~all(isnan(data), 2); - data = data(validRows, :); - if isempty(data) - continue - end - meanAll{s} = mean(data, 1, 'omitnan'); - semAll{s} = std(data, 0, 1, 'omitnan') / sqrt(sum(~isnan(data(:,1)))); - yMax = max(yMax, max(meanAll{s} + semAll{s})); - yMin = min(yMin, min(meanAll{s} - semAll{s})); -end - -% Y limits with 10% padding -yPad = (yMax - yMin) * 0.1; -if params.zScore - yLims = [yMin - yPad, yMax + yPad]; -else - yLims = [max(0, yMin - yPad), yMax + yPad]; -end - -% ------------------------------------------------------------------ -% Single axis plot — all stim types overlaid -% ------------------------------------------------------------------ -ax = axes(fig); -hold(ax, 'on'); - -legendHandles = gobjects(numel(params.stimTypes), 1); % store line handles for legend - -for s = 1:numel(params.stimTypes) - - data = psthAll{s}; - if isempty(data) - continue - end - validRows = ~all(isnan(data), 2); - data = data(validRows, :); - if isempty(data) - continue - end - - meanPSTH = meanAll{s}; - semPSTH = semAll{s}; - - - % Get short legend label for this stim type - stimKey = char(params.stimTypes(s)); - if isKey(stimLegendMap, stimKey) - legendLabel = stimLegendMap(stimKey); - else - legendLabel = stimKey; % fallback to full name if not in map - end - - % Shade ±SEM band - if params.shadeSTD && size(data, 1) > 1 - upper = meanPSTH + semPSTH; - lower = meanPSTH - semPSTH; - xFill = [tAxisPlot(:)', fliplr(tAxisPlot(:)')]; - yFill = [upper(:)', fliplr(lower(:)') ]; - fill(ax, xFill, yFill, colors(s,:), 'FaceAlpha', 0.2, 'EdgeColor', 'none'); - end - - % Mean PSTH line — store handle for legend - legendHandles(s) = plot(ax, tAxisPlot(:)', meanPSTH(:)', ... - 'Color', colors(s,:), 'LineWidth', 1.5, 'DisplayName', legendLabel); - - % Number of contributing experiments as text - nValid = sum(validRows); - fprintf(' [%s] n=%d experiments in plot.\n', legendLabel, nValid); - -end - -% Stim onset and end of post-stim window -xline(ax, 0, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); -xline(ax, params.postStim, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); - -% Y label -if params.zScore - yLabel = 'Z-score'; -else - yLabel = '[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 — only show valid handles (skip stim types with no data) -validHandles = legendHandles(isgraphics(legendHandles)); -legend(validHandles, 'Location', 'northeast', 'FontName', 'helvetica', 'FontSize', 8); - -ax.FontName = 'helvetica'; -ax.FontSize = 8; -hold(ax, 'off'); - -sgtitle(sprintf('N = %d', numel(exList)), 'FontName', 'helvetica', 'FontSize', 11); - -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 5 6]); - -if params.PaperFig - vs_first.printFig(fig, sprintf('PSTH-comparison-%s-%s', ... - params.stimTypes(1), params.stimTypes(2)), PaperFig = params.PaperFig) -end - -end \ No newline at end of file From c6775761b631f4713b6f361f66cba7fa3d9401b5 Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Sat, 18 Apr 2026 03:01:01 +0300 Subject: [PATCH 11/19] Changes to stats, allrasters and SDG --- .../plotSwarmBootstrapWithComparisons.m | 36 +- .../plotRaster.m | 604 ++++++ .../@VStimAnalysis/StatisticsPerNeuron.asv | 521 ----- .../@VStimAnalysis/StatisticsPerNeuron.m | 542 ++++-- .../@VStimAnalysis/VStimAnalysis.asv | 912 --------- .../CalculateReceptiveFields.m | 7 +- .../PlotReceptiveFields.m | 502 +++-- .../@linearlyMovingBallAnalysis/plotRaster.m | 6 +- .../CalculateReceptiveFields.m | 6 +- .../@rectGridAnalysis/PlotReceptiveFields.m | 464 +++-- .../@rectGridAnalysis/plotRaster.m | 16 +- visualStimulationAnalysis/AllExpAnalysis.asv | 1688 ----------------- visualStimulationAnalysis/AllExpAnalysis.m | 75 +- .../RunAnalysisClass.asv | 219 --- visualStimulationAnalysis/RunAnalysisClass.m | 59 +- .../SpatialTuningIndex.m | 402 ++-- visualStimulationAnalysis/plotPSTH_MultiExp.m | 10 +- .../plotRaster_MultiExp.m | 589 +++--- 18 files changed, 2306 insertions(+), 4352 deletions(-) create mode 100644 visualStimulationAnalysis/@StaticDriftingGratingAnalysis/plotRaster.m delete mode 100644 visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv delete mode 100644 visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.asv delete mode 100644 visualStimulationAnalysis/AllExpAnalysis.asv delete mode 100644 visualStimulationAnalysis/RunAnalysisClass.asv diff --git a/general functions/plotSwarmBootstrapWithComparisons.m b/general functions/plotSwarmBootstrapWithComparisons.m index b52264f..dc23511 100644 --- a/general functions/plotSwarmBootstrapWithComparisons.m +++ b/general functions/plotSwarmBootstrapWithComparisons.m @@ -271,30 +271,34 @@ if isempty(maxVisible), maxVisible = yMaxVis; end yText = maxVisible + bracketPad; - if pValues(1) < 1e-3 + if pValues(1) < 0.001 txt = '***'; - if pValues(1) == 0, txt = '****'; end + elseif pValues(1) < 0.01 + txt = '**'; + elseif pValues(1) < 0.05 + txt = '*'; + else + txt = 'ns'; + end - text(ax, 1, yText, txt, ... - 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); + text(ax, 1, yText, txt, ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); - stimA = pairs{1,1}; - stimB = pairs{1,2}; - compText = sprintf('%s > %s', stimA, stimB); - yCompText = yText + textPad * 10; + stimA = pairs{1,1}; + stimB = pairs{1,2}; + compText = sprintf('%s > %s', stimA, stimB); + yCompText = yText + textPad * 10; - text(ax, 1, yCompText, compText, ... - 'HorizontalAlignment', 'center', 'FontSize', 10, 'Clipping', 'off'); + text(ax, 1, yCompText, compText, ... + 'HorizontalAlignment', 'center', 'FontSize', 10, 'Clipping', 'off'); - requiredHeight = yCompText + textPad * 10; - if requiredHeight > yMaxVis - ylim(ax, [ylims(1) requiredHeight]); - else - ylim(ax, [ylims(1) yMaxVis]); - end + requiredHeight = yCompText + textPad * 10; + if requiredHeight > yMaxVis + ylim(ax, [ylims(1) requiredHeight]); else ylim(ax, [ylims(1) yMaxVis]); end + else ylim(ax, [ylims(1) yMaxVis]); end diff --git a/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/plotRaster.m b/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/plotRaster.m new file mode 100644 index 0000000..0bdaf1d --- /dev/null +++ b/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/plotRaster.m @@ -0,0 +1,604 @@ +function plotRaster(obj, params) +% plotRaster Combined static + drifting raster, PSTH, and raw trace. +% +% Plots both grating phases (static and moving) in a single raster for each +% neuron. A solid vertical line marks stimulus onset and offset; a dashed +% vertical line marks the static→moving phase transition. +% +% Trial timing (from ResponseWindow): +% |-- preBase --|-- staticDur --|-- movingDur --|-- preBase --| +% ^ ^ ^ +% stim on phase change stim off +% +% ResponseWindow stores: +% NeuronResp.Onsets(:,1) = static onset per trial (ms) +% NeuronResp.Onsets(:,2) = moving onset per trial (ms) +% NeuronResp.Offsets(:,2) = moving offset per trial (ms) +% NeuronResp.C columns = [stimOnTime, angle, TF, SF] +% +% Usage: +% obj.plotRaster() +% obj.plotRaster('AllResponsiveNeurons', true) +% obj.plotRaster('exNeuronsPhyID', [42 87], 'PaperFig', true) + +% -------------------------------------------------------------------------- +% BUG FIXES vs original moving-ball plotRaster (carried forward from v1): +% 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 use tolerance -> abs(a-b) 0.5. +% 5. ur/u dual-index confusion -> fully documented. +% +% NEW in combined version: +% 6. stimType removed: both phases always shown together. +% 7. Responsive-neuron selection uses min(pStatic, pMoving) to catch +% neurons that respond to only one phase. +% 8. Three xline markers instead of two (onset, transition, offset). +% 9. Title reports p-values for both phases independently. +% -------------------------------------------------------------------------- + +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 = 1 % 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" +end + +if params.inputParams, disp(params); return; end + +% ========================================================================== +% 1. LOAD PRE-COMPUTED RESULTS +% ========================================================================== + +% ResponseWindow struct: fields Static, Moving, C, Onsets, Offsets, stimInter, params +NeuronResp = obj.ResponseWindow; + +% Load statistics for both phases independently +if params.statType == "BootstrapPerNeuron" + Stats = obj.BootstrapPerNeuron; +else + Stats = obj.StatisticsPerNeuron; +end + +% Per-neuron p-values for each phase (vectors, length = nGoodUnits) +pvalsS = Stats.Static.pvalsResponse; % p-value: static phase response +pvalsM = Stats.Moving.pvalsResponse; % p-value: moving phase response + +% For neuron selection: use the more responsive of the two phases +pvalsMin = min(pvalsS, pvalsM); % Element-wise minimum across phases + +% ========================================================================== +% 2. SPIKE SORTING AND STIMULUS TIMING +% ========================================================================== + +% Load phy/Kilosort output: struct with fields ic, t, label, phy_ID +p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); + +% Phy cluster IDs restricted to good (somatic) units +phy_IDg = p.phy_ID(string(p.label') == 'good'); + +% Unit quality labels as string array +label = string(p.label'); + +% Spike train matrix: rows = channels, cols = time samples; good units only +goodU = p.ic(:, label == 'good'); + +% --- Derive combined trial timing from ResponseWindow --- + +% staticDur: duration of the static grating phase (ms), same for all trials +% Onsets(:,1) = static onset; Onsets(:,2) = moving onset +staticDur = round(mean(NeuronResp.Onsets(:,2) - NeuronResp.Onsets(:,1))); % (ms) + +% totalStimDur: full stimulus duration, static + moving (ms) +% Offsets(:,2) = moving offset (end of stimulus) +totalStimDur = round(mean(NeuronResp.Offsets(:,2) - NeuronResp.Onsets(:,1))); % (ms) + +% movingDur: duration of the drifting phase alone (ms) +movingDur = totalStimDur - staticDur; % (ms) + +% Inter-trial interval (ms), used to set the pre-stimulus baseline window +stimInter = NeuronResp.stimInter; + +% Pre-stimulus baseline window: 75% of the ITI to leave a guard band +preBase = round(stimInter - stimInter / 4); % (ms) + +% ========================================================================== +% 3. CONDITION MATRIX C +% ========================================================================== + +% C is saved pre-sorted in ResponseWindow: columns = [stimOnTime, angle, TF, SF] +% stimOnTime here = static onset time (Onsets(:,1)) for each trial +C = NeuronResp.C; + +% Optionally restrict to one grating angle (direction, degrees) +if params.OneAngle ~= "all" + angleVal = str2double(params.OneAngle); + C = C(abs(C(:,2) - angleVal) < 1e-3, :); % Tolerance-based equality + if isempty(C) + error('No trials found for OneAngle = "%s"', params.OneAngle); + end +end + +% Optionally restrict to one temporal frequency (Hz) +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 + +% Optionally restrict to one spatial frequency (cyc/deg) +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 + +% Re-sort after any filtering: angle primary, TF secondary, SF tertiary +[C, ~] = sortrows(C, [2 3 4]); + +% Row vector of static-onset times for each (sorted) trial (ms, absolute recording time) +% This is the reference time used for all matrix constructions below +directimesSorted = C(:,1)'; + +% ========================================================================== +% 4. CONDITION COUNTS +% ========================================================================== + +uAngle = unique(C(:,2)); % Unique grating angles (deg) +uTF = unique(C(:,3)); % Unique temporal frequencies (Hz) +uSF = unique(C(:,4)); % Unique spatial frequencies (cyc/deg) + +angleN = numel(uAngle); % Number of unique angles +tfN = numel(uTF); % Number of unique TFs +sfN = numel(uSF); % Number of unique SFs +nT = size(C, 1); % Total number of trials + +% Number of repeats per unique (angle x TF x SF) combination +trialDivision = nT / (angleN * tfN * sfN); +if mod(trialDivision, 1) ~= 0 + warning('trialDivision is non-integer (%.2f): conditions may be unbalanced.', ... + trialDivision); + trialDivision = floor(trialDivision); +end + +% ========================================================================== +% 5. RESOLVE NEURON SELECTION +% ========================================================================== + +% If phy cluster IDs are provided, convert to indices into goodU. +% This overrides params.exNeurons; phy IDs are stable across re-sorts. +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 + +% Build eNeuron: set of absolute indices into goodU to iterate over +if params.AllSomaticNeurons + eNeuron = 1:size(goodU, 2); % All good units + pvalsOut = [eNeuron; pvalsMin(eNeuron)']; +elseif params.AllResponsiveNeurons + % Include neurons responsive in at least one phase (min p-value < 0.05) + eNeuron = find(pvalsMin < 0.05); + pvalsOut = [eNeuron; pvalsMin(eNeuron)']; + if isempty(eNeuron) + fprintf('No responsive neurons found (min p < 0.05 across both phases).\n'); + return + end +else + eNeuron = params.exNeurons; + pvalsOut = [eNeuron; pvalsMin(eNeuron)']; +end + +% ========================================================================== +% 6. BUILD RASTER MATRIX (combined window: preBase + staticDur + movingDur + preBase) +% ========================================================================== + +% Mr: [nTrials x nSelectedNeurons x nBins] using params.bin ms bins. +% The window starts preBase ms before the static onset and ends preBase ms +% after the moving offset, so both phases are captured in a single matrix. +Mr = BuildBurstMatrix( ... + goodU(:, eNeuron), ... % Selected neurons + round(p.t / params.bin), ... % Spike times in bins + round((directimesSorted - preBase) / params.bin), ... % Window start (bins) + round((totalStimDur + preBase*2) / params.bin)); % Window length (bins) + +% Optionally smooth raster across time with a 1-D Gaussian kernel +if params.Gaussian + Mr = ConvBurstMatrix(Mr, fspecial('gaussian', [1 params.GaussianLength], 3), 'same'); +end + +% Recording channel for each selected neuron (used in title and raw-trace plot) +channels = goodU(1, eNeuron); + +% Total number of time bins in the combined window (for x-axis scaling) +[~, ~, nBins] = size(Mr); + +% ========================================================================== +% 7. PER-NEURON FIGURE LOOP +% ========================================================================== +% +% INDEXING: +% u = absolute index into goodU (e.g., goodU(:, u), phy_IDg(u)) +% ur = relative index into eNeuron (1, 2, 3, ...) -> Mr(:, ur, :), channels(ur) +% Both must be kept in sync; ur is incremented at the end of every branch. + +ur = 1; % Relative index into eNeuron; reset once, incremented every iteration + +for u = eNeuron % u: absolute index into goodU + + fig = figure; + + % ------------------------------------------------------------------ + % 7a. Build 2-D merged raster [nTrials x nBins] for this neuron + % ------------------------------------------------------------------ + + mergeTrials = params.MergeNtrials; + Mr2 = zeros(nT, nBins); % Pre-allocate: rows = trials, cols = time bins + + if mergeTrials > 1 + % Average groups of mergeTrials rows; replicate result for display continuity + 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 + % No merging: squeeze the neuron dimension (ur indexes the selected subset) + Mr2 = squeeze(Mr(:, ur, :)); % [nTrials x nBins] + end + + % Skip neuron if it has no spikes in the entire window + if sum(Mr2, 'all') == 0 + close(fig); + ur = ur + 1; + continue + end + + % ================================================================== + % PANEL 2 (rows 6-16): Combined raster + % ================================================================== + + subplot(18, 1, [6 16]); + + % Convert spike counts/bin to approximate spike rate (spk/s) for display + imagesc(Mr2 .* (1000 / params.bin)); + colormap(flipud(gray(64))); % Dark pixels = high firing rate + + hold on; + + % --- Three vertical markers (all with identical style for clean figures) --- + + % Stimulus onset (static phase starts) + xline(preBase / params.bin, 'k', 'LineWidth', 1.5); + + % Phase transition: static -> moving (dashed, thinner) + xline((preBase + staticDur) / params.bin, '--k', 'LineWidth', 1.2); + + % Stimulus offset (moving phase ends) + xline((preBase + totalStimDur) / params.bin, 'k', 'LineWidth', 1.5); + + % Clamp colormap to [0 1] for cross-neuron comparability + if params.MaxVal_1 + caxis([0 1]); + end + + % --- Horizontal dividing lines between condition blocks --- + % Thick black = angle boundary, thin black = TF boundary, dashed red = SF boundary + angleStart = C(1, 2); + tfStart = C(1, 3); + sfStart = C(1, 4); + + for t = 1:nT + if angleStart ~= C(t, 2) + yline(t - 0.5, 'k', 'LineWidth', 2); % New angle block + angleStart = C(t, 2); + end + if tfStart ~= C(t, 3) + yline(t - 0.5, 'k', 'LineWidth', 0.5); % New TF block + tfStart = C(t, 3); + end + if sfStart ~= C(t, 4) + yline(t - 0.5, '--r', 'LineWidth', 0.5); % New SF block (FIX: was 0.05, invisible) + sfStart = C(t, 4); + end + end + + % Phase labels above the raster (text annotations inside the axes) + % Position them at the horizontal midpoints of each phase + ax0 = gca; + yTop = nT + nT*0.02; % Just above top row + + text(preBase/params.bin + (staticDur/params.bin)/2, yTop, 'Static', ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'FontName', 'helvetica', ... + 'Clipping', 'off'); + text((preBase + staticDur)/params.bin + (movingDur/params.bin)/2, yTop, 'Moving', ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'FontName', 'helvetica', ... + 'Clipping', 'off'); + + % X-axis: hide labels (shared time axis is shown on the PSTH below) + xlim([0, round(totalStimDur + preBase*2) / params.bin]); + xticks([0, preBase/params.bin : 600/params.bin : (totalStimDur+preBase*2)/params.bin, ... + round((totalStimDur+preBase*2)/100)*100 / params.bin]); + xticklabels([]); + + % Y-axis: ticks at the center of each (TF x SF) block within each angle block + secondaryN = sfN * tfN; % Number of conditions per angle + yt = [0]; + for d = 1:angleN + block_ticks = 1 : trialDivision*2*secondaryN : (nT/angleN)-1+trialDivision*secondaryN; + yt = [yt, block_ticks + max(yt) + trialDivision - 1]; %#ok + end + yt = yt(2:end-1); % Remove leading sentinel + yticks(yt); + yticklabels(repmat( ... + trialDivision : trialDivision*2*secondaryN : (nT/angleN)-1+trialDivision*secondaryN, ... + 1, angleN)); + + ax = gca; + ax.YAxis.FontSize = 8; + ax.YAxis.FontName = 'helvetica'; + ylabel('Trials', 'FontSize', 10, 'FontName', 'helvetica'); + + % ================================================================== + % 7b. Identify best response window (searched across the FULL window, + % i.e., both phases together — no phase preference is imposed) + % ================================================================== + + if params.SelectedWindow + + % Step 1: Mean firing rate per condition group -> find best group + 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 + [~, maxRespIn] = max(meanMr); + maxRespIn = maxRespIn - 1; % Convert to 0-based offset for trial range arithmetic + + % Step 2: Extract raster of best condition group [trialDivision x nBins] + X = Mr2(maxRespIn*trialDivision+1 : maxRespIn*trialDivision+trialDivision, :); + + window = 500; % Response window width for best-bin search (ms) + + X(X > 1) = 1; % Clip to [0 1] so outliers don't dominate window selection + + [n_rows, n_cols] = size(X); % n_rows = trialDivision + nWinPos = n_cols - round(window / params.bin) + 1; % Number of window positions + + % Compute mean firing rate inside every sliding window, for every trial + % window_means: [trialDivision 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 + + % Find the (trial, window) pair with the global maximum mean rate + [~, linear_idx] = max(window_means(:)); + [best_row, best_col] = ind2sub(size(window_means), linear_idx); + + % Convert window start from bins to ms (relative to trial onset, i.e., after preBase) + start = best_col * params.bin; % (ms) offset from trial start (0 = static onset) + + % Which phase did the window land in? + if start < staticDur + bestPhase = 'Static'; + else + bestPhase = 'Moving'; + end + + else + % Use the pre-computed NeuronVals from whichever phase has a higher raw rate + [~, bestPhaseIdx] = max([ ... + max(NeuronResp.Static.NeuronVals(u, :, 4)), ... % Max raw rate in static phase + max(NeuronResp.Moving.NeuronVals(u, :, 4))]); % Max raw rate in moving phase + + phaseNames = ["Static", "Moving"]; + bestPhase = phaseNames(bestPhaseIdx); + + [~, maxRespIn] = max(NeuronResp.(bestPhase).NeuronVals(u, :, 4)); + % Column 3 = MaxWinBin (bin index); convert to ms + start = NeuronResp.(bestPhase).NeuronVals(u, maxRespIn, 3) * NeuronResp.params.binRaster - 20; + window = 500; + maxRespIn = maxRespIn - 1; % 0-based offset + best_row = 1; % FIX: was uninitialized in original; default = 1 + end + + % Absolute trial indices belonging to the best condition group + trials = maxRespIn*trialDivision+1 : maxRespIn*trialDivision + trialDivision; + + % Highlight the selected condition group (light grey band, full time extent) + y1 = maxRespIn*trialDivision + trialDivision + 0.5; + y2 = maxRespIn*trialDivision + 0.5; + patch([0, (preBase*2+totalStimDur)/params.bin, (preBase*2+totalStimDur)/params.bin, 0], ... + [y2, y2, y1, y1], 'k', 'FaceAlpha', 0.1, 'EdgeColor', 'none'); + + % Absolute index of the single best trial (from sliding window search) + RasterTrials = trials(best_row); + + % Red patch: mark the best trial and its response window in the raster + % start is in ms relative to static onset; offset by preBase for display bins + 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'); + + % ================================================================== + % PANEL 3 (rows 17-18): PSTH across the full combined window + % ================================================================== + + subplot(18, 1, [17 18]); + + % Rebuild 1 ms-resolution spike matrix for all trials (needed for PSTH bins) + MRhist = BuildBurstMatrix(goodU(:, u), ... + round(p.t), ... % 1 ms resolution + round(directimesSorted - preBase), ... % Window start per trial + round(totalStimDur + preBase*2)); % Full combined window length + + % Select only the best condition group for the PSTH + MRhist = squeeze(MRhist(trials, :, :)); % [nTrialsInGroup x nTimePoints_ms] + + [nT2, nB2] = size(MRhist); % nT2 = trials in group, nB2 = window duration ms + + % Collect spike times (ms from trial start) + spikeTimes = repmat(1:nB2, nT2, 1); % Index grid matching MRhist + spikeTimes = spikeTimes(logical(MRhist)); % Keep only spike locations + + % PSTH bin width: use wider bins for longer stimuli to reduce noise + binWidth = 125; % Default (ms) + if nBins > 300 + binWidth = 250; + end + + edges = 1:binWidth:round(totalStimDur + preBase*2); % Bin edges (ms) + psthCounts = histcounts(spikeTimes, edges); % Spike count per bin + + % Normalize to firing rate [spk/s]: counts / (bin_s * nTrials) + 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((totalStimDur + preBase*2) / 100) * 100]); + + try % Guard against all-zero PSTH (std=0 makes ylim fail) + ylim([0, max(psthRate) + std(psthRate)]); + catch + close(fig); + ur = ur + 1; + continue + end + + % X-axis ticks at 600 ms intervals; labels converted from ms to seconds + xticks([0, preBase:600:(totalStimDur+preBase*2), ... + round((totalStimDur+preBase*2)/100)*100]); + + % Three xline markers matching the raster above + xline(preBase, 'LineWidth', 1.5); % Stim on + xline(preBase + staticDur, '--', 'LineWidth', 1.2); % Phase transition + xline(preBase + totalStimDur, 'LineWidth', 1.5); % Stim off + + xticklabels([-(preBase), 0:600:round((totalStimDur/100))*100, ... + round((totalStimDur/100))*100 + 2*preBase] ./ 1000); + + ax = gca; + 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]); % Two clean ticks + + % ================================================================== + % PANEL 1 (rows 1-3): Raw AP/LFP trace for the best single trial + % ================================================================== + + bin3 = 1; % 1 ms bins for raw trace extraction + + % Build spike matrix around the response window for all group trials + % start is in ms from static onset; add preBase offset to get absolute ms + trialM = BuildBurstMatrix(goodU(:, u), ... + round(p.t / bin3), ... + round((directimesSorted + start) / bin3), ... % Align window to response onset + round(window / bin3)); % 500 ms window + + TrialM = squeeze(trialM(trials, :, :))'; % [nBins x nTrialsInGroup] + + % best_row already identifies the highest-response trial from the window search + chan = goodU(1, u); % Recording channel for this neuron + + subplot(18, 1, [1 3]); + + % Absolute start time of the raw trace (ms in recording time): + % align to start of the response window (start ms after static onset, minus preBase) + startTimes = directimesSorted(RasterTrials) + start - preBase; % (ms) + + % Binary spike vector for the selected trial in the response window + spikes = squeeze(BuildBurstMatrix(goodU(:, u), round(p.t), round(startTimes), round(window))); + + % Render raw voltage trace with spike overlay + [fig, ~, ~] = 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)); % 5 evenly spaced ticks + xticklabels(0:100:window); + ax.XAxis.FontSize = 8; ax.XAxis.FontName = 'helvetica'; + ax.XRuler.TickDirection = 'out'; + ax.XAxisLocation = 'bottom'; + + % Mark the stimulus event nearest to this window + % (start is the offset from static onset; if start > staticDur, this is the phase change) + xline(-start / 1000, 'LineWidth', 1.5); + + xlabel('Time [ms]', 'FontName', 'helvetica', 'FontSize', 10); + ylabel('[\muV]', 'FontSize', 10, 'FontName', 'helvetica'); + + % --- Title: neuron ID + best condition + p-values for both phases -------- + bestAngle = C(maxRespIn*trialDivision + 1, 2); % Angle of best condition group (deg) + bestTF = C(maxRespIn*trialDivision + 1, 3); % TF of best condition (Hz) + bestSF = C(maxRespIn*trialDivision + 1, 4); % SF of best condition (cyc/deg) + + title({ ... + sprintf('U.%d Chan-%d Phy-%d | pS=%.4f pM=%.4f', ... + u, channels(ur), phy_IDg(u), pvalsS(u), pvalsM(u)), ... + sprintf('Best: %.0f deg | TF=%.1f Hz | SF=%.3f c/deg | window in [%s]', ... + bestAngle, bestTF, bestSF, bestPhase) ... + }); + + % ================================================================== + % 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 + + % Keep the last figure open; close all intermediate ones + 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/StatisticsPerNeuron.asv b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv deleted file mode 100644 index 14e1a32..0000000 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv +++ /dev/null @@ -1,521 +0,0 @@ -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 : Z-score of neuronal response normalised by pooled baseline SD. -% If UseLOO=true (default): leave-one-out cross-validated z-score -% at the preferred category, preventing winner's curse inflation -% that scales with nCats (non-comparable across stimuli otherwise). -% If UseLOO=false: z-score computed directly from observed mean -% difference at the preferred category using all trials. Faster -% but potentially inflated when nCats is large. -% -% prefCat : Consensus preferred category index [1 × nNeurons]. -% If UseLOO=true: category most frequently selected across LOO folds. -% If UseLOO=false: category with highest mean Diff across all trials. -% -% validCats : [nCats × nNeurons] logical mask. False where a category has -% >= EmptyTrialPerc fraction of zero-spike trials for a given neuron. -% -% pValTTest : p-value from one-sample t-test against zero, pooled across all -% valid categories per neuron. Complements the permutation test. -% -% 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.MovingWindow = true % use moving window response as observed statistic for permutation test - params.UseLOO = true % if true: LOO cross-validated z-score (recommended, unbiased across stimuli) - % if false: direct z-score at preferred category (faster, potentially inflated) -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 - -% ------------------------------------------------------------------------- -% 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)); % number of distinct speed values - - 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)'; % moving window response [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; % moving phase onset - 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; - -else - % Single-condition stimuli (rectGrid, etc.) - directimesSorted = responseParams.C(:,1)'; - stimDur = responseParams.stimDur; - trialsCat = round(numel(directimesSorted) / size(responseParams.NeuronVals, 2)); - MW = responseParams.NeuronVals(:,:,4)'; % moving window [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); % moving window for this speed condition - end - - if isequal(obj.stimName, 'StaticDriftingGrating') - fieldName = FieldNames{s}; - directimesSorted = Times.(fieldName); - stimDur = Durations.(fieldName); - trialsCat = trialsCats.(fieldName); - MW = MWs.(fieldName); % moving window for this grating condition - end - - % --- Build spike count matrices --- - % Mr: spike counts in the response window (stimulus duration) - Mr = BuildBurstMatrix(goodU, ... - round(p.t), ... - round(directimesSorted), ... - round(stimDur)); - - % Mb: spike counts in the baseline window - % Uses 75% of inter-trial interval before each trial onset — - % conservative buffer to avoid overlap with the preceding stimulus - Mb = BuildBurstMatrix(goodU, ... - round(p.t), ... - round(directimesSorted - 0.75 * obj.VST.interTrialDelay * 1000), ... - round(0.75 * obj.VST.interTrialDelay * 1000)); - - % Always compute full-duration means — needed for empty-trial filtering - % regardless of MovingWindow setting - responses = mean(Mr, 3); % mean spikes/ms over full response window: [nTrials × nNeurons] - baselines = mean(Mb, 3); % mean spikes/ms over full baseline window: [nTrials × nNeurons] - - if params.MovingWindow - % Per-trial sliding window max — captures transient responses - % (e.g. moving ball crossing the receptive field at a specific moment) - % Applied identically to response and baseline so the permutation - % test null distribution uses the same operation as the observed stat - winSize = responseParams.params.durationWindow; - - - % movmean along dim 3 — no loop needed - % 'Endpoints','discard' removes partial windows at edges - mrMov = movmean(Mr, winSize, 3, 'Endpoints', 'discard'); % [nTrials × nNeurons × nSteps] - mbMov = movmean(Mb, winSize, 3, 'Endpoints', 'discard'); - - responsesMW = max(mrMov, [], 3); % [nTrials × nNeurons] max over time - baselinesMW = max(mbMov, [], 3); - - Diff = responsesMW - baselinesMW; % [nTrials × nNeurons] - - else - % Full stimulus duration mean — appropriate for spatially localized - % stimuli where response is sustained (rectGrid, gratings) - Diff = responses - baselines; % [nTrials × nNeurons] - end - - nNeurons = size(goodU, 2); - nCats = round(size(Diff,1) / trialsCat); - - assert(size(Diff,1) == nCats * trialsCat, ... - 'Trial count (%d) not evenly divisible by trialsCat (%d).', ... - size(Diff,1), trialsCat); - - - % ------------------------------------------------------------------------- - % Category-level empty-trial filtering - % Mark category as invalid for a neuron if fraction of zero-spike trials - % meets or exceeds EmptyTrialPerc. Invalid categories are excluded entirely - % (not zeroed) to avoid biasing Diff toward 0. - % ------------------------------------------------------------------------- - validCats = true(nCats, nNeurons); % [nCats × nNeurons]; true = include - - if params.FilterEmptyResponses - responsesReshaped = reshape(responses, trialsCat, nCats, nNeurons); % [trialsCat × nCats × nNeurons] - for c = 1:nCats - for u = 1:nNeurons - emptyTrials = responsesReshaped(:, c, u) == 0; % zero-spike trials in this category - perc = sum(emptyTrials) / trialsCat; % fraction of empty trials - if perc >= params.EmptyTrialPerc - validCats(c, u) = false; % exclude category c for neuron u - end - end - end - end - - % Neurons where ALL categories are invalid — statistics undefined - noValidCat = all(~validCats, 1); % [1 × nNeurons] - - % ------------------------------------------------------------------------- - % Observed max-statistic - % ------------------------------------------------------------------------- - - if params.MovingWindow - DiffReshaped = reshape(DiffMW, trialsCat, nCats, nNeurons); - else - DiffReshaped = reshape(Diff, trialsCat, nCats, nNeurons); % [trialsCat × nCats × nNeurons] - end - catMeans = reshape(mean(DiffReshaped, 1), nCats, nNeurons); % [nCats × nNeurons] - - catMeansMasked = catMeans; - catMeansMasked(~validCats) = -Inf; % invalid categories cannot be preferred - ObsStat = max(catMeansMasked, [], 1); % [1 × nNeurons] - - % ------------------------------------------------------------------------- - % Max-statistic sign-flip permutation test - % - % H0: sign of (response - baseline) is random for each trial, - % i.e. response and baseline drawn from the same distribution. - % Randomly flipping trial signs simulates H0 without parametric assumptions. - % Taking the MAX across categories at each permutation controls FWER - % across categories (Nichols & Holmes 2002). - % - % Note: permutation test always uses Diff (not moving window) because - % the null distribution must be generated from the same trial-level data. - % The observed stat may differ (MovingWindow=true) but the null is always - % sign-flip based — this is a conservative and valid combination. - % ------------------------------------------------------------------------- - - % Always reshape Diff for permutation test regardless of MovingWindow flag - DiffReshaped = reshape(Diff, trialsCat, nCats, nNeurons); % [trialsCat × nCats × nNeurons] - - % Generate all sign vectors at once: [nTrials × nBoot], values ±1 - signs = 2 * randi(2, size(Diff,1), params.nBoot) - 3; - - % Reshape signs to match category structure: [trialsCat × nCats × nBoot] - signsR = reshape(signs, trialsCat, nCats, params.nBoot); - - % Permute for pagemtimes — pages correspond to categories: - % DiffRp : [nNeurons × trialsCat × nCats] - % signsRp : [trialsCat × nBoot × nCats] - DiffRp = permute(DiffReshaped, [3 1 2]); - signsRp = permute(signsR, [1 3 2]); - - % Batched matrix multiply: result is [nNeurons × nBoot × nCats] - catMeansAll = pagemtimes(DiffRp, signsRp) / trialsCat; - - % Permute to [nCats × nNeurons × nBoot] for masking and max - catMeansAll = permute(catMeansAll, [3 1 2]); - - % Exclude invalid categories from null distribution - validCats3D = repmat(validCats, 1, 1, params.nBoot); % [nCats × nNeurons × nBoot] - catMeansAll(~validCats3D) = -Inf; - - % Max across categories → null distribution [nBoot × nNeurons] - % reshape used instead of squeeze to guarantee correct shape when nNeurons=1 or nCats=1 - nullMax = reshape(max(catMeansAll, [], 1), params.nBoot, nNeurons); - - % p-value: proportion of permuted max-statistics >= observed (one-tailed, excitatory) - pVal = mean(nullMax >= ObsStat, 1); % [1 × nNeurons] - pVal(noValidCat) = NaN; % undefined for fully invalid neurons - - % ------------------------------------------------------------------------- - % Z-score: two modes controlled by params.UseLOO - % - % MODE 1 (UseLOO=true, recommended): - % Leave-one-out cross-validated z-score at preferred category. - % Preferred category identified on n-1 trials, held-out trial contributes - % to z-score. Prevents winner's curse inflation that scales with nCats, - % ensuring z-scores are comparable across stimuli with different nCats. - % - % MODE 2 (UseLOO=false): - % Direct z-score at preferred category identified from all trials. - % Faster but subject to winner's curse — z-scores will be inflated - % relative to LOO, and inflation will differ across stimuli with - % different numbers of categories. Use only for exploration. - % - % In both modes: z normalised by pooled baseline SD across all trials, - % which is more stable than per-category SD with few trials per category. - % ------------------------------------------------------------------------- - - % Pooled baseline SD: more stable than per-category with small n - sdBase = std(baselines, 0, 1); % [1 × nNeurons] - - - if params.MovingWindow - % Extract MW value at preferred category per neuron - % Preferred category is simply the one with highest MW value - - % MW is [nCats × nNeurons] — max across categories (dim 1) - catMWMasked = MW; - catMWMasked(~validCats) = -Inf; % exclude invalid categories - [~, prefCat] = max(catMWMasked, [], 1); % [1 × nNeurons] - idx_pref = prefCat + (0:nNeurons-1) * nCats; % linear index [nCats × nNeurons] - mwPrefCat = MW(idx_pref); % [1 × nNeurons] MW at preferred cat - z_mean = mwPrefCat - mean(baselines, 1); % baseline correct - - else - if nCats == 1 - % Single category — LOO and direct z-score are identical - % No selection step needed: preferred category is trivially 1 - prefCat = ones(1, nNeurons); % only one category - z_mean = mean(Diff, 1); % mean over all trials [1 × nNeurons] - - elseif params.UseLOO - % --- LOO cross-validated z-score --- - % Pre-compute per-category trial sums for efficient LOO mean computation - totalSum = zeros(nCats, nNeurons); % [nCats × nNeurons] - for c = 1:nCats - rows = (c-1)*trialsCat + 1 : c*trialsCat; % row range for category c - totalSum(c,:) = sum(Diff(rows,:), 1); % sum over trials in category c - end - - z_loo_acc = zeros(1, nNeurons); % accumulates held-out Diff at preferred category - prefCatCount = zeros(nCats, nNeurons); % tallies preferred category selections across folds - - for k = 1:trialsCat - % LOO category mean: subtract trial k from total sum, divide by n-1 - % kth row of each category's block: row index is (c-1)*trialsCat + k - looMean = (totalSum - Diff((0:nCats-1)*trialsCat + k, :)) / (trialsCat - 1); - % looMean is [nCats × nNeurons] — no reshape needed since Diff rows - % are already one per category after the index (0:nCats-1)*trialsCat+k - - looMeanMasked = looMean; - looMeanMasked(~validCats) = -Inf; % mask invalid categories - - [~, prefCatLOO] = max(looMeanMasked, [], 1); % [1 × nNeurons] preferred cat this fold - - % Linear index into [nCats × nNeurons]: row=prefCatLOO, col=1:nNeurons - idx = prefCatLOO + (0:nNeurons-1) * nCats; - prefCatCount(idx) = prefCatCount(idx) + 1; % tally this fold's selection - - % Held-out trial k: one row per category block - testVals = Diff((0:nCats-1)*trialsCat + k, :); % [nCats × nNeurons] - z_loo_acc = z_loo_acc + testVals(idx); % accumulate test diff at preferred cat - end - - z_mean = z_loo_acc / trialsCat; % average held-out diff across folds [1 × nNeurons] - [~, prefCat] = max(prefCatCount, [], 1); % consensus preferred category [1 × nNeurons] - - else - % --- Direct z-score (no LOO) --- - % Preferred category from all trials — subject to winner's curse - % when nCats is large. Use for exploration only. - catMeansAll2 = reshape(mean(DiffReshaped, 1), nCats, nNeurons); % [nCats × nNeurons] - catMeansAll2(~validCats) = -Inf; % exclude invalid categories - [~, prefCat] = max(catMeansAll2, [], 1);% [1 × nNeurons] preferred category - - % Extract mean Diff at preferred category using linear indexing - idx = prefCat + (0:nNeurons-1) * nCats; % linear index into [nCats × nNeurons] - z_mean = catMeansAll2(idx); % [1 × nNeurons] mean diff at preferred cat - end - - end - - % Final z-score: mean diff normalised by pooled baseline SD - z = z_mean ./ sdBase; % [1 × nNeurons] - z(sdBase == 0) = 0; % silent baseline — z undefined, set to 0 - z(noValidCat) = NaN; % no valid categories — z undefined - - % ------------------------------------------------------------------------- - % One-sample t-test pooled across all valid categories - % - % Tests H0: mean(Diff) = 0 across all valid trials for each neuron. - % Pooling across categories maximises degrees of freedom and avoids - % cherry-picking the preferred category (which would inflate t). - % Complements the permutation test: permutation test is the primary - % significance criterion; t-test is a parametric secondary check. - % - % Caveat: normality assumption cannot be verified with ~10 trials per - % category. The permutation test remains the primary criterion. - % ------------------------------------------------------------------------- - pValTTest = zeros(1, nNeurons); % [1 × nNeurons] t-test p-values - tStat = zeros(1, nNeurons); % [1 × nNeurons] t-statistics - - for u = 1:nNeurons - if noValidCat(u) - % No valid categories — t-test undefined - pValTTest(u) = NaN; - tStat(u) = NaN; - continue - end - - % Build logical row mask for all valid categories for this neuron - validRows = false(size(Diff, 1), 1); % initialise as all false - for c = 1:nCats - if validCats(c, u) % only include valid categories - rows = (c-1)*trialsCat + 1 : c*trialsCat; - validRows(rows) = true; % mark trials for valid category c - end - end - - DiffValid = Diff(validRows, u); % all valid trials for neuron u - [~, pValTTest(u), ~, stats] = ttest(DiffValid); % one-sample t-test vs zero - tStat(u) = stats.tstat; % store t-statistic - end - - pValTTest(noValidCat) = NaN; % redundant safety guard — already set above - 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] z-scores (LOO or direct) - S.(fieldName).ObsDiff = Diff; % [nTrials × nNeurons] response minus baseline - S.(fieldName).ObsResponse = responses; % [nTrials × nNeurons] response spike counts - 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 moving window response - S.(fieldName).pValTTest = pValTTest; % [1 × nNeurons] t-test p-values - S.(fieldName).tStat = tStat; % [1 × nNeurons] t-statistics - else - S.pvalsResponse = pVal; - S.ZScoreU = z; - 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; - end - - S.params = params; % store parameters 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 an empty results struct with correct field names. -% Ensures downstream code receives consistent struct regardless of neuron count. - - emptyFields = {'pvalsResponse','ZScoreU','ObsDiff','ObsResponse', ... - 'ObsBaseline','prefCat','validCats','MaxMovWinResponse', ... - 'pValTTest','tStat'}; % includes t-test fields - - 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/StatisticsPerNeuron.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m index e89b0fa..32c4081 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m @@ -48,17 +48,33 @@ 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 = true % if true: use per-trial sliding window max for pval - % if false: use full stimulus duration mean - params.UseLOO = true % if true: LOO cross-validated z-score (recommended) + params.MovingWindowPVal = true % 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 = true % if true: cap stimulus duration at MaxStimDuration ms + 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 + end % ------------------------------------------------------------------------- @@ -194,8 +210,8 @@ % ------------------------------------------------------------------------- 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); + '(%.0f ms) — capping response window for %s.\n'], ... + stimDur, params.MaxStimDuration, obj.stimName); effectiveStimDur = params.MaxStimDuration; % capped duration used for Mr only else effectiveStimDur = stimDur; % full duration — no capping needed @@ -216,128 +232,196 @@ round(directimesSorted - 0.75 * obj.VST.interTrialDelay * 1000), ... round(0.75 * obj.VST.interTrialDelay * 1000)); - % Always compute full-duration means — needed for empty-trial filtering - % and for the non-moving-window z-score path regardless of MovingWindow flag - responses = mean(Mr, 3); % mean spikes/ms over response window: [nTrials × nNeurons] + % ------------------------------------------------------------------------- + % 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 Diff — method depends on MovingWindow flag - % MovingWindow=true : per-trial sliding window max, applied identically to - % Mr and Mb so null distribution uses the same operation - % MovingWindow=false: full duration mean, appropriate for sustained responses + % 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) % ------------------------------------------------------------------------- - if params.MovingWindowPval - winSize = responseParams.params.durationWindow; % sliding window size in ms/bins - % Guard: both response and baseline must be at least as long as winSize + % 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). ' ... - 'Reduce durationWindow or increase MaxStimDuration.'], ... + '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). ' ... - 'Reduce durationWindow or increase interTrialDelay.'], ... + 'Baseline window (%d ms) shorter than durationWindow (%d ms).', ... size(Mb,3), winSize); - % movmean along dim 3 — vectorised sliding window mean, no loop needed - % 'Endpoints','discard' removes partial windows at array edges - mrMov = movmean(Mr, winSize, 3, 'Endpoints', 'discard'); % [nTrials × nNeurons × nStepsR] - mbMov = movmean(Mb, winSize, 3, 'Endpoints', 'discard'); % [nTrials × nNeurons × nStepsB] - - % Per-trial maximum over all valid window positions: [nTrials × nNeurons] - responsesMW = max(mrMov, [], 3); - baselinesMW = max(mbMov, [], 3); + 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 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 - % Per-trial moving window difference — used for permutation test and z-score - Diff = responsesMW - baselinesMW; % [nTrials × nNeurons] + % 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 — appropriate for sustained/spatially localised responses - Diff = responses - baselines; % [nTrials × nNeurons] + % --- Full duration mean (non-moving-ball stimuli) --- + DiffPVal = Diff; % same as z-score Diff — no special treatment needed end - nNeurons = size(goodU, 2); % number of good units - nCats = round(size(Diff,1) / trialsCat); % number of stimulus categories + nNeurons = size(goodU, 2); + nCats = round(size(Diff,1) / trialsCat); + DiffReshaped = reshape(Diff, trialsCat, nCats, nNeurons); - % Sanity check: total trials must equal nCats × trialsCat assert(size(Diff,1) == nCats * trialsCat, ... - 'Trial count (%d) not evenly divisible by trialsCat (%d). Check responseParams.', ... + 'Trial count (%d) not evenly divisible by trialsCat (%d).', ... size(Diff,1), trialsCat); % ------------------------------------------------------------------------- % Category-level empty-trial filtering - % Uses full-duration responses (not moving window) for the zero-spike check - % because MW inflates apparent spike counts for silent trials + % Always based on full-duration responses — unaffected by permutation mode % ------------------------------------------------------------------------- - validCats = true(nCats, nNeurons); % [nCats × nNeurons]; true = include + validCats = true(nCats, nNeurons); if params.FilterEmptyResponses - responsesReshaped = reshape(responses, trialsCat, nCats, nNeurons); % [trialsCat × nCats × nNeurons] + responsesReshaped = reshape(responses, trialsCat, nCats, nNeurons); for c = 1:nCats for u = 1:nNeurons - emptyTrials = responsesReshaped(:, c, u) == 0; % zero-spike trials in this category - perc = sum(emptyTrials) / trialsCat; % fraction of empty trials + emptyTrials = responsesReshaped(:, c, u) == 0; + perc = sum(emptyTrials) / trialsCat; if perc >= params.EmptyTrialPerc - validCats(c, u) = false; % exclude category c for neuron u + validCats(c, u) = false; end end end end - % Neurons where ALL categories are invalid — statistics undefined noValidCat = all(~validCats, 1); % [1 × nNeurons] % ------------------------------------------------------------------------- - % Observed max-statistic - % Maximum mean Diff across valid categories per neuron [1 × nNeurons] - % ------------------------------------------------------------------------- - DiffReshaped = reshape(Diff, trialsCat, nCats, nNeurons); % [trialsCat × nCats × nNeurons] - catMeans = reshape(mean(DiffReshaped, 1), nCats, nNeurons); % [nCats × nNeurons] - - catMeansMasked = catMeans; - catMeansMasked(~validCats) = -Inf; % invalid categories cannot be preferred - ObsStat = max(catMeansMasked, [], 1); % [1 × nNeurons] - - % ------------------------------------------------------------------------- - % Max-statistic sign-flip permutation test + % Observed max-statistic and permutation test % - % H0: sign of Diff is random per trial (response = baseline distribution). - % Sign-flipping simulates H0 without parametric assumptions. - % Taking MAX across categories controls FWER (Nichols & Holmes 2002). - % Fully vectorised via pagemtimes — no loop over nBoot. + % 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 all sign vectors at once: [nTrials × nBoot], values ±1 + % 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 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] - % Reshape signs to match category structure: [trialsCat × nCats × nBoot] - signsR = reshape(signs, trialsCat, nCats, params.nBoot); + % Batched category means under H0: [nNeurons × nBoot × nCats] + catMeansPermSeg = pagemtimes(DiffRp, signsRp) / trialsCat; - % Permute for pagemtimes: - % DiffRp : [nNeurons × trialsCat × nCats] - % signsRp : [trialsCat × nBoot × nCats] - DiffRp = permute(DiffReshaped, [3 1 2]); - signsRp = permute(signsR, [1 3 2]); + % Permute to [nCats × nNeurons × nBoot], mask invalid categories + catMeansPermSeg = permute(catMeansPermSeg, [3 1 2]); + validCats3D = repmat(validCats, 1, 1, params.nBoot); + catMeansPermSeg(~validCats3D) = -Inf; - % Batched matrix multiply over category pages: [nNeurons × nBoot × nCats] - catMeansAll = pagemtimes(DiffRp, signsRp) / trialsCat; + % Max across categories for this segment: [nBoot × nNeurons] + nullMaxSeg = reshape(max(catMeansPermSeg, [], 1), params.nBoot, nNeurons); - % Permute to [nCats × nNeurons × nBoot] for masking and max - catMeansAll = permute(catMeansAll, [3 1 2]); + % 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]); - % Exclude invalid categories from null distribution - validCats3D = repmat(validCats, 1, 1, params.nBoot); % [nCats × nNeurons × nBoot] - catMeansAll(~validCats3D) = -Inf; + catMeansAll = pagemtimes(DiffRp, signsRp) / trialsCat; + catMeansAll = permute(catMeansAll, [3 1 2]); - % Null distribution: max across categories for each permutation [nBoot × nNeurons] - % reshape instead of squeeze — safe when nNeurons=1 or nCats=1 - nullMax = reshape(max(catMeansAll, [], 1), params.nBoot, nNeurons); + validCats3D = repmat(validCats, 1, 1, params.nBoot); + catMeansAll(~validCats3D) = -Inf; - % p-value: proportion of null max-statistics >= observed (one-tailed, excitatory) - pVal = mean(nullMax >= ObsStat, 1); % [1 × nNeurons] + nullMax = reshape(max(catMeansAll, [], 1), params.nBoot, nNeurons); + 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. @@ -353,99 +437,188 @@ zPerm(nullSD==0) = 0; % degenerate null — set to 0 zPerm(noValidCat) = NaN; % undefined for fully invalid neurons - % ------------------------------------------------------------------------- - % 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. - % ------------------------------------------------------------------------- - sdBase = std(baselines, 0, 1); % [1 × nNeurons] pooled baseline SD - - % if params.MovingWindowZS - % % Preferred category = argmax of MW per neuron - % % MW is [nCats × nNeurons] — already the best window mean per category - % catMWMasked = MW; - % catMWMasked(~validCats) = -Inf; % exclude invalid categories - % [~, prefCat] = max(catMWMasked, [], 1); % [1 × nNeurons] - % - % % Extract MW at preferred category using linear indexing - % idx_pref = prefCat + (0:nNeurons-1) * nCats; % linear index into [nCats × nNeurons] - % mwPrefCat = MW(idx_pref); % [1 × nNeurons] MW at preferred cat - % - % % Baseline correct: both MW and mean(baselines) are in spikes/ms - % z_mean = mwPrefCat - mean(baselines, 1); % [1 × nNeurons] - % - % else - if nCats == 1 - % Single category — preferred is trivially category 1 - % LOO and direct z-score are identical with one category - prefCat = ones(1, nNeurons); % only one category - z_mean = mean(Diff, 1); % mean over all trials [1 × nNeurons] - - elseif params.UseLOO - % --- LOO cross-validated z-score --- - % Pre-compute per-category sums for efficient LOO mean: [nCats × nNeurons] - totalSum = zeros(nCats, nNeurons); - for c = 1:nCats - rows = (c-1)*trialsCat + 1 : c*trialsCat; % trial rows for category c - totalSum(c,:) = sum(Diff(rows,:), 1); % sum over trials - end + 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 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); - z_loo_acc = zeros(1, nNeurons); % accumulates held-out Diff at preferred cat - prefCatCount = zeros(nCats, nNeurons); % tallies preferred category per fold + for k = 1:trialsCat + looMean = (totalSum - Diff((0:nCats-1)*trialsCat + k,:)) / (trialsCat-1); + looMeanMasked = looMean; + looMeanMasked(~validCats) = -Inf; - for k = 1:trialsCat - % LOO mean: subtract held-out trial k from total, divide by n-1 - % Indexing (0:nCats-1)*trialsCat+k gives the kth trial of each category - looMean = (totalSum - Diff((0:nCats-1)*trialsCat + k, :)) / (trialsCat - 1); - looMeanMasked = looMean; - looMeanMasked(~validCats) = -Inf; % exclude invalid categories + [~, prefCatLOO] = max(looMeanMasked, [], 1); % [1 × nNeurons] + idx = prefCatLOO + (0:nNeurons-1) * nCats; + prefCatCount(idx) = prefCatCount(idx) + 1; - [~, prefCatLOO] = max(looMeanMasked, [], 1); % [1 × nNeurons] preferred cat this fold + testVals = Diff((0:nCats-1)*trialsCat + k, :); % [nCats × nNeurons] + z_loo_acc = z_loo_acc + testVals(idx); + end - % Linear index into [nCats × nNeurons] - idx = prefCatLOO + (0:nNeurons-1) * nCats; - prefCatCount(idx) = prefCatCount(idx) + 1; % tally this fold's choice + z_mean = z_loo_acc / trialsCat; + [~, prefCat] = max(prefCatCount, [], 1); - % Held-out trial contribution at preferred category - testVals = Diff((0:nCats-1)*trialsCat + k, :); % [nCats × nNeurons] - z_loo_acc = z_loo_acc + testVals(idx); % accumulate held-out diff + 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 - z_mean = z_loo_acc / trialsCat; % mean held-out diff [1 × nNeurons] - [~, prefCat] = max(prefCatCount, [], 1); % consensus preferred category + % ------------------------------------------------------------------------- + % Normalise by pooled baseline SD — applies to both segmented and standard + % ------------------------------------------------------------------------- + z = z_mean ./ sdBase; + z(sdBase == 0) = 0; + z(noValidCat) = NaN; - else - % --- Direct z-score (no LOO) --- - % Subject to winner's curse — use for exploration only - catMeansDir = reshape(mean(DiffReshaped, 1), nCats, nNeurons); - catMeansDir(~validCats) = -Inf; - [~, prefCat] = max(catMeansDir, [], 1); % [1 × nNeurons] - idx = prefCat + (0:nNeurons-1) * nCats; - z_mean = catMeansDir(idx); % [1 × nNeurons] end - % end - - % Normalise by pooled baseline SD - z = z_mean ./ sdBase; % [1 × nNeurons] - z(sdBase == 0) = 0; % silent baseline — set to 0 - z(noValidCat) = NaN; % no valid categories — undefined % ------------------------------------------------------------------------- % One-sample t-test pooled across all valid categories @@ -495,6 +668,8 @@ 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; @@ -507,6 +682,7 @@ 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 @@ -528,30 +704,30 @@ % 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','validCats', ... - 'MaxMovWinResponse','pValTTest','tStat'}; +emptyFields = {'pvalsResponse','ZScoreU','ZScorePermutation','ObsDiff', ... + 'ObsResponse','ObsBaseline','prefCat','prefSeg','validCats', ... + 'MaxMovWinResponse','pValTTest','tStat'}; - if isequal(obj.stimName, 'linearlyMovingBall') || isequal(obj.stimName, 'linearlyMovingBar') +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.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 + S.Speed2.(f{1}) = []; end + end - else +elseif isequal(obj.stimName, 'StaticDriftingGrating') + for cond = {'Static', 'Moving'} for f = emptyFields - S.(f{1}) = []; + 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/VStimAnalysis.asv b/visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.asv deleted file mode 100644 index 3d8af68..0000000 --- a/visualStimulationAnalysis/@VStimAnalysis/VStimAnalysis.asv +++ /dev/null @@ -1,912 +0,0 @@ -classdef (Abstract) VStimAnalysis < handle - - properties - %diodeUpCross % times [ms] of diode up crossing - %diodeDownCross % times [ms] of diode down crossing - startSessionTrigger = [1 2] % Trigger number for start and end of visual stimulation session - sessionOrderInRecording = [] % the sequential position of the relevant session in case a few sessions exist in the same recording - sessionStartTime % the start time [ms] of the stimulation session - sessionEndTime % the end time [ms] of the stimulation session - visualStimFolder %the folder with visual stimulation files - visualStimAnalysisFolder %the folder with results of visual stimulation analysis (under visualStimFolder) - visualStimPlotsFolder %the folder with results of visual stimulation analysis plots (under visualStimFolder) - spikeSortingFolder %the folder with spike sorting data - visualStimulationFile %the name of the visual stimulation mat file - stimName %the name of the visual stimulation - extracted by removing analysis from the class name - VST %all visual stimulation properties and values - end - - properties (SetObservable, AbortSet = true, SetAccess=public) - dataObj %data recording object - end - - properties (Constant, Abstract) - trialType % The type of trials in terms of flips 'imageTrials' have one flip per trial and 'videoTrials' have many flips per trial - end - - methods (Hidden) - %class constructor - gets name and adds listener to update initialization every time the dataRecording object is changed - %If - function obj=VStimAnalysis(dataObj, params) - arguments (Input) %ResponseWindow.mat - dataObj - params.Session = 1; - end - StimSession = params.Session; - obj.stimName=class(obj);obj.stimName=obj.stimName(1:end-8); % - addlistener(obj, 'dataObj', 'PostSet',@(src,evnt)obj.initialize(StimSession)); - if nargin==0 || isempty(dataObj) - fprintf('No dataRecording object entered as input!!! Most functions will not work!!!\n Please manually populate the dataObj property.\n'); - else - obj.dataObj=dataObj; - end - - - end - end - - methods - - function obj = initialize(obj, StimSession) - %Initialization - extraction of folders and visual parameters subclass name should match the visual stimulation - %extract the visual stimulation parameters from parameter file - obj.visualStimFolder=obj.findFolderInExperiment(obj.dataObj.recordingDir,'visualStimulation'); - obj=setVisualStimulationFile(obj,'Session',StimSession); - obj=getStimParams(obj); - obj.spikeSortingFolder=obj.findFolderInExperiment(obj.dataObj.recordingDir,'kilosort'); - end - - function printFig(obj,f,figName,params) - arguments (Input) - obj - f - figName - params.PaperFig logical = false %print fig in corresponding folder for paper figures - end - %Prints plots in the relevant folder - %f - figure handle - %figName - the name of the figure in the figure folder - set(f,'PaperPositionMode','auto'); - - if params.PaperFig - - parts = split(obj.visualStimPlotsFolder, filesep); - out = [fullfile(parts{1:2}), filesep, 'Paper_figs']; - - %disp(['Printing fig: ',obj.visualStimPlotsFolder,filesep,figName]); - print([out,filesep,figName],'-djpeg','-vector','-r300'); - else - disp(['Printing fig: ',obj.visualStimPlotsFolder,filesep,figName]); - print([obj.visualStimPlotsFolder,filesep,figName],'-djpeg','-vector','-r300'); - end - end - - function results = getStimLFP(obj,params) - %Extracts the LFP for all visual stimulation times from the row data. - arguments (Input) - obj - params.win = [500,500] % duration [1,2] [ms] (for on and off) for LFP analysis - params.channelSkip = 5 %includes every 5th channel - params.getWinFromStimDuration = false %if this option is used, only the on response is calculated - params.overwrite logical = false %if true overwrites results - params.analysisTime = datetime('now') %extract the time at which analysis was performed - params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method - end - if params.inputParams,disp(params),return,end - - %load previous results if analysis was previuosly performed and there is no need to overwrite otherwise continue - results = obj.isOutputAnalysis(obj.getAnalysisFileName,params.overwrite,nargout==1); - if ~isempty(results), return, end - - stimTimes=obj.getSyncedDiodeTriggers; - - %Design decimation Filter - F=filterData(obj.dataObj.samplingFrequencyAP(1)); - F.downSamplingFactor=obj.dataObj.samplingFrequencyAP(1)/250; - F=F.designDownSample; - F.padding=true; - samplingFreqLFP=F.filteredSamplingFrequency; - - %To add: for cases of low memory use a loop for calculating over groups of 10 trials and merge - if params.getWinFromStimDuration - params.win=[obj.VST.stimDuration*1000, 500]; - end - - [LFP_on,~]=obj.dataObj.getData(1:params.channelSkip:obj.dataObj.channelNumbers(end),stimTimes.stimOnFlipTimes,params.win(1)); - LFP_on=F.getFilteredData(LFP_on); - - [LFP_off,~]=obj.dataObj.getData(1:params.channelSkip:obj.dataObj.channelNumbers(end),stimTimes.stimOffFlipTimes,params.win(2)); - LFP_off=F.getFilteredData(LFP_off); - - fprintf('Saving results to file.\n'); - save(obj.getAnalysisFileName,'params','LFP_on','LFP_off','samplingFreqLFP'); - end - - function obj = getStimParams(obj) - %extract visual stimulation parameters from the file saved by VStim classes when running visualStimGUI - VSFile=[obj.visualStimFolder filesep obj.visualStimulationFile]; - disp(['Extracting information from: ' VSFile]); - VS=load(VSFile); - - %create structure - if isfield(VS,'VSMetaData') - for i=1:numel(VS.VSMetaData.allPropName) - obj.VST.(VS.VSMetaData.allPropName{i})=VS.VSMetaData.allPropVal{i}; - end - else % for compatibility with old versions - for i=1:size(VS.props,1) - obj.VST.(VS.props{i,1})=VS.props{i,2}; - end - end - if nargout>0 - VST=obj.VST; - end - - end - - function analysisFile = getAnalysisFileName(obj) - %extract currently running analysis method name and use it to create a unique file name for saving analysis results - db=dbstack;currentMethod=strsplit(db(2).name,'.'); - analysisFile=[obj.visualStimAnalysisFolder,filesep,currentMethod{end},'.mat']; - analysisFile=[obj.visualStimAnalysisFolder,filesep,currentMethod{end},'.mat']; - end - - %check if spike sorting data exists and converts to t,ic format if needed. - function [s]=getSpikeTIcData(obj) - %check that sorting exist - if isdir(obj.spikeSortingFolder) - if isfile([obj.spikeSortingFolder filesep 'sorting_tIc.mat']) - s=load([obj.spikeSortingFolder filesep 'sorting_tIc.mat']); - else - fprintf('Did not find sorting_tIc.mat (t,ic format) in spike sorting folder!\ntrying to convert from Phy format, please wait...\n'); - obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); - s=load([obj.spikeSortingFolder filesep 'sorting_tIc.mat']); - end - else - fprintf('Did not find spike sorting folder, put phy results into a folder starting with kilosort and try again,\n'); - end - end - - function [results] = getCorrSpikePattern(obj,T,trialCat,params) - arguments - obj - T = []%the start times of for the trials included in the analysis and its categories - trialCat = []% the category of each of the trials. - params.win = 1000 %the window post stimTimes times - params.bin = 10 %[ms] - bins size for the generated rasters - params.gaussConvSamples = 5 % one standard deviation of the Gaussian for smoothing rasters in units of bin - params.overwrite logical = false %if true overwrites results - params.analysisTime = datetime('now') %extract the time at which analysis was performed - params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method - end - if params.inputParams,disp(params),return,end - - %load previous results if analysis was previuosly performed and there is no need to overwrite otherwise continue - results = obj.isOutputAnalysis(obj.getAnalysisFileName,params.overwrite,nargout==1); - if ~isempty(results), return, end - - s=obj.getSpikeTIcData; - - M=BuildBurstMatrix(s.ic,round(s.t/params.bin),round(T/params.bin),round(params.win/params.bin)); - M=ConvBurstMatrix(M,fspecial('gaussian',[1 params.gaussConvSamples*3],params.gaussConvSamples),'same'); - [nTrials,nNeu,nBins]=size(M); - - [CI]=CalcCorrAlfaB_tmp2(M); - - M=reshape(M,[nTrials,nNeu*nBins])'; - C=corrcoef(M); - - fprintf('Saving results to file.\n'); - save(obj.getAnalysisFileName,'params','C','CI','trialCat'); - end - - function plotCorrSpikePattern(obj,params) - arguments - obj - params.overwrite logical = true %if true overwrites the existing plots. If false only generates the plots without saving - params.analysisTime = datetime('now') %extract the time at which analysis was performed - params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method - end - if params.inputParams,disp(params),return,end - - res=obj.getCorrSpikePattern; - - nTrials=size(res.C,1); - uniqCat=unique(res.trialCat,'stable'); - nCat=numel(uniqCat); - trialsPerCat=nTrials/nCat; - [x,y]=meshgrid(0:trialsPerCat:nTrials); - - f1=figure; - imagesc(res.C); - hold on; - line(x,y,'Color','k');line(y,x,'Color','k'); - axis(f1.Children,'square'); - set(f1.Children,'XTick',trialsPerCat/2:trialsPerCat:nTrials,'YTick',trialsPerCat/2:trialsPerCat:nTrials,'XTickLabel',uniqCat,'YTickLabel',uniqCat) - if params.overwrite,obj.printFig(f1,'trialCorrMatrix'),end - - f2=figure; - imagesc(res.CI); - hold on; - line(x,y,'Color','k');line(y,x,'Color','k'); - axis(f2.Children,'square'); - set(f2.Children,'XTick',trialsPerCat/2:trialsPerCat:nTrials,'YTick',trialsPerCat/2:trialsPerCat:nTrials,'XTickLabel',uniqCat,'YTickLabel',uniqCat) - if params.overwrite,obj.printFig(f2,'timeInvariantTrialCorrMatrix'),end - - f3=figure; - [DC,order]=DendrogramMatrix(res.CI,'toPlotBinaryTree',1,'maxClusters',8,'figureHandle',f3); - if params.overwrite,obj.printFig(f3,'timeInvariantDendrogramedTrialCorrMatrix'),end - - f4=figure; - text(1:numel(order),order,res.trialCat,'FontSize',8);hold on;plot(order,'.','MarkerSize',10); - xlabel('Trial');ylabel('Reordered trial'); - if params.overwrite,obj.printFig(f4,'timeInvariantDendrogramedOrdering'),end - - f5=figure; - [DC,order]=DendrogramMatrix(res.C,'toPlotBinaryTree',1,'maxClusters',8,'figureHandle',f5); - if params.overwrite,obj.printFig(f5,'dendrogramedTrialCorrMatrix'),end - - f6=figure; - text(1:numel(order),order,res.trialCat,'FontSize',8);hold on;plot(order,'.','MarkerSize',10); - xlabel('Trial');ylabel('Reordered trial'); - if params.overwrite,obj.printFig(f6,'dendrogramedOrdering'),end - end - - function results = getSyncedDiodeTriggers(obj,params) - %Sychronize the times for each stimulation onet or frame between the diode analog data and the time stamps saved by the visual stimulation class used for presenting the stimulations - arguments - obj - params.minDiodeInterval = 0.5 %removes diode frames shorter than minDiodeInterval fraction of the inter frame interval - params.analyzeOnlyOnFlips = false %in some stimuli - the off flips exist but not analyzed. - params.ignoreNLastFlips = 0 %some stimuli have residual flips that do not need to be analyzed. - params.overwrite logical = false %if true overwrites results - params.analysisTime = datetime('now') %extract the time at which analysis was performed - params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method - end - if params.inputParams,disp(params),return,end - - %load previous results if analysis was previuosly performed and there is no need to overwrite otherwise continue - results = obj.isOutputAnalysis(obj.getAnalysisFileName,params.overwrite,nargout==1); - if ~isempty(results), return, end - - diode=obj.getDiodeTriggers; - - allDiodeFlips=sort([diode.diodeUpCross,diode.diodeDownCross]); - allDiodeFlips(1+find(diff(allDiodeFlips)All flip times in the visual stimulation meta data were NaNs!!! Please check that your vStim is valid\nTrying to use the start times of diode signals and expected frame rates\n'); - pTrialEnds=[find(diff(allDiodeFlips)>obj.VST.interTrialDelay*0.9*1000) numel(allDiodeFlips)]; - pTrialStarts=[1 1+find(diff(allDiodeFlips)>obj.VST.interTrialDelay*0.9*1000)]; - allFlips=bsxfun(@plus, (0:size(allFlips,1)-1)*obj.VST.ifi*1000,allDiodeFlips(pTrialStarts)'); - else - allFlips=allFlips(~isnan(allFlips))*1000; - end - - if isequal(obj.stimName,'StaticDriftingGrating') - params.ignoreNLastFlips =2; - end - - %ignore a few last flips - needed for some stimuli - if params.ignoreNLastFlips~=0 - allFlips(end-params.ignoreNLastFlips+1:end)=[]; - end - - expectedFlips=numel(allFlips); - fprintf('%d flips expected, %d found (diff=%d). Linking existing flip times with stimuli...\n',expectedFlips,measuredFlips,expectedFlips-measuredFlips); - if (expectedFlips-measuredFlips)>0.1*expectedFlips - fprintf('There are more than 10 percent mismatch in the number of diode and vStim expected flips. Cant continue!!! Please check diode extraction!\n'); - return; - end - switch obj.trialType - case 'videoTrials' - pTrialEnds=[find(diff(allDiodeFlips)>obj.VST.interTrialDelay*0.9*1000) numel(allDiodeFlips)]; - pTrialStarts=[1 1+find(diff(allDiodeFlips)>obj.VST.interTrialDelay*0.9*1000)]; - stimOnFlipTimes=allDiodeFlips(pTrialStarts); - stimOffFlipTimes=allDiodeFlips(pTrialEnds); - - if isequal(obj.stimName,'StaticDriftingGrating') %Change because static time could be equal to intertrial delat - - % Compute differences - dv_prev = [NaN diff(allDiodeFlips)]; % difference from previous element - dv_next = [diff(allDiodeFlips) NaN]; % difference from next element - pTrialStarts = [1 find(abs(dv_prev) >= obj.VST.static_time*0.7*1000 & abs(dv_next) >= obj.VST.interTrialDelay*0.7*1000)]; - pTrialEnds = [find(abs(dv_prev) <= (1/obj.VST.fps)*10*1000 & abs(dv_next) >= obj.VST.interTrialDelay*0.7*1000) numel(allDiodeFlips)]; - - stimOnFlipTimes=allDiodeFlips(pTrialStarts); - stimOffFlipTimes=allDiodeFlips(pTrialEnds); - end - - if numel(pTrialEnds)~=obj.VST.nTotTrials || numel(pTrialStarts)~=obj.VST.nTotTrials - disp('The total number of trials does not equal the number of inter trial delay gaps! Could not perform trial association'); - end - - trialDiodeFlips=cell(1,obj.VST.nTotTrials); - - try - diodeFrameFlipTimes=nan(size(obj.VST.flip)); - catch - obj.VST.flip = obj.VST.flipOnsetTimeStamp; - diodeFrameFlipTimes=nan(size(obj.VST.flip)); - end - - for i=1:obj.VST.nTotTrials - pFlips=~isnan(obj.VST.flip(i,:)); %not all trials have the same number of flips - currentDiodeFlipTimes=allDiodeFlips(pTrialStarts(i):pTrialEnds(i)); - currentPCFlipTimes=obj.VST.flip(i,pFlips)*1000; - %plot(allFlips-allFlips(1),ones(1,numel(allFlips)),'or');hold on;plot(allDiodeFlips-allDiodeFlips(1),ones(1,numel(allDiodeFlips)),'.k'); - %check for cases in which there was a missed frame in diode signal - this could be from a missed frame or a delayed frame - pDelayed=diff(currentDiodeFlipTimes)>obj.VST.ifi*1000*1.5; - frameMatch=numel(currentPCFlipTimes)-(numel(currentDiodeFlipTimes)+sum(pDelayed)); - if frameMatch>=0 %all frames that were not present were missed - [in,p]=ismembertol(currentPCFlipTimes-currentPCFlipTimes(1),currentDiodeFlipTimes-currentDiodeFlipTimes(1),obj.VST.ifi*1000/2,'DataScale',1); - elseif (frameMatch+sum(pDelayed))==0 && ~isequal(obj.stimName,'StaticDriftingGrating')%at least some of the frames were delayed in presentation and not just missed - %look for a delay in diode flips which may explain a consistent delay - tmpDiode=currentDiodeFlipTimes+cumsum([zeros(1,-frameMatch) pDelayed])*(-obj.VST.ifi*1000); - [in,p]=ismembertol(currentPCFlipTimes-currentPCFlipTimes(1),tmpDiode-tmpDiode(1),obj.VST.ifi*1000/2,'DataScale',1); - elseif frameMatch<0 && (numel(currentPCFlipTimes)-numel(currentDiodeFlipTimes))>=0 %identifies irregularities in timing - in=ones(1,numel(currentDiodeFlipTimes)); - p=1:numel(currentDiodeFlipTimes); - elseif frameMatch<0 %the match is negative and there was no compensation due the previous condition - currentDiodeFlipTimes=currentDiodeFlipTimes(1:numel(currentPCFlipTimes)); %remove excess frames at end and match one to one - in=ones(1,numel(currentPCFlipTimes)); - p=1:numel(currentPCFlipTimes); - else - error('The case which there are more diode flips than stimulated frames was not addressed in the algorithm! Please add to code.') - end - diodeFrameFlipTimes(i,in)=currentDiodeFlipTimes(p(p~=0)); - - %{ - plot(currentPCFlipTimes-currentPCFlipTimes(1),ones(1,numel(currentPCFlipTimes)),'.k');hold on; - plot(currentDiodeFlipTimes-currentDiodeFlipTimes(1),ones(1,numel(currentDiodeFlipTimes)),'or');legend({'trig','diode'}) - %} - end - fprintf('Saving results to file.\n'); - save(obj.getAnalysisFileName,'params','diodeFrameFlipTimes','stimOnFlipTimes','stimOffFlipTimes'); - results = load(obj.getAnalysisFileName); - case 'imageTrials' - if expectedFlips==measuredFlips - if params.analyzeOnlyOnFlips - stimOnFlipTimes=allDiodeFlips; - stimOffFlipTimes=[]; - else - stimOnFlipTimes=allDiodeFlips(1:2:end); - stimOffFlipTimes=allDiodeFlips(2:2:end); - end - else - error('This case of unequal triggers for image type trials was not addressed in the code!'); - return; - end - - fprintf('Saving results to file.\n'); - save(obj.getAnalysisFileName,'params','stimOnFlipTimes','stimOffFlipTimes'); - results=load(obj.getAnalysisFileName); - end - end - - function copyFilesFromRecordingFolder(obj) - %searches visual stimulation files and copies them to a dedicated visual stimulation folder - numberOfParentFolders=2; %How many parent folders to go in looking for the visual stimulation files - - tmpFolder=obj.dataObj.recordingDir; - filesFound=false; - for f=1:numberOfParentFolders - matFiles=dir([tmpFolder,filesep,'*.mat']); - if ~isempty(matFiles) - for i=1:numel(matFiles) - tmpVars=whos('-file',[tmpFolder filesep matFiles(i).name]); - if strcmp(tmpVars.name,'VSMetaData') - copyfile([tmpFolder filesep matFiles(i).name], obj.visualStimFolder); - fprintf('%s was copied to the visual stimulation folder: %s\n',matFiles(i).name,obj.visualStimFolder); - filesFound=true; - end - end - end - if ~filesFound - if tmpFolder(end)==filesep - [tmpFolder, ~, ~] = fileparts(tmpFolder(1:end-1)); - else - [tmpFolder, ~, ~] = fileparts(tmpFolder(1:end)); - end - end - end - - if ~filesFound - error('No visual stimulation mat files found!!! Please copy stimulation files to the visual stimulation folder') - end - end - - function obj=setVisualStimulationFile(obj,params) - %find visual stimulation file according to recording file names and the name of the visual stimulation analysis class - arguments (Input) %ResponseWindow.mat - obj - params.visualStimulationfile = []; - params.Session = 1; - end - - if isempty(params.visualStimulationfile) - VSFiles=dir([obj.visualStimFolder filesep '*.mat']); - if isempty(VSFiles) - obj.copyFilesFromRecordingFolder; - VSFiles=dir([obj.visualStimFolder filesep '*.mat']); - end - - try - dateTime=datetime({VSFiles.date},'InputFormat','dd-MMM-yyyy HH:mm:ss'); - catch - %In case pc regional setting is Israel and hebrew - dateTime=datetime({VSFiles.date},'InputFormat','dd-MMM-yyyy HH:mm:ss','Locale', 'he_IL'); - end - [~,pDate]=sort(dateTime); - VSFiles={VSFiles.name}; %do not switch with line above - VSFiles = VSFiles(~contains(lower(VSFiles), 'metadata')); %exclude metadata - recordingsFound=0; - tmpDateTime = datetime.empty(0,numel(VSFiles)); - pSession = []; - for i=1:numel(VSFiles) - if contains(VSFiles{i},obj.stimName,'IgnoreCase',true) - recordingsFound=recordingsFound+1; - pSession=[pSession i]; - end - try - vStimIdentifiers=split(VSFiles{i},["_","."]); - tmpDateTime(i)=datetime(join(vStimIdentifiers(2:8), "-"),'InputFormat','yyyy-MM-dd-HH-mm-ss-SSS'); - catch - fprintf('!!!!Important!!!!!\nUnable to extract date and time from a visual stimulation file name!!!!\nPlease correct the format or remove the file and run again!!!!\n') - end - end - - if recordingsFound<1 - fprintf('No matchings visual stimulation files found!!!\n Please check the names of visual stimulation files or run setVisualStimulationFile(file) with the filename as input.\n'); - return; - else - obj.visualStimulationFile=VSFiles{pSession(params.Session)}; - [~,order]=sort(tmpDateTime); - obj.sessionOrderInRecording=find(order==pSession(params.Session)); - end - else - obj.visualStimulationFile=visualStimulationfile; - end - - %populate properties and create folders for analysis if needed - [~,fileWithoutExtension]=fileparts(obj.visualStimulationFile); - obj.visualStimAnalysisFolder=[obj.visualStimFolder filesep fileWithoutExtension '_Analysis']; - if ~isfolder(obj.visualStimAnalysisFolder) - mkdir(obj.visualStimAnalysisFolder); - fprintf('Visual stimulation Analysis folders created:\n%s\n',obj.visualStimAnalysisFolder); - end - obj.visualStimPlotsFolder=[obj.visualStimFolder filesep fileWithoutExtension '_Plots']; - if ~isfolder(obj.visualStimPlotsFolder) - mkdir(obj.visualStimPlotsFolder); - fprintf('Visual stimulation analysis Plots folders created:\n%s\n',obj.visualStimAnalysisFolder); - end - end - - function results=getSessionTime(obj,params) - %obj=getSessionTime(obj,params) - Gets the start times of each visual stimulation session from digital triggers in the recoring - arguments (Input) - obj - params.startEndChannel = [] %[1,2] - The digital triger channel for stim onset and offset - params.analysisTime = datetime('now') %extract the time at which analysis was performed - params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method - params.overwrite logical = false %if true overwrites results %if true overwrites results - end - if params.inputParams,disp(params),return,end - - %In future versions remove these properties and dont use them for analysis results - %load previous results if analysis was previuosly performed and there is no need to overwrite - if isfile(obj.getAnalysisFileName) && ~params.overwrite - fprintf('Analysis already exists (use overwrite option to recalculate).\n'); - results=load(obj.getAnalysisFileName); - obj.sessionStartTime=results.sessionStartTime; - obj.sessionEndTime=results.sessionEndTime; - obj.startSessionTrigger=results.startSessionTrigger; - return; - end - - %load previous results if analysis was previuosly performed and there is no need to overwrite otherwise continue - results = obj.isOutputAnalysis(obj.getAnalysisFileName,params.overwrite,nargout==1); - if ~isempty(results), return, end - - if nargin == 2 - obj.startSessionTrigger=params.startEndChannel; - end - T=obj.dataObj.getTrigger; - if isscalar(T{obj.startSessionTrigger(1)}) && isscalar(T{obj.startSessionTrigger(2)}) %only 1 visual stimulation session in the recording - obj.sessionStartTime=T{obj.startSessionTrigger(1)}; - obj.sessionEndTime=T{obj.startSessionTrigger(2)}; - else - fprintf('There are %d session in the recording. Analysis indicated session # %d. If not please modify the sessionOrderInRecording property\n',numel(T{obj.startSessionTrigger(1)}),obj.sessionOrderInRecording); - obj.sessionStartTime=T{obj.startSessionTrigger(1)}(obj.sessionOrderInRecording); - obj.sessionEndTime=T{obj.startSessionTrigger(2)}(obj.sessionOrderInRecording); - end - - sessionStartTime=obj.sessionStartTime; - sessionEndTime=obj.sessionEndTime; - startSessionTrigger=obj.startSessionTrigger; - - %save results in the right file - fprintf('Saving results to file.\n'); - save(obj.getAnalysisFileName,'sessionStartTime','sessionEndTime','startSessionTrigger'); - end - - %Extract the frame flips from the diode signal - function results=getDiodeTriggers(obj,params) - arguments (Input) - obj - %extractionMethod (1,1) string {mustBeMember(extractionMethod,{'diodeThreshold','digitalTriggerDiode'})} = 'diodeThreshold'; - params.extractionMethod string = 'diodeThreshold' %the method used to extract frame flipes-{'diodeThreshold','digitalTriggerDiode'} - params.analogDataCh = 1 - params.overwrite logical = false %if true overwrites results - params.analysisTime = datetime('now') %extract the time at which analysis was performed - params.inputParams = false %if true - prints out the iput parameters so that it is clear what can be manipulated in the method - end - if params.inputParams,disp(params),return,end - - %load previous results if analysis was previuosly performed and there is no need to overwrite otherwise continue - results = obj.isOutputAnalysis(obj.getAnalysisFileName,params.overwrite,nargout==1); - if ~isempty(results), return, end - - fprintf('Extracting diode signal from analog channel #%d\n',params.analogDataCh); - switch params.extractionMethod - case "diodeThreshold" - - if ~any(isempty([obj.sessionStartTime,obj.sessionEndTime])) - [A,t_ms]=obj.dataObj.getAnalogData(params.analogDataCh,obj.sessionStartTime,obj.sessionEndTime-obj.sessionStartTime); %extract diode data for entire recording - - Th=mean(A(1:100:end)); - diodeUpCross=t_ms(A(1:end-1)=Th)+obj.sessionStartTime; - diodeDownCross=t_ms(A(1:end-1)>Th & A(2:end)<=Th)+obj.sessionStartTime; - else - disp('Missing start and end times!!! Please run getSessionTime before extracting triggers'); - return; - end - case "digitalTriggerDiode" - - switch obj.trialType - - case 'videoTrials' - - if ~any(isempty([obj.sessionStartTime,obj.sessionEndTime])) - - if all(obj.trialType == 'videoTrials') && (isequal(obj.stimName,'linearlyMovingBall')... - || isequal(obj.stimName, 'linearlyMovingBar')) - expectedFlipsperTrial = unique(obj.VST.nFrames); - speeds = obj.VST.speeds; - framesNspeed = zeros(2,length(speeds)); - framesNspeed(1,:) = speeds; - %Works for two speeds - framesNspeed(2,speeds == min(speeds)) = max(expectedFlipsperTrial); - framesNspeed(2,speeds == max(speeds)) = min(expectedFlipsperTrial); - elseif isequal(obj.stimName,'StaticDriftingGrating') || isequal(obj.stimName,'movie') - framesNspeed = ones(2,1); - framesNspeed(2,1) = round(obj.VST.actualStimDuration*obj.VST.fps); - - if isequal(obj.stimName,'movie') - framesNspeed(2,1) = round(obj.VST.movFrameCount); - framesNspeed = repmat(framesNspeed,1,numel(obj.VST.movieSequence)); - end - - if isequal(obj.stimName,'StaticDriftingGrating') - framesNspeed(2,1) = framesNspeed(2,1)+1;%adding static time. - framesNspeed = repmat(framesNspeed,1,numel(obj.VST.angleSequence)); - end - - else - framesNspeed = ones(2,1); - end - - t = obj.dataObj.getTrigger; - trialOn = t{3}(t{3} > obj.sessionStartTime & t{3} < obj.sessionEndTime); - trialOff = t{4}(t{4} > obj.sessionStartTime & t{4} < obj.sessionEndTime); - interDelayMs = obj.VST.interTrialDelay*1000; - - %[A,t_ms]=obj.dataObj.getAnalogData(params.analogDataCh,trialOn(1)-interDelayMs/2,trialOff(end)-trialOn(1)+interDelayMs); %extract diode data for entire recording - [A,t_ms]=obj.dataObj.getAnalogData(params.analogDataCh,trialOn(1),trialOff(end)-trialOn(1)+interDelayMs); %extract diode data for entire recording - - DiodeCrosses = cell(2,numel(trialOn)); - moreCross =0; - trialMostcross=inf; - intTrials =[]; - iMC =0; - intrialsNum = 0; - trialFail =0; - failedTrials =[]; - for i =1:length(trialOff) - - startSnip = round((trialOn(i)-trialOn(1))*(obj.dataObj.samplingFrequencyNI/1000))+1; - endSnip = round((trialOff(i)-trialOn(1)+100)*(obj.dataObj.samplingFrequencyNI/1000)); - - if endSnip>length(A) - signal = squeeze(A(startSnip:end)); - t_msS = t_ms(startSnip:end); - else - signal =squeeze(A(startSnip:endSnip)); - t_msS = t_ms(startSnip:endSnip); - end - fDat=medfilt1(signal,15); - Th=mean(fDat(1:100:end)); - stdS = std(fDat(1:100:end)); - sdK = 0.1; - upTimes=t_msS(fDat(1:end-1)=Th-sdK*stdS)+trialOn(1);%+interDelayMs/2; %get real recording times - downTimes=t_msS(fDat(1:end-1)>Th-sdK*stdS & fDat(2:end)<=Th-sdK*stdS )+trialOn(1);%+interDelayMs/2; - - % Filter crossings: Remove those too close together (e.g., < 50 ms) - minISI = 2*floor(1000/obj.VST.fps); % ms - filterISI = @(x) x([true, diff(x) > minISI]); - - try - DiodeCrosses{1,i} = filterISI(upTimes); - DiodeCrosses{2,i} = filterISI(downTimes); - catch - DiodeCrosses{1,i} = upTimes; - DiodeCrosses{2,i} = downTimes; - end - - if (length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i}))*1.1 < framesNspeed(2,i) && ~isequal(obj.stimName,'StaticDriftingGrating') - %if the number of calculated frames is less than 10% - %then perform an interpolation with the - %first and last cross - - if (length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i})) < framesNspeed(2,i)*0.5 - %%Diode failure. Use digital triggers - %%and interpolate. - diodeAll = zeros(1,2); - diodeAll(1) = trialOn(i); - diodeAll(2) = trialOff(i); - ind = 2; - trialFail = trialFail +1; - failedTrials = [failedTrials i]; - else - [~, ind]=min([DiodeCrosses{1,i}(1) DiodeCrosses{2,i}(1)]); %check if trial starts with up or down cross - diodeAll = sort([DiodeCrosses{1,i} DiodeCrosses{2,i}]); - end - - %DiodeInterp = linspace(diodeAll(1),diodeAll(end),framesNspeed(2,i)); - DiodeInterp = diodeAll(1):1000/obj.VST.fps: min([diodeAll(1) + (framesNspeed(2,i)-1)*(1000/obj.VST.fps), trialOff(i)]); - if ind == 2 %Trial starts with down cross - DiodeCrosses{2,i} = DiodeInterp(1:2:end); - DiodeCrosses{1,i} = DiodeInterp(2:2:end); - else - DiodeCrosses{2,i} = DiodeInterp(2:2:end); - DiodeCrosses{1,i} = DiodeInterp(1:2:end); - end - - intTrials = [intTrials i]; - intrialsNum = intrialsNum+1; - - end - - if (length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i}))>framesNspeed(2,i) - %if there are more crosses than there - %should be - moreCross = moreCross+1; - if trialMostcross>(length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i})) - framesNspeed(2,i) - trialMostcross = (length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i})) - framesNspeed(2,i); - iMC = i; - end - end - - if isequal(obj.stimName,'StaticDriftingGrating') %Make sure there is only one start frame. - - [firstCross, idx]= min([DiodeCrosses{1,i}(1),DiodeCrosses{2,i}(1)]); - - if firstCross > t_msS(1) + trialOn(1) + (obj.VST.static_time*1000)/2 %Add first frame that might not be read because of no diode change - if idx ==1 - DiodeCrosses{2,i} = [50+trialOn(i) DiodeCrosses{2,i}]; - else - DiodeCrosses{1,i} = [50+trialOn(i) DiodeCrosses{1,i}]; - end - [firstCross, idx]= min([DiodeCrosses{1,i}(1),DiodeCrosses{2,i}(1)]); - end - - ups = DiodeCrosses{1,i}; - downs = DiodeCrosses{2,i}; - ups = ups(ups == firstCross | ups>firstCross+obj.VST.static_time*1000*0.9); - downs = downs(downs == firstCross | downs>firstCross+obj.VST.static_time*1000*0.9); - - DiodeCrosses{1,i} = ups; - DiodeCrosses{2,i} = downs; - - if (length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i}))*1.1 < framesNspeed(2,i) - [~, ind]=min([DiodeCrosses{1,i}(1) DiodeCrosses{2,i}(1)]); - diodeAll = sort([DiodeCrosses{1,i} DiodeCrosses{2,i}]); - - %DiodeInterp = linspace(diodeAll(1),diodeAll(end),framesNspeed(2,i)); - DiodeInterp = [diodeAll(1) diodeAll(2):1000/obj.VST.fps: diodeAll(2) + (framesNspeed(2,i)-1)*(1000/obj.VST.fps)]; - if ind == 2 %Trial starts with down cross - DiodeCrosses{2,i} = DiodeInterp(1:2:end); - DiodeCrosses{1,i} = DiodeInterp(2:2:end); - else - DiodeCrosses{2,i} = DiodeInterp(2:2:end); - DiodeCrosses{1,i} = DiodeInterp(1:2:end); - end - end - - end %end especial case "if" for static and drifting gratings. - - end %End for loop through trials - - diodeUpCross=cell2mat(DiodeCrosses(1,:)); - diodeDownCross=cell2mat(DiodeCrosses(2,:)); - - fprintf('%d trials out of %d have little or no diode signal, assuming diode failure but correct fliping in trials:',trialFail,length(trialOff)) - fprintf('%.0f ', failedTrials); - fprintf('\n'); - fprintf('%d trials have excess crossings out of %d; trial %d has the most excess crossings: %d',moreCross,length(trialOff),iMC,trialMostcross) - fprintf('\n'); - fprintf('%d Interpolated trials (out of %d) with more than 10%% of crosses missing: ',intrialsNum,length(trialOff)); - fprintf('%.0f ', intTrials); - fprintf('\n'); - %Test - figure;plot(squeeze(fDat)); - hold on;xline((DiodeCrosses{1,i} - trialOn(i))*(obj.dataObj.samplingFrequencyNI/1000)) - xline((DiodeCrosses{2,i} - trialOn(i))*(obj.dataObj.samplingFrequencyNI/1000),'r') - yline(Th) - hold on;xline((upTimes-trialOn(i))*(obj.dataObj.samplingFrequencyNI/1000));xline((50)*(obj.dataObj.samplingFrequencyNI/1000)) - % %xline((trialOff(i)-trialOn(1))*(obj.dataObj.samplingFrequencyNI/1000),'b') - % figure;plot(1:length(DiodeCrosses{1,i}) + length(DiodeCrosses{2,i})-1,diff(sort([DiodeCrosses{1,i} DiodeCrosses{2,i}]))) - else - disp('Missing start and end times!!! Please run getSessionTime before extracting triggers'); - end - - case 'imageTrials' - - t = obj.dataObj.getTrigger; - trialOn = t{3}(t{3} > obj.sessionStartTime & t{3} < obj.sessionEndTime); - trialOff = t{4}(t{4} > obj.sessionStartTime & t{4} < obj.sessionEndTime); - interDelayMs = obj.VST.interTrialDelay*1000; - - %[A,t_ms]=obj.dataObj.getAnalogData(params.analogDataCh,trialOn(1)-interDelayMs/2,trialOff(end)-trialOn(1)+interDelayMs); %extract diode data for entire recording - [A,t_ms]=obj.dataObj.getAnalogData(params.analogDataCh,trialOn(1),trialOff(end)-trialOn(1)+interDelayMs); %extract diode data for entire recording - - DiodeCrosses = cell(2,numel(trialOn)); - moreCross =0; - trialMostcross=inf; - intTrials =[]; - iMC =0; - intrialsNum = 0; - trialFail =0; - failedTrials =[]; - for i =1:length(trialOff) - - startSnip = round((trialOn(i)-trialOn(1))*(obj.dataObj.samplingFrequencyNI/1000))+1; - endSnip = round((trialOff(i)-trialOn(1)+interDelayMs/2)*(obj.dataObj.samplingFrequencyNI/1000)); - - if endSnip>length(A) - signal = squeeze(A(startSnip:end)); - t_msS = t_ms(startSnip:end); - elseif startSnip<1 - signal =squeeze(A(1:endSnip)); - t_msS = t_ms(1:endSnip); - else - signal =squeeze(A(startSnip:endSnip)); - t_msS = t_ms(startSnip:endSnip); - end - - fDat=-1*medfilt1(signal,(obj.VST.stimDuration/4)*1000); - Th=mean(fDat(1:100:end)); - stdS = std(fDat(1:100:end)); - sdK = 0; - upTimes=t_msS(fDat(1:end-1)=Th-sdK*stdS)+trialOn(1);%+interDelayMs/2; %get real recording times - downTimes=t_msS(fDat(1:end-1)>Th-sdK*stdS & fDat(2:end)<=Th-sdK*stdS )+trialOn(1);%+interDelayMs/2; - - if length(upTimes) >1 || length(downTimes)>1 - upTimes=upTimes(1); - downTimes = downTimes(2); - end - - if length(upTimes) <1 || length(downTimes)<1 - - upTimes = trialOn(i)+10; - downTimes = trialOff(i)+10; - end - - DiodeCrosses{1,i} = upTimes; - DiodeCrosses{2,i} = downTimes; - - - end - - figure;plot(squeeze(fDat)); - hold on;xline((DiodeCrosses{1,i} - trialOn(i))*(obj.dataObj.samplingFrequencyNI/1000)) - xline((DiodeCrosses{2,i} - trialOn(i))*(obj.dataObj.samplingFrequencyNI/1000),'r') - - diodeUpCross=cell2mat(DiodeCrosses(1,:)); - diodeDownCross=cell2mat(DiodeCrosses(2,:)); - - end - end - results.diodeUpCross = diodeUpCross; - results.diodeDownCross = diodeDownCross; - - %save results in the right file - fprintf('Saving results to file.\n'); - save(obj.getAnalysisFileName,'params','diodeUpCross','diodeDownCross','Th'); - end - - function f=plotDiodeTriggers(obj) - %Plots the position of detected diode triggers - if isfile([obj.visualStimAnalysisFolder filesep 'getDiodeTriggers.mat']) - D=load([obj.visualStimAnalysisFolder filesep 'getDiodeTriggers.mat']); - else - fprintf('Missing analysis: Running getDiodeTriggers is required!');return; - end - if isfile([obj.visualStimAnalysisFolder filesep 'getSessionTime.mat']) - S=load([obj.visualStimAnalysisFolder filesep 'getSessionTime.mat']); - else - fprintf('Missing analysis: Running getSessionTime is required!');return; - end - - if ~any(isempty([D.diodeUpCross,D.diodeDownCross,obj.sessionStartTime])) - f=figure('Position',[100,100,1200,300]); - h1=plot(D.diodeUpCross,ones(1,numel(D.diodeUpCross)),'^k');hold on; - h2=plot(D.diodeDownCross,ones(1,numel(D.diodeDownCross)),'vk'); - h3=line([S.sessionStartTime;S.sessionEndTime]',[1.01;1.01]','linewidth',3);hold on; - ylim([0.99 1.02]); - l=legend([h1, h2, h3],{'diode up crossings','diode down crossings','stimulation session duration'}); - else - disp('plotting triggers requires missing variables: run getDiodeTriggers, getSessionStartTime again'); - end - end - - end - - methods (Static) - %find a specific folder in the experiment - %folderLocation=findFolderInExperiment(rootFolder,folderNamePart,params) - folderLocation=findFolderInExperiment() - Fig = PlotZScoreComparison() - - function results=isOutputAnalysis(analysisFileName,overwrite,isOutput) - %load previous results if analysis was previuosly performed and there is no need to overwrite - results=[]; - if ~overwrite - if isOutput - if isfile(analysisFileName) - fprintf('Loading saved results from file.\n'); - results=load(analysisFileName); - else - fprintf('No results for this analyis!!! Running analysis first but will not be able to load and output the results.\n'); - end - else - if isfile(analysisFileName) - fprintf('Analysis already exists (use overwrite option to recalculate).\n'); - results=false; - end - end - else - if isOutput - fprintf('Cant not calculate and return results!\n Please run without output argument first and then run again to load the results.\n'); - results=false; - end - end - end - - end -end \ No newline at end of file diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/CalculateReceptiveFields.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/CalculateReceptiveFields.m index b587d59..8759bb4 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/CalculateReceptiveFields.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/CalculateReceptiveFields.m @@ -16,7 +16,7 @@ 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 = "BootstrapPerNeuron" + params.statType string = "maxPermutationTest" params.nGrid = 9 end @@ -44,7 +44,7 @@ if params.statType == "BootstrapPerNeuron" Stats = obj.BootstrapPerNeuron; else - Stats = obj.ShufflingAnalysis; + Stats = obj.StatisticsPerNeuron; end p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); @@ -63,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 diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/PlotReceptiveFields.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/PlotReceptiveFields.m index da2169a..3328c9c 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); % Index of neuron u within the eNeuron vector - ru = find(eNeuron == 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 - %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: 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) - yt = yticks; - yt = yt(1:2:numel(yt)); - yticks(yt); - yticklabels(round(theta_y(yt,1))) + % ── 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 - j = j+1; - - if j ==size(RFuRed,1)*size(RFuRed,2)*size(RFuRed,3) + j = j + 1; % Increment tile counter + + % 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/plotRaster.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m index 47dabd9..fc14584 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m @@ -22,7 +22,7 @@ function plotRaster(obj,params) params.OneDirection string = "all" params.OneLuminosity string = "all" params.PaperFig logical = false - params.statType string = "BootstrapPerNeuron" + params.statType string = "maxPermuteTest" end @@ -31,7 +31,7 @@ function plotRaster(obj,params) if params.statType == "BootstrapPerNeuron" Stats = obj.BootstrapPerNeuron; else - Stats = obj.ShufflingAnalysis; + Stats = obj.StatisticsPerNeuron; end @@ -480,7 +480,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/@rectGridAnalysis/CalculateReceptiveFields.m b/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m index 28c86ed..b4f9410 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/CalculateReceptiveFields.m @@ -19,7 +19,7 @@ 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 = "BootstrapPerNeuron" + params.statType string = "maxPermutationTest" params.nGrid = 9 end @@ -46,7 +46,7 @@ if params.statType == "BootstrapPerNeuron" Stats = obj.BootstrapPerNeuron; else - Stats = obj.ShufflingAnalysis; + Stats = obj.StatisticsPerNeuron; end % Extract spike-sorted unit data: phy IDs, labels, and spike train matrix @@ -67,6 +67,8 @@ fprintf('No responsive neurons.\n') return end +else + respU = 1:size(goodU,2); end % Override with manually specified neuron indices if provided 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 f7436c4..e07f8ec 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m @@ -6,15 +6,15 @@ function plotRaster(obj,params) params.analysisTime = datetime('now') params.inputParams = false params.preBase = 200 - params.bin = 15 + 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 @@ -29,7 +29,7 @@ function plotRaster(obj,params) if params.statType == "BootstrapPerNeuron" Stats = obj.BootstrapPerNeuron; else - Stats = obj.ShufflingAnalysis; + Stats = obj.StatisticsPerNeuron; end directimesSorted = NeuronResp.C(:,1)'; @@ -132,7 +132,7 @@ function plotRaster(obj,params) trialsPerCath = trialsPerCath/mergeTrials; nT = nT/mergeTrials; else - Mr2=Mr(:,ur,:); + Mr2=Mr(:,u,:); mergeTrials =1; end @@ -326,10 +326,6 @@ function plotRaster(obj,params) % trialsPerCath = length(directimesSorted)/(length(unique(seqMatrix))); trials = maxRespIn*trialsPerCath+1:maxRespIn*trialsPerCath + trialsPerCath; - bin3 = 1; - trialM = BuildBurstMatrix(goodU(:,u),round(p.t/bin3),round((directimesSorted+start)/bin3),round((window)/bin3)); - TrialM = squeeze(trialM(trials,:,:))'; - chan = goodU(1,u); @@ -339,7 +335,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 diff --git a/visualStimulationAnalysis/AllExpAnalysis.asv b/visualStimulationAnalysis/AllExpAnalysis.asv deleted file mode 100644 index 2b5c58a..0000000 --- a/visualStimulationAnalysis/AllExpAnalysis.asv +++ /dev/null @@ -1,1688 +0,0 @@ -function fig = 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 -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} = '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); % 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; - spkR_MB = max(rwMB.Speed1.NeuronVals(:,:,4), [], 2); % max across directions, col 4 = resp. rate - 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; - spkR_MB = max(rwMB.Speed2.NeuronVals(:,:,4), [], 2); - 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; - spkR_RG = max(rwRG.NeuronVals(:,:,4), [], 2); - 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) - 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'}); - - 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=false, 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 4 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")(S.TableStimComp.stimulus == pairs{1}); - pair2 = S.TableStimComp.("Z-score")(S.TableStimComp.stimulus == pairs{2}); - colorAnimal = S.TableStimComp.animal(S.TableStimComp.stimulus == pairs{1}); - - % Scatter with animal-coded colour, using subsampled indices - scatter(pair1, pair2, 7, colorAnimal, ... - "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(lines(numel(categories(S.TableStimComp.animal)))) - - 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=false, 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 4 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(S.TableStimComp.stimulus == pairs{1}); - pair2 = S.TableStimComp.SpkR(S.TableStimComp.stimulus == pairs{2}); - colorAnimal = S.TableStimComp.animal(S.TableStimComp.stimulus == pairs{1}); - - scatter(pair1(randiColors), pair2(randiColors), 7, colorAnimal(randiColors), ... - "filled", "MarkerFaceAlpha", 0.4) - 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(lines(numel(categories(S.TableStimComp.animal)))) - - 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, yLegend='Responsive/total units', ... - diff=false, filled=false, Xjitter='none', Alpha=0.6); - -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]); - -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/AllExpAnalysis.m b/visualStimulationAnalysis/AllExpAnalysis.m index b69f009..609a229 100644 --- a/visualStimulationAnalysis/AllExpAnalysis.m +++ b/visualStimulationAnalysis/AllExpAnalysis.m @@ -1,4 +1,4 @@ -function fig = AllExpAnalysis(expList, Stims2Comp, params) +function [tempTable] = AllExpAnalysis(expList, Stims2Comp, params) % PlotZScoreComparison - Compare z-scores and spike rates across visual stimuli % across multiple Neuropixels recordings. % @@ -95,6 +95,7 @@ % 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 % ========================================================================= @@ -269,7 +270,7 @@ % Static / Drifting Gratings (SDG) try vsG = StaticDriftingGratingAnalysis(NP); - params.StimsPresent{4} = 'SDG'; + params.StimsPresent{4} = 'SDGm'; if isempty(vsG.VST) error('Gratings stimulus not found.\n') else @@ -498,13 +499,22 @@ % (Speed2 is faster; the convention is to use the most salient speed) zScores_MB = statsMB.Speed1.ZScoreU; pValuesMB = statsMB.Speed1.pvalsResponse; - spkR_MB = max(rwMB.Speed1.NeuronVals(:,:,4), [], 2); % max across directions, col 4 = resp. rate + 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; - spkR_MB = max(rwMB.Speed2.NeuronVals(:,:,4), [], 2); + 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 @@ -515,7 +525,11 @@ % --- Rect Grid --- zScores_RG = statsRG.ZScoreU; pValuesRG = statsRG.pvalsResponse; - spkR_RG = max(rwRG.NeuronVals(:,:,4), [], 2); + 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 --- @@ -676,12 +690,22 @@ % Append rows to longTablePairComp for this recording if any units found if ~isempty(unitIDs) - 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'}); + 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))), ... @@ -1236,7 +1260,7 @@ ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; ax.XAxis.FontSize = 8; ax.XAxis.FontName = 'helvetica'; - set(fig, 'Units', 'centimeters', 'Position', [20 20 4 6]); + 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) @@ -1336,7 +1360,7 @@ 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 4 6]); + set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); if params.PaperFig vs.printFig(fig, sprintf('spkRate-comparison-Swarm-%s-%s', ... @@ -1673,10 +1697,35 @@ {'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', ... diff --git a/visualStimulationAnalysis/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv deleted file mode 100644 index ed14b9c..0000000 --- a/visualStimulationAnalysis/RunAnalysisClass.asv +++ /dev/null @@ -1,219 +0,0 @@ -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,exNeuronsPhyID=137, selectedLum=255,oneTrial = true,PaperFig = true) %43 - vsRe.CalculateReceptiveFields('overwrite',true) - %[colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=18,allStimParamsCombined=true,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 = [87]%97 74:84 (Neurons, 96_74, ) - NP = loadNPclassFromTable(ex); %73 81 - vs = linearlyMovingBallAnalysis(NP,Session=2); - % 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('exNeuronsPhyID',288,'overwrite',true,'MergeNtrials',3,'PaperFig',true) - % % % %vs.plotCorrSpikePattern - % vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'OneDirection','up','OneLuminosity','white','MergeNtrials',1,'PaperFig',true) - - %vs.plotRaster('exNeurons',9,'AllResponsiveNeurons',false,'overwrite',true,'MergeNtrials',3,MaxVal_1=false) - %vs.CalculateReceptiveFields('overwrite',true,testConvolution=false); - % colorbarLims=vs.PlotReceptiveFields('exNeurons',82,'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); -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 -% %%[89,90,92,93,95,96,97] %Al NV and NI experiments -%[49:54,84:90,92:96] %All SDG experiments -%solve MBR -%bootsrapRespBase -AllExpAnalysis([49:54,64:85 87:97] ,{'MB','RG'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... - overwriteResponse=false,overwriteStats=true)%[49:54,57:91] %%Check why I have different array dimensions in MBR%% - -%% PSTH for all experiments -plotPSTH_MultiExp([49:54], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] - -%% -plotRaster_MultiExp([49:54,64:97], sortBy = "depth",overwrite=false,TakeTopPercentTrials=[]) - -%% Calculate spatial tuning -results= SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_diff" ,overwrite=true, topPercent = 20,useRF=true,onOff=1); - -%% Get neuron depths -getNeuronDepths([49:54,64:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates -%% Gratings - -for ex = [54 84:90] - 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); -end - -%% movie - -for ex = [90] - 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); -end - - -%% image - -for ex = [89,90,92,93,95: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('AllResponsiveNeurons',true,MergeNtrials=1,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 e70e859..29de3a8 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -5,7 +5,7 @@ %% %% Rect Grid -for ex = 69 %84:91 +for ex = [49:54,64:66,68:85 87:97] %84:91 NP = loadNPclassFromTable(ex); %73 81 vsRe = rectGridAnalysis(NP); % vsRe.getSessionTime("overwrite",true); @@ -14,45 +14,44 @@ % vsRe.getSyncedDiodeTriggers("overwrite",true); % % vsRe.plotSpatialTuningSpikes; % % vsRe.plotSpatialTuningLFP; - vsRe.ResponseWindow('overwrite',true) + %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,exNeuronsPhyID=137, selectedLum=255,oneTrial = true,PaperFig = true) %43 - vsRe.CalculateReceptiveFields('overwrite',true) - %[colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=18,allStimParamsCombined=true,PaperFig=true,overwrite=true); + %vsRe.plotRaster(MergeNtrials=1,overwrite=true,AllResponsiveNeurons=false,exNeurons=21, selectedLum=255,oneTrial = true,PaperFig = true) %43 + vsRe.CalculateReceptiveFields('overwrite',true,AllResponsiveNeurons=false) + %[colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=21,allStimParamsCombined=false,PaperFig=true,overwrite=true); %result = vsRe.BootstrapPerNeuron('overwrite',true); - result = vsRe.StatisticsPerNeuron('overwrite',true); + %result = vsRe.StatisticsPerNeuron('overwrite',true); end % vsRe.CalculateReceptiveFields -vsRe.PlotReceptiveFields("exNeurons",18) +%vsRe.PlotReceptiveFields("exNeurons",18) %% Moving ball -for ex = [96]%97 74:84 (Neurons, 96_74, ) +for ex =[49:54,64:66,68:85 87:97]%97 74:84 (Neurons, 96_74, ) NP = loadNPclassFromTable(ex); %73 81 vs = linearlyMovingBallAnalysis(NP,Session=1); % 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('exNeuronsPhyID',288,'overwrite',true,'MergeNtrials',3,'PaperFig',true) - % % % %vs.plotCorrSpikePattern - % vs.plotRaster('AllResponsiveNeurons',true,'overwrite',true,'OneDirection','up','OneLuminosity','white','MergeNtrials',1,'PaperFig',true) - - %vs.plotRaster('exNeurons',9,'AllResponsiveNeurons',false,'overwrite',true,'MergeNtrials',3,MaxVal_1=false) - %vs.CalculateReceptiveFields('overwrite',true,testConvolution=false); - % colorbarLims=vs.PlotReceptiveFields('exNeurons',82,'overwrite',true,'OneDirection','up','OneLuminosity','white','PaperFig',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,'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',82,'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.StatisticsPerNeuron('overwrite',true); end @@ -67,23 +66,24 @@ %[49:54,84:90,92:96] %All SDG experiments %solve MBR %bootsrapRespBase -AllExpAnalysis([49:54,64:85 87:97] ,{'MB','RG'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... - overwriteResponse=false,overwriteStats=true)%[49:54,57:91] %%Check why I have different array dimensions in MBR%% +[tempTableMW] = AllExpAnalysis([49:54,64:66, 68:85 87:97] ,{'SDGm','SDGs'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'SDGm','SDGs'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% %% PSTH for all experiments -plotPSTH_MultiExp([49:54], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] -%% -plotRaster_MultiExp([49:54,64:97], sortBy = "depth",overwrite=false,TakeTopPercentTrials=[]) +%% Raster for all experiment +plotRaster_MultiExp([49:54,64:66,68:85 87:97], sortBy = "peak",overwrite=false,TakeTopPercentTrials=[]) %% Calculate spatial tuning -results= SpatialTuningIndex([49:54,64:97], indexType = "L_amplitude_diff" ,overwrite=true, topPercent = 20,useRF=true,onOff=1); +results= SpatialTuningIndex([49:54,64:66, 68:85 87:97], indexType = "L_amplitude_diff" ,overwrite=false... + , topPercent = 40,useRF=true,onOff=1,unionResponsive = false,allResponsive=true, PaperFig=true); %% 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 = [49] NP = loadNPclassFromTable(ex); %73 81 vs = StaticDriftingGratingAnalysis(NP); vs.getSessionTime("overwrite",true); @@ -94,6 +94,7 @@ r = vs.ResponseWindow('overwrite',true); results = vs.ShufflingAnalysis('overwrite',true); result = vs.BootstrapPerNeuron('overwrite',true); + vs.plotRaster end %% movie diff --git a/visualStimulationAnalysis/SpatialTuningIndex.m b/visualStimulationAnalysis/SpatialTuningIndex.m index 08673a7..7bce7e0 100644 --- a/visualStimulationAnalysis/SpatialTuningIndex.m +++ b/visualStimulationAnalysis/SpatialTuningIndex.m @@ -21,20 +21,48 @@ % 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 + 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. end -% Guard: prefDir requires useRF — it operates on RFuSTDirSizeLum which is -% only available in the RF path +% ------------------------------------------------------------------------- +% 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 % ------------------------------------------------------------------------- @@ -59,15 +87,15 @@ end saveDir = [p '\Combined_lizard_analysis']; -% Build filename encoding experiment range, stim types, RF mode, and prefDir mode -% so that different parameter combinations never share a cache file -stimLabel = strjoin(params.stimTypes, '-'); -rfLabel = ''; -if params.useRF, rfLabel = '_RF'; end -prefLabel = ''; -if params.prefDir, prefLabel = '_prefDir'; end -nameOfFile = sprintf('\\Ex_%d-%d_SpatialTuningIndex_%s%s%s.mat', ... - exList(1), exList(end), stimLabel, rfLabel, prefLabel); +% 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 @@ -95,8 +123,6 @@ nStim = numel(params.stimTypes); % Guard: useRF must apply to all stim types — mixed inputs are not allowed - % because RF (convolution-based) and gridSpikeRate (trial-binned) measures - % are not directly comparable and must not be mixed across stim types if params.useRF supportedRF = ["rectGrid", "linearlyMovingBall"]; unsupported = params.stimTypes(~ismember(params.stimTypes, supportedRF)); @@ -163,7 +189,7 @@ if params.statType == "BootstrapPerNeuron" Stats = obj_s.BootstrapPerNeuron; elseif params.statType == "maxPermuteTest" - Stats = obj_s.StatisticsPerNeuron; + Stats = obj_s.StatisticsPerNeuron; else Stats = obj_s.ShufflingAnalysis; end @@ -195,26 +221,78 @@ end % ---------------------------------------------------------- - % Keep only neurons responsive to ALL stim types (intersection) + % 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 % ---------------------------------------------------------- - sharedPhyIDs = respPhyIDs_all{1}; - for s = 2:nStim - sharedPhyIDs = intersect(sharedPhyIDs, respPhyIDs_all{s}); - end + if ~params.allResponsive && ~params.unionResponsive - if isempty(sharedPhyIDs) - fprintf(' No neurons responsive to all stim types in exp %d — skipping.\n', ex); - continue - end + % 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 - fprintf(' %d neuron(s) responsive to all stim types in exp %d.\n', numel(sharedPhyIDs), ex); + % 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); + 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 @@ -255,7 +333,7 @@ % ------------------------------------------------- % Read RF dimensions upfront — used by both prefDir and default paths - nDir_rf = size(S_rf.RFuSTDirSizeLum, 1); + 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 @@ -274,8 +352,7 @@ % NeuronVals: [nGoodUnits, nConditions, nFeatures] % dim3=1: spike rate % dim3=6: direction in radians - % dim 1 of NeuronVals indexes ALL good units, - % so we need to map via respU_all to get responsive ones. + % dim 1 of NeuronVals indexes ALL good units. % % Speed used during RF computation may differ from % params.speed if only one speed was recorded — @@ -296,10 +373,9 @@ % 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)); + nGoodUnits = size(NeuronVals, 1); + maxRespPerDir = zeros(nGoodUnits, numel(uDirs)); for d = 1:numel(uDirs) - % Logical mask: conditions with this direction dirMask = dirLabels(1,:) == uDirs(d); maxRespPerDir(:,d) = max(spikeRates(:, dirMask), [], 2); end @@ -307,12 +383,12 @@ % Preferred direction index (1:nDir) for each good unit [~, prefDirIdxAllGood] = max(maxRespPerDir, [], 2); % [nGoodUnits, 1] - % Map onto responsive neurons of this stim type - prefDirIdxResp = prefDirIdxAllGood(respU_all{s}); % [nRespUnits, 1] + - % Map onto shared neurons - [~, neuronIdx] = ismember(sharedPhyIDs, phy_IDg(respU_all{s})); - prefDirIdxShared = prefDirIdxResp(neuronIdx); % [nShared, 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', ... @@ -329,9 +405,10 @@ end % rfRaw: [nSize, nLum, rfY, rfX, nShared] - % Shuffle: select same responsive+shared neuron subset - rfShuff = S_rf.RFuShuffST; % [rfY, rfX, nRespUnits] — already responsive-only - rfShuff = rfShuff(:,:, neuronIdx); % [rfY, rfX, nShared] — select shared neurons + % 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; @@ -392,8 +469,8 @@ % ------------------------------------------------- % Select on or off response (dim 1); keep remaining dims explicit - rfFull = S_rf.RFu( params.onOff, :, :, :, :, :); % [1, nLums, nSize, screenRed, screenRed, nN] - rfShuffFull = S_rf.RFuShuffMean(params.onOff, :, :, :, :, :); % same + rfFull = S_rf.RFu( params.onOff, :, :, :, :, :); + rfShuffFull = S_rf.RFuShuffMean(params.onOff, :, :, :, :, :); nLums_rf = size(rfFull, 2); nSize_rf = size(rfFull, 3); @@ -413,16 +490,13 @@ for bi = 1:nGrid for bj = 1:nGrid - % Row and column pixel indices for this spatial block rr = (bi-1)*blockSize + (1:blockSize); cc = (bj-1)*blockSize + (1:blockSize); - % Average over spatial dims 3 and 4 % [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); - % Reshape and permute to [nN, nSize, nLums] % 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]); @@ -443,7 +517,7 @@ % Skipped for linearlyMovingBall+prefDir since neuronIdx % was already applied per-neuron inside that branch. if ~alreadyIndexed - [~, neuronIdx] = ismember(sharedPhyIDs, phy_IDg(respU_all{s})); + [~, neuronIdx] = ismember(sharedPhyIDs, phy_IDg); gridSpikeRateSelected = gridSpikeRateSelected(:,:,neuronIdx,:,:); gridShuffMean = gridShuffMean(:,:,neuronIdx,:,:); end @@ -451,8 +525,6 @@ else % ---------------------------------------------------------- % Standard path: use gridSpikeRate / gridSpikeRateShuff - % Note: linearlyMovingBall may have structural zeros due to - % Y-offset bug in CalculateReceptiveFields — use useRF=true % ---------------------------------------------------------- if stimType == "linearlyMovingBall" warning(['gridSpikeRate for linearlyMovingBall may contain structural zeros ' ... @@ -465,36 +537,31 @@ switch stimType case "rectGrid" - % gridSpikeRate: [nGrid, nGrid, nN, 2, nSize, nLum] - % gridSpikeRateShuff: [nGrid, nGrid, nN, nShuffle, 2, nSize, nLum] gridSpikeRateSelected = gridSpikeRate(:,:,:,params.onOff,:,:); gridShuffSelected = gridSpikeRateShuff(:,:,:,:,params.onOff,:,:); - % Remove onOff singleton: [9,9,nN,1,nSize,nLum] -> [9,9,nN,nSize,nLum] gridSpikeRateSelected = reshape(gridSpikeRateSelected, ... [size(gridSpikeRateSelected,1), size(gridSpikeRateSelected,2), ... size(gridSpikeRateSelected,3), size(gridSpikeRateSelected,5), ... size(gridSpikeRateSelected,6)]); - % Remove onOff singleton: [9,9,nN,nShuffle,1,nSize,nLum] -> [9,9,nN,nShuffle,nSize,nLum] 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; % [nGrid, nGrid, nN, nSize, nLum] - gridShuffSelected = gridSpikeRateShuff; % [nGrid, nGrid, nN, nShuffle, nSize, nLum] + gridSpikeRateSelected = gridSpikeRate; + gridShuffSelected = gridSpikeRateShuff; end - % Map sharedPhyIDs onto indices of this stim's responsive neurons [~, 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); % [nGrid, nGrid, nN, 1, nSize, nLum] + gridShuffMean = mean(gridShuffSelected, 4); end % useRF / standard path @@ -531,23 +598,21 @@ for u = 1:nN - rateVec = rateFlat(:, u); % spike rate at each grid cell - rateVecShuff = rateFlatShuff(:, u); % shuffle baseline at each grid cell + 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); - % Indices of top and rest cells for real and shuffle maps 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 for real and shuffle. - % Guard against empty restIdx: occurs when topPercent is large - % enough that all cells exceed the threshold (all tied at zero). - % In that case there is no spatial contrast — set meanRest = 0. + % 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) @@ -579,15 +644,13 @@ L_amplitude_ratio(u) = ((meanTop - meanRest) / meanAll) / shuffleNorm; % L_geometric: clustering of top cells (low spread = high tuning) - % Convert linear indices to [row, col] grid coordinates [rowIdx, colIdx] = ind2sub([nGrid nGrid], topIdx); - [rowIdxShuff, colIdxShuff] = ind2sub([nGrid nGrid], topIdxShuff); + [rowIdxShuff, colIdxShuff] = ind2sub([nGrid nGrid], topIdxShuff); %#ok - % Mean pairwise distance among top cells, normalised by max possible distance if size(rowIdx, 1) > 1 D = mean(pdist([rowIdx, colIdx], 'euclidean')) / maxDist; else - D = 0; % single top cell: perfectly localised by definition + D = 0; end if size(rowIdxShuff, 1) > 1 DShuff = mean(pdist([rowIdxShuff, colIdxShuff], 'euclidean')) / maxDist; @@ -595,7 +658,7 @@ DShuff = 0; end - % Shuffle-corrected geometric index: positive = more clustered than chance + % Shuffle-corrected geometric index L_geometric(u) = (1 - D) - (1 - DShuff); % L_combined: product of amplitude and geometric indices @@ -603,7 +666,7 @@ end % neuron loop - % Check for NaN indices and report their source for debugging + % 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) @@ -621,10 +684,9 @@ rows.experimentNum = categorical(repmat(ex, nN, 1)); rows.animal = categorical(repmat({animalName}, nN, 1)); rows.NeurID = (1:nN)'; - % Store actual phy cluster ID for each neuron. - % After neuronIdx selection, neuron u in dim 3 corresponds to sharedPhyIDs(u). + % phyID: sharedPhyIDs is stim-specific when allResponsive=true rows.phyID = sharedPhyIDs(:); - rows.onOff = repmat(params.onOff, nN, 1); % meaningful for rectGrid; stored for consistency + rows.onOff = repmat(params.onOff, nN, 1); rows.sizeIdx = repmat(si, nN, 1); rows.lumIdx = repmat(li, nN, 1); @@ -660,58 +722,42 @@ % separately. Uses the same condition filter as the plot (onOff/sizeIdx/lumIdx). % ========================================================================= -% Filter to the requested condition — same as plot filter -idxCond = tbl.onOff == params.onOff & ... - tbl.sizeIdx == params.sizeIdx & ... - tbl.lumIdx == params.lumIdx; - +idxCond = tbl.onOff == params.onOff & ... + tbl.sizeIdx == params.sizeIdx & ... + tbl.lumIdx == params.lumIdx; tblCond = tbl(idxCond, :); -tblCond.value = tblCond.(params.indexType); % column to rank on +tblCond.value = tblCond.(params.indexType); -% Identify the stim label for SB (rectGrid) and MB (linearlyMovingBall) sbLabel = 'rectGrid'; mbLabel = 'linearlyMovingBall'; -% Build one top-unit table per stim type for tt = 1:2 - if tt == 1 - stimLabel = sbLabel; - outField = 'topUnitsSB'; + stimLabel = sbLabel; outField = 'topUnitsSB'; else - stimLabel = mbLabel; - outField = 'topUnitsMB'; + stimLabel = mbLabel; outField = 'topUnitsMB'; end - % Check this stim type was actually computed if ~any(tblCond.stimulus == stimLabel) fprintf(' No data for %s — skipping top unit table.\n', stimLabel); results.(outField) = table(); continue end - % Subset to this stim type - tblStim = tblCond(tblCond.stimulus == stimLabel, :); - - % Global threshold: top 20% across all animals and insertions + tblStim = tblCond(tblCond.stimulus == stimLabel, :); globalThreshold = prctile(tblStim.value, 80); + topMask = tblStim.value >= globalThreshold; + tblTop = sortrows(tblStim(topMask, :), 'value', 'descend'); - % Select top units and sort descending by index value - topMask = tblStim.value >= globalThreshold; - tblTop = sortrows(tblStim(topMask, :), 'value', 'descend'); - - % Build clean output table with one row per top unit - outTbl = table(); - outTbl.animal = tblTop.animal; + outTbl = table(); + outTbl.animal = tblTop.animal; outTbl.experimentNum = tblTop.experimentNum; - outTbl.phyID = tblTop.phyID; % phy cluster ID (Kilosort/phy) - outTbl.indexValue = tblTop.value; % spatial tuning index value + 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 % ========================================================================= @@ -720,81 +766,139 @@ 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; + 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 - tblPlot = tbl(idx, :); - tblPlot.value = tblPlot.(params.indexType); % select which index to plot + pairs = {char(params.stimTypes(1)), char(params.stimTypes(2))}; % ---------------------------------------------------------- - % Compute hierarchical bootstrap p-value for the comparison pair + % Compute p-values: + % allResponsive=false: paired hierBoot on per-neuron differences + % allResponsive=true: two-sample hierBoot on each group separately % ---------------------------------------------------------- - pairs = {char(params.stimTypes(1)), char(params.stimTypes(2))}; % 1x2 cell - ps = zeros(size(pairs, 1), 1); j = 1; for i = 1:size(pairs, 1) - diffs = []; - insers = []; - animals = []; - % Compute per-neuron differences within each insertion - for ins = unique(tblPlot.experimentNum)' - idx1 = tblPlot.experimentNum == categorical(ins) & tblPlot.stimulus == pairs{i,1}; - idx2 = tblPlot.experimentNum == categorical(ins) & tblPlot.stimulus == pairs{i,2}; - - V1 = tblPlot.value(idx1); - V2 = tblPlot.value(idx2); - - if isempty(V1) || isempty(V2) - continue + 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 - 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 - if isempty(diffs) - ps(j) = NaN; else - % Hierarchical bootstrap: respects nested structure - % (neurons within insertions within animals) - bootDiff = hierBoot(diffs, params.nBoot, insers, animals); - ps(j) = mean(bootDiff <= 0); % one-tailed p: P(stim1 <= stim2) + % --------------------------------------------------------- + % 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'); % data max for y-axis scaling + V1max = max(tblPlot.value, [], 'omitnan'); fprintf('Length of ps: %d\n', numel(ps)); fprintf('Size of pairs: %s\n', num2str(size(pairs))); - tblPlot.insertion = tblPlot.experimentNum; % rename for plotting compatibility + % 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 = false, ... - Alpha = params.Alpha, ... - plotMeanSem = true); - - title(sprintf('%s — %s (onOff=%d, size=%d, lum=%d, RF=%d, prefDir=%d)', ... - params.indexType, strjoin(params.stimTypes, '/'), ... - params.onOff, params.sizeIdx, params.lumIdx, params.useRF, params.prefDir), ... - 'FontSize', 9); - - % Save publication-quality figure if requested + 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', ... - params.indexType, strjoin(params.stimTypes, '-'), params.useRF, params.prefDir), ... + 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 diff --git a/visualStimulationAnalysis/plotPSTH_MultiExp.m b/visualStimulationAnalysis/plotPSTH_MultiExp.m index e8ecb3a..8925746 100644 --- a/visualStimulationAnalysis/plotPSTH_MultiExp.m +++ b/visualStimulationAnalysis/plotPSTH_MultiExp.m @@ -5,7 +5,7 @@ function plotPSTH_MultiExp(exList, params) params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] params.binWidth double = 10 params.smooth double = 0 % smoothing window in ms (0 = no smoothing) - params.statType string = "BootstrapPerNeuron" + params.statType string = "maxPermuteTest" params.speed string = "max" params.alpha double = 0.05 params.shadeSTD logical = true @@ -139,8 +139,10 @@ function plotPSTH_MultiExp(exList, params) if params.statType == "BootstrapPerNeuron" Stats = obj.BootstrapPerNeuron; + elseif params.statType == "maxPermuteTest" + Stats = obj.StatisticsPerNeuron; else - Stats = obj.ShufflingAnalysis; + Stats = obj.ShufflingAnalysis; end if params.speed ~= "max" && isequal(obj.stimName,'linearlyMovingBall') @@ -445,7 +447,7 @@ function plotPSTH_MultiExp(exList, params) xlim(ax, [tAxisPlot(1) tAxisPlot(end)]); ylim(ax, yLims); -legend(legendHandles, legendLabels, 'Location', 'northeast', ... +legend(legendHandles, legendLabels, 'Location', 'northwest', ... 'FontName', 'helvetica', 'FontSize', 7); ax.FontName = 'helvetica'; @@ -455,7 +457,7 @@ function plotPSTH_MultiExp(exList, params) hold(ax, 'off'); sgtitle(sprintf('N = %d', numel(exList)), 'FontName', 'helvetica', 'FontSize', 11); -set(fig, 'Units', 'centimeters', 'Position', [20 20 8 6]); +set(fig, 'Units', 'centimeters', 'Position', [20 20 7 4]); if params.PaperFig vs_first.printFig(fig, sprintf('PSTH-depth-%s-%s', ... diff --git a/visualStimulationAnalysis/plotRaster_MultiExp.m b/visualStimulationAnalysis/plotRaster_MultiExp.m index aa5e93c..c2f822f 100644 --- a/visualStimulationAnalysis/plotRaster_MultiExp.m +++ b/visualStimulationAnalysis/plotRaster_MultiExp.m @@ -1,83 +1,120 @@ 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 or recording depth. +% 5. Displays an imagesc raster for each stimulus type side-by-side, +% with a shared x-label and a single colorbar. +% +% ------------------------------------------------------------------------- +% BUGS FIXED +% ------------------------------------------------------------------------- +% BUG 1 — Sort-by-peak double-smoothing +% ConvBurstMatrix was applied in-place and the smoothed version stored +% back into rasterAll, causing neurons to be smoothed twice when +% params.smooth > 0. Fixed: peak-finding uses a local copy (dataForSort); +% rasterAll always stores the original data. +% +% BUG 2 — Wrong colour limits for zScore=true + colormap="gray" +% The else-branch in the tile loop overwrote cLims with raw firing-rate +% percentiles, ignoring climNeg. Fixed: cLims and colormap are computed +% once before the tile loop and reused. +% +% BUG 3 — Stim-offset xline in wrong units +% xline used the ms value on a seconds axis. Fixed: xline now uses +% stimDurAll(s) / 1000. arguments - exList double - params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] - params.binWidth double = 10 - params.smooth double = 0 - params.statType string = "BootstrapPerNeuron" - params.speed string = "max" - params.alpha double = 0.05 - params.postStim double = 500 - params.preBase double = 200 - params.overwrite logical = false - params.TakeTopPercentTrials double = 0.3 - params.zScore logical = true % default true — more meaningful for raster - params.sortBy string = "peak" % "peak" = sort by peak response time, "depth" = sort by depth - params.PaperFig logical = false - params.climPrctile double = 90 % percentile for color limit — lower = more contrast - params.climNeg double = 0 % fixed negative z-score limit (absolute value) - params.colormap string = "gray" + exList double % vector of experiment IDs to include + params.stimTypes (1,:) string = ["rectGrid","linearlyMovingBall"] % stimulus types — one tile each + 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; + % when useCompleteWindow=true this is + % added as a post-offset buffer on top + % of the actual stimulus duration + params.preBase double = 200 % pre-stimulus baseline in ms + params.overwrite logical = false % if true, recompute even if cache exists + params.TakeTopPercentTrials double = 0.3 % top fraction of trials to keep by mean + % firing rate; set empty to keep all + params.zScore logical = true % z-score each neuron using its baseline + params.sortBy string = "peak" % "peak" | "depth" | "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 sorting + params.useCompleteWindow logical = true % if true, read stimulus duration from + % NeuronResp.(fieldName).stimDur and use + % params.postStim as a post-offset buffer end % ------------------------------------------------------------------------- -% Load depth info if sorting by depth +% 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') + if ~exist(depthFile, 'file') % abort early if depth file is missing error('NeuronDepths.mat not found. Run getNeuronDepths() first.'); end - D = load(depthFile); - depthTable = D.depthTable; + D = load(depthFile); % load MAT file containing depth info + depthTable = D.depthTable; % table with columns: Experiment, Unit, Depth_um end % ------------------------------------------------------------------------- -% Build save path +% Derive save/load path from the first experiment % ------------------------------------------------------------------------- -NP_first = loadNPclassFromTable(exList(1)); -vs_first = linearlyMovingBallAnalysis(NP_first); +NP_first = loadNPclassFromTable(exList(1)); % load NP object for first experiment (path only) +vs_first = linearlyMovingBallAnalysis(NP_first); % build analysis object to access file-system path -p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); -p = [p 'lizards']; -if ~exist([p '\Combined_lizard_analysis'], 'dir') +p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); % root directory up to 'lizards' token +p = [p 'lizards']; % reconstruct path including 'lizards' +if ~exist([p '\Combined_lizard_analysis'], 'dir') % create output folder if absent cd(p) mkdir Combined_lizard_analysis end -saveDir = [p '\Combined_lizard_analysis']; +saveDir = [p '\Combined_lizard_analysis']; % full path to output directory -stimLabel = strjoin(params.stimTypes, '-'); -nameOfFile = sprintf('\\Ex_%d-%d_Raster_%s.mat', exList(1), exList(end), stimLabel); +stimLabel = strjoin(params.stimTypes, '-'); % e.g. "rectGrid-linearlyMovingBall" +nameOfFile = sprintf('\\Ex_%d-%d_Raster_%s.mat', ... % unique filename from experiment range + stim types + exList(1), exList(end), stimLabel); % ------------------------------------------------------------------------- -% Decide whether to recompute or load +% Decide whether to recompute or reload from cache % ------------------------------------------------------------------------- -if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite - S = load([saveDir nameOfFile]); - if isequal(S.expList, exList) +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) % verify cached experiment list matches fprintf('Loading saved raster data from:\n %s\n', [saveDir nameOfFile]); - forloop = false; + forloop = false; % skip computation else fprintf('Experiment list mismatch — recomputing.\n'); - forloop = true; + forloop = true; % list differs: recompute end else - forloop = true; + forloop = true; % no cache or overwrite requested end % ========================================================================= -% EXPERIMENT LOOP +% EXPERIMENT LOOP — collect responsive neurons across all experiments % ========================================================================= if forloop - nStim = numel(params.stimTypes); - nExp = numel(exList); + nStim = numel(params.stimTypes); % number of stimulus conditions + nExp = numel(exList); % number of experiments - % rasterAll{s} grows one row per responsive neuron across all experiments - % each row = mean PSTH of one neuron in spk/s (or z-score) - rasterAll = cell(1, nStim); % nNeurons x nBins - depthAll = cell(1, nStim); % nNeurons x 1 — depth of each neuron - expAll = cell(1, nStim); % nNeurons x 1 — which experiment each neuron came from + % Accumulators: one entry per stimulus type, growing one row per neuron + rasterAll = cell(1, nStim); % nNeurons x nBins PSTH matrix per stim + depthAll = cell(1, nStim); % recording depth (um) per neuron + expAll = cell(1, nStim); % experiment ID per neuron row for s = 1:nStim rasterAll{s} = []; @@ -85,27 +122,84 @@ function plotRaster_MultiExp(exList, params) expAll{s} = []; end - lockedPreBase = []; - lockedNBins = []; - lockedEdges = []; - tAxis = []; + % lockedPreBase is shared (same baseline for all stimuli). + % Time-axis variables are per-stimulus (cell/array) because each + % stimulus can have a different total window when useCompleteWindow=true. + lockedPreBase = []; % baseline duration (ms) — locked on first exp + lockedEdges = cell(1, nStim); % bin edges (ms) — one set per stimulus + lockedNBins = zeros(1, nStim); % number of bins per stimulus + tAxis = cell(1, nStim); % left bin edge (ms) per stimulus + stimDurAll = zeros(1, nStim); % stim duration (ms) per stimulus for xline + + % Pre-scan: find the shortest stimulus duration per stimulus type. + % All experiments are truncated to this minimum so that no trial + % window exceeds what every session can provide. + minStimDur = inf(1, nStim); % one minimum per stimulus type + + for ei = 1:nExp + for s = 1:nStim + try + NPtmp = loadNPclassFromTable(exList(ei)); + switch params.stimTypes(s) + case "rectGrid"; objTmp = rectGridAnalysis(NPtmp); + case "linearlyMovingBall"; objTmp = linearlyMovingBallAnalysis(NPtmp); + case "StaticGrating"; objTmp = StaticDriftingGratingAnalysis(NPtmp); + case "MovingGrating"; objTmp = StaticDriftingGratingAnalysis(NPtmp); + end + NRtmp = objTmp.ResponseWindow; + + % Resolve fieldName using the same logic as the main loop + if params.speed ~= "max" && isequal(objTmp.stimName, 'linearlyMovingBall') + fn = 'Speed2'; + elseif isequal(objTmp.stimName, 'linearlyMovingBall') + fn = 'Speed1'; + elseif isequal(params.stimTypes(s), 'StaticGrating') + fn = 'Static'; + elseif isequal(params.stimTypes(s), 'MovingGrating') + fn = 'Moving'; + else + fn = ''; % rectGrid: flat struct, no sub-field + end + + try + dur = NRtmp.(fn).stimDur; % named sub-field (e.g. Speed1, Moving) + catch + dur = NRtmp.stimDur; % fallback: flat struct (e.g. rectGrid) + end + + minStimDur(s) = min(minStimDur(s), dur); % keep shortest duration for this stimulus + 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)); + end + fprintf('\n'); + % ----------------------------------------------------------------- + % Main loop: experiments x stimulus types + % ----------------------------------------------------------------- for ei = 1:nExp ex = exList(ei); fprintf('\n=== Experiment %d ===\n', ex); try - NP = loadNPclassFromTable(ex); + NP = loadNPclassFromTable(ex); % load Neuropixels data object catch ME warning('Could not load experiment %d: %s', ex, ME.message); - continue + continue % skip experiment on load failure end for s = 1:nStim - stimType = params.stimTypes(s); + stimType = params.stimTypes(s); % current stimulus type string + % Build stimulus-specific analysis object try switch stimType case "rectGrid" @@ -121,61 +215,85 @@ function plotRaster_MultiExp(exList, params) end catch ME warning('Could not build %s for exp %d: %s', stimType, ex, ME.message); - continue + continue % skip this stim/exp on failure end - NeuronResp = obj.ResponseWindow; + NeuronResp = obj.ResponseWindow; % full response-window struct for this stimulus + % Select the correct statistics struct if params.statType == "BootstrapPerNeuron" - Stats = obj.BootstrapPerNeuron; + Stats = obj.BootstrapPerNeuron; % bootstrap-based p-values else - Stats = obj.ShufflingAnalysis; + Stats = obj.StatisticsPerNeuron; % default: permutation-test p-values end - % Resolve field name and stim start - fieldName = ''; - startStim = 0; + % Resolve sub-field name and any intra-window stim onset offset + % SUGGESTION: a switch/case or a method on each analysis class + % would be cleaner than chained if-elseif here. + fieldName = ''; % sub-field key into Stats / NeuronResp + startStim = 0; % ms offset to align stim onset to t = 0 if params.speed ~= "max" && isequal(obj.stimName, 'linearlyMovingBall') - fieldName = 'Speed2'; + fieldName = 'Speed2'; % slower ball-speed condition elseif isequal(obj.stimName, 'linearlyMovingBall') - fieldName = 'Speed1'; + fieldName = 'Speed1'; % fastest (max) ball-speed condition elseif isequal(stimType, 'StaticGrating') - fieldName = 'Static'; + fieldName = 'Static'; % static grating sub-field elseif isequal(stimType, 'MovingGrating') - fieldName = 'Moving'; - startStim = obj.VST.static_time * 1000; + fieldName = 'Moving'; % moving grating sub-field + startStim = obj.VST.static_time * 1000; % moving phase starts after static (s -> ms) end - p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); - label = string(p_sort.label'); - goodU = p_sort.ic(:, label == 'good'); + % Convert Phy sorting output to time x unit matrix + p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); % Phy -> tIc format + label = string(p_sort.label'); % quality label per unit + goodU = p_sort.ic(:, label == 'good'); % keep only manually curated 'good' units + % Extract response p-values; try named sub-field first try - pvals = Stats.(fieldName).pvalsResponse; + pvals = Stats.(fieldName).pvalsResponse; % stim-specific p-values catch - pvals = Stats.pvalsResponse; + pvals = Stats.pvalsResponse; % fallback: flat struct end + % Extract stimulus onset times from the condition matrix try - C = NeuronResp.(fieldName).C; + C = NeuronResp.(fieldName).C; % condition matrix for this sub-field catch - C = NeuronResp.C; + C = NeuronResp.C; % fallback end - directimesSorted = C(:, 1)' + startStim; + directimesSorted = C(:, 1)' + startStim; % stim onset times in ms + + % ---------------------------------------------------------- + % Determine the total window for this stimulus + % ---------------------------------------------------------- + preBase = params.preBase; % baseline duration (ms) - preBase = params.preBase; - windowTotal = preBase + params.postStim; + if params.useCompleteWindow + rawStimDur_ms = minStimDur(s); % shortest duration for this stimulus type + windowTotal = preBase + rawStimDur_ms + params.postStim; % baseline + truncated stim + post-offset buffer + else + rawStimDur_ms = params.postStim; % fixed window as before + windowTotal = preBase + params.postStim; + end + % Lock the baseline duration on the very first experiment if isempty(lockedPreBase) - lockedPreBase = preBase; - lockedEdges = 0 : params.binWidth : windowTotal; - lockedNBins = numel(lockedEdges) - 1; - tAxis = lockedEdges(1:end-1); - fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... - lockedPreBase, params.postStim, lockedNBins); + lockedPreBase = preBase; % shared across all stimuli + end + + % Lock bin edges per stimulus on the first experiment that + % provides them — all subsequent experiments use the same edges + % so that every row in rasterAll{s} has identical length. + if isempty(lockedEdges{s}) + lockedEdges{s} = 0 : params.binWidth : windowTotal; % bin edges from 0 to windowTotal (ms) + lockedNBins(s) = numel(lockedEdges{s}) - 1; % number of bins for this stimulus + tAxis{s} = lockedEdges{s}(1:end-1); % left edge of each bin (ms) + stimDurAll(s) = rawStimDur_ms; % store stim duration for xline in plot + fprintf(' [%s] Locked window: preBase=%d ms, stimDur=%.0f ms, nBins=%d\n', ... + stimType, lockedPreBase, rawStimDur_ms, lockedNBins(s)); end - eNeurons = find(pvals < params.alpha); + eNeurons = find(pvals < params.alpha); % indices of significantly responsive neurons if isempty(eNeurons) fprintf(' [%s] No responsive neurons in exp %d.\n', stimType, ex); @@ -185,288 +303,333 @@ function plotRaster_MultiExp(exList, params) fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', stimType, numel(eNeurons), ex); % ---------------------------------------------------------- - % Build per-neuron PSTH + % Build per-neuron PSTH and append to rasterAll % ---------------------------------------------------------- for ni = 1:numel(eNeurons) - u = eNeurons(ni); + u = eNeurons(ni); % index of this neuron in the 'good' list + + % BuildBurstMatrix returns a trials x time-samples binary matrix (1 ms resolution) MRhist = BuildBurstMatrix( ... - goodU(:, u), ... - round(p_sort.t), ... - round(directimesSorted - lockedPreBase), ... - round(windowTotal)); - MRhist = squeeze(MRhist); + goodU(:, u), ... % binary spike train for this unit + round(p_sort.t), ... % sample timestamps (rounded to avoid float errors) + round(directimesSorted - lockedPreBase), ... % trial start = stim onset minus baseline + round(windowTotal)); % window duration (ms, integer) + MRhist = squeeze(MRhist); % collapse singleton dimensions + % Optionally restrict to the highest-firing trials if ~isempty(params.TakeTopPercentTrials) - MeanTrial = mean(MRhist, 2); - [~, ind] = sort(MeanTrial, 'descend'); - takeTrials = ind(1:round(numel(MeanTrial)*params.TakeTopPercentTrials)); - MRhist = MRhist(takeTrials, :); + MeanTrial = mean(MRhist, 2); % mean spike count per trial (full window) + % SUGGESTION: computing the mean over the post-stim + % window only (columns where tAxis{s} >= lockedPreBase) + % would avoid selecting high-baseline trials over + % high-response trials. + [~, ind] = sort(MeanTrial, 'descend'); % rank trials by mean activity + takeTrials = ind(1 : round(numel(MeanTrial) * params.TakeTopPercentTrials)); + MRhist = MRhist(takeTrials, :); % keep top fraction of trials end - nTrials = size(MRhist, 1); - spikeTimes = repmat((1:size(MRhist,2)), nTrials, 1); - spikeTimes = spikeTimes(logical(MRhist)); - counts = histcounts(spikeTimes, lockedEdges); - neuronPSTH = (counts / (params.binWidth * nTrials)) * 1000; % spk/s + nTrials = size(MRhist, 1); % number of trials used + spikeTimes = repmat((1:size(MRhist,2)), nTrials, 1); % bin index for every position in MRhist + spikeTimes = spikeTimes(logical(MRhist)); % keep only bins where spikes occurred + counts = histcounts(spikeTimes, lockedEdges{s}); % spike count per bin (per-stimulus edges) + neuronPSTH = (counts / (params.binWidth * nTrials)) * 1000; % convert counts -> spk/s - % Z-score using baseline + % Z-score using the pre-stimulus baseline if params.zScore - baselineBins = tAxis < lockedPreBase; - bMean = mean(neuronPSTH(baselineBins)); - bStd = std(neuronPSTH(baselineBins)); + baselineBins = tAxis{s} < lockedPreBase; % logical mask for baseline bins (per-stim tAxis) + bMean = mean(neuronPSTH(baselineBins)); % baseline mean firing rate + bStd = std(neuronPSTH(baselineBins)); % baseline standard deviation if bStd > 0 - neuronPSTH = (neuronPSTH - bMean) / bStd; + neuronPSTH = (neuronPSTH - bMean) / bStd; % z-score else - continue % skip neuron if baseline std is zero + % SUGGESTION: silently dropping zero-SD neurons + % biases the population toward cells with measurable + % spontaneous activity. Consider logging dropped + % units or using a minimum-SD floor. + continue % skip: silent baseline -> undefined z-score end end - % Smooth if requested + % Smooth PSTH if requested if params.smooth > 0 - smoothBins = round(params.smooth / params.binWidth); - neuronPSTH = smoothdata(neuronPSTH, 'gaussian', smoothBins); + smoothBins = round(params.smooth / params.binWidth); % smoothing SD in bins + neuronPSTH = smoothdata(neuronPSTH, 'gaussian', smoothBins); % Gaussian smooth end - % Append neuron row - rasterAll{s} = [rasterAll{s}; neuronPSTH]; + rasterAll{s} = [rasterAll{s}; neuronPSTH]; % append neuron as a new row - % Get depth for this neuron + % Store recording depth (needed only for depth-sorted plots) if params.sortBy == "depth" depthRow = depthTable.Experiment == ex & depthTable.Unit == u; if any(depthRow) - depthAll{s}(end+1) = depthTable.Depth_um(depthRow); + depthAll{s}(end+1) = depthTable.Depth_um(depthRow); % depth in um else - depthAll{s}(end+1) = NaN; + depthAll{s}(end+1) = NaN; % not found in depth table end else - depthAll{s}(end+1) = NaN; + depthAll{s}(end+1) = NaN; % depth unused; keeps vectors aligned end - expAll{s}(end+1) = ex; - end + expAll{s}(end+1) = ex; % record source experiment - end % stim loop + end % neuron loop + + end % stimulus loop end % experiment loop % ------------------------------------------------------------------ - % Save + % Save processed data to disk % ------------------------------------------------------------------ - S.expList = exList; - S.lockedEdges = lockedEdges; - S.lockedPreBase = lockedPreBase; - S.params = params; + S.expList = exList; % saved for validation on reload + S.lockedEdges = lockedEdges; % cell array of per-stimulus bin edges + S.lockedPreBase = lockedPreBase; % shared baseline duration + S.stimDurAll = stimDurAll; % per-stimulus stim duration for xline + S.params = params; % full parameter set alongside data for s = 1:numel(params.stimTypes) - stimField = matlab.lang.makeValidName(params.stimTypes(s)); - S.(sprintf('%s_raster', stimField)) = rasterAll{s}; - S.(sprintf('%s_depth', stimField)) = depthAll{s}; - S.(sprintf('%s_exp', stimField)) = expAll{s}; + stimField = matlab.lang.makeValidName(params.stimTypes(s)); % valid MATLAB struct field name + S.(sprintf('%s_raster', stimField)) = rasterAll{s}; + S.(sprintf('%s_depth', stimField)) = depthAll{s}; + S.(sprintf('%s_exp', stimField)) = expAll{s}; end save([saveDir nameOfFile], '-struct', 'S'); fprintf('\nSaved raster data to:\n %s\n', [saveDir nameOfFile]); else - % Load from disk - lockedEdges = S.lockedEdges; - lockedPreBase = S.lockedPreBase; + % ------------------------------------------------------------------ + % Reload cached data from disk + % ------------------------------------------------------------------ + lockedEdges = S.lockedEdges; % restore per-stimulus bin edges + lockedPreBase = S.lockedPreBase; % restore baseline duration + stimDurAll = S.stimDurAll; % restore per-stimulus stim durations rasterAll = cell(1, numel(params.stimTypes)); depthAll = cell(1, numel(params.stimTypes)); expAll = cell(1, numel(params.stimTypes)); for s = 1:numel(params.stimTypes) - stimField = matlab.lang.makeValidName(params.stimTypes(s)); + stimField = matlab.lang.makeValidName(params.stimTypes(s)); rasterAll{s} = S.(sprintf('%s_raster', stimField)); depthAll{s} = S.(sprintf('%s_depth', stimField)); expAll{s} = S.(sprintf('%s_exp', stimField)); end -end -tAxis = lockedEdges(1:end-1); -tAxisPlot = tAxis - lockedPreBase; + % Reconstruct per-stimulus tAxis from the stored edges + tAxis = cell(1, numel(params.stimTypes)); + for s = 1:numel(params.stimTypes) + tAxis{s} = lockedEdges{s}(1:end-1); % left edge of each bin (ms) + end + +end % ========================================================================= % SORT NEURONS % ========================================================================= for s = 1:numel(params.stimTypes) - data = rasterAll{s}; + + data = rasterAll{s}; % nNeurons x nBins matrix if isempty(data); continue; end if params.sortBy == "peak" - % Sort by time of peak response in the post-stimulus window - postStimBins = tAxis >= lockedPreBase; - [~, peakBin] = max(data(:, postStimBins), [], 2); - [~, sortIdx] = sort(peakBin); + postStimBins = tAxis{s} >= lockedPreBase; % post-stim mask using per-stimulus tAxis + + % BUG FIX: previously ConvBurstMatrix overwrote 'data' and the + % smoothed version was stored back into rasterAll (double-smoothing). + % dataForSort is a local copy used only for peak detection. + + if size(data,2) > 100 + dataForSort = ConvBurstMatrix( ... + data, fspecial('gaussian', [1 params.GaussianLength+20], 2), 'same'); % smooth copy for peak detection only + + else + dataForSort = ConvBurstMatrix( ... + data, fspecial('gaussian', [1 params.GaussianLength], 2), 'same'); % smooth copy for peak detection only + end + + [~, peakBin] = max(dataForSort(:, postStimBins), [], 2); % column of peak per neuron + [~, sortIdx] = sort(peakBin); % ascending: early-peaking neurons first + elseif params.sortBy == "depth" - % Sort by depth (shallow to deep) - [~, sortIdx] = sort(depthAll{s}, 'ascend'); + [~, sortIdx] = sort(depthAll{s}, 'ascend'); % shallowest recording sites first + else - sortIdx = 1:size(data, 1); % no sorting + sortIdx = 1:size(data, 1); % no reordering end - rasterAll{s} = data(sortIdx, :); - depthAll{s} = depthAll{s}(sortIdx); - expAll{s} = expAll{s}(sortIdx); + rasterAll{s} = data(sortIdx, :); % reorder rows of the ORIGINAL data + depthAll{s} = depthAll{s}(sortIdx); % reorder depth vector to match + expAll{s} = expAll{s}(sortIdx); % reorder experiment-ID vector to match + end % ========================================================================= % PLOT % ========================================================================= - -stimLegendMap = containers.Map(... +stimLegendMap = containers.Map( ... {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... - {'MB', 'SB', 'MG', 'SG'}); + {'MB', 'SB', 'MG', 'SG'}); % short display labels nStim = numel(params.stimTypes); % ------------------------------------------------------------------ +% Global colour limits — computed once and shared across all tiles +% so the colour scale is directly comparable between stimuli. +% BUG FIX: previously cLims was recomputed incorrectly inside the loop. % ------------------------------------------------------------------ -% Global color limits — use lower percentile for better contrast allValues = []; for s = 1:nStim if ~isempty(rasterAll{s}) - allValues = [allValues, rasterAll{s}(:)']; %#ok + allValues = [allValues, rasterAll{s}(:)']; %#ok % pool all data values for percentile calculation end end if params.zScore - cLimPos = prctile(allValues, params.climPrctile); % data-driven positive limit - cLims = [-params.climNeg, cLimPos]; % asymmetric: fixed neg, data-driven pos + cLimPos = prctile(allValues, params.climPrctile); % data-driven upper z-score limit + cLims = [-params.climNeg, cLimPos]; % asymmetric: fixed lower, data-driven upper else - cLims = [prctile(allValues, 2), prctile(allValues, params.climPrctile)]; + cLims = [prctile(allValues, 2), prctile(allValues, params.climPrctile)]; % symmetric percentile range +end + +% ------------------------------------------------------------------ +% Build diverging colormap once (used when colormap ~= "gray") +% ------------------------------------------------------------------ +if params.zScore && params.colormap ~= "gray" + nColors = 256; % total colour table 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]; % full diverging colormap +else + cmapToUse = flipud(gray); % default: white = low, black = high end % ------------------------------------------------------------------ % Figure and tiled layout % ------------------------------------------------------------------ fig = figure; -set(fig, 'Units', 'centimeters', 'Position', [5 5 5*nStim + 2, 10]); - -tl = tiledlayout(fig, 1, nStim, 'TileSpacing', 'compact', 'Padding', 'compact'); +set(fig, 'Units', 'centimeters', 'Position', [5 5 5*nStim + 2, 10]); % width scales with number of tiles -axAll = gobjects(1, nStim); +tl = tiledlayout(fig, 1, nStim, 'TileSpacing', 'compact', 'Padding', 'compact'); % 1-row tile grid +axAll = gobjects(1, nStim); % pre-allocate axes handles for s = 1:nStim - data = rasterAll{s}; - stimKey = char(params.stimTypes(s)); + data = rasterAll{s}; % nNeurons x nBins for this stimulus + stimKey = char(params.stimTypes(s)); % char for containers.Map lookup if isKey(stimLegendMap, stimKey) - shortName = stimLegendMap(stimKey); + shortName = stimLegendMap(stimKey); % abbreviated label else - shortName = stimKey; + shortName = stimKey; % fallback to full name end - axAll(s) = nexttile(tl); + axAll(s) = nexttile(tl); % create tile and capture axes handle ax = axAll(s); if isempty(data) title(ax, shortName, 'FontName', 'helvetica', 'FontSize', 8); - axis(ax, 'off'); + axis(ax, 'off'); % nothing to plot: hide axes continue end - % imagesc: x = time, y = neuron index - imagesc(ax, tAxisPlot, 1:size(data,1), data); - clim(ax, cLims); - colormap(ax, flipud(gray)); % white = low, black = high - if params.zScore && params.colormap ~= "gray" - cLimPos = prctile(allValues, params.climPrctile); - cLims = [-params.climNeg, cLimPos]; - - % Proportion of colors for each side — white stays at zero - nColors = 256; - nNeg = round(nColors * params.climNeg / (params.climNeg + cLimPos)); - nPos = nColors - nNeg; - - blueHalf = [linspace(0.1, 1, nNeg)', linspace(0.2, 1, nNeg)', linspace(0.8, 1, nNeg)']; - redHalf = [linspace(1, 0.9, nPos)', linspace(1, 0.2, nPos)', linspace(1, 0.05, nPos)']; - colormap(ax, [blueHalf; redHalf]); - else - cLims = [prctile(allValues, 2), prctile(allValues, params.climPrctile)]; - colormap(ax, flipud(gray)); - end + % Per-stimulus time axis in seconds + tAxisPlot = tAxis{s} - lockedPreBase; % shift so stim onset = 0 ms + tAxisSec = tAxisPlot / 1000; % convert to seconds + + imagesc(ax, tAxisSec, 1:size(data,1), data); % raster: x = seconds, y = neuron index + clim(ax, cLims); % shared colour limits + colormap(ax, cmapToUse); % shared colormap % ------------------------------------------------------------------ - % Depth bin boundary lines (only when sorted by depth) + % Depth-bin boundary lines (only when sortBy = "depth") % ------------------------------------------------------------------ if params.sortBy == "depth" && ~isempty(depthAll{s}) - % Load bin edges - depthFile = 'W:\Large_scale_mapping_NP\lizards\Combined_lizard_analysis\NeuronDepths.mat'; - D = load(depthFile); - depthBinEdges = D.depthBinEdges; + D2 = load(depthFile); % load bin edges (file already validated above) + depthBinEdges = D2.depthBinEdges; % edges defining depth layers (um) - binLabelsDepth = {sprintf('%.0f-%.0f um', depthBinEdges(1), depthBinEdges(2)), ... + 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))}; - % Find the last neuron index belonging to each bin boundary - for edge = 2:3 % edges 2 and 3 are the internal boundaries - %lastInBin = find(depthAll{s} <= depthBinEdges(edge), 1, 'last'); - %lastInBin = find(~isnan(depthAll{s}) & depthAll{s} <= depthBinEdges(edge), 1, 'last'); - depthCombined = depthAll{s}; - depthCombined = depthCombined(~isnan(depthCombined)); + depthCombined = depthAll{s}(~isnan(depthAll{s})); % exclude NaN before boundary search + labelX = tAxisSec(1) + 0.05 * range(tAxisSec); % x position for labels: 5% from left edge + + 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); - % Label on the right side showing the bin range - text(ax, tAxisPlot(5), lastInBin - size(data,1)*0.02, ... + yline(ax, lastInBin + 0.5, 'k-', 'LineWidth', 1.2); % horizontal separator between depth bins + text(ax, labelX, lastInBin - size(data,1)*0.02, ... binLabelsDepth{edge-1}, ... 'Color', 'w', 'FontSize', 6, 'FontName', 'helvetica', ... 'HorizontalAlignment', 'left', 'VerticalAlignment', 'top'); end end - % Label for the deepest bin - text(ax, tAxisPlot(5), size(data,1), ... - binLabelsDepth{3}, ... + text(ax, labelX, size(data,1), binLabelsDepth{3}, ... % label for the deepest bin 'Color', 'w', 'FontSize', 6, 'FontName', 'helvetica', ... 'HorizontalAlignment', 'left', 'VerticalAlignment', 'top'); end - % Stim onset and offset lines - xline(ax, 0, 'k--', 'LineWidth', 1.0); - xline(ax, params.postStim, 'k--', 'LineWidth', 1.0); + % Stim onset and offset lines in seconds + xline(ax, 0, 'k--', 'LineWidth', 1.0); % stim onset at t = 0 s + xline(ax, stimDurAll(s)/1000, 'k--', 'LineWidth', 1.0); % stim offset — per-stimulus, in seconds - xlim(ax, [tAxisPlot(1), tAxisPlot(end)]); - ylim(ax, [0.5, size(data,1)+0.5]); - xticks(ax, -params.preBase : 100 : params.postStim); + % Per-tile x-axis range — reflects this stimulus's own duration + xlim(ax, [tAxisSec(1), tAxisSec(end)]); + ylim(ax, [0.5, size(data,1) + 0.5]); % half-row margin above and below + + % Equal-spaced ticks at interval = seconds. + ticksSec = linspace(tAxisSec(1), tAxisSec(end), 5); % 5 perfectly equally spaced ticks + [~, iz] = min(abs(ticksSec)); % find the tick nearest to 0 + ticksSec(iz) = 0; % snap it to exactly 0 for a clean label + xticks(ax, ticksSec); + xticklabels(ax, arrayfun(@(v) sprintf('%.2g', v), ticksSec, 'UniformOutput', false)); - xlabel(ax, 'Time re. stim onset [ms]', 'FontName', 'helvetica', 'FontSize', 8); if s == 1 - ylabel(ax, 'Neuron #', 'FontName', 'helvetica', 'FontSize', 8); + ylabel(ax, 'Neuron #', 'FontName', 'helvetica', 'FontSize', 8); % y-label on leftmost tile only end + title(ax, sprintf('%s (n=%d)', shortName, size(data,1)), ... 'FontName', 'helvetica', 'FontSize', 8); ax.FontName = 'helvetica'; ax.FontSize = 8; - ax.YDir = 'normal'; % neuron 1 at bottom - + ax.YDir = 'normal'; % neuron 1 at bottom, increasing upward + ax.TickDir = 'out'; % outward ticks (publication convention) + ax.Box = 'off'; % remove top/right border end % ------------------------------------------------------------------ -% Single colorbar for the whole layout +% Shared x-label via tiledlayout — one label centred below all tiles +% ------------------------------------------------------------------ +xlabel(tl, 'Time relative to stimulus onset (s)', ... + 'FontName', 'helvetica', 'FontSize', 8); + +% ------------------------------------------------------------------ +% Single colorbar on the rightmost tile % ------------------------------------------------------------------ -cb = colorbar(axAll(end)); +cb = colorbar(axAll(end)); % attach colorbar to last axes if params.zScore cb.Label.String = 'Z-score'; else - cb.Label.String = 'Firing rate [spk/s]'; + cb.Label.String = 'Firing rate (spk/s)'; end cb.Label.FontName = 'helvetica'; cb.Label.FontSize = 8; cb.FontName = 'helvetica'; cb.FontSize = 8; +set(fig, 'Units', 'centimeters', 'Position', [20 20 9 12]); sgtitle(sprintf('N = %d experiments', numel(exList)), ... - 'FontName', 'helvetica', 'FontSize', 10); + 'FontName', 'helvetica', 'FontSize', 10); % super-title above the entire figure if params.PaperFig - vs_first.printFig(fig, sprintf('Raster-%s', stimLabel), PaperFig=params.PaperFig); + vs_first.printFig(fig, sprintf('Raster-%s', stimLabel), PaperFig=params.PaperFig); % export to file end end \ No newline at end of file From db6ba2510c1a331d27b775762bcbc79dd2655305 Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Fri, 24 Apr 2026 23:44:19 +0300 Subject: [PATCH 12/19] Adding statistc per space grid --- .../plotRaster.m | 723 +++++++++--------- .../@VStimAnalysis/StatisticsPerNeuron.m | 55 +- .../StatisticsPerNeuronSpatialGrid.m | 365 +++++++++ .../@VStimAnalysis/plotZScoreReceptiveField.m | 52 ++ .../@imageAnalysis/plotRaster.m | 507 ++++++++---- .../CalculateReceptiveFields.m | 2 +- .../@movieAnalysis/plotRaster.m | 9 +- visualStimulationAnalysis/RunAnalysisClass.m | 37 +- .../computeBallGridCrossings.m | 160 ++++ 9 files changed, 1383 insertions(+), 527 deletions(-) create mode 100644 visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m create mode 100644 visualStimulationAnalysis/@VStimAnalysis/plotZScoreReceptiveField.m create mode 100644 visualStimulationAnalysis/computeBallGridCrossings.m diff --git a/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/plotRaster.m b/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/plotRaster.m index 0bdaf1d..80d1c2d 100644 --- a/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/plotRaster.m @@ -1,63 +1,76 @@ function plotRaster(obj, params) % plotRaster Combined static + drifting raster, PSTH, and raw trace. % -% Plots both grating phases (static and moving) in a single raster for each -% neuron. A solid vertical line marks stimulus onset and offset; a dashed -% vertical line marks the static→moving phase transition. +% 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 stores: -% NeuronResp.Onsets(:,1) = static onset per trial (ms) -% NeuronResp.Onsets(:,2) = moving onset per trial (ms) -% NeuronResp.Offsets(:,2) = moving offset per trial (ms) -% NeuronResp.C columns = [stimOnTime, angle, TF, SF] -% -% Usage: -% obj.plotRaster() -% obj.plotRaster('AllResponsiveNeurons', true) -% obj.plotRaster('exNeuronsPhyID', [42 87], 'PaperFig', true) +% 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 vs original moving-ball plotRaster (carried forward from v1): +% 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 use tolerance -> abs(a-b) abs(a-b) 0.5. -% 5. ur/u dual-index confusion -> fully documented. +% 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. % -% NEW in combined version: -% 6. stimType removed: both phases always shown together. -% 7. Responsive-neuron selection uses min(pStatic, pMoving) to catch -% neurons that respond to only one phase. -% 8. Three xline markers instead of two (onset, transition, offset). -% 9. Title reports p-values for both phases independently. +% 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 = 1 % 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.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 @@ -66,127 +79,108 @@ function plotRaster(obj, params) % 1. LOAD PRE-COMPUTED RESULTS % ========================================================================== -% ResponseWindow struct: fields Static, Moving, C, Onsets, Offsets, stimInter, params NeuronResp = obj.ResponseWindow; -% Load statistics for both phases independently if params.statType == "BootstrapPerNeuron" Stats = obj.BootstrapPerNeuron; else Stats = obj.StatisticsPerNeuron; end -% Per-neuron p-values for each phase (vectors, length = nGoodUnits) -pvalsS = Stats.Static.pvalsResponse; % p-value: static phase response -pvalsM = Stats.Moving.pvalsResponse; % p-value: moving phase response - -% For neuron selection: use the more responsive of the two phases -pvalsMin = min(pvalsS, pvalsM); % Element-wise minimum across phases +pvalsS = Stats.Static.pvalsResponse; +pvalsM = Stats.Moving.pvalsResponse; +pvalsMin = min(pvalsS, pvalsM); % ========================================================================== % 2. SPIKE SORTING AND STIMULUS TIMING % ========================================================================== -% Load phy/Kilosort output: struct with fields ic, t, label, phy_ID -p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); - -% Phy cluster IDs restricted to good (somatic) units +p = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); phy_IDg = p.phy_ID(string(p.label') == 'good'); +label = string(p.label'); +goodU = p.ic(:, label == 'good'); -% Unit quality labels as string array -label = string(p.label'); - -% Spike train matrix: rows = channels, cols = time samples; good units only -goodU = p.ic(:, label == 'good'); - -% --- Derive combined trial timing from ResponseWindow --- - -% staticDur: duration of the static grating phase (ms), same for all trials -% Onsets(:,1) = static onset; Onsets(:,2) = moving onset -staticDur = round(mean(NeuronResp.Onsets(:,2) - NeuronResp.Onsets(:,1))); % (ms) - -% totalStimDur: full stimulus duration, static + moving (ms) -% Offsets(:,2) = moving offset (end of stimulus) +staticDur = round(mean(NeuronResp.Onsets(:,2) - NeuronResp.Onsets(:,1))); % (ms) totalStimDur = round(mean(NeuronResp.Offsets(:,2) - NeuronResp.Onsets(:,1))); % (ms) - -% movingDur: duration of the drifting phase alone (ms) -movingDur = totalStimDur - staticDur; % (ms) - -% Inter-trial interval (ms), used to set the pre-stimulus baseline window -stimInter = NeuronResp.stimInter; - -% Pre-stimulus baseline window: 75% of the ITI to leave a guard band -preBase = round(stimInter - stimInter / 4); % (ms) +movingDur = totalStimDur - staticDur; % (ms) +stimInter = NeuronResp.stimInter; +preBase = round(stimInter - stimInter / 4); % 75% of ITI (ms) % ========================================================================== -% 3. CONDITION MATRIX C +% 3. CONDITION MATRIX C AND OPTIONAL FILTERING % ========================================================================== -% C is saved pre-sorted in ResponseWindow: columns = [stimOnTime, angle, TF, SF] -% stimOnTime here = static onset time (Onsets(:,1)) for each trial -C = NeuronResp.C; +C = NeuronResp.C; % columns: [stimOnTime, angle, TF, SF] -% Optionally restrict to one grating angle (direction, degrees) if params.OneAngle ~= "all" angleVal = str2double(params.OneAngle); - C = C(abs(C(:,2) - angleVal) < 1e-3, :); % Tolerance-based equality - if isempty(C) - error('No trials found for OneAngle = "%s"', params.OneAngle); - end + C = C(abs(C(:,2) - angleVal) < 1e-3, :); + if isempty(C), error('No trials found for OneAngle = "%s"', params.OneAngle); end end - -% Optionally restrict to one temporal frequency (Hz) 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 + if isempty(C), error('No trials found for OneTF = "%s"', params.OneTF); end end - -% Optionally restrict to one spatial frequency (cyc/deg) 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 + if isempty(C), error('No trials found for OneSF = "%s"', params.OneSF); end end -% Re-sort after any filtering: angle primary, TF secondary, SF tertiary +% 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]); -% Row vector of static-onset times for each (sorted) trial (ms, absolute recording time) -% This is the reference time used for all matrix constructions below +% 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 +% 4. CONDITION COUNTS AND DIAGNOSTIC TABLE % ========================================================================== -uAngle = unique(C(:,2)); % Unique grating angles (deg) -uTF = unique(C(:,3)); % Unique temporal frequencies (Hz) -uSF = unique(C(:,4)); % Unique spatial frequencies (cyc/deg) - -angleN = numel(uAngle); % Number of unique angles -tfN = numel(uTF); % Number of unique TFs -sfN = numel(uSF); % Number of unique SFs -nT = size(C, 1); % Total number of trials - -% Number of repeats per unique (angle x TF x SF) combination -trialDivision = nT / (angleN * tfN * sfN); -if mod(trialDivision, 1) ~= 0 - warning('trialDivision is non-integer (%.2f): conditions may be unbalanced.', ... - trialDivision); - trialDivision = floor(trialDivision); +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. RESOLVE NEURON SELECTION +% 5. NEURON SELECTION % ========================================================================== -% If phy cluster IDs are provided, convert to indices into goodU. -% This overrides params.exNeurons; phy IDs are stable across re-sorts. if ~isempty(params.exNeuronsPhyID) [found, neuronIdx] = ismember(params.exNeuronsPhyID, phy_IDg); if any(~found) @@ -198,45 +192,37 @@ function plotRaster(obj, params) num2str(params.exNeuronsPhyID(found)), num2str(params.exNeurons)); end -% Build eNeuron: set of absolute indices into goodU to iterate over if params.AllSomaticNeurons - eNeuron = 1:size(goodU, 2); % All good units - pvalsOut = [eNeuron; pvalsMin(eNeuron)']; + eNeuron = 1:size(goodU, 2); elseif params.AllResponsiveNeurons - % Include neurons responsive in at least one phase (min p-value < 0.05) - eNeuron = find(pvalsMin < 0.05); - pvalsOut = [eNeuron; pvalsMin(eNeuron)']; + eNeuron = find(pvalsMin < 0.05); if isempty(eNeuron) - fprintf('No responsive neurons found (min p < 0.05 across both phases).\n'); - return + fprintf('No responsive neurons (min p < 0.05 across both phases).\n'); return end else - eNeuron = params.exNeurons; - pvalsOut = [eNeuron; pvalsMin(eNeuron)']; + eNeuron = params.exNeurons; end % ========================================================================== -% 6. BUILD RASTER MATRIX (combined window: preBase + staticDur + movingDur + preBase) +% 6. BUILD RASTER MATRIX % ========================================================================== -% Mr: [nTrials x nSelectedNeurons x nBins] using params.bin ms bins. -% The window starts preBase ms before the static onset and ends preBase ms -% after the moving offset, so both phases are captured in a single 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), ... % Selected neurons - round(p.t / params.bin), ... % Spike times in bins - round((directimesSorted - preBase) / params.bin), ... % Window start (bins) - round((totalStimDur + preBase*2) / params.bin)); % Window length (bins) + goodU(:, eNeuron), ... + round(p.t / params.bin), ... + round((directimesSorted - preBase) / params.bin), ... + round((totalStimDur + preBase*2) / params.bin)); -% Optionally smooth raster across time with a 1-D Gaussian kernel if params.Gaussian Mr = ConvBurstMatrix(Mr, fspecial('gaussian', [1 params.GaussianLength], 3), 'same'); end -% Recording channel for each selected neuron (used in title and raw-trace plot) -channels = goodU(1, eNeuron); - -% Total number of time bins in the combined window (for x-axis scaling) +channels = goodU(1, eNeuron); [~, ~, nBins] = size(Mr); % ========================================================================== @@ -244,339 +230,363 @@ function plotRaster(obj, params) % ========================================================================== % % INDEXING: -% u = absolute index into goodU (e.g., goodU(:, u), phy_IDg(u)) -% ur = relative index into eNeuron (1, 2, 3, ...) -> Mr(:, ur, :), channels(ur) -% Both must be kept in sync; ur is incremented at the end of every branch. +% 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; % Relative index into eNeuron; reset once, incremented every iteration +ur = 1; -for u = eNeuron % u: absolute index into goodU +for u = eNeuron fig = figure; % ------------------------------------------------------------------ - % 7a. Build 2-D merged raster [nTrials x nBins] for this neuron + % 7a. 2-D merged raster [nTrials x nBins] % ------------------------------------------------------------------ mergeTrials = params.MergeNtrials; - Mr2 = zeros(nT, nBins); % Pre-allocate: rows = trials, cols = time bins + Mr2 = zeros(nT, nBins); if mergeTrials > 1 - % Average groups of mergeTrials rows; replicate result for display continuity 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 - % No merging: squeeze the neuron dimension (ur indexes the selected subset) Mr2 = squeeze(Mr(:, ur, :)); % [nTrials x nBins] end - % Skip neuron if it has no spikes in the entire window if sum(Mr2, 'all') == 0 - close(fig); - ur = ur + 1; - continue + close(fig); ur = ur + 1; continue end % ================================================================== % PANEL 2 (rows 6-16): Combined raster % ================================================================== - subplot(18, 1, [6 16]); - - % Convert spike counts/bin to approximate spike rate (spk/s) for display - imagesc(Mr2 .* (1000 / params.bin)); - colormap(flipud(gray(64))); % Dark pixels = high firing rate + ax_raster = subplot(18, 1, [6 14]); + imagesc(Mr2 .* (1000 / params.bin)); % Display in spk/s + colormap(flipud(gray(64))); hold on; - % --- Three vertical markers (all with identical style for clean figures) --- + % --- 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 - % Stimulus onset (static phase starts) - xline(preBase / params.bin, 'k', 'LineWidth', 1.5); + if params.MaxVal_1, caxis([0 1]); end - % Phase transition: static -> moving (dashed, thinner) - xline((preBase + staticDur) / params.bin, '--k', 'LineWidth', 1.2); - - % Stimulus offset (moving phase ends) - xline((preBase + totalStimDur) / params.bin, 'k', 'LineWidth', 1.5); - - % Clamp colormap to [0 1] for cross-neuron comparability - if params.MaxVal_1 - caxis([0 1]); - end - - % --- Horizontal dividing lines between condition blocks --- - % Thick black = angle boundary, thin black = TF boundary, dashed red = SF boundary - angleStart = C(1, 2); - tfStart = C(1, 3); - sfStart = C(1, 4); - - for t = 1:nT - if angleStart ~= C(t, 2) - yline(t - 0.5, 'k', 'LineWidth', 2); % New angle block - angleStart = C(t, 2); - end - if tfStart ~= C(t, 3) - yline(t - 0.5, 'k', 'LineWidth', 0.5); % New TF block - tfStart = C(t, 3); - end - if sfStart ~= C(t, 4) - yline(t - 0.5, '--r', 'LineWidth', 0.5); % New SF block (FIX: was 0.05, invisible) - sfStart = C(t, 4); - end + % --- Angle-boundary horizontal lines --- + for a = 2:angleN + yline(angleChangeIdx(a) - 0.5, 'k', 'LineWidth', 2); end - % Phase labels above the raster (text annotations inside the axes) - % Position them at the horizontal midpoints of each phase - ax0 = gca; - yTop = nT + nT*0.02; % Just above top row - - text(preBase/params.bin + (staticDur/params.bin)/2, yTop, 'Static', ... - 'HorizontalAlignment', 'center', 'FontSize', 7, 'FontName', 'helvetica', ... - 'Clipping', 'off'); - text((preBase + staticDur)/params.bin + (movingDur/params.bin)/2, yTop, 'Moving', ... - 'HorizontalAlignment', 'center', 'FontSize', 7, 'FontName', 'helvetica', ... - 'Clipping', 'off'); - - % X-axis: hide labels (shared time axis is shown on the PSTH below) - xlim([0, round(totalStimDur + preBase*2) / params.bin]); - xticks([0, preBase/params.bin : 600/params.bin : (totalStimDur+preBase*2)/params.bin, ... + % 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-axis: ticks at the center of each (TF x SF) block within each angle block - secondaryN = sfN * tfN; % Number of conditions per angle - yt = [0]; - for d = 1:angleN - block_ticks = 1 : trialDivision*2*secondaryN : (nT/angleN)-1+trialDivision*secondaryN; - yt = [yt, block_ticks + max(yt) + trialDivision - 1]; %#ok + % 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 - yt = yt(2:end-1); % Remove leading sentinel - yticks(yt); - yticklabels(repmat( ... - trialDivision : trialDivision*2*secondaryN : (nT/angleN)-1+trialDivision*secondaryN, ... - 1, angleN)); - - ax = gca; - ax.YAxis.FontSize = 8; - ax.YAxis.FontName = 'helvetica'; + + ax_raster.YAxis.FontSize = 8; + ax_raster.YAxis.FontName = 'helvetica'; ylabel('Trials', 'FontSize', 10, 'FontName', 'helvetica'); % ================================================================== - % 7b. Identify best response window (searched across the FULL window, - % i.e., both phases together — no phase preference is imposed) + % 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 - % Step 1: Mean firing rate per condition group -> find best group - j = 1; - meanMr = zeros(1, nT / trialDivision); - for i = 1:trialDivision:nT - meanMr(j) = mean(Mr2(i:i+trialDivision-1, :), 'all'); - j = j + 1; + % 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 - [~, maxRespIn] = max(meanMr); - maxRespIn = maxRespIn - 1; % Convert to 0-based offset for trial range arithmetic + [~, bestAngle_a] = max(meanMr); - % Step 2: Extract raster of best condition group [trialDivision x nBins] - X = Mr2(maxRespIn*trialDivision+1 : maxRespIn*trialDivision+trialDivision, :); + % All trial indices for the best angle block + trials = angleChangeIdx(bestAngle_a) : angleChangeIdx(bestAngle_a+1) - 1; - window = 500; % Response window width for best-bin search (ms) + window = 500; % Sliding-window width (ms) - X(X > 1) = 1; % Clip to [0 1] so outliers don't dominate window selection + % 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); % n_rows = trialDivision - nWinPos = n_cols - round(window / params.bin) + 1; % Number of window positions + [n_rows, n_cols] = size(X); + nWinPos = n_cols - round(window / params.bin) + 1; - % Compute mean firing rate inside every sliding window, for every trial - % window_means: [trialDivision x nWinPos] + % 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); + window_means(:, col) = mean(X(:, col : col+round(window/params.bin)-1), 2); end - % Find the (trial, window) pair with the global maximum mean rate + % Best (trial, window-start) pair [~, linear_idx] = max(window_means(:)); [best_row, best_col] = ind2sub(size(window_means), linear_idx); - % Convert window start from bins to ms (relative to trial onset, i.e., after preBase) - start = best_col * params.bin; % (ms) offset from trial start (0 = static onset) + % '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) - % Which phase did the window land in? - if start < staticDur - bestPhase = 'Static'; - else + bestPhase = 'Static'; + if start >= preBase + staticDur bestPhase = 'Moving'; + elseif start >= preBase + bestPhase = 'Static (stim)'; end else - % Use the pre-computed NeuronVals from whichever phase has a higher raw rate [~, bestPhaseIdx] = max([ ... - max(NeuronResp.Static.NeuronVals(u, :, 4)), ... % Max raw rate in static phase - max(NeuronResp.Moving.NeuronVals(u, :, 4))]); % Max raw rate in moving phase - - phaseNames = ["Static", "Moving"]; - bestPhase = phaseNames(bestPhaseIdx); - + 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)); - % Column 3 = MaxWinBin (bin index); convert to ms - start = NeuronResp.(bestPhase).NeuronVals(u, maxRespIn, 3) * NeuronResp.params.binRaster - 20; - window = 500; - maxRespIn = maxRespIn - 1; % 0-based offset - best_row = 1; % FIX: was uninitialized in original; default = 1 + 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 - % Absolute trial indices belonging to the best condition group - trials = maxRespIn*trialDivision+1 : maxRespIn*trialDivision + trialDivision; - - % Highlight the selected condition group (light grey band, full time extent) - y1 = maxRespIn*trialDivision + trialDivision + 0.5; - y2 = maxRespIn*trialDivision + 0.5; - patch([0, (preBase*2+totalStimDur)/params.bin, (preBase*2+totalStimDur)/params.bin, 0], ... - [y2, y2, y1, y1], 'k', 'FaceAlpha', 0.1, 'EdgeColor', 'none'); + RasterTrials = trials(best_row); % Absolute trial index of the best single trial - % Absolute index of the single best trial (from sliding window search) - RasterTrials = trials(best_row); + % 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: mark the best trial and its response window in the raster - % start is in ms relative to static onset; offset by preBase for display bins - 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'); + % --- 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 across the full combined window + % PANEL 3 (rows 17-18): PSTH for the best angle block % ================================================================== - subplot(18, 1, [17 18]); - - % Rebuild 1 ms-resolution spike matrix for all trials (needed for PSTH bins) - MRhist = BuildBurstMatrix(goodU(:, u), ... - round(p.t), ... % 1 ms resolution - round(directimesSorted - preBase), ... % Window start per trial - round(totalStimDur + preBase*2)); % Full combined window length + ax_psth = subplot(18, 1, [16 18]); - % Select only the best condition group for the PSTH - MRhist = squeeze(MRhist(trials, :, :)); % [nTrialsInGroup x nTimePoints_ms] + 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); % nT2 = trials in group, nB2 = window duration ms - - % Collect spike times (ms from trial start) - spikeTimes = repmat(1:nB2, nT2, 1); % Index grid matching MRhist - spikeTimes = spikeTimes(logical(MRhist)); % Keep only spike locations - - % PSTH bin width: use wider bins for longer stimuli to reduce noise - binWidth = 125; % Default (ms) - if nBins > 300 - binWidth = 250; - end + [nT2, nB2] = size(MRhist); + spikeTimes = repmat(1:nB2, nT2, 1); + spikeTimes = spikeTimes(logical(MRhist)); - edges = 1:binWidth:round(totalStimDur + preBase*2); % Bin edges (ms) - psthCounts = histcounts(spikeTimes, edges); % Spike count per bin + binWidth = 125; + if nBins > 300, binWidth = 250; end - % Normalize to firing rate [spk/s]: counts / (bin_s * nTrials) - psthRate = (psthCounts / (binWidth * nT2)) * 1000; + 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'; + b.FaceColor = 'k'; b.FaceAlpha = 0.3; b.MarkerEdgeColor = 'none'; xlim([0, round((totalStimDur + preBase*2) / 100) * 100]); - try % Guard against all-zero PSTH (std=0 makes ylim fail) + try ylim([0, max(psthRate) + std(psthRate)]); catch - close(fig); - ur = ur + 1; - continue + close(fig); ur = ur + 1; continue end - % X-axis ticks at 600 ms intervals; labels converted from ms to seconds xticks([0, preBase:600:(totalStimDur+preBase*2), ... round((totalStimDur+preBase*2)/100)*100]); - - % Three xline markers matching the raster above - xline(preBase, 'LineWidth', 1.5); % Stim on - xline(preBase + staticDur, '--', 'LineWidth', 1.2); % Phase transition - xline(preBase + totalStimDur, 'LineWidth', 1.5); % Stim off - + 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 = gca; - ax.XAxis.FontSize = 8; ax.XAxis.FontName = 'helvetica'; - ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; + 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]); % Two clean ticks + 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 % ================================================================== - bin3 = 1; % 1 ms bins for raw trace extraction - - % Build spike matrix around the response window for all group trials - % start is in ms from static onset; add preBase offset to get absolute ms - trialM = BuildBurstMatrix(goodU(:, u), ... - round(p.t / bin3), ... - round((directimesSorted + start) / bin3), ... % Align window to response onset - round(window / bin3)); % 500 ms window - - TrialM = squeeze(trialM(trials, :, :))'; % [nBins x nTrialsInGroup] - - % best_row already identifies the highest-response trial from the window search - chan = goodU(1, u); % Recording channel for this neuron + if params.plotRaw + chan = goodU(1, u); subplot(18, 1, [1 3]); - % Absolute start time of the raw trace (ms in recording time): - % align to start of the response window (start ms after static onset, minus preBase) - startTimes = directimesSorted(RasterTrials) + start - preBase; % (ms) - - % Binary spike vector for the selected trial in the response window - spikes = squeeze(BuildBurstMatrix(goodU(:, u), round(p.t), round(startTimes), round(window))); + % --- 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))); - % Render raw voltage trace with spike overlay [fig, ~, ~] = PlotRawDataNP(obj, fig=fig, chan=chan, ... startTimes=startTimes, window=window, spikeTimes=spikes); - ax = gca; - ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; + 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)); % 5 evenly spaced ticks + xticks(0:(xlims(2)/5):xlims(2)); xticklabels(0:100:window); - ax.XAxis.FontSize = 8; ax.XAxis.FontName = 'helvetica'; - ax.XRuler.TickDirection = 'out'; - ax.XAxisLocation = 'bottom'; - % Mark the stimulus event nearest to this window - % (start is the offset from static onset; if start > staticDur, this is the phase change) - xline(-start / 1000, 'LineWidth', 1.5); + % --- 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'); - % --- Title: neuron ID + best condition + p-values for both phases -------- - bestAngle = C(maxRespIn*trialDivision + 1, 2); % Angle of best condition group (deg) - bestTF = C(maxRespIn*trialDivision + 1, 3); % TF of best condition (Hz) - bestSF = C(maxRespIn*trialDivision + 1, 4); % SF of best condition (cyc/deg) + 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: %.0f deg | TF=%.1f Hz | SF=%.3f c/deg | window in [%s]', ... - bestAngle, bestTF, bestSF, bestPhase) ... + 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 % ================================================================== @@ -592,12 +602,9 @@ function plotRaster(obj, params) obj.dataObj.recordingName, u)); end - % Keep the last figure open; close all intermediate ones - if ur ~= length(eNeuron) - close(fig); - end + %if ur ~= length(eNeuron), close(fig); end - ur = ur + 1; % MUST be reached in every code path above + ur = ur + 1; % MUST be reached in every code path above end % end neuron loop diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m index 32c4081..7af3a91 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m @@ -72,8 +72,11 @@ 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 + 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 = true % if true: use StatisticsPerNeuronSpatialGrid + % only applies to linearlyMovingBall + % ignored for other stimuli end @@ -90,6 +93,24 @@ 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, ... + overwrite = params.overwrite); + return +end + + % ------------------------------------------------------------------------- % Fix random seed for reproducibility % Required for published code so permutation results are identical across runs @@ -182,6 +203,8 @@ % ========================================================================= for s = 1:x + + % --- Assign condition-specific variables --- if isfield(responseParams, "Speed1") fieldName = sprintf('Speed%d', s); @@ -212,7 +235,7 @@ 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 only + effectiveStimDur = params.MaxStimDuration; % capped duration used for Mr on1. ly else effectiveStimDur = stimDur; % full duration — no capping needed end @@ -225,12 +248,28 @@ round(directimesSorted), ... round(effectiveStimDur)); % capped or full duration - % 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 - 0.75 * obj.VST.interTrialDelay * 1000), ... - round(0.75 * obj.VST.interTrialDelay * 1000)); + if isequal(obj.stimName, 'StaticDriftingGrating') + if isequal(fieldName,'moving') + + Mb = BuildBurstMatrix(goodU, ... + round(p.t), ... + round(directimesSorted - obj.VST.static_time- 0.75 * obj.VST.interTrialDelay * 1000), ... + round(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 - 0.75 * obj.VST.interTrialDelay * 1000), ... + round(0.75 * obj.VST.interTrialDelay * 1000)); + end + else + Mb = BuildBurstMatrix(goodU, ... + round(p.t), ... + round(directimesSorted - 0.75 * obj.VST.interTrialDelay * 1000), ... + round(0.75 * obj.VST.interTrialDelay * 1000)); + end % ------------------------------------------------------------------------- % Always compute full-duration means for z-score and empty-trial filtering diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m new file mode 100644 index 0000000..7d6af9b --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m @@ -0,0 +1,365 @@ +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.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; + + if ndims(nFramesFull) == 3 + nFramesThisSpeed = squeeze(nFramesFull(s, :, :)); % [nOffsets × nDirections] + else + nFramesThisSpeed = nFramesFull; % single-speed fallback + end + + % 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); + + 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 - obj.VST.interTrialDelay * 1000), ... + 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] + + % ------------------------------------------------------------------------- + % 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(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.params = params; % store parameters for reproducibility + +end % end speed loop + +% --- Save and return --- +fprintf('Saving results to file.\n'); +save(obj.getAnalysisFileName, '-struct', 'S'); +results = S; + +end % end main function \ No newline at end of file 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/@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 8759bb4..045e538 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/CalculateReceptiveFields.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/CalculateReceptiveFields.m @@ -401,7 +401,7 @@ h = h+1; end -%implay(squeeze(videoTrials(9,:,:,:))); +implay(squeeze(videoTrials(9,:,:,:))); for t = 1:numel(IndexDiv) 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/RunAnalysisClass.m b/visualStimulationAnalysis/RunAnalysisClass.m index 29de3a8..a9f8634 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -66,7 +66,7 @@ %[49:54,84:90,92:96] %All SDG experiments %solve MBR %bootsrapRespBase -[tempTableMW] = AllExpAnalysis([49:54,64:66, 68:85 87:97] ,{'SDGm','SDGs'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'SDGm','SDGs'},PaperFig=true,... +[tempTableMW] = AllExpAnalysis([49:54,64:66, 68:85 87:97] ,{'MB','RG'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... overwriteResponse=false,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% %% PSTH for all experiments @@ -83,23 +83,26 @@ getNeuronDepths([49:54,64:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates %% Gratings -for ex = [49] +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.plotRaster + % 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=true) %0.5208 %2.0833 + vs.plotRaster(MaxVal_1=false) + close all end %% movie -for ex = [90] +for ex = [92:97] NP = loadNPclassFromTable(ex); %73 81 vs = movieAnalysis(NP); % vs.getSessionTime("overwrite",true); @@ -110,12 +113,14 @@ 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 = [89,90,92,93,95:97] +for ex = [97] NP = loadNPclassFromTable(ex); %73 81 vs = imageAnalysis(NP); %vs.getSessionTime("overwrite",true); @@ -123,9 +128,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/computeBallGridCrossings.m b/visualStimulationAnalysis/computeBallGridCrossings.m new file mode 100644 index 0000000..5a9d43b --- /dev/null +++ b/visualStimulationAnalysis/computeBallGridCrossings.m @@ -0,0 +1,160 @@ +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. + + % ------------------------------------------------------------------------- + % Load ball trajectory data + % ChangePosX, ChangePosY: [nTrials × nFrames] ball centre pixel coordinates + % ------------------------------------------------------------------------- + Xpos = obj.VST.ballTrajectoriesX; % original 4D: [nSpeeds × nOffsets × nDirs × nFrames] + Ypos = obj.VST.ballTrajectoriesY; + + + if size(Xpos,1) > 1 + Xpos = Xpos(speedIndx,:,:,1:unique(obj.VST.nFrames(speedIndx,:,:))); % select trajectories for this speed + Ypos = Ypos(speedIndx,:,:,1:unique(obj.VST.nFrames(speedIndx,:,:))); + end + + % 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) + 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 +changePosDir1x = Xpos(:,:,1,:); +changePosDir1y = Ypos(:,:,1,:); + % ------------------------------------------------------------------------- + % 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 = zeros(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 = (gx - 0.5) * cellW; + 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])) + % ------------------------------------------------------------------------- + % 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); + + 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 +end \ No newline at end of file From 2e4dc687ed68d23760cb5351cc9a7e5401918920 Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Tue, 28 Apr 2026 02:35:38 +0300 Subject: [PATCH 13/19] Changes to stat calculation using grids --- .../computeBallGridCrossings.asv | 279 +++++++++++++ .../computeBallGridCrossings.m | 385 ++++++++++++------ 2 files changed, 540 insertions(+), 124 deletions(-) create mode 100644 visualStimulationAnalysis/computeBallGridCrossings.asv diff --git a/visualStimulationAnalysis/computeBallGridCrossings.asv b/visualStimulationAnalysis/computeBallGridCrossings.asv new file mode 100644 index 0000000..0e955f3 --- /dev/null +++ b/visualStimulationAnalysis/computeBallGridCrossings.asv @@ -0,0 +1,279 @@ +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 + +% Reconstruct from stimulus design parameters +[Xpos, Ypos] = reconstructBallTrajectoriesFromGeometry(obj, speedIndx, nFramesPerOffsetDir); +% 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 +changePosDir1x = Xpos(:,:,1,:); +changePosDir1y = Ypos(:,:,1,:); +% ------------------------------------------------------------------------- +% 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 = zeros(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])) + +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/computeBallGridCrossings.m b/visualStimulationAnalysis/computeBallGridCrossings.m index 5a9d43b..e6a133e 100644 --- a/visualStimulationAnalysis/computeBallGridCrossings.m +++ b/visualStimulationAnalysis/computeBallGridCrossings.m @@ -21,140 +21,277 @@ % one trial in that direction crosses the cell. % nTrialsPerCellDir : [nGridCells × nDirections] trial count per cell×dir. - % ------------------------------------------------------------------------- - % Load ball trajectory data - % ChangePosX, ChangePosY: [nTrials × nFrames] ball centre pixel coordinates - % ------------------------------------------------------------------------- - Xpos = obj.VST.ballTrajectoriesX; % original 4D: [nSpeeds × nOffsets × nDirs × nFrames] - Ypos = obj.VST.ballTrajectoriesY; +% ------------------------------------------------------------------------- +% ------------------------------------------------------------------------- +% 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,:,:))); % select trajectories for this speed + Xpos = Xpos(speedIndx,:,:,1:unique(obj.VST.nFrames(speedIndx,:,:))); Ypos = Ypos(speedIndx,:,:,1:unique(obj.VST.nFrames(speedIndx,:,:))); end - % 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) - 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 +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 -changePosDir1x = Xpos(:,:,1,:); -changePosDir1y = Ypos(:,:,1,:); - % ------------------------------------------------------------------------- - % 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 = zeros(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 = (gx - 0.5) * cellW; - 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 + +% ------------------------------------------------------------------------- +% 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; - figure;imagesc(reshape(mean(dwellFrames,1),[9,9])) - % ------------------------------------------------------------------------- - % 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); - - 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)'; + % 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 From f839fe28a6862640c86d0fd91d3027910077ca6f Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Fri, 1 May 2026 00:39:38 +0300 Subject: [PATCH 14/19] Adding spatialtuning sorting in raster plot --- .../plotSwarmBootstrapWithComparisons.m | 512 ++++++++----- .../@VStimAnalysis/StatisticsPerNeuron.m | 33 +- .../StatisticsPerNeuronSpatialGrid.m | 2 +- .../PlotReceptiveFields.m | 4 +- visualStimulationAnalysis/AllExpAnalysis.m | 4 +- .../RunAnalysisClass.asv | 227 ++++++ visualStimulationAnalysis/RunAnalysisClass.m | 20 +- .../computeBallGridCrossings.m | 8 +- .../plotRaster_MultiExp.m | 682 +++++++++++------- 9 files changed, 1028 insertions(+), 464 deletions(-) create mode 100644 visualStimulationAnalysis/RunAnalysisClass.asv diff --git a/general functions/plotSwarmBootstrapWithComparisons.m b/general functions/plotSwarmBootstrapWithComparisons.m index dc23511..33c5851 100644 --- a/general functions/plotSwarmBootstrapWithComparisons.m +++ b/general functions/plotSwarmBootstrapWithComparisons.m @@ -1,42 +1,121 @@ function [fig, randiColors] = plotSwarmBootstrapWithComparisons(tbl, pairs, pValues, valueField, params) +% PLOTSWARMBOOTSTRAPWITHCOMPARISONS Per-stimulus swarm plot with hierarchical +% bootstrap central tendency, uncertainty bar, and pairwise significance brackets. +% +% [fig, randiColors] = 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' (default) or 'percentile' (95% bootstrap CI) +% 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 and the random dot-draw permutation. +% +% Bootstrap details: uses hierBoot (Saravanan et al. 2020) when grouping +% levels are present, resampling each level with replacement in turn. For +% insertion-level data with grouping {'animal','insertion'}, the within- +% insertion step resamples a single observation, so the procedure naturally +% reduces to an animal->insertion bootstrap without special-casing. +% Categorical grouping columns are coerced to numeric category codes before +% hierBoot is called (hierBoot pre-allocates intermediate levels as +% nan(size(data)), so it requires numeric inputs). +% ------------------------------------------------------------------------- +% Argument validation block. MATLAB enforces types/sizes before the body runs. +% ------------------------------------------------------------------------- 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 logical = true - params.Alpha = 0.2 - params.plotMeanSem logical = true - params.colorByZScore logical = false % color dots by zScore column in tbl instead of by animal - params.showBothAndDiff logical = true % show raw (both stim types) in left tile AND difference in right tile - params.drawLines logical = false %draw lines between points + tbl table % observation table + pairs cell = {} % stim pairs to test + pValues double = [] % p-value per pair (NaN allowed) + 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 % ------------------------------------------------------------------------- -% Validate Z-score coloring request +% Up-front input validation. % ------------------------------------------------------------------------- + +% Either raw mode (1 field) or fraction mode (2 fields). Fail loudly otherwise. +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 can only work if the column exists; downgrade with a warning. if params.colorByZScore && ~ismember('zScore', tbl.Properties.VariableNames) - warning('colorByZScore=true but tbl has no zScore column — falling back to animal coloring.'); + warning('colorByZScore=true but tbl has no zScore column; falling back to animal coloring.'); params.colorByZScore = false; end -% showBothAndDiff implicitly requires diff=false at the top level, because -% it manages the diff internally in the right tile +% showBothAndDiff places diff in its own tile; honor the two-tile mode. if params.showBothAndDiff && params.diff - warning('showBothAndDiff=true overrides params.diff — diff will be shown in the right tile only.'); + warning('showBothAndDiff=true overrides params.diff; diff appears in the right tile only.'); params.diff = false; end +% Seed RNG once for the entire call so bootstraps and dot-draw orders are +% deterministic. Critical for figure reproducibility in a paper. +rng(params.rngSeed); + +% ------------------------------------------------------------------------- +% Resolve bootstrap grouping variables. +% Sentinel '__auto__' means "auto-fill from columns present in the table". +% Explicit {} from the caller forces a flat (non-hierarchical) bootstrap. % ------------------------------------------------------------------------- -% Shared parameters derived from yMaxVis +if isscalar(params.bootGroupVars) && strcmp(params.bootGroupVars{1}, '__auto__') + cands = {'animal','insertion'}; + 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 (e.g., one number-of-responsive-units value per insertion). +% Otherwise it is neuron-level (many neurons per insertion per stimulus). +% Drives buildDiffTable's pairing strategy AND plotRawSwarm's line-grouping. +% ------------------------------------------------------------------------- +isInsertionLevel = height(tbl) == height(unique(tbl(:, {'insertion','stimulus'}))); + +% ------------------------------------------------------------------------- +% Padding / spacing constants derived from the y-axis cap. % ------------------------------------------------------------------------- yMaxVis = params.yMaxVis; bracketPad = yMaxVis * 0.05; @@ -45,59 +124,55 @@ semAlpha = 0.6; % ------------------------------------------------------------------------- -% Pre-process tbl: rename stim labels and compute value column +% Pre-process tbl: rename legacy stimulus labels and compute the value column. % ------------------------------------------------------------------------- -tbl = renameStimulusLabels(tbl); % RG->SB, SDGs->SG, SDGm->MG -pairs = renamePairLabels(pairs); % same substitution in pairs +tbl = renameStimulusLabels(tbl); +pairs = renamePairLabels(pairs); if params.fraction - assert(numel(valueField) == 2, 'Fraction mode requires two valueField entries.'); + % Element-wise ratio. NaN/Inf may arise if denominator has zeros — they + % are filtered downstream by the bootstrap/plotting code. tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); else tbl.value = tbl.(valueField{1}); end +% Drop unused categorical levels so colormaps and category counts are accurate. tbl.stimulus = removecats(tbl.stimulus); tbl.animal = removecats(tbl.animal); tbl.insertion = removecats(tbl.insertion); % ------------------------------------------------------------------------- -% Build figure: single axes OR tiledlayout(1,2) for showBothAndDiff +% Build figure: either single axes or a 1x2 tiledlayout depending on mode. % ------------------------------------------------------------------------- fig = figure; -set(fig, 'Color', 'w'); +set(fig, 'Color', 'w'); % white background for publication if params.showBothAndDiff - % Two tiles: [raw both stim types | difference] - tl = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact'); - - axRaw = nexttile(tl, 1); % left tile: raw swarm - axDiff = nexttile(tl, 2); % right tile: difference swarm + % Left tile: every stimulus shown raw. Right tile: difference for pairs{1,:}. + tl = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact'); + axRaw = nexttile(tl, 1); + axDiff = nexttile(tl, 2); - % --- LEFT TILE: raw (both stim types) --- randiColors = plotRawSwarm(axRaw, tbl, pairs, pValues, params, ... - yMaxVis, bracketPad, stackPad, textPad, semAlpha); + yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel); - % --- RIGHT TILE: difference --- - tblDiff = buildDiffTable(tbl, pairs, params); + tblDiff = buildDiffTable(tbl, pairs, params, isInsertionLevel); plotDiffSwarm(axDiff, tblDiff, pairs, pValues, params, ... yMaxVis, bracketPad, textPad); - else - % Single axes mode + % Single-axes mode: either the raw swarm or the difference, not both. ax = axes(fig); %#ok hold(ax, 'on'); - set(ax, 'Clipping', 'off'); + set(ax, 'Clipping', 'off'); % allow brackets/text outside ylim if params.diff - % Difference mode only - tblDiff = buildDiffTable(tbl, pairs, params); + tblDiff = buildDiffTable(tbl, pairs, params, isInsertionLevel); randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... yMaxVis, bracketPad, textPad); else - % Raw mode only randiColors = plotRawSwarm(ax, tbl, pairs, pValues, params, ... - yMaxVis, bracketPad, stackPad, textPad, semAlpha); + yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel); end end @@ -106,91 +181,109 @@ % ========================================================================= % LOCAL FUNCTION: plotRawSwarm -% Plots raw values for all stim types with lines between paired neurons. -% Returns randiColors (permuted indices used for dot ordering). +% Plots all observations grouped by stimulus, with optional connecting lines +% between paired neurons across stim types. Returns the random draw permutation. % ========================================================================= function randiColors = plotRawSwarm(ax, tbl, pairs, pValues, params, ... - yMaxVis, bracketPad, stackPad, textPad, semAlpha) + yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel) hold(ax, 'on'); -set(ax, 'Clipping', 'off'); +set(ax, 'Clipping', 'off'); % brackets may sit above yMaxVis -stimuli = categories(tbl.stimulus); -tblPlot = tbl; +stimuli = categories(tbl.stimulus); % ordered category list +tblPlot = tbl; % alias to keep names short -% Randomize dot draw order so overlapping colors are visible +% Random permutation of dot indices => overlapping colors don't form layers. +% rng() was seeded once in the main function, so this is reproducible. randiColors = randperm(height(tblPlot)); -% Determine dot color data: Z-score (diverging) or animal (categorical) +% Choose dot color source: continuous zScore (diverging) or categorical animal. if params.colorByZScore - % Use Z-score values — colormap set to diverging RdBu below colorData = tblPlot.zScore(randiColors); else - % Use animal labels — colormap set to lines() below colorData = tblPlot.animal(randiColors); end -% Draw swarm +% Draw the swarm. swarmchart accepts a categorical x-axis directly. if params.filled s = swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... params.dotSize, colorData, 'filled', ... 'MarkerFaceAlpha', params.Alpha); else + % SizeData=30 below intentionally overrides params.dotSize for legibility + % of open markers; consider exposing as its own param if you want full control. s = swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... params.dotSize, colorData, ... 'MarkerEdgeAlpha', params.Alpha, 'LineWidth', 1, 'SizeData', 30); end s.XJitter = params.Xjitter; -% Set colormap and colorbar +% Configure the colormap to match the chosen color source. if params.colorByZScore - % Diverging red-blue colormap centred at 0 colormap(ax, buildRdBuColormap(256)); maxZ = max(abs(tblPlot.zScore), [], 'omitnan'); - if maxZ == 0, maxZ = 1; end - clim(ax, [-maxZ maxZ]); + if isempty(maxZ) || maxZ == 0, maxZ = 1; end % degenerate safety + clim(ax, [-maxZ maxZ]); % symmetric around zero cb = colorbar(ax); cb.Label.String = 'Z-score'; else colormap(ax, lines(numel(categories(tblPlot.animal)))); end -% Draw lines between paired neurons across stim types +% ------------------------------------------------------------------------- +% Optional: draw a thin line for each unit across stimulus columns. +% Choice of unit identifier depends on data granularity: +% * insertion-level: insertion *is* the unit, so group by insertion. +% * neuron-level : need NeurID; insertion would erroneously merge units +% from the same penetration into a single line. +% ------------------------------------------------------------------------- if params.drawLines - cats = categories(tblPlot.stimulus); - xMap = containers.Map(cats, 1:numel(cats)); - xNum = arrayfun(@(i) xMap(char(tblPlot.stimulus(i))), 1:height(tblPlot)); - - - try - tblPlot.NeurID; - UI = 'NeurID'; - catch - UI = 'insertion'; + if isInsertionLevel + unitIDvar = 'insertion'; + elseif ismember('NeurID', tblPlot.Properties.VariableNames) + unitIDvar = 'NeurID'; + else + unitIDvar = ''; + warning(['drawLines=true on neuron-level data without NeurID; ', ... + 'skipping connecting lines (insertion would merge ', ... + 'multiple units into one line).']); end - for i = 1:numel(unique(tblPlot.(UI))) - idx = double(tblPlot.(UI)) == i; - if sum(idx) < 2, continue; end - line(ax, xNum(idx), tblPlot.value(idx), ... - 'Color', [0 0 0 0.1], 'LineWidth', 0.1); + if ~isempty(unitIDvar) + cats = categories(tblPlot.stimulus); + % Map stimulus categories to numeric x positions for line() calls. + xMap = containers.Map(cats, 1:numel(cats)); + xNum = arrayfun(@(i) xMap(char(tblPlot.stimulus(i))), 1:height(tblPlot)); + + % Iterate over actual unit values (categorical or numeric); equality + % comparison works on both, so no type-specific branching is needed. + unitIDs = unique(tblPlot.(unitIDvar)); + for u = 1:numel(unitIDs) + idx = tblPlot.(unitIDvar) == unitIDs(u); + if nnz(idx) < 2, continue; end % need >=2 stim columns to draw + 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'; +ax.Layer = 'top'; % axis ticks above swarm dots -% Bootstrap mean + SEM per stimulus +% Hierarchical bootstrap mean ± SE (or 95% CI) per stimulus column. if params.plotMeanSem plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha); end -% Pairwise significance brackets +% Pairwise significance brackets (only if pairs and pValues are aligned). if ~isempty(pairs) && numel(pValues) == size(pairs,1) - plotBrackets(ax, tblPlot, stimuli, pairs, pValues, yMaxVis, bracketPad, stackPad, textPad); + plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... + yMaxVis, bracketPad, stackPad, textPad); end +% Cap visible y-range. Brackets/text use Clipping=off, so they remain visible +% even above this cap (intentional for tight figures). ylim(ax, [ax.YLim(1) yMaxVis]); end % plotRawSwarm @@ -198,7 +291,8 @@ % ========================================================================= % LOCAL FUNCTION: plotDiffSwarm -% Plots the per-neuron difference (stimA - stimB) as a single swarm column. +% One swarm column showing per-neuron (or per-insertion) (stimA - stimB), +% with a 4-tier significance annotation matching plotBrackets. % ========================================================================= function randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... yMaxVis, bracketPad, textPad) @@ -206,16 +300,16 @@ hold(ax, 'on'); set(ax, 'Clipping', 'off'); +% Reproducible draw order; rng() set once in main. randiColors = randperm(height(tblDiff)); -% Determine dot color data +% Same color-source logic as raw plot, but only if zScore made it through buildDiffTable. if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) colorData = tblDiff.zScore(randiColors); else colorData = tblDiff.animal(randiColors); end -% Draw swarm if params.filled s = swarmchart(ax, tblDiff.stimulus(randiColors), tblDiff.value(randiColors), ... params.dotSize, colorData, 'filled', ... @@ -227,11 +321,10 @@ end s.XJitter = params.Xjitter; -% Set colormap if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) colormap(ax, buildRdBuColormap(256)); maxZ = max(abs(tblDiff.zScore), [], 'omitnan'); - if maxZ == 0, maxZ = 1; end + if isempty(maxZ) || maxZ == 0, maxZ = 1; end clim(ax, [-maxZ maxZ]); cb = colorbar(ax); cb.Label.String = 'Z-score'; @@ -239,20 +332,21 @@ colormap(ax, lines(numel(categories(tblDiff.animal)))); end -% Zero reference line +% Visual reference at zero so the sign of differences is obvious at a glance. yline(ax, 0, 'LineWidth', 1, 'Alpha', 0.7); ylabel(ax, params.yLegend); ax.Box = 'off'; ax.Layer = 'top'; -% Bootstrap mean + SEM if params.plotMeanSem stimuli = categories(tblDiff.stimulus); plotMeanSemBars(ax, tblDiff, stimuli, params, 0.6); end -% Significance annotation for diff mode +% ------------------------------------------------------------------------- +% Significance annotation (four-tier scheme matching plotBrackets). +% ------------------------------------------------------------------------- ylims = ylim(ax); if ~isempty(pValues) && numel(pValues) >= 1 @@ -266,39 +360,39 @@ return end - % Guard against empty vals producing empty maxVisible + % Place the annotation just above the highest visible (capped) value. maxVisible = max(min(vals(:), yMaxVis(1))); if isempty(maxVisible), maxVisible = yMaxVis; end yText = maxVisible + bracketPad; - if pValues(1) < 0.001 - txt = '***'; - elseif pValues(1) < 0.01 - txt = '**'; - elseif pValues(1) < 0.05 - txt = '*'; - else - txt = 'ns'; + if pValues(1) < 0.001, txt = '***'; + elseif pValues(1) < 0.01, txt = '**'; + elseif pValues(1) < 0.05, txt = '*'; + else, txt = 'ns'; end + % Hard-coded x=1: the diff plot has exactly one column. If you ever extend + % this to multiple difference columns, parameterize x. text(ax, 1, yText, txt, ... 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); - stimA = pairs{1,1}; - stimB = pairs{1,2}; - compText = sprintf('%s > %s', stimA, stimB); - yCompText = yText + textPad * 10; + % Comparison label (e.g. "SB > SG"), placed above the stars. + compTextPad = 10 * textPad; + stimA = pairs{1,1}; + stimB = pairs{1,2}; + compText = sprintf('%s > %s', stimA, stimB); + yCompText = yText + compTextPad; text(ax, 1, yCompText, compText, ... 'HorizontalAlignment', 'center', 'FontSize', 10, 'Clipping', 'off'); - requiredHeight = yCompText + textPad * 10; + % Expand y-limits if the comparison label needs more room than yMaxVis allows. + requiredHeight = yCompText + compTextPad; if requiredHeight > yMaxVis ylim(ax, [ylims(1) requiredHeight]); else ylim(ax, [ylims(1) yMaxVis]); end - else ylim(ax, [ylims(1) yMaxVis]); end @@ -310,10 +404,13 @@ % ========================================================================= % LOCAL FUNCTION: buildDiffTable -% Computes per-neuron difference (stimA - stimB) within each insertion. -% If colorByZScore, also computes mean zScore across the pair for each neuron. +% Per-unit (stimA - stimB) within insertion. Pairing strategy is chosen by +% data granularity: +% * insertion-level (one row per insertion-stimulus): direct subtraction. +% * neuron-level + NeurID present: match by NeurID (intersect). +% * neuron-level without NeurID: row-order fallback with warning. % ========================================================================= -function tblDiff = buildDiffTable(tbl, pairs, params) +function tblDiff = buildDiffTable(tbl, pairs, params, isInsertionLevel) assert(~isempty(pairs) && size(pairs,1) >= 1, ... 'diff mode requires at least one stimulus pair.'); @@ -321,48 +418,85 @@ stimA = pairs{1,1}; stimB = pairs{1,2}; +hasNeurID = ismember('NeurID', tbl.Properties.VariableNames); + +% Only warn when NeurID is genuinely needed (neuron-level data) and missing. +if ~hasNeurID && ~isInsertionLevel + warning(['buildDiffTable: NeurID column not present and table appears to ', ... + 'be neuron-level (multiple rows per insertion-stimulus pair). ', ... + 'Pairing by row order — fragile if rows are reordered. Add NeurID.']); +end + ins = categories(tbl.insertion); -diffVals = []; +diffVals = []; % accumulators for the output table animals = []; insers = []; -zScores = []; % mean zScore across pair, used only if colorByZScore +zScores = []; % only filled 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; - if ~any(idxA) || ~any(idxB), continue; end - if params.fraction - vA = tbl.(valueField{1})(idxA) ./ tbl.(valueField{2})(idxA); - vB = tbl.(valueField{1})(idxB) ./ tbl.(valueField{2})(idxB); + if isInsertionLevel + % One row per side guaranteed by the granularity check. + vA = tbl.value(idxA); + vB = tbl.value(idxB); + an = tbl.animal(idxA); + if useZ + zPair = (tbl.zScore(idxA) + tbl.zScore(idxB)) / 2; + end + + elseif hasNeurID + % Neuron-level with explicit IDs: safest matching. + 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); + if useZ + zPair = (tA.zScore(iA) + tB.zScore(iB)) / 2; + end + else + % Row-order fallback for neuron-level data without NeurID. vA = tbl.value(idxA); vB = tbl.value(idxB); + if numel(vA) ~= numel(vB) + warning('Insertion %s: %d stimA rows but %d stimB rows; skipping.', ... + ins{i}, numel(vA), numel(vB)); + continue + end + an = repmat(tbl.animal(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; repmat(tbl.animal(find(idxA,1)), length(vA), 1)]; %#ok - insers = [insers; repmat(i, length(vA), 1)]; %#ok - - % For Z-score coloring: average zScore across both stim types per neuron - if params.colorByZScore && ismember('zScore', tbl.Properties.VariableNames) - zA = tbl.zScore(idxA); - zB = tbl.zScore(idxB); - zScores = [zScores; (zA + zB) / 2]; %#ok + animals = [animals; an]; %#ok + insers = [insers; repmat(i, numel(vA), 1)]; %#ok + if useZ + zScores = [zScores; zPair]; %#ok end end -valid = ~isnan(diffVals); +% Drop NaN differences (e.g., from zero-denominator fractions). +valid = ~isnan(diffVals); stimName = sprintf('%s-%s', stimA, stimB); -tblDiff = table(); +tblDiff = table(); tblDiff.insertion = categorical(insers(valid)); tblDiff.stimulus = categorical(repmat({stimName}, sum(valid), 1)); tblDiff.animal = animals(valid); tblDiff.value = diffVals(valid); -if params.colorByZScore && ~isempty(zScores) +if useZ tblDiff.zScore = zScores(valid); end @@ -371,40 +505,93 @@ % ========================================================================= % LOCAL FUNCTION: plotMeanSemBars -% Draws bootstrapped mean ± SEM bars for each stimulus group. +% Hierarchical-bootstrap central tendency and uncertainty per stimulus column. +% +% Reports the SAMPLE mean as the point estimate (consistent with conventional +% reporting; mean(bootMean) converges to it as nBoot->Inf but is unconventional). +% Uncertainty bar uses params.ciMethod: +% 'sem' -> ±1 SE from std(bootMean) +% 'percentile' -> [2.5, 97.5] percentile CI (recommended for skewed data) +% +% Resampling is hierarchical via hierBoot (Saravanan et al. 2020), with one +% level per entry of params.bootGroupVars (typically {'animal','insertion'}). +% Falls back to a flat bootstrap (bootstrp) when no levels are configured. % ========================================================================= function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) for i = 1:numel(stimuli) - idx = tblPlot.stimulus == stimuli{i}; + idx = tblPlot.stimulus == stimuli{i}; if ~any(idx), continue; end + % Pull values + matching cluster IDs, dropping NaNs from all together. + % Without this step bootstrp(@mean, vals) returns NaN whenever any sample + % contains a NaN, while the analytical SE silently omits NaNs — the + % original code switched between these two policies at n=500. vals = tblPlot.value(idx); - if numel(vals) < 3 - fprintf('Number of values to bootstrap is less than 3\n'); + 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 - if height(tblPlot) < 500 + % Sample mean as point estimate. + mu = mean(vals); + + % Pull each grouping column for this stimulus, aligned with the NaN drop. + % NOTE: hierBoot pre-allocates intermediate level arrays via nan(size(data)) + % (i.e., as double), so any categorical grouping column must be coerced to + % its underlying integer category code before being passed in. The codes + % preserve group identity for the equality comparisons hierBoot performs. + 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); % numeric-only contract + end + groupVals{g} = col; + end + + % Hierarchical bootstrap of the mean. Empty group list => flat bootstrap. + % For insertion-level data with groups {'animal','insertion'}, the + % within-insertion resampling step picks the same single observation each + % time, so the procedure naturally collapses to an animal->insertion + % bootstrap without any special-casing here. + if isempty(groupVars) bootMean = bootstrp(params.nBoot, @mean, vals); - mu = mean(bootMean); - sem = std(bootMean); else - mu = mean(vals, 'omitnan'); - sem = std(vals, 'omitnan') / sqrt(sum(~isnan(vals))); + bootMean = hierBoot(vals, params.nBoot, groupVals{:}); + end + + % Uncertainty bar from the bootstrap distribution. + 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 - % SEM error bar - line(ax, [i i], mu + [-1 1]*sem, ... + % 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], [mu+sem mu+sem], ... + line(ax, [i-capW i+capW], [yHi yHi], ... 'Color', [0 0 0 semAlpha], 'LineWidth', 2); - line(ax, [i-capW i+capW], [mu-sem mu-sem], ... + line(ax, [i-capW i+capW], [yLo yLo], ... 'Color', [0 0 0 semAlpha], 'LineWidth', 2); - % Mean line + % Mean line — slightly wider than caps so the point estimate stands out. dx = 0.15; plot(ax, [i-dx i+dx], [mu mu], 'k-', 'LineWidth', 1.2); end @@ -414,16 +601,19 @@ function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) % ========================================================================= % LOCAL FUNCTION: plotBrackets -% Draws pairwise significance brackets above the swarm. +% Pairwise significance brackets above the swarm. Four-tier annotation +% (***, **, *, ns), consistent with plotDiffSwarm. % ========================================================================= function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... yMaxVis, bracketPad, stackPad, textPad) fprintf('=== DEBUGGING BRACKETS ===\n'); -fprintf('Number of pairs: %d\n', size(pairs,1)); +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), ', ')); +fprintf('Stimuli in plot: %s\n', strjoin(cellstr(stimuli), ', ')); +% Track y-positions of already-placed brackets so subsequent ones stack +% rather than overlap. usedHeights = zeros(size(pairs,1), 1); for k = 1:size(pairs,1) @@ -432,22 +622,22 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... 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'); + fprintf('SKIPPING: One or both stimuli not found in plot.\n'); continue end vals1 = tblPlot.value(tblPlot.stimulus == pairs{k,1}); vals2 = tblPlot.value(tblPlot.stimulus == pairs{k,2}); - fprintf('vals1 count: %d, vals2 count: %d\n', numel(vals1), numel(vals2)); + % Cap each value at yMaxVis so the bracket is anchored to what's visible. maxVisible = max(min([vals1; vals2], yMaxVis)); - yBase = maxVisible + bracketPad; + yBase = maxVisible + bracketPad; + % Vertical stacking against previously placed brackets. y = yBase; while any(abs(usedHeights(1:k-1) - y) < stackPad) y = y + stackPad; @@ -457,21 +647,19 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... fprintf('Bracket y position: %.3f\n', y); fprintf('p-value: %.4e\n', pValues(k)); - % Bracket lines - 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'); - - % Significance stars - if pValues(k) < 1e-3 - txt = '***'; - if pValues(k) == 0, txt = '****'; end - fprintf('Drawing text: %s\n', txt); - text(ax, mean([x1 x2]), y + textPad, txt, ... - 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); - else - fprintf('p-value not significant enough (>= 1e-3)\n'); + % Bracket horizontal + two short verticals. + 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'); + + if pValues(k) < 0.001, txt = '***'; + elseif pValues(k) < 0.01, txt = '**'; + elseif pValues(k) < 0.05, txt = '*'; + else, txt = 'ns'; end + fprintf('Drawing text: %s\n', txt); + text(ax, mean([x1 x2]), y + textPad, txt, ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); end end % plotBrackets @@ -492,7 +680,7 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... % ========================================================================= % LOCAL FUNCTION: renamePairLabels -% Applies same label substitutions to the pairs cell array. +% Same legacy substitutions, applied element-wise to the pairs cell array. % ========================================================================= function pairs = renamePairLabels(pairs) if isempty(pairs), return; end @@ -506,18 +694,16 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... % ========================================================================= % LOCAL FUNCTION: buildRdBuColormap -% Returns an n-row diverging Red-Blue colormap centred at 0. -% Blue = negative (low Z), White = zero, Red = positive (high Z). +% n-row diverging Red-Blue colormap centred on white. +% Blue = negative, White = zero, Red = positive. % ========================================================================= function cmap = buildRdBuColormap(n) half = floor(n/2); -% Blue -> White (low to mid) blueToWhite = [linspace(0.02, 1, half)', ... linspace(0.44, 1, half)', ... linspace(0.69, 1, half)']; -% White -> Red (mid to high) whiteToRed = [linspace(1, 0.70, half)', ... linspace(1, 0.09, half)', ... linspace(1, 0.09, half)']; diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m index 7af3a91..f75e224 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m @@ -48,7 +48,7 @@ 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 = true % if true: use per-trial sliding window max for + 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. @@ -77,6 +77,8 @@ params.SpatialGridMode = true % if true: use StatisticsPerNeuronSpatialGrid % only applies to linearlyMovingBall % ignored for other stimuli + params.BaseRespWindow = 200 %Fixed window for baseline and response + params.useSegments = false %Use segmented approach end @@ -236,8 +238,11 @@ '(%.0f ms) — capping response window for %s.\n'], ... stimDur, params.MaxStimDuration, obj.stimName); effectiveStimDur = params.MaxStimDuration; % capped duration used for Mr on1. ly - else + elseif params.MovingWindowPVal effectiveStimDur = stimDur; % full duration — no capping needed + else + effectiveStimDur = params.BaseRespWindow; + end % --- Build spike count matrices --- @@ -251,24 +256,24 @@ if isequal(obj.stimName, 'StaticDriftingGrating') if isequal(fieldName,'moving') - Mb = BuildBurstMatrix(goodU, ... + Mb = BuildBurstMatrix(goodU, ... round(p.t), ... - round(directimesSorted - obj.VST.static_time- 0.75 * obj.VST.interTrialDelay * 1000), ... - round(0.75 * obj.VST.interTrialDelay * 1000)); - + 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 - 0.75 * obj.VST.interTrialDelay * 1000), ... - round(0.75 * obj.VST.interTrialDelay * 1000)); + round(directimesSorted - params.BaseRespWindow), ... + round(params.BaseRespWindow)); end else Mb = BuildBurstMatrix(goodU, ... round(p.t), ... - round(directimesSorted - 0.75 * obj.VST.interTrialDelay * 1000), ... - round(0.75 * obj.VST.interTrialDelay * 1000)); + round(directimesSorted - params.BaseRespWindow), ... + round(params.BaseRespWindow)); end % ------------------------------------------------------------------------- @@ -289,7 +294,7 @@ % ------------------------------------------------------------------------- % Flag: use segmented approach for moving ball when sliding window disabled - useSegments = ~params.MovingWindowPVal && isfield(responseParams, "Speed1"); + %useSegments = ~params.MovingWindowPVal && isfield(responseParams, "Speed1"); if params.MovingWindowPVal % --- Sliding window approach --- @@ -308,7 +313,7 @@ baselinesMW = max(mbMov, [], 3); % [nTrials × nNeurons] per-trial max window baseline DiffPVal = responsesMW - baselinesMW; % [nTrials × nNeurons] - elseif useSegments + 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. @@ -384,7 +389,7 @@ signs = 2 * randi(2, size(Diff,1), params.nBoot) - 3; signsR = reshape(signs, trialsCat, nCats, params.nBoot); % [trialsCat × nCats × nBoot] - if useSegments + if params.useSegments % --- Segmented permutation test --- % ObsStat: max mean DiffSeg across valid categories AND segments [1 × nNeurons] % DiffSeg: [nTrials × nNeurons × nSegs] @@ -518,7 +523,7 @@ % ------------------------------------------------------------------------- - if useSegments + if params.useSegments if params.UseLOO % ------------------------------------------------------------------------- diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m index 7d6af9b..de2bc1f 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m @@ -146,7 +146,7 @@ % One baseline per trial, shared across all grid cells crossed in that trial % ------------------------------------------------------------------------- MbMat = BuildBurstMatrix(goodU, round(p.t), ... - round(trialTimes - obj.VST.interTrialDelay * 1000), ... + round(trialTimes - winSize), ... winSize); baselines = mean(MbMat, 3); % [nTrials × nNeurons] diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/PlotReceptiveFields.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/PlotReceptiveFields.m index 3328c9c..84952ca 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/PlotReceptiveFields.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/PlotReceptiveFields.m @@ -200,8 +200,8 @@ % ═══════════════════════════════════════════════════════════════════════════════ for u = eNeuron - ru = find(eNeuron == u); % Index of neuron u within the eNeuron vector - + %ru = find(eNeuron == u); % Index of neuron u within the eNeuron vector + ru =u; % ── Optional: plot the direction-summed (combined) RF ────────────────── if params.allCombined diff --git a/visualStimulationAnalysis/AllExpAnalysis.m b/visualStimulationAnalysis/AllExpAnalysis.m index 609a229..d51b990 100644 --- a/visualStimulationAnalysis/AllExpAnalysis.m +++ b/visualStimulationAnalysis/AllExpAnalysis.m @@ -1254,7 +1254,7 @@ % 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=false, Alpha=0.7); + {'Z-score'}, yLegend='Z-score', yMaxVis=ZscoreYlimUp, diff=true, plotMeanSem=true, Alpha=0.7); ax = gca; ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; @@ -1354,7 +1354,7 @@ 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=false, Alpha=0.7); + {'SpkR'}, yLegend='SpkR', yMaxVis=V1max, diff=true, plotMeanSem=true, Alpha=0.7); ax = gca; ax.YAxis.FontSize = 8; ax.YAxis.FontName = 'helvetica'; diff --git a/visualStimulationAnalysis/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv new file mode 100644 index 0000000..1d61012 --- /dev/null +++ b/visualStimulationAnalysis/RunAnalysisClass.asv @@ -0,0 +1,227 @@ +cd('\\sil3\data\Large_scale_mapping_NP') +excelFile = 'Experiment_Excel.xlsx'; + +data = readtable(excelFile); + +%% +%% Rect Grid +for ex = [74] %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=82, selectedLum=255,oneTrial = true,PaperFig = true) %43 + vsRe.CalculateReceptiveFields('overwrite',true,AllResponsiveNeurons=false) + [colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=82,allStimParamsCombined=false,PaperFig=true,overwrite=true,colorbarLims=[]); + %result = vsRe.BootstrapPerNeuron('overwrite',true); + %result = vsRe.StatisticsPerNeuron('overwrite',true); + +end +% vsRe.CalculateReceptiveFields +%vsRe.PlotReceptiveFields("exNeurons",18) + +%% Moving ball + +for ex =[74]%97 74:84 (Neurons, 96_74, ) + NP = loadNPclassFromTable(ex); %73 81 + vs = linearlyMovingBallAnalysis(NP,Session=1); + % 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,'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',82,'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); +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 +[tempTableMW] = AllExpAnalysis([49:54,64:66, 68:85 87:97],{'MB','RG'},StatMethod='maxPermuteTest', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% + +%% PSTH for all experiments +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=false, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] + +%% Raster for all experiment +plotRaster_MultiExp([49:54,64:66,68:85 87:97], sortBy = "spatialTuning",overwrite=false,TakeTopPercentTrials=[],PaperFig=true) + +%% Calculate spatial tuning +results= SpatialTuningIndex([49:54,64:66, 68:85 87:97], indexType = "L_amplitude_diff" ,overwrite=false... + , topPercent = 30,useRF=true,onOff=1,unionResponsive = false,allResponsive=true, PaperFig=true); + +%% 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=true) %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 a9f8634..3e35e64 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -5,7 +5,7 @@ %% %% Rect Grid -for ex = [49:54,64:66,68:85 87:97] %84:91 +for ex = [69] %84:91 NP = loadNPclassFromTable(ex); %73 81 vsRe = rectGridAnalysis(NP); % vsRe.getSessionTime("overwrite",true); @@ -17,9 +17,9 @@ %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.plotRaster(MergeNtrials=1,overwrite=true,AllResponsiveNeurons=false,exNeurons=82, selectedLum=255,oneTrial = true,PaperFig = true) %43 vsRe.CalculateReceptiveFields('overwrite',true,AllResponsiveNeurons=false) - %[colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=21,allStimParamsCombined=false,PaperFig=true,overwrite=true); + [colorbarLimsRG] = vsRe.PlotReceptiveFields(exNeurons=21,allStimParamsCombined=false,PaperFig=true,overwrite=true); %result = vsRe.BootstrapPerNeuron('overwrite',true); %result = vsRe.StatisticsPerNeuron('overwrite',true); @@ -29,7 +29,7 @@ %% Moving ball -for ex =[49:54,64:66,68:85 87:97]%97 74:84 (Neurons, 96_74, ) +for ex =[69]%97 74:84 (Neurons, 96_74, ) NP = loadNPclassFromTable(ex); %73 81 vs = linearlyMovingBallAnalysis(NP,Session=1); % vs.getSessionTime("overwrite",true); @@ -44,10 +44,10 @@ %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('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',82,'overwrite',true,'OneDirection','up','OneLuminosity','white','PaperFig',true); + 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]; @@ -66,18 +66,18 @@ %[49:54,84:90,92:96] %All SDG experiments %solve MBR %bootsrapRespBase -[tempTableMW] = AllExpAnalysis([49:54,64:66, 68:85 87:97] ,{'MB','RG'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... +[tempTableMW] = AllExpAnalysis([49:54,64:66, 68:85 87:97],{'MB','RG'},StatMethod='maxPermuteTest', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... overwriteResponse=false,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% %% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=false, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] %% Raster for all experiment -plotRaster_MultiExp([49:54,64:66,68:85 87:97], sortBy = "peak",overwrite=false,TakeTopPercentTrials=[]) +plotRaster_MultiExp([49:54,64:66,68:85 87:97], sortBy = "spatialTuning",overwrite=false,TakeTopPercentTrials=[],PaperFig=true) %% Calculate spatial tuning results= SpatialTuningIndex([49:54,64:66, 68:85 87:97], indexType = "L_amplitude_diff" ,overwrite=false... - , topPercent = 40,useRF=true,onOff=1,unionResponsive = false,allResponsive=true, PaperFig=true); + , topPercent = 30,useRF=true,onOff=1,unionResponsive = false,allResponsive=true, PaperFig=true); %% Get neuron depths getNeuronDepths([49:54,64:97]) %[49:54,64:72,84:97] %% PV140 missing depth coordinates diff --git a/visualStimulationAnalysis/computeBallGridCrossings.m b/visualStimulationAnalysis/computeBallGridCrossings.m index e6a133e..600cf8e 100644 --- a/visualStimulationAnalysis/computeBallGridCrossings.m +++ b/visualStimulationAnalysis/computeBallGridCrossings.m @@ -162,10 +162,10 @@ end end -figure;imagesc(reshape(mean(dwellFrames,1),[9,9])) - -figure;imagesc(reshape(mean(dwellFrames, 1, 'omitnan'), [nGrid nGrid])); -colorbar; title('Original trajectories'); +%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(:)) diff --git a/visualStimulationAnalysis/plotRaster_MultiExp.m b/visualStimulationAnalysis/plotRaster_MultiExp.m index c2f822f..bf5065a 100644 --- a/visualStimulationAnalysis/plotRaster_MultiExp.m +++ b/visualStimulationAnalysis/plotRaster_MultiExp.m @@ -7,100 +7,107 @@ function plotRaster_MultiExp(exList, params) % 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 or recording depth. +% 4. Sorts neurons by peak-response time, recording depth, +% spatial-tuning index, 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. % % ------------------------------------------------------------------------- -% BUGS FIXED +% CHANGE LOG % ------------------------------------------------------------------------- -% BUG 1 — Sort-by-peak double-smoothing -% ConvBurstMatrix was applied in-place and the smoothed version stored -% back into rasterAll, causing neurons to be smoothed twice when -% params.smooth > 0. Fixed: peak-finding uses a local copy (dataForSort); -% rasterAll always stores the original data. -% -% BUG 2 — Wrong colour limits for zScore=true + colormap="gray" -% The else-branch in the tile loop overwrote cLims with raw firing-rate -% percentiles, ignoring climNeg. Fixed: cLims and colormap are computed -% once before the tile loop and reused. -% -% BUG 3 — Stim-offset xline in wrong units -% xline used the ms value on a seconds axis. Fixed: xline now uses -% stimDurAll(s) / 1000. +% 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. arguments - exList double % vector of experiment IDs to include - params.stimTypes (1,:) string = ["rectGrid","linearlyMovingBall"] % stimulus types — one tile each - 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; - % when useCompleteWindow=true this is - % added as a post-offset buffer on top - % of the actual stimulus duration - params.preBase double = 200 % pre-stimulus baseline in ms - params.overwrite logical = false % if true, recompute even if cache exists - params.TakeTopPercentTrials double = 0.3 % top fraction of trials to keep by mean - % firing rate; set empty to keep all - params.zScore logical = true % z-score each neuron using its baseline - params.sortBy string = "peak" % "peak" | "depth" | "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 sorting - params.useCompleteWindow logical = true % if true, read stimulus duration from - % NeuronResp.(fieldName).stimDur and use - % params.postStim as a post-offset buffer + exList double % vector of experiment IDs to include + params.stimTypes (1,:) string = ["rectGrid","linearlyMovingBall"] % stimulus types — one tile each + params.binWidth double = 10 % PSTH bin width in ms + params.smooth double = 0 % Gaussian smoothing SD in ms (0 = off) + params.statType string = "MaxPermuteTest" % which statistics field to use + params.speed string = "max" % ball-speed selector: "max" or other + params.alpha double = 0.05 % significance threshold + params.postStim double = 0 % post-stimulus window in ms + params.preBase double = 200 % pre-stimulus baseline in ms + params.overwrite logical = false % if true, recompute even if cache exists + params.TakeTopPercentTrials double = 0.3 % 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" | "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 end +% ------------------------------------------------------------------------- +% Sanity check: baseline must be an integer multiple of bin width +% ------------------------------------------------------------------------- +assert(mod(params.preBase, params.binWidth) == 0, ... + 'preBase (%g ms) must be a multiple of binWidth (%g ms).', ... + params.preBase, params.binWidth); + % ------------------------------------------------------------------------- % 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 early if depth file is missing + if ~exist(depthFile, 'file') % abort if file is missing error('NeuronDepths.mat not found. Run getNeuronDepths() first.'); end - D = load(depthFile); % load MAT file containing depth info - depthTable = D.depthTable; % table with columns: Experiment, Unit, Depth_um + 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 first experiment (path only) -vs_first = linearlyMovingBallAnalysis(NP_first); % build analysis object to access file-system path +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 -p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); % root directory up to 'lizards' token -p = [p 'lizards']; % reconstruct path including 'lizards' -if ~exist([p '\Combined_lizard_analysis'], 'dir') % create output folder if absent +if ~exist([p '\Combined_lizard_analysis'], 'dir') % create output dir if absent cd(p) mkdir Combined_lizard_analysis end -saveDir = [p '\Combined_lizard_analysis']; % full path to output directory +saveDir = [p '\Combined_lizard_analysis']; % output directory -stimLabel = strjoin(params.stimTypes, '-'); % e.g. "rectGrid-linearlyMovingBall" -nameOfFile = sprintf('\\Ex_%d-%d_Raster_%s.mat', ... % unique filename from experiment range + stim types - exList(1), exList(end), stimLabel); +stimLabel = strjoin(params.stimTypes, '-'); % e.g. "rectGrid-linearlyMovingBall" +nameOfFile = sprintf('\\Ex_%d-%d_Raster_%s.mat', ... + exList(1), exList(end), stimLabel); % 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) % verify cached experiment list matches +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 + forloop = false; % skip computation else fprintf('Experiment list mismatch — recomputing.\n'); - forloop = true; % list differs: recompute + forloop = true; % mismatch: recompute end else - forloop = true; % no cache or overwrite requested + forloop = true; % no cache or overwrite requested end % ========================================================================= @@ -108,47 +115,50 @@ function plotRaster_MultiExp(exList, params) % ========================================================================= if forloop - nStim = numel(params.stimTypes); % number of stimulus conditions - nExp = numel(exList); % number of experiments + nStim = numel(params.stimTypes); % number of stimulus conditions + nExp = numel(exList); % number of experiments - % Accumulators: one entry per stimulus type, growing one row per neuron - rasterAll = cell(1, nStim); % nNeurons x nBins PSTH matrix per stim - depthAll = cell(1, nStim); % recording depth (um) per neuron - expAll = cell(1, nStim); % experiment ID per neuron row + % --- 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 for s = 1:nStim - rasterAll{s} = []; + rasterAll{s} = []; % initialise empty depthAll{s} = []; expAll{s} = []; + phyAll{s} = []; end - % lockedPreBase is shared (same baseline for all stimuli). - % Time-axis variables are per-stimulus (cell/array) because each - % stimulus can have a different total window when useCompleteWindow=true. - lockedPreBase = []; % baseline duration (ms) — locked on first exp - lockedEdges = cell(1, nStim); % bin edges (ms) — one set per stimulus - lockedNBins = zeros(1, nStim); % number of bins per stimulus - tAxis = cell(1, nStim); % left bin edge (ms) per stimulus - stimDurAll = zeros(1, nStim); % stim duration (ms) per stimulus for xline + % Counter for neurons dropped due to zero-SD baseline + nDroppedZeroSD = zeros(1, nStim); % per-stim counter + + % --- Shared time-axis variables --- + lockedPreBase = []; % baseline duration (ms) — locked on first exp + lockedEdges = cell(1, nStim); % bin edges (ms) per stimulus + lockedNBins = zeros(1, nStim); % bin count per stimulus + tAxis = cell(1, nStim); % left bin edge (ms) per stimulus + stimDurAll = zeros(1, nStim); % stim duration (ms) per stim for xline - % Pre-scan: find the shortest stimulus duration per stimulus type. - % All experiments are truncated to this minimum so that no trial - % window exceeds what every session can provide. - minStimDur = inf(1, nStim); % one minimum per stimulus type + % ----------------------------------------------------------------- + % 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)); + NPtmp = loadNPclassFromTable(exList(ei)); % load NP object switch params.stimTypes(s) case "rectGrid"; objTmp = rectGridAnalysis(NPtmp); case "linearlyMovingBall"; objTmp = linearlyMovingBallAnalysis(NPtmp); case "StaticGrating"; objTmp = StaticDriftingGratingAnalysis(NPtmp); case "MovingGrating"; objTmp = StaticDriftingGratingAnalysis(NPtmp); end - NRtmp = objTmp.ResponseWindow; + NRtmp = objTmp.ResponseWindow; % response-window struct - % Resolve fieldName using the same logic as the main loop + % Resolve fieldName (same logic as main loop) if params.speed ~= "max" && isequal(objTmp.stimName, 'linearlyMovingBall') fn = 'Speed2'; elseif isequal(objTmp.stimName, 'linearlyMovingBall') @@ -158,16 +168,16 @@ function plotRaster_MultiExp(exList, params) elseif isequal(params.stimTypes(s), 'MovingGrating') fn = 'Moving'; else - fn = ''; % rectGrid: flat struct, no sub-field + fn = ''; % rectGrid: flat struct end try - dur = NRtmp.(fn).stimDur; % named sub-field (e.g. Speed1, Moving) + dur = NRtmp.(fn).stimDur; % sub-field duration catch - dur = NRtmp.stimDur; % fallback: flat struct (e.g. rectGrid) + dur = NRtmp.stimDur; % flat struct fallback end - minStimDur(s) = min(minStimDur(s), dur); % keep shortest duration for this stimulus + minStimDur(s) = min(minStimDur(s), dur); % keep shortest for this stim catch % skip quietly if experiment/stim cannot be loaded end @@ -176,7 +186,7 @@ function plotRaster_MultiExp(exList, params) fprintf('Minimum stimulus durations per type (ms):'); for s = 1:nStim - fprintf(' %s = %.0f ms', params.stimTypes(s), minStimDur(s)); + fprintf(' %s = %.0f ms', params.stimTypes(s), minStimDur(s)); % display per-stim duration end fprintf('\n'); @@ -185,21 +195,21 @@ function plotRaster_MultiExp(exList, params) % ----------------------------------------------------------------- for ei = 1:nExp - ex = exList(ei); + ex = exList(ei); % current experiment ID fprintf('\n=== Experiment %d ===\n', ex); try - NP = loadNPclassFromTable(ex); % load Neuropixels data object + NP = loadNPclassFromTable(ex); % load Neuropixels data object catch ME warning('Could not load experiment %d: %s', ex, ME.message); - continue % skip experiment on load failure + continue % skip on load failure end for s = 1:nStim - stimType = params.stimTypes(s); % current stimulus type string + stimType = params.stimTypes(s); % current stimulus string - % Build stimulus-specific analysis object + % --- Build stimulus-specific analysis object --- try switch stimType case "rectGrid" @@ -215,183 +225,201 @@ function plotRaster_MultiExp(exList, params) end catch ME warning('Could not build %s for exp %d: %s', stimType, ex, ME.message); - continue % skip this stim/exp on failure + continue % skip this stim/exp end - NeuronResp = obj.ResponseWindow; % full response-window struct for this stimulus + NeuronResp = obj.ResponseWindow; % response-window struct - % Select the correct statistics struct + % --- Select statistics struct --- if params.statType == "BootstrapPerNeuron" - Stats = obj.BootstrapPerNeuron; % bootstrap-based p-values + Stats = obj.BootstrapPerNeuron; % bootstrap p-values else - Stats = obj.StatisticsPerNeuron; % default: permutation-test p-values + Stats = obj.StatisticsPerNeuron; % default: permutation test end - % Resolve sub-field name and any intra-window stim onset offset - % SUGGESTION: a switch/case or a method on each analysis class - % would be cleaner than chained if-elseif here. - fieldName = ''; % sub-field key into Stats / NeuronResp - startStim = 0; % ms offset to align stim onset to t = 0 + % --- Resolve sub-field name and stim-onset offset --- + fieldName = ''; % sub-field key + startStim = 0; % ms offset for stim onset if params.speed ~= "max" && isequal(obj.stimName, 'linearlyMovingBall') - fieldName = 'Speed2'; % slower ball-speed condition + fieldName = 'Speed2'; % slower speed condition elseif isequal(obj.stimName, 'linearlyMovingBall') - fieldName = 'Speed1'; % fastest (max) ball-speed condition + fieldName = 'Speed1'; % fastest speed condition elseif isequal(stimType, 'StaticGrating') - fieldName = 'Static'; % static grating sub-field + fieldName = 'Static'; % static grating sub-field elseif isequal(stimType, 'MovingGrating') - fieldName = 'Moving'; % moving grating sub-field - startStim = obj.VST.static_time * 1000; % moving phase starts after static (s -> ms) + fieldName = 'Moving'; % moving grating sub-field + startStim = obj.VST.static_time * 1000; % moving phase onset (s -> ms) end - % Convert Phy sorting output to time x unit matrix - p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); % Phy -> tIc format - label = string(p_sort.label'); % quality label per unit - goodU = p_sort.ic(:, label == 'good'); % keep only manually curated 'good' units + % --- 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 % Phy cluster ID per good unit - % Extract response p-values; try named sub-field first + % --- Response p-values --- try - pvals = Stats.(fieldName).pvalsResponse; % stim-specific p-values + pvals = Stats.(fieldName).pvalsResponse; % stim-specific catch - pvals = Stats.pvalsResponse; % fallback: flat struct + pvals = Stats.pvalsResponse; % flat struct fallback end - % Extract stimulus onset times from the condition matrix + % --- Stimulus onset times --- try - C = NeuronResp.(fieldName).C; % condition matrix for this sub-field + C = NeuronResp.(fieldName).C; % condition matrix catch - C = NeuronResp.C; % fallback + C = NeuronResp.C; % fallback end - directimesSorted = C(:, 1)' + startStim; % stim onset times in ms + directimesSorted = C(:, 1)' + startStim; % onset times in ms - % ---------------------------------------------------------- - % Determine the total window for this stimulus - % ---------------------------------------------------------- - preBase = params.preBase; % baseline duration (ms) + % --- Determine total trial window --- + preBase = params.preBase; % baseline in ms if params.useCompleteWindow - rawStimDur_ms = minStimDur(s); % shortest duration for this stimulus type - windowTotal = preBase + rawStimDur_ms + params.postStim; % baseline + truncated stim + post-offset buffer + rawStimDur_ms = minStimDur(s); % truncate to shortest duration + windowTotal = preBase + rawStimDur_ms + params.postStim; else - rawStimDur_ms = params.postStim; % fixed window as before + rawStimDur_ms = params.postStim; % fixed window windowTotal = preBase + params.postStim; end - % Lock the baseline duration on the very first experiment + % Lock baseline on first experiment if isempty(lockedPreBase) - lockedPreBase = preBase; % shared across all stimuli + lockedPreBase = preBase; % shared across all stimuli end - % Lock bin edges per stimulus on the first experiment that - % provides them — all subsequent experiments use the same edges - % so that every row in rasterAll{s} has identical length. + % Lock bin edges per stim on first encounter if isempty(lockedEdges{s}) - lockedEdges{s} = 0 : params.binWidth : windowTotal; % bin edges from 0 to windowTotal (ms) - lockedNBins(s) = numel(lockedEdges{s}) - 1; % number of bins for this stimulus - tAxis{s} = lockedEdges{s}(1:end-1); % left edge of each bin (ms) - stimDurAll(s) = rawStimDur_ms; % store stim duration for xline in plot + 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 - eNeurons = find(pvals < params.alpha); % indices of significantly responsive neurons + % --- Find responsive neurons --- + eNeurons = find(pvals < params.alpha); % indices of significant neurons 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); + fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', ... + stimType, numel(eNeurons), ex); % ---------------------------------------------------------- - % Build per-neuron PSTH and append to rasterAll + % Per-neuron PSTH % ---------------------------------------------------------- for ni = 1:numel(eNeurons) - u = eNeurons(ni); % index of this neuron in the 'good' list + u = eNeurons(ni); % index into the good-unit list - % BuildBurstMatrix returns a trials x time-samples binary matrix (1 ms resolution) + % Binary spike matrix: trials x time at 1 ms resolution MRhist = BuildBurstMatrix( ... - goodU(:, u), ... % binary spike train for this unit - round(p_sort.t), ... % sample timestamps (rounded to avoid float errors) - round(directimesSorted - lockedPreBase), ... % trial start = stim onset minus baseline - round(windowTotal)); % window duration (ms, integer) - MRhist = squeeze(MRhist); % collapse singleton dimensions - - % Optionally restrict to the highest-firing trials - if ~isempty(params.TakeTopPercentTrials) - MeanTrial = mean(MRhist, 2); % mean spike count per trial (full window) - % SUGGESTION: computing the mean over the post-stim - % window only (columns where tAxis{s} >= lockedPreBase) - % would avoid selecting high-baseline trials over - % high-response trials. - [~, ind] = sort(MeanTrial, 'descend'); % rank trials by mean activity - takeTrials = ind(1 : round(numel(MeanTrial) * params.TakeTopPercentTrials)); - MRhist = MRhist(takeTrials, :); % keep top fraction of trials + 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 + + % --- 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); % bin index for every position in MRhist - spikeTimes = spikeTimes(logical(MRhist)); % keep only bins where spikes occurred - counts = histcounts(spikeTimes, lockedEdges{s}); % spike count per bin (per-stimulus edges) - neuronPSTH = (counts / (params.binWidth * nTrials)) * 1000; % convert counts -> spk/s + 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 the pre-stimulus baseline + % --- Z-score using pre-stimulus baseline --- if params.zScore - baselineBins = tAxis{s} < lockedPreBase; % logical mask for baseline bins (per-stim tAxis) - bMean = mean(neuronPSTH(baselineBins)); % baseline mean firing rate - bStd = std(neuronPSTH(baselineBins)); % baseline standard deviation + baselineBins = tAxis{s} < lockedPreBase; % mask for baseline bins + bMean = mean(neuronPSTH(baselineBins)); % baseline mean + bStd = std(neuronPSTH(baselineBins)); % baseline SD if bStd > 0 - neuronPSTH = (neuronPSTH - bMean) / bStd; % z-score + neuronPSTH = (neuronPSTH - bMean) / bStd; % z-score else - % SUGGESTION: silently dropping zero-SD neurons - % biases the population toward cells with measurable - % spontaneous activity. Consider logging dropped - % units or using a minimum-SD floor. - continue % skip: silent baseline -> undefined z-score + % 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 + % --- Smooth PSTH if requested --- if params.smooth > 0 - smoothBins = round(params.smooth / params.binWidth); % smoothing SD in bins - neuronPSTH = smoothdata(neuronPSTH, 'gaussian', smoothBins); % Gaussian smooth + smoothBins = round(params.smooth / params.binWidth); % smoothing SD in bin units + neuronPSTH = smoothdata(neuronPSTH, 'gaussian', smoothBins); end - rasterAll{s} = [rasterAll{s}; neuronPSTH]; % append neuron as a new row + % --- 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 - % Store recording depth (needed only for depth-sorted plots) + % 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); % depth in um + depthAll{s}(end+1) = depthTable.Depth_um(depthRow); else - depthAll{s}(end+1) = NaN; % not found in depth table + depthAll{s}(end+1) = NaN; % not found end else - depthAll{s}(end+1) = NaN; % depth unused; keeps vectors aligned + depthAll{s}(end+1) = NaN; % unused; keeps vector aligned end - expAll{s}(end+1) = ex; % record source experiment - end % neuron loop 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 + + % FIX 9: verify accumulator alignment after all experiments + for s = 1:nStim + nRows = size(rasterAll{s}, 1); + 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); + end + % ------------------------------------------------------------------ % Save processed data to disk % ------------------------------------------------------------------ - S.expList = exList; % saved for validation on reload - S.lockedEdges = lockedEdges; % cell array of per-stimulus bin edges - S.lockedPreBase = lockedPreBase; % shared baseline duration - S.stimDurAll = stimDurAll; % per-stimulus stim duration for xline - S.params = params; % full parameter set alongside data + 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 MATLAB struct field name + stimField = matlab.lang.makeValidName(params.stimTypes(s)); % valid struct field name S.(sprintf('%s_raster', stimField)) = rasterAll{s}; S.(sprintf('%s_depth', stimField)) = depthAll{s}; S.(sprintf('%s_exp', stimField)) = expAll{s}; + S.(sprintf('%s_phy', stimField)) = phyAll{s}; % NEW: Phy cluster IDs end save([saveDir nameOfFile], '-struct', 'S'); @@ -399,29 +427,130 @@ function plotRaster_MultiExp(exList, params) else % ------------------------------------------------------------------ - % Reload cached data from disk + % Reload cached data % ------------------------------------------------------------------ - lockedEdges = S.lockedEdges; % restore per-stimulus bin edges - lockedPreBase = S.lockedPreBase; % restore baseline duration - stimDurAll = S.stimDurAll; % restore per-stimulus stim durations + lockedEdges = S.lockedEdges; % restore bin edges + lockedPreBase = S.lockedPreBase; % restore baseline + stimDurAll = S.stimDurAll; % restore stim durations rasterAll = cell(1, numel(params.stimTypes)); depthAll = cell(1, numel(params.stimTypes)); expAll = cell(1, numel(params.stimTypes)); + phyAll = cell(1, numel(params.stimTypes)); for s = 1:numel(params.stimTypes) stimField = matlab.lang.makeValidName(params.stimTypes(s)); rasterAll{s} = S.(sprintf('%s_raster', stimField)); depthAll{s} = S.(sprintf('%s_depth', stimField)); expAll{s} = S.(sprintf('%s_exp', stimField)); + + % 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" + 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 end - % Reconstruct per-stimulus tAxis from the stored edges + % 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 of each bin (ms) + 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 + stimMask = string(tuningTable.stimulus) == params.stimTypes(s); + 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)); + 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 % ========================================================================= @@ -429,38 +558,49 @@ function plotRaster_MultiExp(exList, params) % ========================================================================= for s = 1:numel(params.stimTypes) - data = rasterAll{s}; % nNeurons x nBins matrix + data = rasterAll{s}; % nNeurons x nBins if isempty(data); continue; end if params.sortBy == "peak" - postStimBins = tAxis{s} >= lockedPreBase; % post-stim mask using per-stimulus tAxis - - % BUG FIX: previously ConvBurstMatrix overwrote 'data' and the - % smoothed version was stored back into rasterAll (double-smoothing). - % dataForSort is a local copy used only for peak detection. - - if size(data,2) > 100 - dataForSort = ConvBurstMatrix( ... - data, fspecial('gaussian', [1 params.GaussianLength+20], 2), 'same'); % smooth copy for peak detection only + % --- 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'); else - dataForSort = ConvBurstMatrix( ... - data, fspecial('gaussian', [1 params.GaussianLength], 2), 'same'); % smooth copy for peak detection only + dataForSort = ConvBurstMatrix( ... + data, fspecial('gaussian', [1 params.GaussianLength], 2), 'same'); end - [~, peakBin] = max(dataForSort(:, postStimBins), [], 2); % column of peak per neuron - [~, sortIdx] = sort(peakBin); % ascending: early-peaking neurons first + [~, peakBin] = max(dataForSort(:, postStimBins), [], 2); % peak column per neuron + [~, sortIdx] = sort(peakBin); % early-peaking first elseif params.sortBy == "depth" - [~, sortIdx] = sort(depthAll{s}, 'ascend'); % shallowest recording sites first + % --- 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'); else - sortIdx = 1:size(data, 1); % no reordering + % --- No reordering --- + sortIdx = 1:size(data, 1); end - rasterAll{s} = data(sortIdx, :); % reorder rows of the ORIGINAL data - depthAll{s} = depthAll{s}(sortIdx); % reorder depth vector to match - expAll{s} = expAll{s}(sortIdx); % reorder experiment-ID vector to match + % 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 + + if params.sortBy == "spatialTuning" + tuningAll{s} = tuningAll{s}(sortIdx); % keep tuning vector aligned + end end @@ -468,144 +608,150 @@ function plotRaster_MultiExp(exList, params) % PLOT % ========================================================================= +% Short display labels for each stimulus type stimLegendMap = containers.Map( ... {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... - {'MB', 'SB', 'MG', 'SG'}); % short display labels + {'MB', 'SB', 'MG', 'SG'}); nStim = numel(params.stimTypes); % ------------------------------------------------------------------ -% Global colour limits — computed once and shared across all tiles -% so the colour scale is directly comparable between stimuli. -% BUG FIX: previously cLims was recomputed incorrectly inside the loop. +% 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 data values for percentile calculation + allValues = [allValues, rasterAll{s}(:)']; %#ok % pool all values end end if params.zScore - cLimPos = prctile(allValues, params.climPrctile); % data-driven upper z-score limit - cLims = [-params.climNeg, cLimPos]; % asymmetric: fixed lower, data-driven upper + cLimPos = prctile(allValues, params.climPrctile); % data-driven upper limit + cLims = [-params.climNeg, cLimPos]; % fixed lower, data-driven upper else - cLims = [prctile(allValues, 2), prctile(allValues, params.climPrctile)]; % symmetric percentile range + % Asymmetric percentile clipping: 2nd pctl as floor, climPrctile as ceiling + cLims = [prctile(allValues, 2), prctile(allValues, params.climPrctile)]; end % ------------------------------------------------------------------ -% Build diverging colormap once (used when colormap ~= "gray") +% Build colormap once % ------------------------------------------------------------------ if params.zScore && params.colormap ~= "gray" - nColors = 256; % total colour table 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]; % full diverging colormap + + % 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); % default: white = low, black = high + 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 number of tiles +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'); % 1-row tile grid -axAll = gobjects(1, nStim); % pre-allocate axes handles +tl = tiledlayout(fig, 1, nStim, 'TileSpacing', 'compact', 'Padding', 'compact'); +axAll = gobjects(1, nStim); % pre-allocate axes handles for s = 1:nStim - data = rasterAll{s}; % nNeurons x nBins for this stimulus - stimKey = char(params.stimTypes(s)); % char for containers.Map lookup + 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 + shortName = stimLegendMap(stimKey); % abbreviated label else - shortName = stimKey; % fallback to full name + shortName = stimKey; % fallback end - axAll(s) = nexttile(tl); % create tile and capture axes handle + 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: hide axes + axis(ax, 'off'); % nothing to plot continue end - % Per-stimulus time axis in seconds - tAxisPlot = tAxis{s} - lockedPreBase; % shift so stim onset = 0 ms - tAxisSec = tAxisPlot / 1000; % convert to seconds + % --- 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: x = seconds, y = neuron index - clim(ax, cLims); % shared colour limits - colormap(ax, cmapToUse); % shared colormap + 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 (only when sortBy = "depth") + % Depth-bin boundary lines (depth sort only) % ------------------------------------------------------------------ if params.sortBy == "depth" && ~isempty(depthAll{s}) - D2 = load(depthFile); % load bin edges (file already validated above) - depthBinEdges = D2.depthBinEdges; % edges defining depth layers (um) + 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 before boundary search - labelX = tAxisSec(1) + 0.05 * range(tAxisSec); % x position for labels: 5% from left edge + 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 + 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 between depth bins + 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}, ... % label for the deepest bin + text(ax, labelX, size(data,1), binLabelsDepth{3}, ... % deepest bin label 'Color', 'w', 'FontSize', 6, 'FontName', 'helvetica', ... 'HorizontalAlignment', 'left', 'VerticalAlignment', 'top'); end - % Stim onset and offset lines in seconds - xline(ax, 0, 'k--', 'LineWidth', 1.0); % stim onset at t = 0 s - xline(ax, stimDurAll(s)/1000, 'k--', 'LineWidth', 1.0); % stim offset — per-stimulus, in seconds + % --- 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 - % Per-tile x-axis range — reflects this stimulus's own duration - xlim(ax, [tAxisSec(1), tAxisSec(end)]); - ylim(ax, [0.5, size(data,1) + 0.5]); % half-row margin above and below + % --- Axis formatting --- + xlim(ax, [tAxisSec(1), tAxisSec(end)]); % per-stim x range + ylim(ax, [0.5, size(data,1) + 0.5]); % half-row margin - % Equal-spaced ticks at interval = seconds. - ticksSec = linspace(tAxisSec(1), tAxisSec(end), 5); % 5 perfectly equally spaced ticks - [~, iz] = min(abs(ticksSec)); % find the tick nearest to 0 - ticksSec(iz) = 0; % snap it to exactly 0 for a clean label + 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); xticklabels(ax, arrayfun(@(v) sprintf('%.2g', v), ticksSec, 'UniformOutput', false)); if s == 1 - ylabel(ax, 'Neuron #', 'FontName', 'helvetica', 'FontSize', 8); % y-label on leftmost tile only + ylabel(ax, 'Neuron #', 'FontName', 'helvetica', 'FontSize', 8); % y-label on leftmost tile end title(ax, sprintf('%s (n=%d)', shortName, size(data,1)), ... 'FontName', 'helvetica', 'FontSize', 8); ax.FontName = 'helvetica'; - ax.FontSize = 8; - ax.YDir = 'normal'; % neuron 1 at bottom, increasing upward - ax.TickDir = 'out'; % outward ticks (publication convention) - ax.Box = 'off'; % remove top/right border + ax.FontSize = 8; + ax.YDir = 'normal'; % neuron 1 at bottom + ax.TickDir = 'out'; % outward ticks + ax.Box = 'off'; % no top/right border end % ------------------------------------------------------------------ -% Shared x-label via tiledlayout — one label centred below all tiles +% Shared x-label % ------------------------------------------------------------------ xlabel(tl, 'Time relative to stimulus onset (s)', ... 'FontName', 'helvetica', 'FontSize', 8); @@ -613,7 +759,7 @@ function plotRaster_MultiExp(exList, params) % ------------------------------------------------------------------ % Single colorbar on the rightmost tile % ------------------------------------------------------------------ -cb = colorbar(axAll(end)); % attach colorbar to last axes +cb = colorbar(axAll(end)); % attach to last axes if params.zScore cb.Label.String = 'Z-score'; else @@ -626,10 +772,10 @@ function plotRaster_MultiExp(exList, params) set(fig, 'Units', 'centimeters', 'Position', [20 20 9 12]); sgtitle(sprintf('N = %d experiments', numel(exList)), ... - 'FontName', 'helvetica', 'FontSize', 10); % super-title above the entire figure + 'FontName', 'helvetica', 'FontSize', 10); % super-title if params.PaperFig - vs_first.printFig(fig, sprintf('Raster-%s', stimLabel), PaperFig=params.PaperFig); % export to file + vs_first.printFig(fig, sprintf('Raster-%s', stimLabel), PaperFig=params.PaperFig); end end \ No newline at end of file From 63362c6104f88c5fbadd2c95ca78db2eef7fc601 Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Wed, 6 May 2026 18:37:07 +0300 Subject: [PATCH 15/19] 5.6.2026 --- .../@VStimAnalysis/StatisticsPerNeuron.m | 20 +- .../StatisticsPerNeuronSpatialGrid.m | 11 +- visualStimulationAnalysis/AllExpAnalysis.m | 2152 +++++------------ visualStimulationAnalysis/AllExpAnalysisV1.m | 1735 +++++++++++++ .../RunAnalysisClass.asv | 227 -- visualStimulationAnalysis/RunAnalysisClass.m | 18 +- .../SpatialTuningIndex.m | 409 +++- 7 files changed, 2805 insertions(+), 1767 deletions(-) create mode 100644 visualStimulationAnalysis/AllExpAnalysisV1.m delete mode 100644 visualStimulationAnalysis/RunAnalysisClass.asv diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m index f75e224..edcd24d 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m @@ -108,6 +108,7 @@ GridSize = 9, ... GridAnalysisWindow = 200, ... MinTrialsPerCell = 3, ... + ApplyFDR = params.ApplyFDR, ... overwrite = params.overwrite); return end @@ -192,6 +193,23 @@ 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; @@ -272,7 +290,7 @@ else Mb = BuildBurstMatrix(goodU, ... round(p.t), ... - round(directimesSorted - params.BaseRespWindow), ... + round(directimesSorted - min([params.BaseRespWindow responseParams.stimInter-100])), ... round(params.BaseRespWindow)); end diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m index de2bc1f..f520349 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m @@ -25,6 +25,7 @@ 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 @@ -261,6 +262,12 @@ [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 @@ -268,6 +275,7 @@ % ------------------------------------------------------------------------- nullMean = mean(nullStatsAll, 1); % [1 × nNeurons] z = (obsStat - nullMean) ./ sdBase; % [1 × nNeurons] + z_mean = obsStat; z(sdBase == 0) = 0; % silent baseline — set to 0 % ------------------------------------------------------------------------- @@ -352,6 +360,7 @@ 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 @@ -359,7 +368,7 @@ % --- Save and return --- fprintf('Saving results to file.\n'); -save(obj.getAnalysisFileName, '-struct', 'S'); +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/AllExpAnalysis.m b/visualStimulationAnalysis/AllExpAnalysis.m index d51b990..29f928a 100644 --- a/visualStimulationAnalysis/AllExpAnalysis.m +++ b/visualStimulationAnalysis/AllExpAnalysis.m @@ -1,1735 +1,837 @@ -function [tempTable] = AllExpAnalysis(expList, Stims2Comp, params) -% PlotZScoreComparison - Compare z-scores and spike rates across visual stimuli -% across multiple Neuropixels recordings. +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. % -% 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. +% 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 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). +% INPUTS +% expList (1,:) double Row vector of experiment IDs from master Excel. +% params Name-value See arguments block below. % -% OUTPUT: -% fig - figure handle of the last figure created. +% OUTPUTS +% tempTable table Fraction-responsive table (one row per insertion × +% stimulus), filtered to insertions containing all +% compared stimuli. % -% ------------------------------------------------------------------------- -% 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. +% EXAMPLE +% tempTable = AllExpAnalysis([49:54 64:66], ... +% ComparePairs = {'SDGm','SDGs'}, ... +% StatMethod = 'maxPermuteTest', ... +% PaperFig = true); % -% 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 +% See also: hierBoot, plotSwarmBootstrapWithComparisons % ========================================================================= -% SECTION 1 – INITIALISE BOOKKEEPING VARIABLES +% ARGUMENTS BLOCK % ========================================================================= - -% 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 +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 2 – DETERMINE OUTPUT FILE PATH AND WHETHER THE LOOP IS NEEDED +% SECTION 1 — SETUP: DETERMINE STIMULI, PATHS, AND CACHING % ========================================================================= -% 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 +% Unique stimulus names that need to be loaded and analysed +stimsNeeded = unique(params.ComparePairs, 'stable'); % e.g. {'SDGm','SDGs'} -% 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}); +% Number of experiments to process +nExp = numel(expList); -% Extract base path up to (and including) the 'lizards' folder -p = extractBefore(vs.getAnalysisFileName, 'lizards'); -p = [p 'lizards']; +% Load the first experiment to extract directory paths +NP0 = loadNPclassFromTable(expList(1)); % Neuropixels recording object +vs0 = linearlyMovingBallAnalysis(NP0); % analysis object (used for path only) -% Create the 'Combined_lizard_analysis' subdirectory if it does not exist -if ~exist([p '\Combined_lizard_analysis'], 'dir') - cd(p) - mkdir Combined_lizard_analysis +% 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 -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 +% Construct a descriptive filename for the cached pooled data +nameOfFile = sprintf('Ex_%d-%d_Combined_%s.mat', ... + expList(1), expList(end), strjoin(stimsNeeded, '-')); - if isequal(expList2, expList) - forloop = false; % saved data matches → skip re-processing - else - forloop = true; % experiment list changed → must re-process +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 -else - forloop = true; % file does not exist or overwrite requested end % ========================================================================= -% SECTION 3 – INITIALISE LONG-FORMAT TABLES +% SECTION 2 — 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 +% 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'}); -% longTable: one row per insertion × stimulus; stores counts of responsive -% and total somatic neurons for fraction-responsive analysis. -longTable = table( ... +% 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 – number of responsive neurons - double.empty(0,1), ... % totalSomaticN – total neurons in recording + 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 4 – PER-EXPERIMENT FOR-LOOP +% SECTION 3 — PER-EXPERIMENT 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) +if runLoop - % Load the Neuropixels recording object for this experiment - NP = loadNPclassFromTable(ex); + % 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 - % Instantiate analysis objects for the two stimuli present in all sessions - vs = linearlyMovingBallAnalysis(NP); % moving ball (MB) - vsR = rectGridAnalysis(NP); % rectangular grid (RG) + j = 1; % 1-based experiment counter (indexes cell arrays if needed later) - % 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}; + for ex = expList % ---- iterate over each experiment ID ---- % ------------------------------------------------------------------ - % 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. + % 3a — Load the recording and check stimulus availability % ------------------------------------------------------------------ - % 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 + NP = loadNPclassFromTable(ex); % load Neuropixels recording object + fprintf('Processing recording: %s\n', NP.recordingName); % status message - % 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 + % 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); - % 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}; + % 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 - 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 + if ~allPresent + fprintf(' → Skipping: not all stimuli present.\n'); + continue % skip to next experiment 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). + % 3b — Parse metadata and update animal / insertion counters + % (only reached if all stimuli are present) % ------------------------------------------------------------------ - % 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 + % 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 - % 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 + % 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) - % 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 + % Update animal and insertion counters + animalChanged = (animalID ~= prevAnimal); % new animal? + if animalChanged + animalCount = animalCount + 1; % increment animal counter + prevAnimal = animalID; % update tracker 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 + if insNum ~= prevInsertion || animalChanged % new insertion? + insertionCount = insertionCount + 1; % increment insertion counter + prevInsertion = insNum; % update tracker end % ------------------------------------------------------------------ - % 4c – Retrieve statistics structs (dispatch on chosen method) + % 3c — Run ResponseWindow + statistics for each stimulus % ------------------------------------------------------------------ - 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; + 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 - % 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 + % 3d — Extract z-scores, p-values, spike rates for each stimulus + % All stimuli are guaranteed present at this point. % ------------------------------------------------------------------ - % --- 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 + stimData = struct(); % one sub-struct per stimulus + nUnits = []; % total unit count (set from first stim) - spkDiff_MB = max(rwMB.Speed1.NeuronVals(:,:,5), [], 2); % col 5 = response – baseline + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; % stimulus name (e.g. 'SDGm') + key = getObjKey(sn); % shared-object key (e.g. 'SDG') - 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 + [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(:); - % 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); + if isempty(nUnits) + nUnits = numel(z); % set unit count from first stimulus + end 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) + % 3e — Optional: Benjamini-Hochberg FDR correction per recording % ------------------------------------------------------------------ - 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)'; + 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 % ------------------------------------------------------------------ - % 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) + % 3f — Build OR significance mask across all compared stimuli + % A neuron is included if it is significant for ANY stimulus + % in ComparePairs. % ------------------------------------------------------------------ - 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; + 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 - % ------------------------------------------------------------------ - % 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 + unitIDs = find(orMask); % indices of neurons passing the OR filter + nSig = numel(unitIDs); % count of significant neurons % ------------------------------------------------------------------ - % 4h – Build pairwise comparison table entries (ComparePairs mode) + % 3g — Append rows to TableStimComp (neuron-level pairwise table) + % Each significant neuron gets one row PER stimulus. % ------------------------------------------------------------------ - 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, ... + 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'}); - 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'}); + TableStimComp = [TableStimComp; newRows]; % append to pooled table 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 + % 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. % ------------------------------------------------------------------ - % 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; + 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 - % ---- 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) - % ------------------------------------------------------------------ + fprintf(' → %d / %d units pass OR filter.\n', nSig, nUnits); + j = j + 1; % advance experiment counter - Animal = string(regexp(vs.getAnalysisFileName, 'PV\d+', 'match', 'once')); - Insertion = regexp(vs.getAnalysisFileName, 'Insertion\d+', 'match', 'once'); - Insertion = str2double(regexp(Insertion, '\d+', 'match')); + end % ---- end for ex = expList ---- - % Fallback: some animals use 'SA##' naming convention - if isequal(Animal, "") - Animal = string(regexp(vs.getAnalysisFileName, 'SA\d+', 'match', 'once')); - end + % ===================================================================== + % SECTION 4 — SAVE POOLED DATA + % ===================================================================== - % 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 + 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 - if AnimalChanged - animal = animal + 1; % new animal encountered - AnimalNames{animal} = Animal; % store its name - AnimalI = Animal; % update tracker - end + save(savePath, '-struct', 'S'); % save struct fields as top-level vars + fprintf('Saved pooled data to %s\n', savePath); - % 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 +end % end if runLoop - % ------------------------------------------------------------------ - % 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; +% ========================================================================= +% SECTION 5 — GUARD: ABORT EARLY IF NO SIGNIFICANT NEURONS WERE FOUND +% ========================================================================= - j = j + 1; % advance experiment counter +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 - 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 +% 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 – PAIRWISE COMPARISON (ComparePairs mode) +% SECTION 6 — SHARED PLOTTING SETUP % ========================================================================= -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 +% Reload an analysis object for figure-saving paths +NP = loadNPclassFromTable(expList(1)); +vs = linearlyMovingBallAnalysis(NP); - % 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; +% 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 - % 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); +% Numeric animal index for each row (used for colour lookup) +animalIdxAll = double(S.TableStimComp.animal); - % Restrict table to complete insertions (have both stimuli) and relevant rows - tempTable = S.TableStimComp( ... - hasAll(G) & ismember(S.TableStimComp.stimulus, unique(categorical(pairs))), :); +% 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 - nBoot = 10000; % number of hierarchical bootstrap iterations +% Label-replacement map for display (internal abbreviation → paper label) +labelMap = {'RG','SB'; 'SDGs','SG'; 'SDGm','MG'}; - % 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); +% ========================================================================= +% SECTION 7 — Z-SCORE PAIRWISE COMPARISON +% ========================================================================= - % 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 +% --- 7a: Hierarchical bootstrap for each pair --- - % ----------------------------------------------------------------------- - % 6a – Z-score comparison via hierarchical bootstrapping - % ----------------------------------------------------------------------- +pValsZ = zeros(1, size(pairsAll, 1)); % one p-value per pair - j = 1; - ps = zeros(1, size(pairs, 1)); % one p-value per stimulus 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 - for i = 1:size(pairs, 1) +% --- 7b: Swarm plot of Z-scores --- - diffs = []; % per-neuron differences (stim1 – stim2) pooled across insertions - insers = []; % insertion label for each difference - animals = []; % animal label for each difference +ZscoreYlim = ceil(max(S.TableStimComp.('Z-score'))) + 4; % y-axis ceiling - for ins = unique(S.TableStimComp.insertion)' +fig = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsZ, {'Z-score'}, ... + yLegend = 'Z-score', ... + yMaxVis = ZscoreYlim, ... + diff = true, ... + plotMeanSem = true, ... + Alpha = 0.7); - % 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}; +formatAxes(gca, 8, 'helvetica'); % consistent font styling +set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); +colormap(fig, sharedCmap); % enforce shared colour scheme - V1 = S.TableStimComp.('Z-score')(idx1); - V2 = S.TableStimComp.('Z-score')(idx2); +if params.PaperFig + vs.printFig(fig, sprintf('Zscore-Swarm-%s', strjoin(stimsNeeded,'-')), ... + PaperFig = params.PaperFig); +end - % Unique animal for this insertion (should be exactly one) - animal = unique(S.TableStimComp.animal(idx1)); +% --- 7c: Scatter plot — first stimulus vs second stimulus (Z-score) --- - % 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 +if numel(stimsNeeded) == 2 + fig = plotPairScatter(S.TableStimComp, stimsNeeded, ... + 'Z-score', sharedCmap, animalIdxAll, labelMap); + title('Z-score'); - % 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; + if params.PaperFig + vs.printFig(fig, sprintf('Zscore-Scatter-%s', strjoin(stimsNeeded,'-')), ... + PaperFig = params.PaperFig); end +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); +% ========================================================================= +% SECTION 8 — SPIKE-RATE PAIRWISE COMPARISON +% ========================================================================= - ylims = ylim; +% --- 8a: Hierarchical bootstrap for each pair --- - if params.PaperFig - vs.printFig(fig, sprintf('Zcore-comparison-Swarm-%s-%s', ... - params.ComparePairs{1}, params.ComparePairs{2}), PaperFig=params.PaperFig); - end +pValsSpk = zeros(1, size(pairsAll, 1)); - % ----------------------------------------------------------------------- - % 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. - % ----------------------------------------------------------------------- +for pi = 1:size(pairsAll, 1) + pValsSpk(pi) = bootstrapPairDifference( ... + S.TableStimComp, pairsAll(pi,:), params.nBoot, 'SpkR'); +end - fig = figure; +% --- 8b: Swarm plot of spike rates --- - pair1 = S.TableStimComp.("Z-score")(mask1); - pair2 = S.TableStimComp.("Z-score")(mask2); - % cIdx already computed above — direct RGB lookup, no implicit categorical conversion +spkMax = max(S.TableStimComp.SpkR); % y-axis ceiling - % Scatter with animal-coded colour, using subsampled indices - scatter(pair1, pair2, 7, sharedCmap(cIdx,:), ... - "filled", "MarkerFaceAlpha", 0.3) - hold on - axis equal +fig = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsSpk, {'SpkR'}, ... + yLegend = 'SpkR', ... + yMaxVis = spkMax, ... + diff = true, ... + plotMeanSem = true, ... + Alpha = 0.7); - lims = [min(S.TableStimComp.("Z-score")), max(S.TableStimComp.("Z-score"))]; - plot(lims, lims, 'k--', 'LineWidth', 1.5) % identity line - ylim(lims); xlim(lims) +formatAxes(gca, 8, 'helvetica'); +colormap(fig, sharedCmap); +set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); - % 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 +if params.PaperFig + vs.printFig(fig, sprintf('SpkRate-Swarm-%s', strjoin(stimsNeeded,'-')), ... + PaperFig = params.PaperFig); +end - xlabel(s{1}); ylabel(s{2}) - colormap(fig, sharedCmap) +% --- 8c: Scatter plot — first stimulus vs second stimulus (spike rate) --- - 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 numel(stimsNeeded) == 2 + fig = plotPairScatter(S.TableStimComp, stimsNeeded, ... + 'SpkR', sharedCmap, animalIdxAll, labelMap); + title('Spk. rate'); if params.PaperFig - vs.printFig(fig, sprintf('Zcore-comparison-Scatter-%s-%s', ... - params.ComparePairs{1}, params.ComparePairs{2}), PaperFig=params.PaperFig); + vs.printFig(fig, sprintf('SpkRate-Scatter-%s', strjoin(stimsNeeded,'-')), ... + PaperFig = params.PaperFig); end +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)' +% ========================================================================= +% 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). +% ========================================================================= - idx1 = S.TableStimComp.insertion == categorical(ins) & ... - S.TableStimComp.stimulus == pairs{j,1}; - idx2 = S.TableStimComp.insertion == categorical(ins) & ... - S.TableStimComp.stimulus == pairs{j,2}; +% 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); - V1 = S.TableStimComp.SpkR(idx1); - V2 = S.TableStimComp.SpkR(idx2); +% Restrict to complete insertions and relevant stimuli only +tempTable = S.TableRespNeurs( ... + hasAll(G) & ismember(S.TableRespNeurs.stimulus, categorical(stimsNeeded)), :); - 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 +% Bootstrap the difference in responsive fraction for each pair +pValsFrac = zeros(1, size(pairsAll, 1)); - bootDiff = hierBoot(diffs, nBoot, insers, animals); - ps(j) = mean(bootDiff <= 0); - j = j + 1; - end +for pi = 1:size(pairsAll, 1) - V1max = max(diffs); % use max observed difference to set y-axis ceiling + diffs = []; % will hold one fraction-difference per insertion - [fig, randiColors] = plotSwarmBootstrapWithComparisons(S.TableStimComp, pairs, ps, ... - {'SpkR'}, yLegend='SpkR', yMaxVis=V1max, diff=true, plotMeanSem=true, Alpha=0.7); + for ins = unique(S.TableRespNeurs.insertion)' % iterate over insertions - 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]); + % 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 params.PaperFig - vs.printFig(fig, sprintf('spkRate-comparison-Swarm-%s-%s', ... - params.ComparePairs{1}, params.ComparePairs{2}), PaperFig=params.PaperFig); + 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 - % ----------------------------------------------------------------------- - % 6d – Scatter plot: first vs second stimulus (Spike Rate) - % ----------------------------------------------------------------------- + % 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 - 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') +% 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(''); - if params.PaperFig - vs.printFig(fig, sprintf('spkRate-comparison-Scatter-%s-%s', ... - params.ComparePairs{1}, params.ComparePairs{2}), PaperFig=params.PaperFig); - end +% 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); -else - % ========================================================================= - % SECTION 7 – MULTI-STIMULUS OVERVIEW (non-pairwise mode) - % Compares ALL stimuli in Stims2Comp using swarm + scatter. - % ========================================================================= +% 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); - fig = figure; - tiledlayout(2, 2, "TileSpacing", "compact"); +if params.PaperFig + vs.printFig(fig, sprintf('ResponsiveUnits-%s', strjoin(stimsNeeded,'-')), ... + PaperFig = params.PaperFig); +end - % 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); +end % end function AllExpAnalysis - % 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}; +% ######################################################################### +% LOCAL HELPER FUNCTIONS +% ######################################################################### - % 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) +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) - for i = 1:numel(Stims2Comp2) + vsObjs = containers.Map(); % cache of loaded analysis objects + present = containers.Map(); % presence flag per stimulus - 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))); + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; % stimulus name + key = getObjKey(sn); % shared object key - % Concatenate z-scores across experiments - if ~params.EachStimSignif - StimZS{i} = cell2mat(S.stimValsSignif2oneStim.(matches{1}))'; - else - StimZS{i} = cell2mat(S.stimValsSignif.(matches{1}))'; - end + % 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 - % Build pattern for spike rate OR spike difference (diffResp flag) - if ~params.diffResp - pattern = ['^spKr.*' ending ending2 '$']; - else - pattern = ['^diffSpk.*' ending ending2 '$']; - 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 - matches = fn(~cellfun('isempty', regexp(fn, pattern))); + vsObjs(key) = obj; % cache the object - 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'); + catch ME + fprintf(' %s: could not load (%s).\n', key, ME.message); + present(sn) = false; % constructor failed → not present + end 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{:})'; + % 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 - - % 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 +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 - % ----------------------------------------------------------------------- +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. - y = cell2mat(StimZS); % all z-scores concatenated (length = total neurons × stims) + switch stimName + case {'SDGm','SDGs'}, key = 'SDG'; + otherwise, key = stimName; % MB, RG, MBR, NI, NV, FFF + end +end - 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); +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 - 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 +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'); +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 - % ----------------------------------------------------------------------- - % 7c – Z-score scatter (two selected stimuli) - % ----------------------------------------------------------------------- + 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 - nexttile + case 'MBR' + sub = stats.Speed1; % moving bar: Speed1 only + rwSub = rw.Speed1; - % 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 + case 'SDGm' + sub = stats.Moving; % drifting gratings: moving condition + rwSub = rw.Moving; - 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 + case 'SDGs' + sub = stats.Static; % drifting gratings: static condition + rwSub = rw.Static; - % ----------------------------------------------------------------------- - % 7d – Spike-rate swarm chart - % ----------------------------------------------------------------------- + otherwise % RG, FFF, NI, NV — flat struct + sub = stats; + rwSub = rw; + end - y = cell2mat(stimRSP); % all spike rates concatenated + % --- Extract z-scores and p-values --- + z = sub.ZScoreU(:); % force column vector + p = sub.pvalsResponse(:); - nexttile - if ~params.EachStimSignif - swarmchart(x, y, 5, colormapUsed(allColorIndices,:), 'filled', 'MarkerFaceAlpha', 0.7); + % --- Extract spike rate --- + if useZmean && isfield(sub, 'z_mean') + spkR = sub.z_mean(:); % normalised response (z_mean) 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; + spkR = max(rwSub.NeuronVals(:,:,4), [], 2); % peak rate across directions 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 + % --- Extract spike-rate difference (response – baseline) --- + spkDiff = max(rwSub.NeuronVals(:,:,5), [], 2); -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; + % --- 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 -% 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)); +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. -% Bootstrap the difference in responsive fraction between the two stimuli -for i = 1:size(pairs, 1) + diffs = []; % per-neuron differences pooled across insertions + insers = []; % insertion label for each difference + animals = []; % animal label for each difference - diffs = []; + for ins = unique(tbl.insertion)' % iterate over insertions - for ins = unique(S.TableRespNeurs.insertion)' + % 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}; - idx1 = S.TableRespNeurs.insertion == categorical(ins) & ... - S.TableRespNeurs.stimulus == pairs{j,1}; - idx2 = S.TableRespNeurs.insertion == categorical(ins) & ... - S.TableRespNeurs.stimulus == pairs{j,2}; + V1 = tbl.(metric)(idx1); % metric values for stim 1 + V2 = tbl.(metric)(idx2); % metric values for stim 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; + 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 - % 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; + % Run hierarchical bootstrap (resample animals → insertions → neurons) + bootMeans = hierBoot(diffs, nBoot, insers, animals); + pVal = mean(bootMeans <= 0); % one-sided p-value 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); +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. -TotalRespUnits = sum(tempTable.respNeur); + 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); -TotalRespUnitsPair1 = sum(tempTable.respNeur(tempTable.stimulus == string(pairs{1}))); + formatAxes(gca, 8, 'helvetica'); + set(fig, 'Units', 'centimeters', 'Position', [20 20 5 5]); +end -TotalRespUnitsPair2 = sum(tempTable.respNeur(tempTable.stimulus == string(pairs{2}))); +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 -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); +function pAdj = bhFDR(pVals) +% bhFDR Benjamini-Hochberg FDR correction. +% Adjusts a vector of p-values to control the false discovery rate. -% 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); + n = numel(pVals); % number of tests + [pSorted, sortIdx] = sort(pVals(:)); % sort ascending + ranks = (1:n)'; % integer ranks -if params.PaperFig - vs.printFig(fig, sprintf('ResponsiveUnits-comparison-%s-%s', ... - params.ComparePairs{1}, params.ComparePairs{2}), PaperFig=params.PaperFig); -end + % 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 -end % end function PlotZScoreComparison \ No newline at end of file + % Unsort back to original order + pAdj(sortIdx) = pAdj; +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/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv deleted file mode 100644 index 1d61012..0000000 --- a/visualStimulationAnalysis/RunAnalysisClass.asv +++ /dev/null @@ -1,227 +0,0 @@ -cd('\\sil3\data\Large_scale_mapping_NP') -excelFile = 'Experiment_Excel.xlsx'; - -data = readtable(excelFile); - -%% -%% Rect Grid -for ex = [74] %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=82, selectedLum=255,oneTrial = true,PaperFig = true) %43 - vsRe.CalculateReceptiveFields('overwrite',true,AllResponsiveNeurons=false) - [colorbarLims] = vsRe.PlotReceptiveFields(exNeurons=82,allStimParamsCombined=false,PaperFig=true,overwrite=true,colorbarLims=[]); - %result = vsRe.BootstrapPerNeuron('overwrite',true); - %result = vsRe.StatisticsPerNeuron('overwrite',true); - -end -% vsRe.CalculateReceptiveFields -%vsRe.PlotReceptiveFields("exNeurons",18) - -%% Moving ball - -for ex =[74]%97 74:84 (Neurons, 96_74, ) - NP = loadNPclassFromTable(ex); %73 81 - vs = linearlyMovingBallAnalysis(NP,Session=1); - % 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,'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',82,'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); -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 -[tempTableMW] = AllExpAnalysis([49:54,64:66, 68:85 87:97],{'MB','RG'},StatMethod='maxPermuteTest', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... - overwriteResponse=false,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% - -%% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=false, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] - -%% Raster for all experiment -plotRaster_MultiExp([49:54,64:66,68:85 87:97], sortBy = "spatialTuning",overwrite=false,TakeTopPercentTrials=[],PaperFig=true) - -%% Calculate spatial tuning -results= SpatialTuningIndex([49:54,64:66, 68:85 87:97], indexType = "L_amplitude_diff" ,overwrite=false... - , topPercent = 30,useRF=true,onOff=1,unionResponsive = false,allResponsive=true, PaperFig=true); - -%% 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=true) %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 3e35e64..53db21a 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -17,7 +17,7 @@ %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=82, selectedLum=255,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); @@ -29,7 +29,7 @@ %% Moving ball -for ex =[69]%97 74:84 (Neurons, 96_74, ) +for ex =[74]%97 74:84 (Neurons, 96_74, ) NP = loadNPclassFromTable(ex); %73 81 vs = linearlyMovingBallAnalysis(NP,Session=1); % vs.getSessionTime("overwrite",true); @@ -66,18 +66,22 @@ %[49:54,84:90,92:96] %All SDG experiments %solve MBR %bootsrapRespBase -[tempTableMW] = AllExpAnalysis([49:54,64:66, 68:85 87:97],{'MB','RG'},StatMethod='maxPermuteTest', overwrite=false,ComparePairs={'MB','RG'},PaperFig=true,... +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'RG','SDGs'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% + +%% +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],{'MB','RG'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... overwriteResponse=false,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% %% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=false, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] %% Raster for all experiment -plotRaster_MultiExp([49:54,64:66,68:85 87:97], sortBy = "spatialTuning",overwrite=false,TakeTopPercentTrials=[],PaperFig=true) +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=false... - , topPercent = 30,useRF=true,onOff=1,unionResponsive = false,allResponsive=true, PaperFig=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 @@ -95,7 +99,7 @@ % 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=true) %0.5208 %2.0833 + 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 diff --git a/visualStimulationAnalysis/SpatialTuningIndex.m b/visualStimulationAnalysis/SpatialTuningIndex.m index 7bce7e0..6551d7a 100644 --- a/visualStimulationAnalysis/SpatialTuningIndex.m +++ b/visualStimulationAnalysis/SpatialTuningIndex.m @@ -33,9 +33,25 @@ % 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. + % 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 % ------------------------------------------------------------------------- @@ -581,6 +597,19 @@ 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 % ---------------------------------------------------------- @@ -689,6 +718,12 @@ 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 @@ -871,9 +906,8 @@ if isequal(pairs{2},'rectGrid'), pairs{2} = 'SB'; end - tblPlot.stimulus(tblPlot.stimulus == " linearlyMovingBall ") = "MB"; - - tblPlot.stimulus(tblPlot.stimulus == " rectGrid ") = "SB"; + tblPlot.stimulus(tblPlot.stimulus == "linearlyMovingBall") = "MB"; + tblPlot.stimulus(tblPlot.stimulus == "rectGrid") = "SB"; [fig, ~] = plotSwarmBootstrapWithComparisons(tblPlot, pairs, ps, {'value'}, ... yLegend = params.yLegend, ... @@ -907,4 +941,367 @@ 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 From 3039ab00cfe4193e8839ea22b5749204bd0ac0ac Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Mon, 11 May 2026 19:23:59 +0300 Subject: [PATCH 16/19] Adding statistics per caegory --- .../plotSwarmBootstrapWithComparisons.asv | 759 ++++++++++ .../plotSwarmBootstrapWithComparisons.m | 188 ++- .../StaticDriftingGratingAnalysis.m | 2 +- .../@VStimAnalysis/StatisticsPerNeuron.asv | 806 +++++++++++ .../@VStimAnalysis/StatisticsPerNeuron.m | 15 +- .../StatisticsPerNeuronPerCategory.asv | 531 +++++++ .../StatisticsPerNeuronPerCategory.m | 527 +++++++ .../StatisticsPerNeuronSpatialGrid.m | 12 +- .../fullFieldFlashAnalysis.m | 2 +- .../@imageAnalysis/imageAnalysis.m | 2 +- .../linearlyMovingBallAnalysis.m | 43 +- .../@movieAnalysis/movieAnalysis.m | 2 +- visualStimulationAnalysis/AllExpAnalysis.asv | 1120 +++++++++++++++ visualStimulationAnalysis/AllExpAnalysis.m | 1273 ++++++++++------- visualStimulationAnalysis/AllExpAnalysisV3.m | 837 +++++++++++ .../RunAnalysisClass.asv | 246 ++++ visualStimulationAnalysis/RunAnalysisClass.m | 23 +- .../computeBallGridCrossings.asv | 279 ---- 18 files changed, 5828 insertions(+), 839 deletions(-) create mode 100644 general functions/plotSwarmBootstrapWithComparisons.asv create mode 100644 visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv create mode 100644 visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.asv create mode 100644 visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.m create mode 100644 visualStimulationAnalysis/AllExpAnalysis.asv create mode 100644 visualStimulationAnalysis/AllExpAnalysisV3.m create mode 100644 visualStimulationAnalysis/RunAnalysisClass.asv delete mode 100644 visualStimulationAnalysis/computeBallGridCrossings.asv diff --git a/general functions/plotSwarmBootstrapWithComparisons.asv b/general functions/plotSwarmBootstrapWithComparisons.asv new file mode 100644 index 0000000..97e0e31 --- /dev/null +++ b/general functions/plotSwarmBootstrapWithComparisons.asv @@ -0,0 +1,759 @@ +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] = 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' (default) or 'percentile' (95% bootstrap CI) +% 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 and the random dot-draw permutation. +% +% Bootstrap details: uses hierBoot (Saravanan et al. 2020) when grouping +% levels are present, resampling each level with replacement in turn. For +% insertion-level data with grouping {'animal','insertion'}, the within- +% insertion step resamples a single observation, so the procedure naturally +% reduces to an animal->insertion bootstrap without special-casing. +% Categorical grouping columns are coerced to numeric category codes before +% hierBoot is called (hierBoot pre-allocates intermediate levels as +% nan(size(data)), so it requires numeric inputs). + +% ------------------------------------------------------------------------- +% Argument validation block. MATLAB enforces types/sizes before the body runs. +% ------------------------------------------------------------------------- +arguments + tbl table % observation table + pairs cell = {} % stim pairs to test + pValues double = [] % p-value per pair (NaN allowed) + 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). Fail loudly otherwise. +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 can only work if the column exists; downgrade with a warning. +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; honor the two-tile mode. +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 for the entire call so bootstraps and dot-draw orders are +% deterministic. Critical for figure reproducibility in a paper. +rng(params.rngSeed); + +% ------------------------------------------------------------------------- +% Resolve bootstrap grouping variables. +% Sentinel '__auto__' means "auto-fill from columns present in the table". +% Explicit {} from the caller forces a flat (non-hierarchical) bootstrap. +% ------------------------------------------------------------------------- +if isscalar(params.bootGroupVars) && strcmp(params.bootGroupVars{1}, '__auto__') + cands = {'animal','insertion'}; + 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 (e.g., one number-of-responsive-units value per insertion). +% Otherwise it is neuron-level (many neurons per insertion per stimulus). +% Drives buildDiffTable's pairing strategy AND plotRawSwarm's line-grouping. +% ------------------------------------------------------------------------- +isInsertionLevel = height(tbl) == height(unique(tbl(:, {'insertion','stimulus'}))); + +% ------------------------------------------------------------------------- +% Padding / spacing constants derived from the y-axis cap. +% ------------------------------------------------------------------------- +yMaxVis = params.yMaxVis; +bracketPad = yMaxVis * 0.05; +stackPad = yMaxVis * 0.05; +textPad = yMaxVis * 0.01; +semAlpha = 0.6; + +% ------------------------------------------------------------------------- +% Pre-process tbl: rename legacy stimulus labels and compute the value column. +% ------------------------------------------------------------------------- +tbl = renameStimulusLabels(tbl); +pairs = renamePairLabels(pairs); +tbl = reorderStimulusByLevel(tbl); % sort categorical by trailing numeric value + +if params.fraction + % Element-wise ratio. NaN/Inf may arise if denominator has zeros — they + % are filtered downstream by the bootstrap/plotting code. + tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); +else + tbl.value = tbl.(valueField{1}); +end + +% Drop unused categorical levels so colormaps and category counts are accurate. +tbl.stimulus = removecats(tbl.stimulus); +tbl.animal = removecats(tbl.animal); +tbl.insertion = removecats(tbl.insertion); + +% ------------------------------------------------------------------------- +% Build figure: either single axes or a 1x2 tiledlayout depending on mode. +% ------------------------------------------------------------------------- +fig = figure; +set(fig, 'Color', 'w'); % white background for publication + +if params.showBothAndDiff + % Left tile: every stimulus shown raw. Right tile: difference for pairs{1,:}. + 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); + + tblDiff = buildDiffTable(tbl, pairs, params, isInsertionLevel); + plotDiffSwarm(axDiff, tblDiff, pairs, pValues, params, ... + yMaxVis, bracketPad, textPad); +else + % Single-axes mode: either the raw swarm or the difference, not both. + ax = axes(fig); %#ok + hold(ax, 'on'); + set(ax, 'Clipping', 'off'); % allow brackets/text outside ylim + + 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 + +end % main function + + +% ========================================================================= +% LOCAL FUNCTION: plotRawSwarm +% Plots all observations grouped by stimulus, with optional connecting lines +% between paired neurons across stim types. Returns the random draw permutation. +% ========================================================================= +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; % alias to keep names short + +% Random permutation of dot indices => overlapping colors don't form layers. +% rng() was seeded once in the main function, so this is reproducible. +randiColors = randperm(height(tblPlot)); + +% Choose dot color source: continuous zScore (diverging) or categorical animal. +if params.colorByZScore + colorData = tblPlot.zScore(randiColors); +else + colorData = tblPlot.animal(randiColors); +end + +% Draw the swarm. swarmchart accepts a categorical x-axis directly. +if params.filled + s = swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... + params.dotSize, colorData, 'filled', ... + 'MarkerFaceAlpha', params.Alpha); +else + % SizeData=30 below intentionally overrides params.dotSize for legibility + % of open markers; consider exposing as its own param if you want full control. + s = swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... + params.dotSize, colorData, ... + 'MarkerEdgeAlpha', params.Alpha, 'LineWidth', 1, 'SizeData', 30); +end +s.XJitter = params.Xjitter; + +% Configure the colormap to match the chosen color source. +if params.colorByZScore + colormap(ax, buildRdBuColormap(256)); + maxZ = max(abs(tblPlot.zScore), [], 'omitnan'); + if isempty(maxZ) || maxZ == 0, maxZ = 1; end % degenerate safety + clim(ax, [-maxZ maxZ]); % symmetric around zero + cb = colorbar(ax); + cb.Label.String = 'Z-score'; +else + colormap(ax, lines(numel(categories(tblPlot.animal)))); +end + +% ------------------------------------------------------------------------- +% Optional: draw a thin line for each unit across stimulus columns. +% Choice of unit identifier depends on data granularity: +% * insertion-level: insertion *is* the unit, so group by insertion. +% * neuron-level : need NeurID; insertion would erroneously merge units +% from the same penetration into a single line. +% ------------------------------------------------------------------------- +if params.drawLines && numel(stimuli) <= 2 + if isInsertionLevel + unitIDvar = 'insertion'; + elseif ismember('NeurID', tblPlot.Properties.VariableNames) + unitIDvar = 'NeurID'; + else + unitIDvar = ''; + warning(['drawLines=true on neuron-level data without NeurID; ', ... + 'skipping connecting lines (insertion would merge ', ... + 'multiple units into one line).']); + end + + if ~isempty(unitIDvar) + cats = categories(tblPlot.stimulus); + % Map stimulus categories to numeric x positions for line() calls. + xMap = containers.Map(cats, 1:numel(cats)); + xNum = arrayfun(@(i) xMap(char(tblPlot.stimulus(i))), 1:height(tblPlot)); + + % Iterate over actual unit values (categorical or numeric); equality + % comparison works on both, so no type-specific branching is needed. + unitIDs = unique(tblPlot.(unitIDvar)); + for u = 1:numel(unitIDs) + idx = tblPlot.(unitIDvar) == unitIDs(u); + if nnz(idx) < 2, continue; end % need >=2 stim columns to draw + 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'; % axis ticks above swarm dots + +% Hierarchical bootstrap mean ± SE (or 95% CI) per stimulus column. +if params.plotMeanSem + plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha); +end + +% Pairwise significance brackets (only if pairs and pValues are aligned). +if ~isempty(pairs) && numel(pValues) == size(pairs,1) + plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... + yMaxVis, bracketPad, stackPad, textPad); +end + +% Cap visible y-range. Brackets/text use Clipping=off, so they remain visible +% even above this cap (intentional for tight figures). +ylim(ax, [ax.YLim(1) yMaxVis]); + +end % plotRawSwarm + + +% ========================================================================= +% LOCAL FUNCTION: plotDiffSwarm +% One swarm column showing per-neuron (or per-insertion) (stimA - stimB), +% with a 4-tier significance annotation matching plotBrackets. +% ========================================================================= +function randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... + yMaxVis, bracketPad, textPad) + +hold(ax, 'on'); +set(ax, 'Clipping', 'off'); + +% Reproducible draw order; rng() set once in main. +randiColors = randperm(height(tblDiff)); + +% Same color-source logic as raw plot, but only if zScore made it through buildDiffTable. +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; + +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 + +% Visual reference at zero so the sign of differences is obvious at a glance. +yline(ax, 0, 'LineWidth', 1, 'Alpha', 0.7); + +ylabel(ax, params.yLegend); +ax.Box = 'off'; +ax.Layer = 'top'; + +if params.plotMeanSem + stimuli = categories(tblDiff.stimulus); + plotMeanSemBars(ax, tblDiff, stimuli, params, 0.6); +end + +% ------------------------------------------------------------------------- +% Significance annotation (four-tier scheme matching plotBrackets). +% ------------------------------------------------------------------------- +ylims = ylim(ax); +if ~isempty(pValues) && numel(pValues) >= 1 + + fprintf('=== DIFF MODE SIGNIFICANCE ===\n'); + fprintf('p-value: %.4e\n', pValues(1)); + + vals = tblDiff.value; + if isempty(vals) + fprintf('No values to annotate.\n'); + ylim(ax, [ylims(1) yMaxVis]); + return + end + + % Place the annotation just above the highest visible (capped) value. + maxVisible = max(min(vals(:), yMaxVis(1))); + if isempty(maxVisible), maxVisible = yMaxVis; end + yText = maxVisible + bracketPad; + + % Skip stars for non-significant + if isnan(pValues(1)) || pValues(1) >= 0.05 + % no stars drawn + else + if pValues(1) < 0.001, txt = '***'; + elseif pValues(1) < 0.01, txt = '**'; + else, txt = '*'; + end + % Hard-coded x=1: the diff plot has exactly one column. + text(ax, 1, yText, txt, ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); + end + + % Comparison label (e.g. "SB > SG"), placed above the stars. + compTextPad = 10 * textPad; + stimA = pairs{1,1}; + stimB = pairs{1,2}; + compText = sprintf('%s > %s', stimA, stimB); + yCompText = yText + compTextPad; + + text(ax, 1, yCompText, compText, ... + 'HorizontalAlignment', 'center', 'FontSize', 10, 'Clipping', 'off'); + + % Expand y-limits if the comparison label needs more room than yMaxVis allows. + requiredHeight = yCompText + compTextPad; + if requiredHeight > yMaxVis + ylim(ax, [ylims(1) requiredHeight]); + else + ylim(ax, [ylims(1) yMaxVis]); + end +else + ylim(ax, [ylims(1) yMaxVis]); +end + +fprintf('=== END DIFF MODE SIGNIFICANCE ===\n'); + +end % plotDiffSwarm + + +% ========================================================================= +% LOCAL FUNCTION: buildDiffTable +% Per-unit (stimA - stimB) within insertion. Pairing strategy is chosen by +% data granularity: +% * insertion-level (one row per insertion-stimulus): direct subtraction. +% * neuron-level + NeurID present: 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 = pairs{1,1}; +stimB = pairs{1,2}; + +hasNeurID = ismember('NeurID', tbl.Properties.VariableNames); + +% Only warn when NeurID is genuinely needed (neuron-level data) and missing. +if ~hasNeurID && ~isInsertionLevel + warning(['buildDiffTable: NeurID column not present and table appears to ', ... + 'be neuron-level (multiple rows per insertion-stimulus pair). ', ... + 'Pairing by row order — fragile if rows are reordered. Add NeurID.']); +end + +ins = categories(tbl.insertion); +diffVals = []; % accumulators for the output table +animals = categorical.empty(0, 1); % MUST be categorical so vertcat preserves type +insers = []; +zScores = []; % only filled 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; + if ~any(idxA) || ~any(idxB), continue; end + + if isInsertionLevel + % One row per side guaranteed by the granularity check. + vA = tbl.value(idxA); + vB = tbl.value(idxB); + an = tbl.animal(idxA); + if useZ + zPair = (tbl.zScore(idxA) + tbl.zScore(idxB)) / 2; + end + + elseif hasNeurID + % Neuron-level with explicit IDs: safest matching. + 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); + if useZ + zPair = (tA.zScore(iA) + tB.zScore(iB)) / 2; + end + + else + % Row-order fallback for neuron-level data without NeurID. + vA = tbl.value(idxA); + vB = tbl.value(idxB); + if numel(vA) ~= numel(vB) + warning('Insertion %s: %d stimA rows but %d stimB rows; skipping.', ... + ins{i}, numel(vA), numel(vB)); + continue + end + an = repmat(tbl.animal(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; repmat(i, numel(vA), 1)]; %#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 = categorical(insers(valid)); +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 column. +% +% Reports the SAMPLE mean as the point estimate (consistent with conventional +% reporting; mean(bootMean) converges to it as nBoot->Inf but is unconventional). +% Uncertainty bar uses params.ciMethod: +% 'sem' -> ±1 SE from std(bootMean) +% 'percentile' -> [2.5, 97.5] percentile CI (recommended for skewed data) +% +% Resampling is hierarchical via hierBoot (Saravanan et al. 2020), with one +% level per entry of params.bootGroupVars (typically {'animal','insertion'}). +% Falls back to a flat bootstrap (bootstrp) when no levels are configured. +% ========================================================================= +function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) + +for i = 1:numel(stimuli) + idx = tblPlot.stimulus == stimuli{i}; + if ~any(idx), continue; end + + % Pull values + matching cluster IDs, dropping NaNs from all together. + % Without this step bootstrp(@mean, vals) returns NaN whenever any sample + % contains a NaN, while the analytical SE silently omits NaNs — the + % original code switched between these two policies at n=500. + 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 + + % Sample mean as point estimate. + mu = mean(vals); + + % Pull each grouping column for this stimulus, aligned with the NaN drop. + % NOTE: hierBoot pre-allocates intermediate level arrays via nan(size(data)) + % (i.e., as double), so any categorical grouping column must be coerced to + % its underlying integer category code before being passed in. The codes + % preserve group identity for the equality comparisons hierBoot performs. + 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); % numeric-only contract + end + groupVals{g} = col; + end + + % Hierarchical bootstrap of the mean. Empty group list => flat bootstrap. + % For insertion-level data with groups {'animal','insertion'}, the + % within-insertion resampling step picks the same single observation each + % time, so the procedure naturally collapses to an animal->insertion + % bootstrap without any special-casing here. + if isempty(groupVars) + bootMean = bootstrp(params.nBoot, @mean, vals); + else + bootMean = hierBoot(vals, params.nBoot, groupVals{:}); + end + + % Uncertainty bar from the bootstrap distribution. + 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 — slightly wider than caps so the point estimate stands out. + dx = 0.15; + plot(ax, [i-dx i+dx], [mu mu], 'k-', 'LineWidth', 1.2); +end + +end % plotMeanSemBars + + +% ========================================================================= +% LOCAL FUNCTION: plotBrackets +% Pairwise significance brackets above the swarm. Four-tier annotation +% (***, **, *, ns), consistent with plotDiffSwarm. +% ========================================================================= +function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... + yMaxVis, bracketPad, stackPad, textPad) + +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), ', ')); + +% Track y-positions of already-placed brackets so subsequent ones stack +% rather than overlap. +usedHeights = zeros(size(pairs,1), 1); + +for k = 1:size(pairs,1) + + fprintf('\n--- Pair %d: %s vs %s ---\n', k, pairs{k,1}, pairs{k,2}); + + % Skip non-significant pairs entirely (no bracket, no text) + if isnan(pValues(k)) || pValues(k) >= 0.05 + fprintf('SKIPPING: non-significant (p=%.4g).\n', pValues(k)); + 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 in plot.\n'); + continue + end + + vals1 = tblPlot.value(tblPlot.stimulus == pairs{k,1}); + vals2 = tblPlot.value(tblPlot.stimulus == pairs{k,2}); + fprintf('vals1 count: %d, vals2 count: %d\n', numel(vals1), numel(vals2)); + + % Cap each value at yMaxVis so the bracket is anchored to what's visible. + maxVisible = max(min([vals1; vals2], yMaxVis)); + yBase = maxVisible + bracketPad; + + % Vertical stacking against previously placed brackets. + 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)); + + % Bracket horizontal + two short verticals. + 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'); + + if pValues(k) < 0.001, txt = '***'; + elseif pValues(k) < 0.01, txt = '**'; + elseif pValues(k) < 0.05, txt = '*'; + end + fprintf('Drawing text: %s\n', txt); + text(ax, mean([x1 x2]), y + textPad, txt, ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); +end + +end % plotBrackets + + +% ========================================================================= +% LOCAL FUNCTION: renameStimulusLabels +% Replaces legacy stimulus abbreviations in tbl.stimulus. +% ========================================================================= +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 +% Same legacy substitutions, applied element-wise to the pairs cell array. +% ========================================================================= +function pairs = renamePairLabels(pairs) +if isempty(pairs), return; end +for i = 1:numel(pairs) + if strcmp(pairs{i}, 'RG'), pairs{i} = 'SB'; end + if strcmp(pairs{i}, 'SDGm'), pairs{i} = 'MG'; end + if strcmp(pairs{i}, 'SDGs'), pairs{i} = 'SG'; end +end +end + + +% ========================================================================= +% LOCAL FUNCTION: buildRdBuColormap +% n-row diverging Red-Blue colormap centred on white. +% Blue = negative, White = zero, Red = positive. +% ========================================================================= +function cmap = buildRdBuColormap(n) +half = floor(n/2); + +blueToWhite = [linspace(0.02, 1, half)', ... + linspace(0.44, 1, half)', ... + linspace(0.69, 1, half)']; + +whiteToRed = [linspace(1, 0.70, half)', ... + linspace(1, 0.09, half)', ... + linspace(1, 0.09, half)']; + +cmap = [blueToWhite; whiteToRed]; +end + +% ========================================================================= +% LOCAL FUNCTION: 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 (e.g. mode-1 labels like 'MB','SDGm'). +% ========================================================================= +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}; % decode trailing token + last = strrep(last, 'p', '.'); % 'p' -> '.' (decimal) + last = strrep(last, 'neg', '-'); % 'neg' -> '-' (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 + 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 \ No newline at end of file diff --git a/general functions/plotSwarmBootstrapWithComparisons.m b/general functions/plotSwarmBootstrapWithComparisons.m index 33c5851..502a504 100644 --- a/general functions/plotSwarmBootstrapWithComparisons.m +++ b/general functions/plotSwarmBootstrapWithComparisons.m @@ -1,4 +1,4 @@ -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. % @@ -128,6 +128,7 @@ % ------------------------------------------------------------------------- tbl = renameStimulusLabels(tbl); pairs = renamePairLabels(pairs); +tbl = reorderStimulusByLevel(tbl); % sort categorical by trailing numeric value if params.fraction % Element-wise ratio. NaN/Inf may arise if denominator has zeros — they @@ -149,7 +150,7 @@ set(fig, 'Color', 'w'); % white background for publication if params.showBothAndDiff - % Left tile: every stimulus shown raw. Right tile: difference for pairs{1,:}. + % Left tile: every stimulus shown raw. Right tile: most-significant pair's diff. tl = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact'); axRaw = nexttile(tl, 1); axDiff = nexttile(tl, 2); @@ -157,12 +158,21 @@ randiColors = plotRawSwarm(axRaw, tbl, pairs, pValues, params, ... yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel); - tblDiff = buildDiffTable(tbl, pairs, params, isInsertionLevel); - plotDiffSwarm(axDiff, tblDiff, pairs, pValues, params, ... + % 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, not both. - ax = axes(fig); %#ok + ax = axes(fig); hold(ax, 'on'); set(ax, 'Clipping', 'off'); % allow brackets/text outside ylim @@ -176,9 +186,21 @@ end end +% ------------------------------------------------------------------------- +% Additional figure: one tile per pairwise difference (only if multi-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 % Plots all observations grouped by stimulus, with optional connecting lines @@ -237,7 +259,7 @@ % * neuron-level : need NeurID; insertion would erroneously merge units % from the same penetration into a single line. % ------------------------------------------------------------------------- -if params.drawLines +if params.drawLines && numel(stimuli) <= 2 if isInsertionLevel unitIDvar = 'insertion'; elseif ismember('NeurID', tblPlot.Properties.VariableNames) @@ -365,34 +387,36 @@ if isempty(maxVisible), maxVisible = yMaxVis; end yText = maxVisible + bracketPad; - if pValues(1) < 0.001, txt = '***'; - elseif pValues(1) < 0.01, txt = '**'; - elseif pValues(1) < 0.05, txt = '*'; - else, txt = 'ns'; - end - - % Hard-coded x=1: the diff plot has exactly one column. If you ever extend - % this to multiple difference columns, parameterize x. - text(ax, 1, yText, txt, ... - 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); - - % Comparison label (e.g. "SB > SG"), placed above the stars. - compTextPad = 10 * textPad; - stimA = pairs{1,1}; - stimB = pairs{1,2}; - compText = sprintf('%s > %s', stimA, stimB); - yCompText = yText + compTextPad; - - text(ax, 1, yCompText, compText, ... - 'HorizontalAlignment', 'center', 'FontSize', 10, 'Clipping', 'off'); - - % Expand y-limits if the comparison label needs more room than yMaxVis allows. - requiredHeight = yCompText + compTextPad; - if requiredHeight > yMaxVis - ylim(ax, [ylims(1) requiredHeight]); + % Skip stars for non-significant + if isnan(pValues(1)) || pValues(1) >= 0.05 + % no stars drawn else - ylim(ax, [ylims(1) yMaxVis]); + if pValues(1) < 0.001, txt = '***'; + elseif pValues(1) < 0.01, txt = '**'; + else, txt = '*'; + end + % Hard-coded x=1: the diff plot has exactly one column. + text(ax, 1, yText, txt, ... + 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); end + + % % Comparison label (e.g. "SB > SG"), placed above the stars. + % compTextPad = 10 * textPad; + % stimA = pairs{1,1}; + % stimB = pairs{1,2}; + % compText = sprintf('%s > %s', stimA, stimB); + % yCompText = yText + compTextPad; + % + % text(ax, 1, yCompText, compText, ... + % 'HorizontalAlignment', 'center', 'FontSize', 10, 'Clipping', 'off'); + % + % % Expand y-limits if the comparison label needs more room than yMaxVis allows. + % requiredHeight = yCompText + compTextPad; + % if requiredHeight > yMaxVis + % ylim(ax, [ylims(1) requiredHeight]); + % else + ylim(ax, [ylims(1) yMaxVis]); + % end else ylim(ax, [ylims(1) yMaxVis]); end @@ -428,10 +452,10 @@ end ins = categories(tbl.insertion); -diffVals = []; % accumulators for the output table -animals = []; +diffVals = []; % accumulators for the output table +animals = categorical.empty(0, 1); % MUST be categorical so vertcat preserves type insers = []; -zScores = []; % only filled if colorByZScore +zScores = []; % only filled if colorByZScore useZ = params.colorByZScore && ismember('zScore', tbl.Properties.VariableNames); @@ -620,6 +644,12 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... fprintf('\n--- Pair %d: %s vs %s ---\n', k, pairs{k,1}, pairs{k,2}); + % Skip non-significant pairs entirely (no bracket, no text) + if isnan(pValues(k)) || pValues(k) >= 0.05 + fprintf('SKIPPING: non-significant (p=%.4g).\n', pValues(k)); + 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); @@ -655,7 +685,6 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... if pValues(k) < 0.001, txt = '***'; elseif pValues(k) < 0.01, txt = '**'; elseif pValues(k) < 0.05, txt = '*'; - else, txt = 'ns'; end fprintf('Drawing text: %s\n', txt); text(ax, mean([x1 x2]), y + textPad, txt, ... @@ -685,9 +714,11 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... function pairs = renamePairLabels(pairs) if isempty(pairs), return; end for i = 1:numel(pairs) - if strcmp(pairs{i}, 'RG'), pairs{i} = 'SB'; end - if strcmp(pairs{i}, 'SDGm'), pairs{i} = 'MG'; end - if strcmp(pairs{i}, 'SDGs'), pairs{i} = 'SG'; end + p = string(pairs{i}); + p = replace(p, "RG", "SB"); + p = replace(p, "SDGs", "SG"); % must come before SDGm — strict prefix + p = replace(p, "SDGm", "MG"); + pairs{i} = char(p); end end @@ -709,4 +740,83 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... linspace(1, 0.09, half)']; cmap = [blueToWhite; whiteToRed]; +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 (e.g. mode-1 labels like 'MB','SDGm'). +% ========================================================================= +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}; % decode trailing token + last = strrep(last, 'p', '.'); % 'p' -> '.' (decimal) + last = strrep(last, 'neg', '-'); % 'neg' -> '-' (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 + 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 + +% ========================================================================= +% LOCAL FUNCTION: plotAllPairDiffs +% Stand-alone figure with one tile per pairwise difference. Each tile is a +% diff swarm + significance annotation, matching the format of plotDiffSwarm. +% ========================================================================= +function figAll = plotAllPairDiffs(tbl, pairs, pValues, params, ... + isInsertionLevel, yMaxVis, bracketPad, textPad) + +nPairs = size(pairs, 1); + +figAll = figure; +set(figAll, 'Color', 'w'); + +% 'flow' layout adapts to any pair count without manual rows/cols tuning. +tl = tiledlayout(figAll, 'flow', 'TileSpacing', 'compact', 'Padding', 'compact'); +title(tl, 'All pairwise differences'); + +for k = 1:nPairs + ax = nexttile(tl); + + pairK = pairs(k, :); + pValK = pValues(k); + + tblDiff = buildDiffTable(tbl, pairK, params, isInsertionLevel); + + % Empty diff table (e.g. no overlapping insertions) – leave tile blank + if height(tblDiff) == 0 + title(ax, sprintf('%s − %s (no data)', pairK{1}, pairK{2}), ... + 'FontSize', 8); + continue + end + + plotDiffSwarm(ax, tblDiff, pairK, pValK, params, ... + yMaxVis, bracketPad, textPad); + + title(ax, sprintf('%s − %s', pairK{1}, pairK{2}), 'FontSize', 8); +end + end \ No newline at end of file diff --git a/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/StaticDriftingGratingAnalysis.m b/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/StaticDriftingGratingAnalysis.m index af86adc..ad74e09 100644 --- a/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/StaticDriftingGratingAnalysis.m +++ b/visualStimulationAnalysis/@StaticDriftingGratingAnalysis/StaticDriftingGratingAnalysis.m @@ -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/@VStimAnalysis/StatisticsPerNeuron.asv b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv new file mode 100644 index 0000000..cd6cac4 --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv @@ -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 = 1000 %Fixed window for baseline and response + params.useSegments = false %Use segmented approach + params.maxCategory = true %Use the max category to calculate the observed statistics + +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/StatisticsPerNeuron.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m index edcd24d..36e598c 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m @@ -74,11 +74,12 @@ %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 = true % if true: use StatisticsPerNeuronSpatialGrid + params.SpatialGridMode = false % if true: use StatisticsPerNeuronSpatialGrid % only applies to linearlyMovingBall % ignored for other stimuli - params.BaseRespWindow = 200 %Fixed window for baseline and response + params.BaseRespWindow = 1000 %Fixed window for baseline and response params.useSegments = false %Use segmented approach + params.maxCategory = false %Use the max category to calculate the observed statistic and the null distribution across bootstrap iterations end @@ -473,8 +474,18 @@ 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; diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.asv b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.asv new file mode 100644 index 0000000..40eabc0 --- /dev/null +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.asv @@ -0,0 +1,531 @@ +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 = 800 % 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) + colNames = responseParams.colNames{1}(5:end); + +elseif isGratingStat + C = responseParams.static.C; + 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 index f520349..1a319fd 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronSpatialGrid.m @@ -83,11 +83,6 @@ % ------------------------------------------------------------------------- nFramesFull = obj.VST.nFrames; - if ndims(nFramesFull) == 3 - nFramesThisSpeed = squeeze(nFramesFull(s, :, :)); % [nOffsets × nDirections] - else - nFramesThisSpeed = nFramesFull; % single-speed fallback - end % Build per-trial frame count using C(:,2)=direction and C(:,3)=offset uDirsAll = unique(C(:,2)); @@ -95,6 +90,13 @@ 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 diff --git a/visualStimulationAnalysis/@fullFieldFlashAnalysis/fullFieldFlashAnalysis.m b/visualStimulationAnalysis/@fullFieldFlashAnalysis/fullFieldFlashAnalysis.m index 40f3592..226e267 100644 --- a/visualStimulationAnalysis/@fullFieldFlashAnalysis/fullFieldFlashAnalysis.m +++ b/visualStimulationAnalysis/@fullFieldFlashAnalysis/fullFieldFlashAnalysis.m @@ -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 d28040c..0a835d8 100644 --- a/visualStimulationAnalysis/@imageAnalysis/imageAnalysis.m +++ b/visualStimulationAnalysis/@imageAnalysis/imageAnalysis.m @@ -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/@linearlyMovingBallAnalysis/linearlyMovingBallAnalysis.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/linearlyMovingBallAnalysis.m index 9e6d6eb..c13e103 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/linearlyMovingBallAnalysis.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/linearlyMovingBallAnalysis.m @@ -14,7 +14,8 @@ arguments (Input) %ResponseWindow.mat dataObj params.Session double = 1 - params.MultipleOffsets logical = true + params.MultipleOffsets logical = false + params.Multiplesizes logical = false end if nargin==0 dataObj=[]; @@ -24,18 +25,38 @@ obj@VStimAnalysis(dataObj,'Session',params.Session); obj.Session = params.Session; - 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); + 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 - % Reconstruct the object with the fallback session - overwrites the first construction - obj = linearlyMovingBallAnalysis(dataObj, 'Session', params.Session, 'MultipleOffsets', false); - obj.Session = params.Session; end + + end end diff --git a/visualStimulationAnalysis/@movieAnalysis/movieAnalysis.m b/visualStimulationAnalysis/@movieAnalysis/movieAnalysis.m index b3f9aad..4f16f1e 100644 --- a/visualStimulationAnalysis/@movieAnalysis/movieAnalysis.m +++ b/visualStimulationAnalysis/@movieAnalysis/movieAnalysis.m @@ -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/AllExpAnalysis.asv b/visualStimulationAnalysis/AllExpAnalysis.asv new file mode 100644 index 0000000..6b85275 --- /dev/null +++ b/visualStimulationAnalysis/AllExpAnalysis.asv @@ -0,0 +1,1120 @@ +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 % Experiment IDs from master Excel + params.ComparePairs cell % Stimuli to compare + params.CompareCategory = "" % Empty -> mode 1 + % string -> mode 2 (single category) + % cell of strings -> mode 3 (per-stimulus categories) + params.CompareLevels cell = {} % Cell of numeric vectors, one per stimulus. + % Non-empty -> mode 3. + params.useGeneralFilter logical = false % In mode 2/3: use general per-neuron p-values + % (StatMethod) for responsiveness instead of per-level p. + params.threshold double = 0.05 % p-value cutoff for responsiveness + params.StatMethod string = 'ObsWindow' % 'ObsWindow' | 'bootsrapRespBase' | 'maxPermuteTest' + params.overwrite logical = false + params.overwriteResponse logical = false + params.overwriteStats logical = false + params.RespDurationWin double = 100 + params.shuffles double = 2000 + params.useZmean logical = true + params.useFDR logical = false + params.PaperFig logical = false + params.nBoot double = 10000 + params.nBootCategory double = 10000 +end + +% ========================================================================= +% SECTION 1 — DETECT MODE AND VALIDATE +% ========================================================================= + +% Detect operating mode based on parameter combinations +if ~isempty(params.CompareLevels) + % Mode 3: specific-level across stimuli + mode = 3; + 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 + catList = cell(1, numel(params.CompareCategory)); + for i = 1:numel(params.CompareCategory) + if iscell(params.CompareCategory) + catList{i} = char(strtrim(params.CompareCategory{i})); + else + catList{i} = char(strtrim(params.CompareCategory(i))); + end + end + fprintf('=== Mode 3: specific-level cross-stimulus comparison ===\n'); + +elseif (ischar(params.CompareCategory) || isstring(params.CompareCategory)) && ... + strtrim(string(params.CompareCategory)) ~= "" + % Mode 2: within-stimulus, all levels + mode = 2; + assert(numel(params.ComparePairs) == 1, ... + 'Mode 2: requires exactly one stimulus in ComparePairs.'); + stimName = params.ComparePairs{1}; + catName = char(strtrim(string(params.CompareCategory))); + fprintf('=== Mode 2: within-stimulus category "%s" in %s ===\n', catName, stimName); + +else + % Mode 1: across-stimulus + mode = 1; + assert(numel(params.ComparePairs) >= 2, ... + 'Mode 1: requires >=2 stimuli in ComparePairs.'); +end + +% Boolean shortcuts (used throughout the function) +isCategoryMode = (mode == 2); +isSpecificLevelMode = (mode == 3); + +% Unique stimulus names that need to be loaded (one per stimulus) +stimsNeeded = unique(params.ComparePairs, 'stable'); + +% Load the first experiment to extract directory paths +NP0 = loadNPclassFromTable(expList(1)); +vs0 = linearlyMovingBallAnalysis(NP0); + +rootPath = extractBefore(vs0.dataObj.recordingDir, 'lizards'); +rootPath = [rootPath 'lizards']; +saveDir = fullfile(rootPath, 'Combined_lizard_analysis'); +if ~exist(saveDir, 'dir') + mkdir(saveDir); +end + +% Construct a descriptive filename for the cached pooled data +switch mode + case 1 + nameOfFile = sprintf('Ex_%d-%d_Combined_%s.mat', ... + expList(1), expList(end), strjoin(stimsNeeded, '-')); + case 2 + nameOfFile = sprintf('Ex_%d-%d_Combined_%s_%s.mat', ... + expList(1), expList(end), stimName, lower(catName)); + case 3 + % Encode all (stim, cat, levels) in the filename + 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); + +% Decide whether the per-experiment loop needs to run +runLoop = true; +if exist(savePath, 'file') == 2 && ~params.overwrite + S = load(savePath); + if isfield(S, 'expList') && isequal(S.expList, expList) + runLoop = false; + end +end + +% ========================================================================= +% SECTION 2 — INITIALISE LONG-FORMAT TABLES +% ========================================================================= + +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 = 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 = {}; +fixedCompLabels = {}; +if isSpecificLevelMode + % Build the canonical comparison labels and (stim, cat, level) tuples now + [fixedCompLabels, mode3Items] = buildMode3Items(stimsNeeded, catList, params.CompareLevels); +end + +% ========================================================================= +% SECTION 3 — PER-EXPERIMENT LOOP +% ========================================================================= + +if runLoop + + animalCount = 0; + insertionCount = 0; + prevAnimal = ""; + prevInsertion = 0; + + for ex = expList + + % ---- 3a: Load recording and check stimulus availability ---- + NP = loadNPclassFromTable(ex); + fprintf('Processing recording: %s\n', NP.recordingName); + + [vsObjs, present] = loadStimulusObjects(NP, stimsNeeded); + + allPresent = true; + for si = 1:numel(stimsNeeded) + if ~present(stimsNeeded{si}) + allPresent = false; break + end + end + if ~allPresent + fprintf(' -> Skipping: stimulus not present.\n'); + continue + end + + % ---- 3b: Mode-specific session selection ---- + + if isCategoryMode + % Mode 2: find session of stimName with >=2 levels of catName + key = getObjKey(stimName); + vsObj = vsObjs(key); + + [levels, ~, vsObj] = findCategoryLevels(vsObj, NP, stimName, catName, params); + + if numel(levels) < 2 + fprintf(' -> Skipping: <2 levels for "%s".\n', catName); + continue + end + + vsObjs(key) = vsObj; + + currentLabels = arrayfun(@(v) levelToFieldName(catName, v), ... + levels, 'UniformOutput', false); + + 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 session containing ALL requested levels + sessionFound = true; + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + cat = catList{si}; + lvls = params.CompareLevels{si}; + key = getObjKey(sn); + + [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; + end + if ~sessionFound + continue + end + end + + % ---- 3c: Parse metadata and update animal/insertion counters ---- + + animalID = string(regexp(NP.recordingName, 'PV\d+', 'match', 'once')); + if animalID == "" + animalID = string(regexp(NP.recordingName, 'SA\d+', 'match', 'once')); + end + + insStr = regexp(NP.recordingDir, 'Insertion\d+', 'match', 'once'); + insNum = str2double(regexp(insStr, '\d+', 'match')); + + animalChanged = (animalID ~= prevAnimal); + if animalChanged + animalCount = animalCount + 1; + prevAnimal = animalID; + end + if insNum ~= prevInsertion || animalChanged + insertionCount = insertionCount + 1; + prevInsertion = insNum; + end + + % ---- 3d: Run statistics and extract per-item data ---- + + stimData = struct(); + nUnits = []; + compLabels = {}; + generalPbyStim = struct(); % for optional general filter + + if isCategoryMode + % Mode 2: single stimulus, all levels + key = getObjKey(stimName); + vsObj = vsObjs(key); + + % General per-neuron stats (for optional general filter) + runStimStats(vsObj, params); + vsObjs(key) = vsObj; + [~, generalP, ~, ~] = extractStimData( ... + vsObj, stimName, params.StatMethod, params.useZmean); + generalPbyStim.(stimName) = generalP; + + % Per-category stats + catStats = vsObj.StatisticsPerNeuronPerCategory( ... + 'compareCategory', catName, ... + 'nBoot', params.nBootCategory, ... + 'overwrite', params.overwriteStats); + + for li = 1:numel(levelLabels) + fName = levelLabels{li}; + stimData.(fName).z = catStats.(fName).ZScoreU(:); + stimData.(fName).p = catStats.(fName).pvalsResponse(:); + stimData.(fName).spkR = catStats.(fName).ObsStat(:); + if isempty(nUnits), nUnits = numel(stimData.(fName).z); end + end + compLabels = levelLabels; + + elseif isSpecificLevelMode + % Mode 3: each stimulus contributes one or more (stim, cat, level) items + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + cat = catList{si}; + lvls = params.CompareLevels{si}; + key = getObjKey(sn); + vsObj = vsObjs(key); + + % General per-neuron stats (for optional general filter) + runStimStats(vsObj, params); + vsObjs(key) = vsObj; + [~, generalP, ~, ~] = extractStimData( ... + vsObj, sn, params.StatMethod, params.useZmean); + generalPbyStim.(sn) = generalP; + + % Per-category stats for this stimulus + category + catStats = vsObj.StatisticsPerNeuronPerCategory( ... + 'compareCategory', cat, ... + 'nBoot', params.nBootCategory, ... + 'overwrite', params.overwriteStats); + + % Extract each requested level + for lvi = 1:numel(lvls) + lv = lvls(lvi); + fName = levelToFieldName(cat, lv); % key in catStats + 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 we have all the labels expected from parameters + if ~isequal(sort(compLabels(:)), sort(fixedCompLabels(:))) + fprintf(' -> Skipping: comparison label mismatch.\n'); + continue + end + + else + % Mode 1: across-stimulus (one item per stimulus) + objKeys = keys(vsObjs); + for k = 1:numel(objKeys) + key = objKeys{k}; + vsObj = vsObjs(key); + runStimStats(vsObj, params); + vsObjs(key) = vsObj; + end + + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + key = getObjKey(sn); + [z, p, spkR, ~] = extractStimData( ... + vsObjs(key), sn, params.StatMethod, params.useZmean); + stimData.(sn).z = z(:); + stimData.(sn).p = p(:); + stimData.(sn).spkR = spkR(:); + generalPbyStim.(sn) = p(:); + if isempty(nUnits), nUnits = numel(z); end + end + compLabels = stimsNeeded; + end + + % ---- 3e: Optional FDR correction ---- + if params.useFDR + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + stimData.(cl).p = bhFDR(stimData.(cl).p); + end + end + + % ---- 3f: Significance mask ---- + + if params.useGeneralFilter && (isCategoryMode || isSpecificLevelMode) + % Use general per-stimulus p-values (StatMethod), OR'd across stimuli + orMask = false(nUnits, 1); + stimNames_ = fieldnames(generalPbyStim); + for si = 1:numel(stimNames_) + gp = generalPbyStim.(stimNames_{si}); + if params.useFDR, gp = bhFDR(gp); end + 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); + nSig = numel(unitIDs); + + % ---- 3g: Append to TableStimComp ---- + if nSig > 0 + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + newRows = table( ... + repmat(categorical(cellstr(animalID)), nSig, 1), ... + repmat(categorical(insertionCount), nSig, 1), ... + repmat(categorical(cellstr(cl)), nSig, 1), ... + categorical(unitIDs), ... + stimData.(cl).z(orMask), ... + stimData.(cl).spkR(orMask), ... + 'VariableNames', {'animal','insertion','stimulus','NeurID','Z-score','SpkR'}); + TableStimComp = [TableStimComp; newRows]; %#ok + end + end + + % ---- 3h: Append to TableRespNeurs ---- + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + nResp = sum(stimData.(cl).p < params.threshold); + newRow = table( ... + categorical(cellstr(animalID)), ... + categorical(insertionCount), ... + categorical(cellstr(cl)), ... + nResp, nUnits, ... + 'VariableNames', TableRespNeurs.Properties.VariableNames); + TableRespNeurs = [TableRespNeurs; newRow]; %#ok + end + + fprintf(' -> %d / %d units pass filter.\n', nSig, nUnits); + + end % end for ex + + % ---- 4: Save pooled data ---- + S.expList = expList; + S.TableStimComp = TableStimComp; + S.TableRespNeurs = TableRespNeurs; + S.params = params; + S.mode = mode; + if isCategoryMode, S.levelLabels = levelLabels; end + if isSpecificLevelMode, S.fixedCompLabels = fixedCompLabels; end + + save(savePath, '-struct', 'S'); + fprintf('Saved pooled data to %s\n', savePath); +end + +% ========================================================================= +% SECTION 5 — GUARD +% ========================================================================= + +if isempty(S.TableStimComp) || height(S.TableStimComp) == 0 + warning('AllExpAnalysis:noUnits', 'No significant units found. Returning empty.'); + tempTable = table(); + return +end + +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 +% (handles legacy caches and 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 6 — SHARED PLOTTING SETUP +% ========================================================================= + +NP = loadNPclassFromTable(expList(1)); +vs = linearlyMovingBallAnalysis(NP, 'MultipleOffsets', false, 'Multiplesizes', false); + +animalOrder = categories(S.TableStimComp.animal); +nAnimals = numel(animalOrder); +sharedCmap = lines(nAnimals); +animalIdxAll = double(S.TableStimComp.animal); + +compLabels = cellstr(categories(S.TableStimComp.stimulus)); +pairsAll = nchoosek(compLabels, 2); + +labelMap = {'RG','SB'; 'SDGs','SG'; 'SDGm','MG'}; + +% ========================================================================= +% SECTION 7 — Z-SCORE PAIRWISE COMPARISON +% ========================================================================= + +pValsZ = zeros(1, size(pairsAll, 1)); +for pi = 1:size(pairsAll, 1) + pValsZ(pi) = bootstrapPairDifference( ... + S.TableStimComp, pairsAll(pi,:), params.nBoot, 'Z-score'); +end + +ZscoreYlim = ceil(max(S.TableStimComp.('Z-score'))) + 4; + +[fig,~,figAllZ] = plotSwarmBootstrapWithComparisons( ... + S.TableStimComp, pairsAll, pValsZ, {'Z-score'}, ... + yLegend = 'Z-score', yMaxVis = ZscoreYlim, ... + diff = true, plotMeanSem = true, Alpha = 0.7); + +formatAxes(gca, 8, 'helvetica'); +set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); +colormap(fig, sharedCmap); + +if params.PaperFig + vs.printFig(fig, sprintf('Zscore-Swarm-%s', strjoin(compLabels,'-')), ... + PaperFig = params.PaperFig); +end + +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 8 — SPIKE-RATE PAIRWISE COMPARISON +% ========================================================================= + +pValsSpk = zeros(1, size(pairsAll, 1)); +for pi = 1:size(pairsAll, 1) + pValsSpk(pi) = bootstrapPairDifference( ... + S.TableStimComp, pairsAll(pi,:), params.nBoot, 'SpkR'); +end + +spkMax = max(S.TableStimComp.SpkR); + +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(compLabels,'-')), ... + PaperFig = params.PaperFig); +end + +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 9 — FRACTION-RESPONSIVE ANALYSIS +% ========================================================================= + +[G, ~] = findgroups(S.TableRespNeurs.insertion); +hasAll = splitapply( ... + @(s) all(ismember(categorical(compLabels), s)), ... + S.TableRespNeurs.stimulus, G); + +tempTable = S.TableRespNeurs( ... + hasAll(G) & ismember(S.TableRespNeurs.stimulus, categorical(compLabels)), :); + +pValsFrac = zeros(1, size(pairsAll, 1)); +for pi = 1:size(pairsAll, 1) + diffs = []; + for ins = unique(S.TableRespNeurs.insertion)' + 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); + f1 = S.TableRespNeurs.respNeur(idx1) / total; + f2 = S.TableRespNeurs.respNeur(idx2) / total; + diffs(end+1, 1) = f1 - f2; %#ok + end + end + bootDiff = bootstrp(params.nBoot, @mean, diffs); + %pValsFrac(pi) = mean(bootDiff <= 0); + pLeft = mean(bootDiff <= 0); + pRight = mean(bootDiff >= 0); + pValsFrac(pi) = min(2 * min(pLeft, pRight), 1); +end + +[G, ~] = findgroups(tempTable.insertion); +totals = splitapply(@sum, tempTable.respNeur, G); +tempTable.TotalRespNeur = totals(G); + +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); + +% 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 + +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, ' - ')]; + +formatAxes(gca, 8, 'helvetica'); +set(fig, 'Units', 'centimeters', 'Position', [20 20 5 6]); +ylabel('Responsive / Total responsive'); +title(''); + +pos = get(gca, 'Position'); +pos(2) = pos(2) + 0.05; +set(gca, 'Position', pos); + +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(compLabels,'-')), ... + PaperFig = params.PaperFig); +end + +end % end function AllExpAnalysis + + +% ######################################################################### +% LOCAL HELPER FUNCTIONS +% ######################################################################### + +function [labels, items] = buildMode3Items(stimsNeeded, catList, levelsCell) +% buildMode3Items Build the canonical comparison labels and (stim, cat, lvl) tuples. +% labels{k} = 'MB_dir_0' etc. items{k} = struct('stim', 'MB', 'cat', 'direction', 'lv', 0). + + labels = {}; + items = struct('stim', {}, 'cat', {}, 'lv', {}); + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + cat = catList{si}; + lvls = levelsCell{si}; + for lvi = 1:numel(lvls) + lv = lvls(lvi); + 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); + if strlength(catAbbr) > 3 + catAbbr = extractBetween(catAbbr, 1, 3); + catAbbr = char(catAbbr); + end + lbl = sprintf('%s_%s_%g', stimName, catAbbr, levelValue); + lbl = strrep(lbl, '.', 'p'); + lbl = strrep(lbl, '-', 'neg'); +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/buggy cached column names are refreshed. + + vsObj = []; + allFound = false; + + for session = [1, 2] + candidate = createStimulusObject(NP, stimName, session); + if isempty(candidate) || isempty(candidate.VST) + continue + end + + % Recompute ResponseWindow first (fixes stale column names if overwrite is on) + candidate.ResponseWindow( ... + 'overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + rw = candidate.ResponseWindow; + + [C, colNames] = getCmatrix(rw, stimName); + if isempty(C) || isempty(colNames) + continue + end + + catIdx = find(strcmpi(colNames, catName)); + if isempty(catIdx) + continue + end + + catColIdx = catIdx + 1; + availLevels = uniquetol(C(~isnan(C(:, catColIdx)), catColIdx), 1e-6); + + ok = true; + for lv = requestedLevels(:)' + if ~any(abs(availLevels - lv) < 1e-6) + 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. +% +% ResponseWindow is recomputed with params.overwriteResponse before reading +% colNames/C, to ensure stale/buggy cached column names are refreshed. + + 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 first (fixes stale column names if overwrite is on) + vsObj.ResponseWindow( ... + 'overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + rw = vsObj.ResponseWindow; + + [C, colNames] = getCmatrix(rw, stimName); + if isempty(C) || isempty(colNames) + continue + end + + catIdx = find(strcmpi(colNames, catName)); + if isempty(catIdx) + fprintf(' Category "%s" not found. Available: %s\n', ... + catName, strjoin(colNames, ', ')); + return + end + + catColIdx = catIdx + 1; + rawCol = C(:, catColIdx); + rawCol = rawCol(~isnan(rawCol)); + levels = uniquetol(rawCol, 1e-6); + + if numel(levels) >= 2 + fprintf(' Found %d levels of "%s" (session %d): [%s]\n', ... + numel(levels), catName, session, num2str(levels', '%.4g ')); + return + 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 C matrix and column names from a ResponseWindow struct. + + C = []; + colNames = {}; + + switch stimName + case {'MB', 'MBR'} + 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); + + 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 + 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. + + vsObj = []; + try + key = getObjKey(stimName); + if session == 0 + 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 + 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. + + vsObjs = containers.Map(); + present = containers.Map(); + + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + key = getObjKey(sn); + + if ~vsObjs.isKey(key) + 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 + 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. + switch stimName + case {'SDGm','SDGs'}, key = 'SDG'; + otherwise, key = stimName; + end +end + + +function fName = levelToFieldName(catName, value) +% levelToFieldName Build a valid 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'); + fName = strrep(fName, '-', 'neg'); +end + + +function runStimStats(vsObj, params) +% runStimStats Run ResponseWindow + the chosen statistical method. + + 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); + otherwise + error('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 from a stats struct. + + switch statMethod + case 'ObsWindow', stats = vsObj.ShufflingAnalysis; + case 'bootsrapRespBase', stats = vsObj.BootstrapPerNeuron; + case 'maxPermuteTest', stats = vsObj.StatisticsPerNeuron; + end + + rw = vsObj.ResponseWindow; + + switch stimName + + case 'MB' + % Find best speed per neuron (lowest p-value across speeds) + speedFields = fieldnames(stats); + speedFields = speedFields(contains(speedFields, 'Speed')); + nSpeeds = numel(speedFields); + + allP = []; + allZ = []; + allR = []; + allDf = []; + + for iS = 1:nSpeeds + sName = speedFields{iS}; + subTmp = stats.(sName); + rwTmp = rw.(sName); + + 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 + + if strcmp(statMethod, 'bootsrapRespBase') && isfield(subTmp, 'ObsResponse') + allR(:,iS) = mean(subTmp.ObsResponse, 1)'; %#ok + end + end + + [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; + case 'SDGm' + sub = stats.Moving; rwSub = rw.Moving; + case 'SDGs' + sub = stats.Static; rwSub = rw.Static; + otherwise + sub = stats; rwSub = rw; + end + + z = sub.ZScoreU(:); + p = sub.pvalsResponse(:); + + if useZmean && isfield(sub, 'z_mean') + spkR = sub.z_mean(:); + else + spkR = max(rwSub.NeuronVals(:,:,4), [], 2); + end + + spkDiff = max(rwSub.NeuronVals(:,:,5), [], 2); + + if strcmp(statMethod, 'bootsrapRespBase') && isfield(sub, 'ObsResponse') + spkR = mean(sub.ObsResponse, 1)'; + end +end + + +function pVal = bootstrapPairDifference(tbl, pair, nBoot, metric) +% bootstrapPairDifference Hierarchical bootstrap test for a single pair. + + diffs = []; + insers = []; + animals = []; + + for ins = unique(tbl.insertion)' + idx1 = tbl.insertion == categorical(ins) & tbl.stimulus == pair{1}; + idx2 = tbl.insertion == categorical(ins) & tbl.stimulus == pair{2}; + + V1 = tbl.(metric)(idx1); + V2 = tbl.(metric)(idx2); + + if isempty(V1) || isempty(V2), continue; end + + animal = unique(tbl.animal(idx1)); + diffs = [diffs; V1 - V2]; + insers = [insers; double(repmat(ins, numel(V1), 1))]; + animals = [animals; double(repmat(animal, numel(V1), 1))]; + end + + bootMeans = hierBoot(diffs, nBoot, insers, animals); + % Two-tailed: probability under H0 that |bootstrap mean| is at least as + % extreme as observed. More conservative than one-tailed; appropriate when + % the direction of the effect is not pre-specified. + pLeft = mean(bootMeans <= 0); + pRight = mean(bootMeans >= 0); + pVal = 2 * min(pLeft, pRight); + pVal = min(pVal, 1); % cap at 1 (rare edge case) + %pVal = mean(bootMeans <= 0); +end + + +function fig = plotPairScatter(tbl, compLabels, metric, cmap, animalIdx, labelMap) +% plotPairScatter Scatter first vs second comparison item for a metric. + + fig = figure; + + mask1 = tbl.stimulus == compLabels{1}; + mask2 = tbl.stimulus == compLabels{2}; + v1 = tbl.(metric)(mask1); + v2 = tbl.(metric)(mask2); + cIdx = animalIdx(mask1); + + scatter(v1, v2, 7, cmap(cIdx,:), 'filled', 'MarkerFaceAlpha', 0.3); + hold on; axis equal; + + lims = [min(tbl.(metric)), max(tbl.(metric))]; + plot(lims, lims, 'k--', 'LineWidth', 1.5); + xlim(lims); ylim(lims); + + 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); + + 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. + + n = numel(pVals); + [pSorted, sortIdx] = sort(pVals(:)); + ranks = (1:n)'; + + pAdj = pSorted .* n ./ ranks; + pAdj = min(pAdj, 1); + for k = n-1:-1:1 + pAdj(k) = min(pAdj(k), pAdj(k+1)); + end + + pAdj(sortIdx) = pAdj; +end \ No newline at end of file diff --git a/visualStimulationAnalysis/AllExpAnalysis.m b/visualStimulationAnalysis/AllExpAnalysis.m index 29f928a..6a4bdf3 100644 --- a/visualStimulationAnalysis/AllExpAnalysis.m +++ b/visualStimulationAnalysis/AllExpAnalysis.m @@ -3,89 +3,168 @@ % 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. +% 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 × -% stimulus), filtered to insertions containing all -% compared stimuli. +% 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'}); % -% EXAMPLE -% tempTable = AllExpAnalysis([49:54 64:66], ... -% ComparePairs = {'SDGm','SDGs'}, ... -% StatMethod = 'maxPermuteTest', ... -% PaperFig = true); +% % Mode 2 +% t = AllExpAnalysis([49:54 64:66], ... +% ComparePairs = {'MB'}, CompareCategory = "size"); % -% See also: hierBoot, plotSwarmBootstrapWithComparisons +% % 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 % 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 + expList (1,:) double % Experiment IDs from master Excel + params.ComparePairs cell % Stimuli to compare + params.CompareCategory = "" % Empty -> mode 1 + % string -> mode 2 (single category) + % cell of strings -> mode 3 (per-stimulus categories) + params.CompareLevels cell = {} % Cell of numeric vectors, one per stimulus. + % Non-empty -> mode 3. + params.useGeneralFilter logical = false % In mode 2/3: use general per-neuron p-values + % (StatMethod) for responsiveness instead of per-level p. + params.threshold double = 0.05 % p-value cutoff for responsiveness + params.StatMethod string = 'ObsWindow' % 'ObsWindow' | 'bootsrapRespBase' | 'maxPermuteTest' + params.overwrite logical = false + params.overwriteResponse logical = false + params.overwriteStats logical = false + params.RespDurationWin double = 100 + params.shuffles double = 2000 + params.useZmean logical = true + params.useFDR logical = false + params.PaperFig logical = false + params.nBoot double = 10000 + params.nBootCategory double = 10000 end % ========================================================================= -% SECTION 1 — SETUP: DETERMINE STIMULI, PATHS, AND CACHING +% SECTION 1 — DETECT MODE AND VALIDATE % ========================================================================= -% Unique stimulus names that need to be loaded and analysed -stimsNeeded = unique(params.ComparePairs, 'stable'); % e.g. {'SDGm','SDGs'} +% Detect operating mode based on parameter combinations +if ~isempty(params.CompareLevels) + % Mode 3: specific-level across stimuli + mode = 3; + 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 + catList = cell(1, numel(params.CompareCategory)); + for i = 1:numel(params.CompareCategory) + if iscell(params.CompareCategory) + catList{i} = char(strtrim(params.CompareCategory{i})); + else + catList{i} = char(strtrim(params.CompareCategory(i))); + end + end + fprintf('=== Mode 3: specific-level cross-stimulus comparison ===\n'); + +elseif (ischar(params.CompareCategory) || isstring(params.CompareCategory)) && ... + strtrim(string(params.CompareCategory)) ~= "" + % Mode 2: within-stimulus, all levels + mode = 2; + assert(numel(params.ComparePairs) == 1, ... + 'Mode 2: requires exactly one stimulus in ComparePairs.'); + stimName = params.ComparePairs{1}; + catName = char(strtrim(string(params.CompareCategory))); + fprintf('=== Mode 2: within-stimulus category "%s" in %s ===\n', catName, stimName); + +else + % Mode 1: across-stimulus + mode = 1; + assert(numel(params.ComparePairs) >= 2, ... + 'Mode 1: requires >=2 stimuli in ComparePairs.'); +end + +% Boolean shortcuts (used throughout the function) +isCategoryMode = (mode == 2); +isSpecificLevelMode = (mode == 3); -% Number of experiments to process -nExp = numel(expList); +% Unique stimulus names that need to be loaded (one per stimulus) +stimsNeeded = unique(params.ComparePairs, 'stable'); % 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 +NP0 = loadNPclassFromTable(expList(1)); +vs0 = linearlyMovingBallAnalysis(NP0); + +rootPath = extractBefore(vs0.dataObj.recordingDir, 'lizards'); +rootPath = [rootPath 'lizards']; +saveDir = fullfile(rootPath, 'Combined_lizard_analysis'); +if ~exist(saveDir, 'dir') 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 +switch mode + case 1 + nameOfFile = sprintf('Ex_%d-%d_Combined_%s.mat', ... + expList(1), expList(end), strjoin(stimsNeeded, '-')); + case 2 + nameOfFile = sprintf('Ex_%d-%d_Combined_%s_%s.mat', ... + expList(1), expList(end), stimName, lower(catName)); + case 3 + % Encode all (stim, cat, levels) in the filename + 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); -% 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 +% Decide whether the per-experiment loop needs to run +runLoop = true; +if exist(savePath, 'file') == 2 && ~params.overwrite + S = load(savePath); if isfield(S, 'expList') && isequal(S.expList, expList) - runLoop = false; % cache is valid → skip loop + runLoop = false; end end @@ -93,305 +172,374 @@ % 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 + 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 × 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 + 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 = {}; +fixedCompLabels = {}; +if isSpecificLevelMode + % Build the canonical comparison labels and (stim, cat, level) tuples now + [fixedCompLabels, mode3Items] = buildMode3Items(stimsNeeded, catList, params.CompareLevels); +end + % ========================================================================= % 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 ---- + animalCount = 0; + insertionCount = 0; + prevAnimal = ""; + prevInsertion = 0; - % ------------------------------------------------------------------ - % 3a — Load the recording and check stimulus availability - % ------------------------------------------------------------------ + for ex = expList - NP = loadNPclassFromTable(ex); % load Neuropixels recording object - fprintf('Processing recording: %s\n', NP.recordingName); % status message + % ---- 3a: Load recording and check stimulus availability ---- + NP = loadNPclassFromTable(ex); + fprintf('Processing recording: %s\n', NP.recordingName); - % 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 + allPresent = true; for si = 1:numel(stimsNeeded) if ~present(stimsNeeded{si}) - allPresent = false; % at least one missing - break + allPresent = false; break end end if ~allPresent - fprintf(' → Skipping: not all stimuli present.\n'); - continue % skip to next experiment + fprintf(' -> Skipping: stimulus not present.\n'); + continue + end + + % ---- 3b: Mode-specific session selection ---- + + if isCategoryMode + % Mode 2: find session of stimName with >=2 levels of catName + key = getObjKey(stimName); + vsObj = vsObjs(key); + + [levels, ~, vsObj] = findCategoryLevels(vsObj, NP, stimName, catName, params); + + if numel(levels) < 2 + fprintf(' -> Skipping: <2 levels for "%s".\n', catName); + continue + end + + vsObjs(key) = vsObj; + + currentLabels = arrayfun(@(v) levelToFieldName(catName, v), ... + levels, 'UniformOutput', false); + + 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 session containing ALL requested levels + sessionFound = true; + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + cat = catList{si}; + lvls = params.CompareLevels{si}; + key = getObjKey(sn); + + [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; + end + if ~sessionFound + continue + end end - % ------------------------------------------------------------------ - % 3b — Parse metadata and update animal / insertion counters - % (only reached if all stimuli are present) - % ------------------------------------------------------------------ + % ---- 3c: Parse metadata and update animal/insertion counters ---- - % 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 + if animalID == "" 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) + insStr = regexp(NP.recordingDir, 'Insertion\d+', 'match', 'once'); + insNum = str2double(regexp(insStr, '\d+', 'match')); - % Update animal and insertion counters - animalChanged = (animalID ~= prevAnimal); % new animal? + animalChanged = (animalID ~= prevAnimal); if animalChanged - animalCount = animalCount + 1; % increment animal counter - prevAnimal = animalID; % update tracker + animalCount = animalCount + 1; + prevAnimal = animalID; end - if insNum ~= prevInsertion || animalChanged % new insertion? - insertionCount = insertionCount + 1; % increment insertion counter - prevInsertion = insNum; % update tracker + if insNum ~= prevInsertion || animalChanged + insertionCount = insertionCount + 1; + prevInsertion = insNum; end - % ------------------------------------------------------------------ - % 3c — Run ResponseWindow + statistics for each stimulus - % ------------------------------------------------------------------ + % ---- 3d: Run statistics and extract per-item data ---- + + stimData = struct(); + nUnits = []; + compLabels = {}; + generalPbyStim = struct(); % for optional general filter + + if isCategoryMode + % Mode 2: single stimulus, all levels + key = getObjKey(stimName); + vsObj = vsObjs(key); + + % General per-neuron stats (for optional general filter) + runStimStats(vsObj, params); + vsObjs(key) = vsObj; + [~, generalP, ~, ~] = extractStimData( ... + vsObj, stimName, params.StatMethod, params.useZmean); + generalPbyStim.(stimName) = generalP; + + % Per-category stats + catStats = vsObj.StatisticsPerNeuronPerCategory( ... + 'compareCategory', catName, ... + 'nBoot', params.nBootCategory, ... + 'overwrite', params.overwriteStats); + + for li = 1:numel(levelLabels) + fName = levelLabels{li}; + stimData.(fName).z = catStats.(fName).ZScoreU(:); + stimData.(fName).p = catStats.(fName).pvalsResponse(:); + stimData.(fName).spkR = catStats.(fName).ObsStat(:); + if isempty(nUnits), nUnits = numel(stimData.(fName).z); end + end + compLabels = levelLabels; - 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 + elseif isSpecificLevelMode + % Mode 3: each stimulus contributes one or more (stim, cat, level) items + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + cat = catList{si}; + lvls = params.CompareLevels{si}; + key = getObjKey(sn); + vsObj = vsObjs(key); + + % General per-neuron stats (for optional general filter) + runStimStats(vsObj, params); + vsObjs(key) = vsObj; + [~, generalP, ~, ~] = extractStimData( ... + vsObj, sn, params.StatMethod, params.useZmean); + generalPbyStim.(sn) = generalP; + + % Per-category stats for this stimulus + category + catStats = vsObj.StatisticsPerNeuronPerCategory( ... + 'compareCategory', cat, ... + 'nBoot', params.nBootCategory, ... + 'overwrite', params.overwriteStats); + + % Extract each requested level + for lvi = 1:numel(lvls) + lv = lvls(lvi); + fName = levelToFieldName(cat, lv); % key in catStats + 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 - % ------------------------------------------------------------------ - % 3d — Extract z-scores, p-values, spike rates for each stimulus - % All stimuli are guaranteed present at this point. - % ------------------------------------------------------------------ + % Verify we have all the labels expected from parameters + if ~isequal(sort(compLabels(:)), sort(fixedCompLabels(:))) + fprintf(' -> Skipping: comparison label mismatch.\n'); + continue + end - stimData = struct(); % one sub-struct per stimulus - nUnits = []; % total unit count (set from first stim) + else + % Mode 1: across-stimulus (one item per stimulus) + objKeys = keys(vsObjs); + for k = 1:numel(objKeys) + key = objKeys{k}; + vsObj = vsObjs(key); + runStimStats(vsObj, params); + vsObjs(key) = vsObj; + end - 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 + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + key = getObjKey(sn); + [z, p, spkR, ~] = extractStimData( ... + vsObjs(key), sn, params.StatMethod, params.useZmean); + stimData.(sn).z = z(:); + stimData.(sn).p = p(:); + stimData.(sn).spkR = spkR(:); + generalPbyStim.(sn) = p(:); + if isempty(nUnits), nUnits = numel(z); end end + compLabels = stimsNeeded; end - % ------------------------------------------------------------------ - % 3e — Optional: Benjamini-Hochberg FDR correction per recording - % ------------------------------------------------------------------ - + % ---- 3e: Optional FDR correction ---- if params.useFDR - for si = 1:numel(stimsNeeded) - sn = stimsNeeded{si}; - stimData.(sn).p = bhFDR(stimData.(sn).p); % adjust p-values for FDR + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + stimData.(cl).p = bhFDR(stimData.(cl).p); end end - % ------------------------------------------------------------------ - % 3f — Build OR significance mask across all compared stimuli - % A neuron is included if it is significant for ANY stimulus - % in ComparePairs. - % ------------------------------------------------------------------ + % ---- 3f: Significance mask ---- - 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 + if params.useGeneralFilter && (isCategoryMode || isSpecificLevelMode) + % Use general per-stimulus p-values (StatMethod), OR'd across stimuli + orMask = false(nUnits, 1); + stimNames_ = fieldnames(generalPbyStim); + for si = 1:numel(stimNames_) + gp = generalPbyStim.(stimNames_{si}); + if params.useFDR, gp = bhFDR(gp); end + 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 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. - % ------------------------------------------------------------------ + unitIDs = find(orMask); + nSig = numel(unitIDs); + % ---- 3g: Append to TableStimComp ---- if nSig > 0 - for si = 1:numel(stimsNeeded) - sn = stimsNeeded{si}; - - % Build a mini-table for this stimulus × this recording + 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(sn)), nSig, 1), ... % stimulus column - categorical(unitIDs), ... % neuron ID column - stimData.(sn).z(orMask), ... % z-score column - stimData.(sn).spkR(orMask), ... % spike rate column + repmat(categorical(cellstr(animalID)), nSig, 1), ... + repmat(categorical(insertionCount), nSig, 1), ... + repmat(categorical(cellstr(cl)), nSig, 1), ... + categorical(unitIDs), ... + stimData.(cl).z(orMask), ... + stimData.(cl).spkR(orMask), ... 'VariableNames', {'animal','insertion','stimulus','NeurID','Z-score','SpkR'}); - - TableStimComp = [TableStimComp; newRows]; % append to pooled table + TableStimComp = [TableStimComp; newRows]; %#ok 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 + % ---- 3h: Append to TableRespNeurs ---- + for ci = 1:numel(compLabels) + cl = compLabels{ci}; + nResp = sum(stimData.(cl).p < params.threshold); newRow = table( ... - categorical(cellstr(animalID)), ... % animal - categorical(insertionCount), ... % insertion - categorical(cellstr(sn)), ... % stimulus - nResp, ... % respNeur - nUnits, ... % totalSomaticN + categorical(cellstr(animalID)), ... + categorical(insertionCount), ... + categorical(cellstr(cl)), ... + nResp, nUnits, ... 'VariableNames', TableRespNeurs.Properties.VariableNames); - TableRespNeurs = [TableRespNeurs; newRow]; % append row + TableRespNeurs = [TableRespNeurs; newRow]; %#ok end - fprintf(' → %d / %d units pass OR filter.\n', nSig, nUnits); - j = j + 1; % advance experiment counter + fprintf(' -> %d / %d units pass filter.\n', nSig, nUnits); - end % ---- end for ex = expList ---- + end % end for ex - % ===================================================================== - % SECTION 4 — SAVE POOLED DATA - % ===================================================================== + % ---- 4: Save pooled data ---- + S.expList = expList; + S.TableStimComp = TableStimComp; + S.TableRespNeurs = TableRespNeurs; + S.params = params; + S.mode = mode; + if isCategoryMode, S.levelLabels = levelLabels; end + if isSpecificLevelMode, S.fixedCompLabels = fixedCompLabels; end - 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 + save(savePath, '-struct', 'S'); fprintf('Saved pooled data to %s\n', savePath); - -end % end if runLoop +end % ========================================================================= -% SECTION 5 — GUARD: ABORT EARLY IF NO SIGNIFICANT NEURONS WERE FOUND +% SECTION 5 — GUARD % ========================================================================= 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 + warning('AllExpAnalysis:noUnits', 'No significant units found. Returning empty.'); + tempTable = 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; +% Defensive: ensure animal/insertion/stimulus are string-based categoricals +% (handles legacy caches and 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 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 +vs = linearlyMovingBallAnalysis(NP, 'MultipleOffsets', false, 'Multiplesizes', false); -% Numeric animal index for each row (used for colour lookup) +animalOrder = categories(S.TableStimComp.animal); +nAnimals = numel(animalOrder); +sharedCmap = lines(nAnimals); 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 +compLabels = cellstr(categories(S.TableStimComp.stimulus)); +pairsAll = nchoosek(compLabels, 2); -% 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 = zeros(1, size(pairsAll, 1)); +for pi = 1:size(pairsAll, 1) 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 +ZscoreYlim = ceil(max(S.TableStimComp.('Z-score'))) + 4; -fig = plotSwarmBootstrapWithComparisons( ... +[fig,~,figAllZ] = plotSwarmBootstrapWithComparisons( ... S.TableStimComp, pairsAll, pValsZ, {'Z-score'}, ... - yLegend = 'Z-score', ... - yMaxVis = ZscoreYlim, ... - diff = true, ... - plotMeanSem = true, ... - Alpha = 0.7); + yLegend = 'Z-score', yMaxVis = ZscoreYlim, ... + diff = true, plotMeanSem = true, Alpha = 0.7); -formatAxes(gca, 8, 'helvetica'); % consistent font styling +formatAxes(gca, 8, 'helvetica'); set(fig, 'Units', 'centimeters', 'Position', [20 20 10 6]); -colormap(fig, sharedCmap); % enforce shared colour scheme +colormap(fig, sharedCmap); if params.PaperFig - vs.printFig(fig, sprintf('Zscore-Swarm-%s', strjoin(stimsNeeded,'-')), ... + vs.printFig(fig, sprintf('Zscore-Swarm-%s', strjoin(compLabels,'-')), ... PaperFig = params.PaperFig); end -% --- 7c: Scatter plot — first stimulus vs second stimulus (Z-score) --- - -if numel(stimsNeeded) == 2 - fig = plotPairScatter(S.TableStimComp, stimsNeeded, ... +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(stimsNeeded,'-')), ... + vs.printFig(fig, sprintf('Zscore-Scatter-%s', strjoin(compLabels,'-')), ... PaperFig = params.PaperFig); end end @@ -400,144 +548,120 @@ % 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 +spkMax = max(S.TableStimComp.SpkR); -fig = plotSwarmBootstrapWithComparisons( ... +[fig,~,figAllZ] = plotSwarmBootstrapWithComparisons( ... S.TableStimComp, pairsAll, pValsSpk, {'SpkR'}, ... - yLegend = 'SpkR', ... - yMaxVis = spkMax, ... - diff = true, ... - plotMeanSem = true, ... - Alpha = 0.7); + 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,'-')), ... + vs.printFig(fig, sprintf('SpkRate-Swarm-%s', strjoin(compLabels,'-')), ... PaperFig = params.PaperFig); end -% --- 8c: Scatter plot — first stimulus vs second stimulus (spike rate) --- - -if numel(stimsNeeded) == 2 - fig = plotPairScatter(S.TableStimComp, stimsNeeded, ... +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(stimsNeeded,'-')), ... + vs.printFig(fig, sprintf('SpkRate-Scatter-%s', strjoin(compLabels,'-')), ... 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? +[G, ~] = findgroups(S.TableRespNeurs.insertion); +hasAll = splitapply( ... + @(s) all(ismember(categorical(compLabels), s)), ... S.TableRespNeurs.stimulus, G); -% Restrict to complete insertions and relevant stimuli only tempTable = S.TableRespNeurs( ... - hasAll(G) & ismember(S.TableRespNeurs.stimulus, categorical(stimsNeeded)), :); + hasAll(G) & ismember(S.TableRespNeurs.stimulus, categorical(compLabels)), :); -% 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 + diffs = []; + for ins = unique(S.TableRespNeurs.insertion)' 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 + total = S.TableRespNeurs.totalSomaticN(idx1); + f1 = S.TableRespNeurs.respNeur(idx1) / total; + f2 = S.TableRespNeurs.respNeur(idx2) / total; + diffs(end+1, 1) = f1 - f2; %#ok 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 + bootDiff = bootstrp(params.nBoot, @mean, diffs); + %pValsFrac(pi) = mean(bootDiff <= 0); + pLeft = mean(bootDiff <= 0); + pRight = mean(bootDiff >= 0); + pValsFrac(pi) = min(2 * min(pLeft, pRight), 1); 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, ' - ')]; + fraction = true, showBothAndDiff = false, ... + yLegend = 'Responsive/total units', ... + diff = false, filled = false, Xjitter = 'none', ... + Alpha = 0.6, drawLines = true); + +% 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 + +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, ' - ')]; 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 +pos = get(gca, 'Position'); +pos(2) = pos(2) + 0.05; 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); + '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,'-')), ... + vs.printFig(fig, sprintf('ResponsiveUnits-%s', strjoin(compLabels,'-')), ... PaperFig = params.PaperFig); end @@ -548,66 +672,239 @@ % 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. +function [labels, items] = buildMode3Items(stimsNeeded, catList, levelsCell) +% buildMode3Items Build the canonical comparison labels and (stim, cat, lvl) tuples. +% labels{k} = 'MB_dir_0' etc. items{k} = struct('stim', 'MB', 'cat', 'direction', 'lv', 0). + + labels = {}; + items = struct('stim', {}, 'cat', {}, 'lv', {}); + for si = 1:numel(stimsNeeded) + sn = stimsNeeded{si}; + cat = catList{si}; + lvls = levelsCell{si}; + for lvi = 1:numel(lvls) + lv = lvls(lvi); + 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); + if strlength(catAbbr) > 3 + catAbbr = extractBetween(catAbbr, 1, 3); + catAbbr = char(catAbbr); + end + lbl = sprintf('%s_%s_%g', stimName, catAbbr, levelValue); + lbl = strrep(lbl, '.', 'p'); + lbl = strrep(lbl, '-', 'neg'); +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. % -% INPUTS -% NP Neuropixels recording object -% stimsNeeded cell array of stimulus abbreviations +% ResponseWindow is recomputed with params.overwriteResponse before reading +% colNames/C, to ensure stale/buggy cached column names are refreshed. + + vsObj = []; + allFound = false; + + for session = [1, 2] + candidate = createStimulusObject(NP, stimName, session); + if isempty(candidate) || isempty(candidate.VST) + continue + end + + % Recompute ResponseWindow first (fixes stale column names if overwrite is on) + candidate.ResponseWindow( ... + 'overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + rw = candidate.ResponseWindow; + + [C, colNames] = getCmatrix(rw, stimName); + if isempty(C) || isempty(colNames) + continue + end + + catIdx = find(strcmpi(colNames, catName)); + if isempty(catIdx) + continue + end + + catColIdx = catIdx + 1; + availLevels = uniquetol(C(~isnan(C(:, catColIdx)), catColIdx), 1e-6); + + ok = true; + for lv = requestedLevels(:)' + if ~any(abs(availLevels - lv) < 1e-6) + 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. % -% OUTPUTS -% vsObjs containers.Map objKey → analysis object -% present containers.Map stimName → logical (true if recorded) +% ResponseWindow is recomputed with params.overwriteResponse before reading +% colNames/C, to ensure stale/buggy cached column names are refreshed. + + 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 - vsObjs = containers.Map(); % cache of loaded analysis objects - present = containers.Map(); % presence flag per stimulus + % Recompute ResponseWindow first (fixes stale column names if overwrite is on) + vsObj.ResponseWindow( ... + 'overwrite', params.overwriteResponse, ... + 'durationWindow', params.RespDurationWin); + rw = vsObj.ResponseWindow; + + [C, colNames] = getCmatrix(rw, stimName); + if isempty(C) || isempty(colNames) + continue + end + + catIdx = find(strcmpi(colNames, catName)); + if isempty(catIdx) + fprintf(' Category "%s" not found. Available: %s\n', ... + catName, strjoin(colNames, ', ')); + return + end + + catColIdx = catIdx + 1; + rawCol = C(:, catColIdx); + rawCol = rawCol(~isnan(rawCol)); + levels = uniquetol(rawCol, 1e-6); + + if numel(levels) >= 2 + fprintf(' Found %d levels of "%s" (session %d): [%s]\n', ... + numel(levels), catName, session, num2str(levels', '%.4g ')); + return + 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 C matrix and column names from a ResponseWindow struct. + + C = []; + colNames = {}; + + switch stimName + case {'MB', 'MBR'} + 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); + + 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 + 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. + + vsObj = []; + try + key = getObjKey(stimName); + if session == 0 + 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 + 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. + + vsObjs = containers.Map(); + present = containers.Map(); for si = 1:numel(stimsNeeded) - sn = stimsNeeded{si}; % stimulus name - key = getObjKey(sn); % shared object key + sn = stimsNeeded{si}; + key = getObjKey(sn); - % 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 + obj = createStimulusObject(NP, sn, 0); - vsObjs(key) = obj; % cache the object + if isempty(obj) || isempty(obj.VST) + fprintf(' %s: stimulus not found.\n', key); + present(sn) = false; + else + present(sn) = true; + end - catch ME - fprintf(' %s: could not load (%s).\n', key, ME.message); - present(sn) = false; % constructor failed → not present + if ~isempty(obj) + vsObjs(key) = obj; 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 + present(sn) = vsObjs.isKey(key) && ~isempty(vsObjs(key).VST); end end end @@ -615,194 +912,183 @@ 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. - +% getObjKey Map stimulus abbreviation to shared analysis-object key. switch stimName case {'SDGm','SDGs'}, key = 'SDG'; - otherwise, key = stimName; % MB, RG, MBR, NI, NV, FFF + otherwise, key = stimName; end end +function fName = levelToFieldName(catName, value) +% levelToFieldName Build a valid 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'); + fName = strrep(fName, '-', 'neg'); +end + + function runStimStats(vsObj, params) -% runStimStats Run ResponseWindow and the chosen statistical method. -% -% Dispatches to ShufflingAnalysis, BootstrapPerNeuron, or -% StatisticsPerNeuron depending on params.StatMethod. +% runStimStats Run ResponseWindow + the chosen statistical method. - % Compute or load the response window vsObj.ResponseWindow( ... - 'overwrite', params.overwriteResponse, ... - 'durationWindow', params.RespDurationWin); + '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); - + '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); + error('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) +% extractStimData Pull z-scores, p-values, spike rate from a stats struct. - % --- Retrieve the stats struct (dispatch on statistical method) --- switch statMethod - case 'ObsWindow', stats = vsObj.ShufflingAnalysis; + case 'ObsWindow', stats = vsObj.ShufflingAnalysis; case 'bootsrapRespBase', stats = vsObj.BootstrapPerNeuron; - case 'maxPermuteTest', stats = vsObj.StatisticsPerNeuron; + case 'maxPermuteTest', stats = vsObj.StatisticsPerNeuron; end - rw = vsObj.ResponseWindow; % response-window struct (spike rates stored here) + rw = vsObj.ResponseWindow; - % --- 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; + % Find best speed per neuron (lowest p-value across speeds) + speedFields = fieldnames(stats); + speedFields = speedFields(contains(speedFields, 'Speed')); + nSpeeds = numel(speedFields); + + allP = []; + allZ = []; + allR = []; + allDf = []; + + for iS = 1:nSpeeds + sName = speedFields{iS}; + subTmp = stats.(sName); + rwTmp = rw.(sName); + + 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 + + if strcmp(statMethod, 'bootsrapRespBase') && isfield(subTmp, 'ObsResponse') + allR(:,iS) = mean(subTmp.ObsResponse, 1)'; %#ok + end end - case 'MBR' - sub = stats.Speed1; % moving bar: Speed1 only - rwSub = rw.Speed1; + [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; case 'SDGm' - sub = stats.Moving; % drifting gratings: moving condition - rwSub = rw.Moving; - + sub = stats.Moving; 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; + sub = stats.Static; rwSub = rw.Static; + otherwise + sub = stats; rwSub = rw; end - % --- Extract z-scores and p-values --- - z = sub.ZScoreU(:); % force column vector + z = sub.ZScoreU(:); p = sub.pvalsResponse(:); - % --- Extract spike rate --- if useZmean && isfield(sub, 'z_mean') - spkR = sub.z_mean(:); % normalised response (z_mean) + spkR = sub.z_mean(:); else - spkR = max(rwSub.NeuronVals(:,:,4), [], 2); % peak rate across directions + spkR = max(rwSub.NeuronVals(:,:,4), [], 2); 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 + spkR = mean(sub.ObsResponse, 1)'; 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 + diffs = []; + insers = []; + animals = []; - % Select rows: this insertion × each stimulus in the pair + for ins = unique(tbl.insertion)' 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 + V1 = tbl.(metric)(idx1); + V2 = tbl.(metric)(idx2); - if isempty(V1) || isempty(V2) - continue % skip incomplete insertions - end - - animal = unique(tbl.animal(idx1)); % animal for this insertion + if isempty(V1) || isempty(V2), continue; end - 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 + animal = unique(tbl.animal(idx1)); + diffs = [diffs; V1 - V2]; + insers = [insers; double(repmat(ins, numel(V1), 1))]; + animals = [animals; double(repmat(animal, numel(V1), 1))]; end - % Run hierarchical bootstrap (resample animals → insertions → neurons) bootMeans = hierBoot(diffs, nBoot, insers, animals); - pVal = mean(bootMeans <= 0); % one-sided p-value + % Two-tailed: probability under H0 that |bootstrap mean| is at least as + % extreme as observed. More conservative than one-tailed; appropriate when + % the direction of the effect is not pre-specified. + pLeft = mean(bootMeans <= 0); + pRight = mean(bootMeans >= 0); + pVal = 2 * min(pLeft, pRight); + pVal = min(pVal, 1); % cap at 1 (rare edge case) + %pVal = mean(bootMeans <= 0); 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. +function fig = plotPairScatter(tbl, compLabels, metric, cmap, animalIdx, labelMap) +% plotPairScatter Scatter first vs second comparison item for a metric. 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) + mask1 = tbl.stimulus == compLabels{1}; + mask2 = tbl.stimulus == compLabels{2}; + v1 = tbl.(metric)(mask1); + v2 = tbl.(metric)(mask2); + cIdx = animalIdx(mask1); - % Scatter with animal-coded colour scatter(v1, v2, 7, cmap(cIdx,:), 'filled', 'MarkerFaceAlpha', 0.3); - hold on; - axis equal; + hold on; axis equal; - % Identity line (y = x) - lims = [min(tbl.(metric)), max(tbl.(metric))]; % data range + lims = [min(tbl.(metric)), max(tbl.(metric))]; plot(lims, lims, 'k--', 'LineWidth', 1.5); xlim(lims); ylim(lims); - % Axis labels — apply display-name substitutions - xLab = stimsNeeded{1}; - yLab = stimsNeeded{2}; + 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); + xlabel(xLab); ylabel(yLab); colormap(fig, cmap); formatAxes(gca, 8, 'helvetica'); @@ -812,26 +1098,23 @@ function runStimStats(vsObj, params) 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; + 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 + n = numel(pVals); + [pSorted, sortIdx] = sort(pVals(:)); + ranks = (1:n)'; - % 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 = pSorted .* n ./ ranks; + pAdj = min(pAdj, 1); + for k = n-1:-1:1 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/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..cde8ff8 --- /dev/null +++ b/visualStimulationAnalysis/RunAnalysisClass.asv @@ -0,0 +1,246 @@ +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]%97 74:84 (Neurons, 96_74, ) + NP = loadNPclassFromTable(ex); %73 81 + vs = linearlyMovingBallAnalysis(NP,Session=1); + % 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,'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 +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],StatMethod='maxPermuteTest', overwrite=false,ComparePairs={'MB','SDGm'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true,useFDR=false);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% + +%% +t = AllExpAnalysis([49:54,64:66,68:85 87:97], ... + ComparePairs = {'MB','SDGm'}, ... + CompareCategory = {'directions','angles'}, ... + CompareLevels = {[0],[0]}, ... + StatMethod = 'maxPermuteTest', ... + useGeneralFilter = false, ... + PaperFig = true); + +%% +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'SDGm'},CompareCategory="spatFrequency",PaperFig=true,... + overwriteResponse=true,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% + + +%% +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],{'MB','RG'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% + +%% PSTH for all experiments +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] + +%% 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=false... + , 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 53db21a..cde8ff8 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -29,7 +29,7 @@ %% Moving ball -for ex =[74]%97 74:84 (Neurons, 96_74, ) +for ex =[84]%97 74:84 (Neurons, 96_74, ) NP = loadNPclassFromTable(ex); %73 81 vs = linearlyMovingBallAnalysis(NP,Session=1); % vs.getSessionTime("overwrite",true); @@ -51,7 +51,8 @@ %result = vs.BootstrapPerNeuron('overwrite',true);%('overwrite',true); % pvals0_6Filter =result.Speed2.pvalsResponse'; % compare = [pvals,pvalsNoFilt,pvals0_6Filter]; - %result = vs.StatisticsPerNeuron('overwrite',true); + result = vs.StatisticsPerNeuron('overwrite',true); + result = vs.StatisticsPerNeuronPerCategory('compareCategory','sizes','overwrite',true); end @@ -66,8 +67,22 @@ %[49:54,84:90,92:96] %All SDG experiments %solve MBR %bootsrapRespBase -[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'RG','SDGs'},PaperFig=true,... - overwriteResponse=false,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],StatMethod='maxPermuteTest', overwrite=false,ComparePairs={'MB','SDGm'},PaperFig=true,... + overwriteResponse=false,overwriteStats=true,useFDR=false);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% + +%% +t = AllExpAnalysis([49:54,64:66,68:85 87:97], ... + ComparePairs = {'MB','SDGm'}, ... + CompareCategory = {'directions','angles'}, ... + CompareLevels = {[0],[0]}, ... + StatMethod = 'maxPermuteTest', ... + useGeneralFilter = false, ... + PaperFig = true); + +%% +[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'SDGm'},CompareCategory="spatFrequency",PaperFig=true,... + overwriteResponse=true,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% + %% [tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],{'MB','RG'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... diff --git a/visualStimulationAnalysis/computeBallGridCrossings.asv b/visualStimulationAnalysis/computeBallGridCrossings.asv deleted file mode 100644 index 0e955f3..0000000 --- a/visualStimulationAnalysis/computeBallGridCrossings.asv +++ /dev/null @@ -1,279 +0,0 @@ -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 - -% Reconstruct from stimulus design parameters -[Xpos, Ypos] = reconstructBallTrajectoriesFromGeometry(obj, speedIndx, nFramesPerOffsetDir); -% 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 -changePosDir1x = Xpos(:,:,1,:); -changePosDir1y = Ypos(:,:,1,:); -% ------------------------------------------------------------------------- -% 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 = zeros(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])) - -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 From 9b271cf936ccd1d34c57b4fc8cac78f226cc28be Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Fri, 15 May 2026 01:15:29 +0300 Subject: [PATCH 17/19] Adding params saving --- .../plotSwarmBootstrapWithComparisons.asv | 137 +- .../plotSwarmBootstrapWithComparisons.m | 28 +- .../@VStimAnalysis/StatisticsPerNeuron.asv | 806 ------------ .../@VStimAnalysis/StatisticsPerNeuron.m | 4 +- .../StatisticsPerNeuronPerCategory.asv | 10 +- visualStimulationAnalysis/AllExpAnalysis.asv | 936 +++++++++----- visualStimulationAnalysis/AllExpAnalysis.m | 949 +++++++++----- .../RunAnalysisClass.asv | 45 +- visualStimulationAnalysis/RunAnalysisClass.m | 57 +- .../plotPSTH_MultiExp.asv | 1090 ++++++++++++++++ visualStimulationAnalysis/plotPSTH_MultiExp.m | 1117 +++++++++++++---- 11 files changed, 3451 insertions(+), 1728 deletions(-) delete mode 100644 visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv create mode 100644 visualStimulationAnalysis/plotPSTH_MultiExp.asv diff --git a/general functions/plotSwarmBootstrapWithComparisons.asv b/general functions/plotSwarmBootstrapWithComparisons.asv index 97e0e31..1361f7a 100644 --- a/general functions/plotSwarmBootstrapWithComparisons.asv +++ b/general functions/plotSwarmBootstrapWithComparisons.asv @@ -150,7 +150,7 @@ fig = figure; set(fig, 'Color', 'w'); % white background for publication if params.showBothAndDiff - % Left tile: every stimulus shown raw. Right tile: difference for pairs{1,:}. + % Left tile: every stimulus shown raw. Right tile: most-significant pair's diff. tl = tiledlayout(fig, 1, 2, 'TileSpacing', 'compact', 'Padding', 'compact'); axRaw = nexttile(tl, 1); axDiff = nexttile(tl, 2); @@ -158,12 +158,21 @@ if params.showBothAndDiff randiColors = plotRawSwarm(axRaw, tbl, pairs, pValues, params, ... yMaxVis, bracketPad, stackPad, textPad, semAlpha, isInsertionLevel); - tblDiff = buildDiffTable(tbl, pairs, params, isInsertionLevel); - plotDiffSwarm(axDiff, tblDiff, pairs, pValues, params, ... + % 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, not both. - ax = axes(fig); %#ok + ax = axes(fig); hold(ax, 'on'); set(ax, 'Clipping', 'off'); % allow brackets/text outside ylim @@ -177,9 +186,21 @@ else end end +% ------------------------------------------------------------------------- +% Additional figure: one tile per pairwise difference (only if multi-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 % Plots all observations grouped by stimulus, with optional connecting lines @@ -219,6 +240,15 @@ else end s.XJitter = params.Xjitter; +Str = string(unique(tbl.stimulus)); + +% Extract first number (integer or decimal, positive/negative) +numStr = regexp(Str, '-?\d+\.?\d*', 'match', 'once'); +hold(ax, 'on'); +if any(~cellfun(@isempty, numStr)) + xticklabels(ax,numStr); +end + % Configure the colormap to match the chosen color source. if params.colorByZScore colormap(ax, buildRdBuColormap(256)); @@ -322,6 +352,14 @@ else end s.XJitter = params.Xjitter; +Str = string(unique(tblDiff.stimulus)); + +numStr = regexp(Str, '-?\d+\.?\d*', 'match', 'once'); + +if any(~cellfun(@isempty, numStr)) + xticklabels(ax, numStr); +end + if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) colormap(ax, buildRdBuColormap(256)); maxZ = max(abs(tblDiff.zScore), [], 'omitnan'); @@ -379,23 +417,23 @@ if ~isempty(pValues) && numel(pValues) >= 1 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); end - % Comparison label (e.g. "SB > SG"), placed above the stars. - compTextPad = 10 * textPad; - stimA = pairs{1,1}; - stimB = pairs{1,2}; - compText = sprintf('%s > %s', stimA, stimB); - yCompText = yText + compTextPad; - - text(ax, 1, yCompText, compText, ... - 'HorizontalAlignment', 'center', 'FontSize', 10, 'Clipping', 'off'); - - % Expand y-limits if the comparison label needs more room than yMaxVis allows. - requiredHeight = yCompText + compTextPad; - if requiredHeight > yMaxVis - ylim(ax, [ylims(1) requiredHeight]); - else - ylim(ax, [ylims(1) yMaxVis]); - end + % % Comparison label (e.g. "SB > SG"), placed above the stars. + % compTextPad = 10 * textPad; + % stimA = pairs{1,1}; + % stimB = pairs{1,2}; + % compText = sprintf('%s > %s', stimA, stimB); + % yCompText = yText + compTextPad; + % + % text(ax, 1, yCompText, compText, ... + % 'HorizontalAlignment', 'center', 'FontSize', 10, 'Clipping', 'off'); + % + % % Expand y-limits if the comparison label needs more room than yMaxVis allows. + % requiredHeight = yCompText + compTextPad; + % if requiredHeight > yMaxVis + % ylim(ax, [ylims(1) requiredHeight]); + % else + ylim(ax, [ylims(1) yMaxVis]); + % end else ylim(ax, [ylims(1) yMaxVis]); end @@ -541,7 +579,7 @@ for i = 1:numel(stimuli) end % Sample mean as point estimate. - mu = mean(vals); + %mu = mean(vals); % Pull each grouping column for this stimulus, aligned with the NaN drop. % NOTE: hierBoot pre-allocates intermediate level arrays via nan(size(data)) @@ -570,6 +608,11 @@ for i = 1:numel(stimuli) bootMean = hierBoot(vals, params.nBoot, groupVals{:}); end + % Hierarchical-bootstrap point estimate (consistent with the CI). + % mean(bootMean) converges to the hierarchical mean, which weights + % animals/insertions equally — matching the mixed-model logic. + mu = mean(bootMean); + % Uncertainty bar from the bootstrap distribution. switch lower(params.ciMethod) case 'sem' @@ -693,9 +736,11 @@ end function pairs = renamePairLabels(pairs) if isempty(pairs), return; end for i = 1:numel(pairs) - if strcmp(pairs{i}, 'RG'), pairs{i} = 'SB'; end - if strcmp(pairs{i}, 'SDGm'), pairs{i} = 'MG'; end - if strcmp(pairs{i}, 'SDGs'), pairs{i} = 'SG'; end + p = string(pairs{i}); + p = replace(p, "RG", "SB"); + p = replace(p, "SDGs", "SG"); % must come before SDGm — strict prefix + p = replace(p, "SDGm", "MG"); + pairs{i} = char(p); end end @@ -745,6 +790,8 @@ 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 @@ -756,4 +803,44 @@ catsFinal = catsAlpha(idxNum); tbl.stimulus = reordercats(tbl.stimulus, catsFinal); +end + +% ========================================================================= +% LOCAL FUNCTION: plotAllPairDiffs +% Stand-alone figure with one tile per pairwise difference. Each tile is a +% diff swarm + significance annotation, matching the format of plotDiffSwarm. +% ========================================================================= +function figAll = plotAllPairDiffs(tbl, pairs, pValues, params, ... + isInsertionLevel, yMaxVis, bracketPad, textPad) + +nPairs = size(pairs, 1); + +figAll = figure; +set(figAll, 'Color', 'w'); + +% 'flow' layout adapts to any pair count without manual rows/cols tuning. +tl = tiledlayout(figAll, 'flow', 'TileSpacing', 'compact', 'Padding', 'compact'); +title(tl, 'All pairwise differences'); + +for k = 1:nPairs + ax = nexttile(tl); + + pairK = pairs(k, :); + pValK = pValues(k); + + tblDiff = buildDiffTable(tbl, pairK, params, isInsertionLevel); + + % Empty diff table (e.g. no overlapping insertions) – leave tile blank + if height(tblDiff) == 0 + title(ax, sprintf('%s − %s (no data)', pairK{1}, pairK{2}), ... + 'FontSize', 8); + continue + end + + plotDiffSwarm(ax, tblDiff, pairK, pValK, params, ... + yMaxVis, bracketPad, textPad); + + title(ax, sprintf('%s − %s', pairK{1}, pairK{2}), 'FontSize', 8); +end + end \ No newline at end of file diff --git a/general functions/plotSwarmBootstrapWithComparisons.m b/general functions/plotSwarmBootstrapWithComparisons.m index 502a504..53b7c8c 100644 --- a/general functions/plotSwarmBootstrapWithComparisons.m +++ b/general functions/plotSwarmBootstrapWithComparisons.m @@ -240,6 +240,15 @@ end s.XJitter = params.Xjitter; +Str = string(unique(tbl.stimulus)); + +% Extract first number (integer or decimal, positive/negative) +numStr = regexp(Str, '-?\d+\.?\d*', 'match', 'once'); +hold(ax, 'on'); +if any(~cellfun(@isempty, numStr)) + xticklabels(ax,numStr); +end + % Configure the colormap to match the chosen color source. if params.colorByZScore colormap(ax, buildRdBuColormap(256)); @@ -343,6 +352,16 @@ end s.XJitter = params.Xjitter; +Str = string(unique(tblDiff.stimulus)); + +nums = regexp(Str, '-?\d+\.?\d*', 'match'); + +numStr = strjoin(nums, '-'); + +if any(~cellfun(@isempty, nums)) + xticklabels(ax, numStr); +end + if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) colormap(ax, buildRdBuColormap(256)); maxZ = max(abs(tblDiff.zScore), [], 'omitnan'); @@ -562,7 +581,7 @@ function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) end % Sample mean as point estimate. - mu = mean(vals); + %mu = mean(vals); % Pull each grouping column for this stimulus, aligned with the NaN drop. % NOTE: hierBoot pre-allocates intermediate level arrays via nan(size(data)) @@ -591,6 +610,11 @@ function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) bootMean = hierBoot(vals, params.nBoot, groupVals{:}); end + % Hierarchical-bootstrap point estimate (consistent with the CI). + % mean(bootMean) converges to the hierarchical mean, which weights + % animals/insertions equally — matching the mixed-model logic. + mu = mean(bootMean); + % Uncertainty bar from the bootstrap distribution. switch lower(params.ciMethod) case 'sem' @@ -768,6 +792,8 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... % 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 diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv deleted file mode 100644 index cd6cac4..0000000 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.asv +++ /dev/null @@ -1,806 +0,0 @@ -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 = 1000 %Fixed window for baseline and response - params.useSegments = false %Use segmented approach - params.maxCategory = true %Use the max category to calculate the observed statistics - -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/StatisticsPerNeuron.m b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m index 36e598c..dd9fe04 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuron.m @@ -77,9 +77,9 @@ params.SpatialGridMode = false % if true: use StatisticsPerNeuronSpatialGrid % only applies to linearlyMovingBall % ignored for other stimuli - params.BaseRespWindow = 1000 %Fixed window for baseline and response + params.BaseRespWindow = 300 %Fixed window for baseline and response params.useSegments = false %Use segmented approach - params.maxCategory = false %Use the max category to calculate the observed statistic and the null distribution across bootstrap iterations + params.maxCategory = true %Use the max category to calculate the observed statistic and the null distribution across bootstrap iterations end diff --git a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.asv b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.asv index 40eabc0..faecef3 100644 --- a/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.asv +++ b/visualStimulationAnalysis/@VStimAnalysis/StatisticsPerNeuronPerCategory.asv @@ -33,14 +33,14 @@ arguments (Input) obj params.compareCategory = '' % category name to compare (case-insensitive) params.nBoot = 10000 % permutation iterations - params.BaseRespWindow = 800 % ms response window from stimulus onset + 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 + 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. @@ -140,11 +140,7 @@ if isMovingBall elseif isGratingMov % Use Moving phase for grating C = responseParams.C; - C(:,1) = C(:,1) - colNames = responseParams.colNames{1}(5:end); - -elseif isGratingStat - C = responseParams.static.C; + C(:,1) = C(:,1) +obj.VST.static_time*1000; colNames = responseParams.colNames{1}(5:end); else diff --git a/visualStimulationAnalysis/AllExpAnalysis.asv b/visualStimulationAnalysis/AllExpAnalysis.asv index 6b85275..8a1d7e0 100644 --- a/visualStimulationAnalysis/AllExpAnalysis.asv +++ b/visualStimulationAnalysis/AllExpAnalysis.asv @@ -55,37 +55,60 @@ function [tempTable] = AllExpAnalysis(expList, params) % ARGUMENTS BLOCK % ========================================================================= arguments - expList (1,:) double % Experiment IDs from master Excel - params.ComparePairs cell % Stimuli to compare - params.CompareCategory = "" % Empty -> mode 1 - % string -> mode 2 (single category) - % cell of strings -> mode 3 (per-stimulus categories) - params.CompareLevels cell = {} % Cell of numeric vectors, one per stimulus. - % Non-empty -> mode 3. - params.useGeneralFilter logical = false % In mode 2/3: use general per-neuron p-values - % (StatMethod) for responsiveness instead of per-level p. - params.threshold double = 0.05 % p-value cutoff for responsiveness - params.StatMethod string = 'ObsWindow' % 'ObsWindow' | 'bootsrapRespBase' | 'maxPermuteTest' - params.overwrite logical = false - params.overwriteResponse logical = false - params.overwriteStats logical = false - params.RespDurationWin double = 100 - params.shuffles double = 2000 - params.useZmean logical = true - params.useFDR logical = false - params.PaperFig logical = false - params.nBoot double = 10000 - params.nBootCategory double = 10000 + 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 AND VALIDATE +% SECTION 1 — DETECT MODE, VALIDATE, AND APPLY GLOBAL OVERRIDES % ========================================================================= -% Detect operating mode based on parameter combinations +% --- 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-level across stimuli + % 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), ... @@ -93,90 +116,108 @@ if ~isempty(params.CompareLevels) assert(numel(params.CompareLevels) == numel(params.ComparePairs), ... 'Mode 3: CompareLevels must have same length as ComparePairs.'); - % Normalise CompareCategory to a cell of char arrays + % 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})); + catList{i} = char(strtrim(params.CompareCategory{i})); % cell input else - catList{i} = char(strtrim(params.CompareCategory(i))); + 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: within-stimulus, all levels + % 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}; - catName = char(strtrim(string(params.CompareCategory))); + + 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: across-stimulus + % 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); -isSpecificLevelMode = (mode == 3); +% 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 (one per stimulus) +% Unique stimulus names that need to be loaded (deduplicated, preserving order) stimsNeeded = unique(params.ComparePairs, 'stable'); -% Load the first experiment to extract directory paths +% ========================================================================= +% SECTION 2 — DIRECTORY SETUP AND CACHE MANAGEMENT +% ========================================================================= + +% Load the first experiment to extract directory paths for saving NP0 = loadNPclassFromTable(expList(1)); vs0 = linearlyMovingBallAnalysis(NP0); -rootPath = extractBefore(vs0.dataObj.recordingDir, 'lizards'); -rootPath = [rootPath 'lizards']; -saveDir = fullfile(rootPath, 'Combined_lizard_analysis'); +% 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); + mkdir(saveDir); % create if it does not exist end -% Construct a descriptive filename for the cached pooled data +% 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 - % Encode all (stim, cat, levels) in the filename + % 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), '_'); + 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); +savePath = fullfile(saveDir, nameOfFile); % full path for the cache .mat -% Decide whether the per-experiment loop needs to run +% 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); + S = load(savePath); % load cached struct if isfield(S, 'expList') && isequal(S.expList, expList) - runLoop = false; + runLoop = false; % cache is valid — skip the loop end end % ========================================================================= -% SECTION 2 — INITIALISE LONG-FORMAT TABLES +% 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), ... @@ -184,36 +225,45 @@ TableRespNeurs = table( ... % 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 = {}; -fixedCompLabels = {}; +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 3 — PER-EXPERIMENT LOOP +% SECTION 4 — PER-EXPERIMENT LOOP % ========================================================================= if runLoop - animalCount = 0; - insertionCount = 0; - prevAnimal = ""; - prevInsertion = 0; + % --- 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 - % ---- 3a: Load recording and check stimulus availability ---- - NP = loadNPclassFromTable(ex); + % ---- 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 + allPresent = false; break % at least one missing — skip end end if ~allPresent @@ -221,25 +271,29 @@ if runLoop continue end - % ---- 3b: Mode-specific session selection ---- + % ---- 4b: Mode-specific session selection ---- if isCategoryMode % Mode 2: find session of stimName with >=2 levels of catName - key = getObjKey(stimName); - vsObj = vsObjs(key); + 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 + continue % not enough levels for comparison end - vsObjs(key) = vsObj; + 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, ', ')); @@ -252,126 +306,167 @@ if runLoop end elseif isSpecificLevelMode - % Mode 3: for each stimulus, find session containing ALL requested levels + % Mode 3: for each stimulus, find a session containing ALL requested levels sessionFound = true; for si = 1:numel(stimsNeeded) - sn = stimsNeeded{si}; - cat = catList{si}; - lvls = params.CompareLevels{si}; + 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; + vsObjs(key) = vsObj; % store the valid session object end if ~sessionFound - continue + continue % skip this experiment entirely end end - % ---- 3c: Parse metadata and update animal/insertion counters ---- + % ---- 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')); - animalChanged = (animalID ~= prevAnimal); - if animalChanged - animalCount = animalCount + 1; - prevAnimal = animalID; + % 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 - if insNum ~= prevInsertion || animalChanged - insertionCount = insertionCount + 1; - prevInsertion = insNum; + + % 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 - % ---- 3d: Run statistics and extract per-item data ---- + % ---- 4d: Run statistics and extract per-item data ---- - stimData = struct(); - nUnits = []; - compLabels = {}; - generalPbyStim = struct(); % for optional general filter + 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 + % ---- Mode 2: single stimulus, all levels of one category ---- + key = getObjKey(stimName); vsObj = vsObjs(key); - % General per-neuron stats (for optional general filter) + % Run general per-neuron statistics (StatisticsPerNeuron) runStimStats(vsObj, params); - vsObjs(key) = vsObj; - [~, generalP, ~, ~] = extractStimData( ... - vsObj, stimName, params.StatMethod, params.useZmean); + 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; - % Per-category stats - catStats = vsObj.StatisticsPerNeuronPerCategory( ... + % Auto-detect GratingType from stimulus abbreviation + gratingType = detectGratingType(stimName); + + % Build name-value arguments for StatisticsPerNeuronPerCategory + catStatsArgs = { ... 'compareCategory', catName, ... 'nBoot', params.nBootCategory, ... - 'overwrite', params.overwriteStats); + '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}; - stimData.(fName).z = catStats.(fName).ZScoreU(:); - stimData.(fName).p = catStats.(fName).pvalsResponse(:); - stimData.(fName).spkR = catStats.(fName).ObsStat(:); + 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; + compLabels = levelLabels; % items to compare = level labels elseif isSpecificLevelMode - % Mode 3: each stimulus contributes one or more (stim, cat, level) items + % ---- Mode 3: each stimulus contributes one or more (stim, cat, level) items ---- + for si = 1:numel(stimsNeeded) - sn = stimsNeeded{si}; - cat = catList{si}; - lvls = params.CompareLevels{si}; + sn = stimsNeeded{si}; % stimulus name + cat = catList{si}; % category for this stimulus + lvls = params.CompareLevels{si}; % requested levels key = getObjKey(sn); vsObj = vsObjs(key); - % General per-neuron stats (for optional general filter) + % Run general per-neuron statistics runStimStats(vsObj, params); vsObjs(key) = vsObj; - [~, generalP, ~, ~] = extractStimData( ... - vsObj, sn, params.StatMethod, params.useZmean); + + % Extract general p-values (for optional useGeneralFilter) + [~, generalP, ~, ~] = extractStimData(vsObj, sn, params.useZmean); generalPbyStim.(sn) = generalP; - % Per-category stats for this stimulus + category - catStats = vsObj.StatisticsPerNeuronPerCategory( ... + % Auto-detect GratingType from stimulus abbreviation + gratingType = detectGratingType(sn); + + % Build name-value arguments for StatisticsPerNeuronPerCategory + catStatsArgs = { ... 'compareCategory', cat, ... 'nBoot', params.nBootCategory, ... - 'overwrite', params.overwriteStats); + '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{:}); - % Extract each requested level + % Extract data for each requested level for lvi = 1:numel(lvls) - lv = lvls(lvi); - fName = levelToFieldName(cat, lv); % key in catStats - cLabel = makeCompLabel(sn, cat, lv); % short composite label + lv = lvls(lvi); % numeric level value + fName = levelToFieldName(cat, lv); % key in catStats + 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 + compLabels{end+1} = cLabel; %#ok if isempty(nUnits), nUnits = numel(stimData.(cLabel).z); end end end - % Verify we have all the labels expected from parameters + % 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) + % ---- 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}; @@ -380,37 +475,44 @@ if runLoop 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.StatMethod, params.useZmean); + [z, p, spkR, ~] = extractStimData(vsObjs(key), sn, params.useZmean); stimData.(sn).z = z(:); stimData.(sn).p = p(:); stimData.(sn).spkR = spkR(:); - generalPbyStim.(sn) = p(:); + generalPbyStim.(sn) = p(:); % general p = per-stimulus p in mode 1 if isempty(nUnits), nUnits = numel(z); end end - compLabels = stimsNeeded; + compLabels = stimsNeeded; % items to compare = stimulus names end - % ---- 3e: Optional FDR correction ---- + % ---- 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); + stimData.(cl).p = bhFDR(stimData.(cl).p); % correct per-item p-values end end - % ---- 3f: Significance mask ---- + % ---- 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 (StatMethod), OR'd across stimuli + % 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 + if params.useFDR, gp = bhFDR(gp); end % apply FDR if requested orMask = orMask | (gp < params.threshold); end else @@ -422,57 +524,125 @@ if runLoop end end - unitIDs = find(orMask); - nSig = numel(unitIDs); + unitIDs = find(orMask); % indices of neurons passing the filter + nSig = numel(unitIDs); % count of significant neurons - % ---- 3g: Append to TableStimComp ---- + % ---- 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), ... - repmat(categorical(insertionCount), nSig, 1), ... - repmat(categorical(cellstr(cl)), nSig, 1), ... - categorical(unitIDs), ... - stimData.(cl).z(orMask), ... - stimData.(cl).spkR(orMask), ... + 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 + TableStimComp = [TableStimComp; newRows]; %#ok end end - % ---- 3h: Append to TableRespNeurs ---- + % ---- 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); + nResp = sum(stimData.(cl).p < params.threshold); % neurons below threshold newRow = table( ... - categorical(cellstr(animalID)), ... - categorical(insertionCount), ... - categorical(cellstr(cl)), ... - nResp, nUnits, ... + 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 + TableRespNeurs = [TableRespNeurs; newRow]; %#ok end fprintf(' -> %d / %d units pass filter.\n', nSig, nUnits); end % end for ex - % ---- 4: Save pooled data ---- - S.expList = expList; - S.TableStimComp = TableStimComp; - S.TableRespNeurs = TableRespNeurs; - S.params = params; - S.mode = mode; - if isCategoryMode, S.levelLabels = levelLabels; end + % ========================================================================= + % 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 5 — GUARD +% 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 @@ -481,59 +651,76 @@ if isempty(S.TableStimComp) || height(S.TableStimComp) == 0 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 -% (handles legacy caches and prevents numeric-named categoricals from being -% silently converted to double inside plotSwarmBootstrapWithComparisons) +% 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 6 — SHARED PLOTTING SETUP +% 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); -animalOrder = categories(S.TableStimComp.animal); -nAnimals = numel(animalOrder); -sharedCmap = lines(nAnimals); -animalIdxAll = double(S.TableStimComp.animal); +% 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) -compLabels = cellstr(categories(S.TableStimComp.stimulus)); -pairsAll = nchoosek(compLabels, 2); +% 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 7 — Z-SCORE PAIRWISE COMPARISON +% 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 -ZscoreYlim = ceil(max(S.TableStimComp.('Z-score'))) + 4; +% 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'))); -[fig,~,figAllZ] = plotSwarmBootstrapWithComparisons( ... +% 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); -formatAxes(gca, 8, 'helvetica'); +% 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); @@ -545,31 +732,43 @@ if numel(compLabels) == 2 end % ========================================================================= -% SECTION 8 — SPIKE-RATE PAIRWISE COMPARISON +% 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 -spkMax = max(S.TableStimComp.SpkR); +% Upper y-limit for spike rate +spkMax = max(S.TableStimComp.SpkR) +0.1*max(S.TableStimComp.SpkR); -fig = plotSwarmBootstrapWithComparisons( ... +% 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); -formatAxes(gca, 8, 'helvetica'); -colormap(fig, sharedCmap); +% 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); @@ -581,43 +780,67 @@ if numel(compLabels) == 2 end % ========================================================================= -% SECTION 9 — FRACTION-RESPONSIVE ANALYSIS +% 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 = []; + + 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)' - idx1 = S.TableRespNeurs.insertion == categorical(ins) & ... + + % 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 == categorical(ins) & ... + 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); - f1 = S.TableRespNeurs.respNeur(idx1) / total; - f2 = S.TableRespNeurs.respNeur(idx2) / total; - diffs(end+1, 1) = f1 - f2; %#ok + 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 - bootDiff = bootstrp(params.nBoot, @mean, diffs); - %pValsFrac(pi) = mean(bootDiff <= 0); + + % 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'}, ... @@ -626,7 +849,7 @@ fig = plotSwarmBootstrapWithComparisons( ... diff = false, filled = false, Xjitter = 'none', ... Alpha = 0.6, drawLines = true); -% Total unique responsive neurons across all (animal, insertion) pairs +% Count total unique responsive neurons across all (animal, insertion) pairs totalResp = 0; animals = unique(S.TableStimComp.animal); for a = 1:numel(animals) @@ -639,32 +862,65 @@ for a = 1:numel(animals) 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, ' - ')]; -formatAxes(gca, 8, 'helvetica'); +% 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', 9, 'FontWeight', 'bold', ... + '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 @@ -672,20 +928,36 @@ 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 the canonical comparison labels and (stim, cat, lvl) tuples. -% labels{k} = 'MB_dir_0' etc. items{k} = struct('stim', 'MB', 'cat', 'direction', 'lv', 0). +% buildMode3Items Build canonical comparison labels and (stim, cat, lvl) tuples. +% labels{k} = 'MB_dir_0' etc. +% items(k) = struct('stim','MB', 'cat','direction', 'lv',0). labels = {}; items = struct('stim', {}, 'cat', {}, 'lv', {}); + for si = 1:numel(stimsNeeded) - sn = stimsNeeded{si}; - cat = catList{si}; - lvls = levelsCell{si}; + sn = stimsNeeded{si}; % stimulus name + cat = catList{si}; % category name + lvls = levelsCell{si}; % numeric level vector + for lvi = 1:numel(lvls) - lv = lvls(lvi); + 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 + items(end+1) = struct('stim', sn, 'cat', cat, 'lv', lv); %#ok end end end @@ -695,14 +967,14 @@ function lbl = makeCompLabel(stimName, catName, levelValue) % makeCompLabel Short composite label: '__'. % Category truncated to 3 chars; decimals -> 'p'; negative -> 'neg'. - catAbbr = lower(catName); + catAbbr = lower(catName); % lowercase category if strlength(catAbbr) > 3 - catAbbr = extractBetween(catAbbr, 1, 3); + catAbbr = extractBetween(catAbbr, 1, 3); % truncate to 3 characters catAbbr = char(catAbbr); end - lbl = sprintf('%s_%s_%g', stimName, catAbbr, levelValue); - lbl = strrep(lbl, '.', 'p'); - lbl = strrep(lbl, '-', 'neg'); + 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 @@ -710,40 +982,43 @@ function [vsObj, allFound] = findSessionWithLevels(NP, stimName, catName, reques % 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/buggy cached column names are refreshed. +% 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); + candidate = createStimulusObject(NP, stimName, session); % try this session if isempty(candidate) || isempty(candidate.VST) - continue + continue % session not available end - % Recompute ResponseWindow first (fixes stale column names if overwrite is on) + % 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 + continue % category not in this stimulus end - catColIdx = catIdx + 1; + 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-6) + if ~any(abs(availLevels - lv) < 1e-2) ok = false; break end end @@ -760,9 +1035,6 @@ 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. -% -% ResponseWindow is recomputed with params.overwriteResponse before reading -% colNames/C, to ensure stale/buggy cached column names are refreshed. levels = []; catColIdx = 0; @@ -774,17 +1046,19 @@ function [levels, catColIdx, vsObj] = findCategoryLevels(vsObj, NP, stimName, ca continue end - % Recompute ResponseWindow first (fixes stale column names if overwrite is on) + % 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', ... @@ -792,15 +1066,15 @@ function [levels, catColIdx, vsObj] = findCategoryLevels(vsObj, NP, stimName, ca return end - catColIdx = catIdx + 1; - rawCol = C(:, catColIdx); - rawCol = rawCol(~isnan(rawCol)); - levels = uniquetol(rawCol, 1e-6); + 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 + return % success — exit early else fprintf(' Only %d level of "%s" in session %d.\n', ... numel(levels), catName, session); @@ -810,19 +1084,21 @@ end function [C, colNames] = getCmatrix(rw, stimName) -% getCmatrix Extract the C matrix and column names from a ResponseWindow struct. +% 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); + colNames = rw.colNames{1}(5:end); % skip first 4 metadata columns case 'SDGm' if isfield(rw, 'C') @@ -837,6 +1113,7 @@ function [C, colNames] = getCmatrix(rw, stimName) end otherwise + % Generic fallback if isfield(rw, 'C') C = rw.C; colNames = rw.colNames{1}(5:end); @@ -847,11 +1124,13 @@ end function vsObj = createStimulusObject(NP, stimName, session) % createStimulusObject Create an analysis object, optionally with Session. +% Returns [] on failure. vsObj = []; try - key = getObjKey(stimName); + 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); @@ -862,6 +1141,7 @@ function vsObj = createStimulusObject(NP, stimName, session) 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); @@ -881,15 +1161,17 @@ 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(); - present = containers.Map(); + vsObjs = containers.Map(); % key -> analysis object + present = containers.Map(); % stimName -> true/false for si = 1:numel(stimsNeeded) - sn = stimsNeeded{si}; - key = getObjKey(sn); + 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) @@ -903,6 +1185,7 @@ function [vsObjs, present] = loadStimulusObjects(NP, stimsNeeded) 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 @@ -913,6 +1196,7 @@ 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; @@ -921,65 +1205,78 @@ end function fName = levelToFieldName(catName, value) -% levelToFieldName Build a valid field name matching StatisticsPerNeuronPerCategory's convention. +% 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'); - fName = strrep(fName, '-', 'neg'); + fName = strrep(fName, '.', 'p'); % decimal point -> 'p' + fName = strrep(fName, '-', 'neg'); % minus sign -> 'neg' end function runStimStats(vsObj, params) -% runStimStats Run ResponseWindow + the chosen statistical method. +% 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); - 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('Unknown StatMethod "%s".', params.StatMethod); - end + % 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, statMethod, useZmean) -% extractStimData Pull z-scores, p-values, spike rate from a stats struct. - - switch statMethod - case 'ObsWindow', stats = vsObj.ShufflingAnalysis; - case 'bootsrapRespBase', stats = vsObj.BootstrapPerNeuron; - case 'maxPermuteTest', stats = vsObj.StatisticsPerNeuron; - 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 - rw = vsObj.ResponseWindow; + % Always read from StatisticsPerNeuron (only stat method retained) + stats = vsObj.StatisticsPerNeuron; + rw = vsObj.ResponseWindow; switch stimName case 'MB' - % Find best speed per neuron (lowest p-value across speeds) + % MB has multiple speeds — find best speed per neuron (lowest p) speedFields = fieldnames(stats); speedFields = speedFields(contains(speedFields, 'Speed')); nSpeeds = numel(speedFields); - allP = []; - allZ = []; - allR = []; - allDf = []; + 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}; - subTmp = stats.(sName); - rwTmp = rw.(sName); + 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 @@ -987,102 +1284,135 @@ function [z, p, spkR, spkDiff] = extractStimData(vsObj, stimName, statMethod, us 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 - - if strcmp(statMethod, 'bootsrapRespBase') && isfield(subTmp, 'ObsResponse') - allR(:,iS) = mean(subTmp.ObsResponse, 1)'; %#ok + 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); + 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; + sub = stats.Speed1; rwSub = rw.Speed1; % bar: single speed case 'SDGm' - sub = stats.Moving; rwSub = rw.Moving; + sub = stats.Moving; rwSub = rw.Moving; % moving grating sub-struct case 'SDGs' - sub = stats.Static; rwSub = rw.Static; + sub = stats.Static; rwSub = rw.Static; % static grating sub-struct otherwise - sub = stats; rwSub = rw; + sub = stats; rwSub = rw; % generic: top-level struct end - z = sub.ZScoreU(:); - p = sub.pvalsResponse(:); + % 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(:); + spkR = sub.z_mean(:); % z-scored mean response else - spkR = max(rwSub.NeuronVals(:,:,4), [], 2); + spkR = max(rwSub.NeuronVals(:,:,4), [], 2); % peak spike rate end - spkDiff = max(rwSub.NeuronVals(:,:,5), [], 2); - - if strcmp(statMethod, 'bootsrapRespBase') && isfield(sub, 'ObsResponse') - spkR = mean(sub.ObsResponse, 1)'; - 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 = []; - insers = []; - animals = []; + 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)' - idx1 = tbl.insertion == categorical(ins) & tbl.stimulus == pair{1}; - idx2 = tbl.insertion == categorical(ins) & tbl.stimulus == pair{2}; - V1 = tbl.(metric)(idx1); - V2 = tbl.(metric)(idx2); + % 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 - if isempty(V1) || isempty(V2), continue; end + % Extract sub-tables with the metric, NeurID, and animal columns + sub1 = tbl(mask1, {metric, 'NeurID', 'animal'}); + sub2 = tbl(mask2, {metric, 'NeurID'}); - animal = unique(tbl.animal(idx1)); - diffs = [diffs; V1 - V2]; - insers = [insers; double(repmat(ins, numel(V1), 1))]; - animals = [animals; double(repmat(animal, numel(V1), 1))]; + % 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: probability under H0 that |bootstrap mean| is at least as - % extreme as observed. More conservative than one-tailed; appropriate when - % the direction of the effect is not pre-specified. - pLeft = mean(bootMeans <= 0); - pRight = mean(bootMeans >= 0); - pVal = 2 * min(pLeft, pRight); - pVal = min(pVal, 1); % cap at 1 (rare edge case) - %pVal = mean(bootMeans <= 0); + + % 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 first vs second comparison item for a metric. +% 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); - v2 = tbl.(metric)(mask2); - cIdx = animalIdx(mask1); + 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}); @@ -1091,6 +1421,7 @@ function fig = plotPairScatter(tbl, compLabels, metric, cmap, animalIdx, labelMa 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 @@ -1105,16 +1436,19 @@ 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 - n = numel(pVals); - [pSorted, sortIdx] = sort(pVals(:)); - ranks = (1:n)'; + pAdj = pSorted .* n ./ ranks; % BH adjustment: p * n / rank + pAdj = min(pAdj, 1); % cap at 1 - pAdj = pSorted .* n ./ ranks; - pAdj = min(pAdj, 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; + pAdj(sortIdx) = pAdj; % restore original order end \ No newline at end of file diff --git a/visualStimulationAnalysis/AllExpAnalysis.m b/visualStimulationAnalysis/AllExpAnalysis.m index 6a4bdf3..881885f 100644 --- a/visualStimulationAnalysis/AllExpAnalysis.m +++ b/visualStimulationAnalysis/AllExpAnalysis.m @@ -55,37 +55,60 @@ % ARGUMENTS BLOCK % ========================================================================= arguments - expList (1,:) double % Experiment IDs from master Excel - params.ComparePairs cell % Stimuli to compare - params.CompareCategory = "" % Empty -> mode 1 - % string -> mode 2 (single category) - % cell of strings -> mode 3 (per-stimulus categories) - params.CompareLevels cell = {} % Cell of numeric vectors, one per stimulus. - % Non-empty -> mode 3. - params.useGeneralFilter logical = false % In mode 2/3: use general per-neuron p-values - % (StatMethod) for responsiveness instead of per-level p. - params.threshold double = 0.05 % p-value cutoff for responsiveness - params.StatMethod string = 'ObsWindow' % 'ObsWindow' | 'bootsrapRespBase' | 'maxPermuteTest' - params.overwrite logical = false - params.overwriteResponse logical = false - params.overwriteStats logical = false - params.RespDurationWin double = 100 - params.shuffles double = 2000 - params.useZmean logical = true - params.useFDR logical = false - params.PaperFig logical = false - params.nBoot double = 10000 - params.nBootCategory double = 10000 + 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 AND VALIDATE +% SECTION 1 — DETECT MODE, VALIDATE, AND APPLY GLOBAL OVERRIDES % ========================================================================= -% Detect operating mode based on parameter combinations +% --- 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-level across stimuli + % 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), ... @@ -93,90 +116,108 @@ assert(numel(params.CompareLevels) == numel(params.ComparePairs), ... 'Mode 3: CompareLevels must have same length as ComparePairs.'); - % Normalise CompareCategory to a cell of char arrays + % 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})); + catList{i} = char(strtrim(params.CompareCategory{i})); % cell input else - catList{i} = char(strtrim(params.CompareCategory(i))); + 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: within-stimulus, all levels + % 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}; - catName = char(strtrim(string(params.CompareCategory))); + + 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: across-stimulus + % 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); -isSpecificLevelMode = (mode == 3); +% 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 (one per stimulus) +% Unique stimulus names that need to be loaded (deduplicated, preserving order) stimsNeeded = unique(params.ComparePairs, 'stable'); -% Load the first experiment to extract directory paths +% ========================================================================= +% SECTION 2 — DIRECTORY SETUP AND CACHE MANAGEMENT +% ========================================================================= + +% Load the first experiment to extract directory paths for saving NP0 = loadNPclassFromTable(expList(1)); vs0 = linearlyMovingBallAnalysis(NP0); -rootPath = extractBefore(vs0.dataObj.recordingDir, 'lizards'); -rootPath = [rootPath 'lizards']; -saveDir = fullfile(rootPath, 'Combined_lizard_analysis'); +% 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); + mkdir(saveDir); % create if it does not exist end -% Construct a descriptive filename for the cached pooled data +% 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 - % Encode all (stim, cat, levels) in the filename + % 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), '_'); + 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); +savePath = fullfile(saveDir, nameOfFile); % full path for the cache .mat -% Decide whether the per-experiment loop needs to run +% 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); + S = load(savePath); % load cached struct if isfield(S, 'expList') && isequal(S.expList, expList) - runLoop = false; + runLoop = false; % cache is valid — skip the loop end end % ========================================================================= -% SECTION 2 — INITIALISE LONG-FORMAT TABLES +% 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), ... @@ -184,36 +225,45 @@ % 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 = {}; -fixedCompLabels = {}; +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 3 — PER-EXPERIMENT LOOP +% SECTION 4 — PER-EXPERIMENT LOOP % ========================================================================= if runLoop - animalCount = 0; - insertionCount = 0; - prevAnimal = ""; - prevInsertion = 0; + % --- 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 - % ---- 3a: Load recording and check stimulus availability ---- - NP = loadNPclassFromTable(ex); + % ---- 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 + allPresent = false; break % at least one missing — skip end end if ~allPresent @@ -221,25 +271,29 @@ continue end - % ---- 3b: Mode-specific session selection ---- + % ---- 4b: Mode-specific session selection ---- if isCategoryMode % Mode 2: find session of stimName with >=2 levels of catName - key = getObjKey(stimName); - vsObj = vsObjs(key); + 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 + continue % not enough levels for comparison end - vsObjs(key) = vsObj; + 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, ', ')); @@ -252,126 +306,180 @@ end elseif isSpecificLevelMode - % Mode 3: for each stimulus, find session containing ALL requested levels + % Mode 3: for each stimulus, find a session containing ALL requested levels sessionFound = true; for si = 1:numel(stimsNeeded) - sn = stimsNeeded{si}; - cat = catList{si}; - lvls = params.CompareLevels{si}; + 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; + vsObjs(key) = vsObj; % store the valid session object end if ~sessionFound - continue + continue % skip this experiment entirely end end - % ---- 3c: Parse metadata and update animal/insertion counters ---- + % ---- 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')); - animalChanged = (animalID ~= prevAnimal); - if animalChanged - animalCount = animalCount + 1; - prevAnimal = animalID; + % 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 - if insNum ~= prevInsertion || animalChanged - insertionCount = insertionCount + 1; - prevInsertion = insNum; + + % 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 - % ---- 3d: Run statistics and extract per-item data ---- + % ---- 4d: Run statistics and extract per-item data ---- - stimData = struct(); - nUnits = []; - compLabels = {}; - generalPbyStim = struct(); % for optional general filter + 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 + % ---- Mode 2: single stimulus, all levels of one category ---- + key = getObjKey(stimName); vsObj = vsObjs(key); - % General per-neuron stats (for optional general filter) + % Run general per-neuron statistics (StatisticsPerNeuron) runStimStats(vsObj, params); - vsObjs(key) = vsObj; - [~, generalP, ~, ~] = extractStimData( ... - vsObj, stimName, params.StatMethod, params.useZmean); + 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; - % Per-category stats - catStats = vsObj.StatisticsPerNeuronPerCategory( ... + % Auto-detect GratingType from stimulus abbreviation + gratingType = detectGratingType(stimName); + + % Build name-value arguments for StatisticsPerNeuronPerCategory + catStatsArgs = { ... 'compareCategory', catName, ... 'nBoot', params.nBootCategory, ... - 'overwrite', params.overwriteStats); + '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}; - stimData.(fName).z = catStats.(fName).ZScoreU(:); - stimData.(fName).p = catStats.(fName).pvalsResponse(:); - stimData.(fName).spkR = catStats.(fName).ObsStat(:); + 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; + compLabels = levelLabels; % items to compare = level labels elseif isSpecificLevelMode - % Mode 3: each stimulus contributes one or more (stim, cat, level) items + % ---- Mode 3: each stimulus contributes one or more (stim, cat, level) items ---- + for si = 1:numel(stimsNeeded) - sn = stimsNeeded{si}; - cat = catList{si}; - lvls = params.CompareLevels{si}; + sn = stimsNeeded{si}; % stimulus name + cat = catList{si}; % category for this stimulus + lvls = params.CompareLevels{si}; % requested levels key = getObjKey(sn); vsObj = vsObjs(key); - % General per-neuron stats (for optional general filter) + % Run general per-neuron statistics runStimStats(vsObj, params); vsObjs(key) = vsObj; - [~, generalP, ~, ~] = extractStimData( ... - vsObj, sn, params.StatMethod, params.useZmean); + + % Extract general p-values (for optional useGeneralFilter) + [~, generalP, ~, ~] = extractStimData(vsObj, sn, params.useZmean); generalPbyStim.(sn) = generalP; - % Per-category stats for this stimulus + category - catStats = vsObj.StatisticsPerNeuronPerCategory( ... + % Auto-detect GratingType from stimulus abbreviation + gratingType = detectGratingType(sn); + + % Build name-value arguments for StatisticsPerNeuronPerCategory + catStatsArgs = { ... 'compareCategory', cat, ... 'nBoot', params.nBootCategory, ... - 'overwrite', params.overwriteStats); + '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); - % Extract each requested level + % 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); - fName = levelToFieldName(cat, lv); % key in catStats - cLabel = makeCompLabel(sn, cat, lv); % short composite label + 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 + compLabels{end+1} = cLabel; %#ok if isempty(nUnits), nUnits = numel(stimData.(cLabel).z); end end end - % Verify we have all the labels expected from parameters + % 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) + % ---- 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}; @@ -380,37 +488,44 @@ 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.StatMethod, params.useZmean); + [z, p, spkR, ~] = extractStimData(vsObjs(key), sn, params.useZmean); stimData.(sn).z = z(:); stimData.(sn).p = p(:); stimData.(sn).spkR = spkR(:); - generalPbyStim.(sn) = p(:); + generalPbyStim.(sn) = p(:); % general p = per-stimulus p in mode 1 if isempty(nUnits), nUnits = numel(z); end end - compLabels = stimsNeeded; + compLabels = stimsNeeded; % items to compare = stimulus names end - % ---- 3e: Optional FDR correction ---- + % ---- 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); + stimData.(cl).p = bhFDR(stimData.(cl).p); % correct per-item p-values end end - % ---- 3f: Significance mask ---- + % ---- 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 (StatMethod), OR'd across stimuli + % 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 + if params.useFDR, gp = bhFDR(gp); end % apply FDR if requested orMask = orMask | (gp < params.threshold); end else @@ -422,57 +537,125 @@ end end - unitIDs = find(orMask); - nSig = numel(unitIDs); + unitIDs = find(orMask); % indices of neurons passing the filter + nSig = numel(unitIDs); % count of significant neurons - % ---- 3g: Append to TableStimComp ---- + % ---- 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), ... - repmat(categorical(insertionCount), nSig, 1), ... - repmat(categorical(cellstr(cl)), nSig, 1), ... - categorical(unitIDs), ... - stimData.(cl).z(orMask), ... - stimData.(cl).spkR(orMask), ... + 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 + TableStimComp = [TableStimComp; newRows]; %#ok end end - % ---- 3h: Append to TableRespNeurs ---- + % ---- 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); + nResp = sum(stimData.(cl).p < params.threshold); % neurons below threshold newRow = table( ... - categorical(cellstr(animalID)), ... - categorical(insertionCount), ... - categorical(cellstr(cl)), ... - nResp, nUnits, ... + 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 + TableRespNeurs = [TableRespNeurs; newRow]; %#ok end fprintf(' -> %d / %d units pass filter.\n', nSig, nUnits); end % end for ex - % ---- 4: Save pooled data ---- - S.expList = expList; - S.TableStimComp = TableStimComp; - S.TableRespNeurs = TableRespNeurs; - S.params = params; - S.mode = mode; - if isCategoryMode, S.levelLabels = levelLabels; end + % ========================================================================= + % 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 5 — GUARD +% 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 @@ -481,59 +664,76 @@ 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 -% (handles legacy caches and prevents numeric-named categoricals from being -% silently converted to double inside plotSwarmBootstrapWithComparisons) +% 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 6 — SHARED PLOTTING SETUP +% 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); -animalOrder = categories(S.TableStimComp.animal); -nAnimals = numel(animalOrder); -sharedCmap = lines(nAnimals); -animalIdxAll = double(S.TableStimComp.animal); +% 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) -compLabels = cellstr(categories(S.TableStimComp.stimulus)); -pairsAll = nchoosek(compLabels, 2); +% 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 7 — Z-SCORE PAIRWISE COMPARISON +% 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 -ZscoreYlim = ceil(max(S.TableStimComp.('Z-score'))) + 4; +% 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'))); -[fig,~,figAllZ] = plotSwarmBootstrapWithComparisons( ... +% 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); -formatAxes(gca, 8, 'helvetica'); +% 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); @@ -545,31 +745,43 @@ end % ========================================================================= -% SECTION 8 — SPIKE-RATE PAIRWISE COMPARISON +% 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 -spkMax = max(S.TableStimComp.SpkR); +% Upper y-limit for spike rate +spkMax = max(S.TableStimComp.SpkR) +0.1*max(S.TableStimComp.SpkR); -[fig,~,figAllZ] = plotSwarmBootstrapWithComparisons( ... +% 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); -formatAxes(gca, 8, 'helvetica'); -colormap(fig, sharedCmap); +% 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); @@ -581,43 +793,67 @@ end % ========================================================================= -% SECTION 9 — FRACTION-RESPONSIVE ANALYSIS +% 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 = []; + + 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)' - idx1 = S.TableRespNeurs.insertion == categorical(ins) & ... + + % 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 == categorical(ins) & ... + 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); - f1 = S.TableRespNeurs.respNeur(idx1) / total; - f2 = S.TableRespNeurs.respNeur(idx2) / total; - diffs(end+1, 1) = f1 - f2; %#ok + 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 - bootDiff = bootstrp(params.nBoot, @mean, diffs); - %pValsFrac(pi) = mean(bootDiff <= 0); + + % 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'}, ... @@ -626,7 +862,7 @@ diff = false, filled = false, Xjitter = 'none', ... Alpha = 0.6, drawLines = true); -% Total unique responsive neurons across all (animal, insertion) pairs +% Count total unique responsive neurons across all (animal, insertion) pairs totalResp = 0; animals = unique(S.TableStimComp.animal); for a = 1:numel(animals) @@ -639,32 +875,65 @@ 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, ' - ')]; -formatAxes(gca, 8, 'helvetica'); +% 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', 9, 'FontWeight', 'bold', ... + '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 @@ -672,20 +941,36 @@ % 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 the canonical comparison labels and (stim, cat, lvl) tuples. -% labels{k} = 'MB_dir_0' etc. items{k} = struct('stim', 'MB', 'cat', 'direction', 'lv', 0). +% buildMode3Items Build canonical comparison labels and (stim, cat, lvl) tuples. +% labels{k} = 'MB_dir_0' etc. +% items(k) = struct('stim','MB', 'cat','direction', 'lv',0). labels = {}; items = struct('stim', {}, 'cat', {}, 'lv', {}); + for si = 1:numel(stimsNeeded) - sn = stimsNeeded{si}; - cat = catList{si}; - lvls = levelsCell{si}; + sn = stimsNeeded{si}; % stimulus name + cat = catList{si}; % category name + lvls = levelsCell{si}; % numeric level vector + for lvi = 1:numel(lvls) - lv = lvls(lvi); + 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 + items(end+1) = struct('stim', sn, 'cat', cat, 'lv', lv); %#ok end end end @@ -695,14 +980,14 @@ % makeCompLabel Short composite label: '__'. % Category truncated to 3 chars; decimals -> 'p'; negative -> 'neg'. - catAbbr = lower(catName); + catAbbr = lower(catName); % lowercase category if strlength(catAbbr) > 3 - catAbbr = extractBetween(catAbbr, 1, 3); + catAbbr = extractBetween(catAbbr, 1, 3); % truncate to 3 characters catAbbr = char(catAbbr); end - lbl = sprintf('%s_%s_%g', stimName, catAbbr, levelValue); - lbl = strrep(lbl, '.', 'p'); - lbl = strrep(lbl, '-', 'neg'); + 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 @@ -710,40 +995,43 @@ % 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/buggy cached column names are refreshed. +% 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); + candidate = createStimulusObject(NP, stimName, session); % try this session if isempty(candidate) || isempty(candidate.VST) - continue + continue % session not available end - % Recompute ResponseWindow first (fixes stale column names if overwrite is on) + % 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 + continue % category not in this stimulus end - catColIdx = catIdx + 1; + 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-6) + if ~any(abs(availLevels - lv) < 1e-2) ok = false; break end end @@ -760,9 +1048,6 @@ 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. -% -% ResponseWindow is recomputed with params.overwriteResponse before reading -% colNames/C, to ensure stale/buggy cached column names are refreshed. levels = []; catColIdx = 0; @@ -774,17 +1059,19 @@ continue end - % Recompute ResponseWindow first (fixes stale column names if overwrite is on) + % 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', ... @@ -792,15 +1079,15 @@ return end - catColIdx = catIdx + 1; - rawCol = C(:, catColIdx); - rawCol = rawCol(~isnan(rawCol)); - levels = uniquetol(rawCol, 1e-6); + 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 + return % success — exit early else fprintf(' Only %d level of "%s" in session %d.\n', ... numel(levels), catName, session); @@ -810,19 +1097,21 @@ function [C, colNames] = getCmatrix(rw, stimName) -% getCmatrix Extract the C matrix and column names from a ResponseWindow struct. +% 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); + colNames = rw.colNames{1}(5:end); % skip first 4 metadata columns case 'SDGm' if isfield(rw, 'C') @@ -837,6 +1126,7 @@ end otherwise + % Generic fallback if isfield(rw, 'C') C = rw.C; colNames = rw.colNames{1}(5:end); @@ -847,11 +1137,13 @@ function vsObj = createStimulusObject(NP, stimName, session) % createStimulusObject Create an analysis object, optionally with Session. +% Returns [] on failure. vsObj = []; try - key = getObjKey(stimName); + 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); @@ -862,6 +1154,7 @@ 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); @@ -881,15 +1174,17 @@ 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(); - present = containers.Map(); + vsObjs = containers.Map(); % key -> analysis object + present = containers.Map(); % stimName -> true/false for si = 1:numel(stimsNeeded) - sn = stimsNeeded{si}; - key = getObjKey(sn); + 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) @@ -903,6 +1198,7 @@ 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 @@ -913,6 +1209,7 @@ 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; @@ -921,65 +1218,78 @@ function fName = levelToFieldName(catName, value) -% levelToFieldName Build a valid field name matching StatisticsPerNeuronPerCategory's convention. +% 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'); - fName = strrep(fName, '-', 'neg'); + fName = strrep(fName, '.', 'p'); % decimal point -> 'p' + fName = strrep(fName, '-', 'neg'); % minus sign -> 'neg' end function runStimStats(vsObj, params) -% runStimStats Run ResponseWindow + the chosen statistical method. +% 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); - 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('Unknown StatMethod "%s".', params.StatMethod); - end + % 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, statMethod, useZmean) -% extractStimData Pull z-scores, p-values, spike rate from a stats struct. - - switch statMethod - case 'ObsWindow', stats = vsObj.ShufflingAnalysis; - case 'bootsrapRespBase', stats = vsObj.BootstrapPerNeuron; - case 'maxPermuteTest', stats = vsObj.StatisticsPerNeuron; - 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 - rw = vsObj.ResponseWindow; + % Always read from StatisticsPerNeuron (only stat method retained) + stats = vsObj.StatisticsPerNeuron; + rw = vsObj.ResponseWindow; switch stimName case 'MB' - % Find best speed per neuron (lowest p-value across speeds) + % MB has multiple speeds — find best speed per neuron (lowest p) speedFields = fieldnames(stats); speedFields = speedFields(contains(speedFields, 'Speed')); nSpeeds = numel(speedFields); - allP = []; - allZ = []; - allR = []; - allDf = []; + 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}; - subTmp = stats.(sName); - rwTmp = rw.(sName); + 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 @@ -987,102 +1297,135 @@ function runStimStats(vsObj, params) 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 - - if strcmp(statMethod, 'bootsrapRespBase') && isfield(subTmp, 'ObsResponse') - allR(:,iS) = mean(subTmp.ObsResponse, 1)'; %#ok + 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); + 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; + sub = stats.Speed1; rwSub = rw.Speed1; % bar: single speed case 'SDGm' - sub = stats.Moving; rwSub = rw.Moving; + sub = stats.Moving; rwSub = rw.Moving; % moving grating sub-struct case 'SDGs' - sub = stats.Static; rwSub = rw.Static; + sub = stats.Static; rwSub = rw.Static; % static grating sub-struct otherwise - sub = stats; rwSub = rw; + sub = stats; rwSub = rw; % generic: top-level struct end - z = sub.ZScoreU(:); - p = sub.pvalsResponse(:); + % 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(:); + spkR = sub.z_mean(:); % z-scored mean response else - spkR = max(rwSub.NeuronVals(:,:,4), [], 2); + spkR = max(rwSub.NeuronVals(:,:,4), [], 2); % peak spike rate end - spkDiff = max(rwSub.NeuronVals(:,:,5), [], 2); - - if strcmp(statMethod, 'bootsrapRespBase') && isfield(sub, 'ObsResponse') - spkR = mean(sub.ObsResponse, 1)'; - 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 = []; - insers = []; - animals = []; + 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)' - idx1 = tbl.insertion == categorical(ins) & tbl.stimulus == pair{1}; - idx2 = tbl.insertion == categorical(ins) & tbl.stimulus == pair{2}; - V1 = tbl.(metric)(idx1); - V2 = tbl.(metric)(idx2); + % 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 isempty(V1) || isempty(V2), continue; end + if ~any(mask1) || ~any(mask2), continue; end % skip if either is absent - animal = unique(tbl.animal(idx1)); - diffs = [diffs; V1 - V2]; - insers = [insers; double(repmat(ins, numel(V1), 1))]; - animals = [animals; double(repmat(animal, numel(V1), 1))]; + % 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: probability under H0 that |bootstrap mean| is at least as - % extreme as observed. More conservative than one-tailed; appropriate when - % the direction of the effect is not pre-specified. - pLeft = mean(bootMeans <= 0); - pRight = mean(bootMeans >= 0); - pVal = 2 * min(pLeft, pRight); - pVal = min(pVal, 1); % cap at 1 (rare edge case) - %pVal = mean(bootMeans <= 0); + + % 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 first vs second comparison item for a metric. +% 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); - v2 = tbl.(metric)(mask2); - cIdx = animalIdx(mask1); + 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}); @@ -1091,6 +1434,7 @@ function runStimStats(vsObj, params) 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 @@ -1105,16 +1449,19 @@ function formatAxes(ax, fontSize, fontName) 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 - n = numel(pVals); - [pSorted, sortIdx] = sort(pVals(:)); - ranks = (1:n)'; + pAdj = pSorted .* n ./ ranks; % BH adjustment: p * n / rank + pAdj = min(pAdj, 1); % cap at 1 - pAdj = pSorted .* n ./ ranks; - pAdj = min(pAdj, 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; + pAdj(sortIdx) = pAdj; % restore original order end \ No newline at end of file diff --git a/visualStimulationAnalysis/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv index cde8ff8..7a82483 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.asv +++ b/visualStimulationAnalysis/RunAnalysisClass.asv @@ -67,35 +67,42 @@ end %[49:54,84:90,92:96] %All SDG experiments %solve MBR %bootsrapRespBase -[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],StatMethod='maxPermuteTest', overwrite=false,ComparePairs={'MB','SDGm'},PaperFig=true,... - overwriteResponse=false,overwriteStats=true,useFDR=false);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% -%% -t = AllExpAnalysis([49:54,64:66,68:85 87:97], ... - ComparePairs = {'MB','SDGm'}, ... - CompareCategory = {'directions','angles'}, ... - CompareLevels = {[0],[0]}, ... - StatMethod = 'maxPermuteTest', ... - useGeneralFilter = false, ... - PaperFig = true); +%% 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); + +%% Compares MB across different categoryis, 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'},CompareCategory="directions",PaperFig=true,... + overwriteResponse=false,overwriteStats=true); -%% -[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'SDGm'},CompareCategory="spatFrequency",PaperFig=true,... - overwriteResponse=true,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% +%% 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"] -%% -[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],{'MB','RG'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... - overwriteResponse=false,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% -%% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %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"] + +%% +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50, postStim= 1500, stimTypes={"MB"},splitBy="directions",useCategoryPvals = true); %stimTypes=["linearlyMovingBall"] %% 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=false... +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 diff --git a/visualStimulationAnalysis/RunAnalysisClass.m b/visualStimulationAnalysis/RunAnalysisClass.m index cde8ff8..1aa9ce9 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -67,35 +67,54 @@ %[49:54,84:90,92:96] %All SDG experiments %solve MBR %bootsrapRespBase -[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],StatMethod='maxPermuteTest', overwrite=false,ComparePairs={'MB','SDGm'},PaperFig=true,... - overwriteResponse=false,overwriteStats=true,useFDR=false);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% -%% -t = AllExpAnalysis([49:54,64:66,68:85 87:97], ... - ComparePairs = {'MB','SDGm'}, ... - CompareCategory = {'directions','angles'}, ... - CompareLevels = {[0],[0]}, ... - StatMethod = 'maxPermuteTest', ... - useGeneralFilter = false, ... - PaperFig = true); +%% FIGURE 1 MOVING BALL VS STATIC BALL -%% -[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'SDGm'},CompareCategory="spatFrequency",PaperFig=true,... - overwriteResponse=true,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% +%% 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); +%% 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); -%% -[tempTableMW] = AllExpAnalysis([49:54,64:66,68:85 87:97],{'MB','RG'},StatMethod='maxPermuteTest', overwrite=true,ComparePairs={'MB','RG'},PaperFig=true,... - overwriteResponse=false,overwriteStats=true);%[49:54,57:91] %%Check why I have different array dimensions in MBR%% +%% FIGURE 3 SIZES AND LOCALITY COMPARISON + +%% Compares MB 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'},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=true,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); +%% 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); -%% PSTH for all experiments -plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50); %stimTypes=["linearlyMovingBall"] +%% %% 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"] + +%% +plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50, postStim= 1500, stimTypes={"MB"},splitBy="directions",useCategoryPvals = true); %stimTypes=["linearlyMovingBall"] %% 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=false... +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 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 index 8925746..1bc365e 100644 --- a/visualStimulationAnalysis/plotPSTH_MultiExp.m +++ b/visualStimulationAnalysis/plotPSTH_MultiExp.m @@ -1,179 +1,340 @@ 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 - params.stimTypes (1,:) string = ["rectGrid", "linearlyMovingBall"] - params.binWidth double = 10 - params.smooth double = 0 % smoothing window in ms (0 = no smoothing) - params.statType string = "maxPermuteTest" - params.speed string = "max" - params.alpha double = 0.05 - params.shadeSTD logical = true - params.postStim double = 500 - params.preBase double = 200 - params.overwrite logical = false - params.TakeTopPercentTrials double = 0.3 - params.zScore logical = false - params.PaperFig logical = false - params.byDepth logical = false % plot 3 depth bins per stim type + 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 info from saved file (only if byDepth is requested) +% 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); + D = load(depthFile); depthTable = D.depthTable; depthBinEdges = D.depthBinEdges; nDepthBins = 3; fprintf('Depth bins loaded:\n'); - fprintf(' Bin 1 (shallow): %.0f - %.0f um\n', depthBinEdges(1), depthBinEdges(2)); - fprintf(' Bin 2 (middle) : %.0f - %.0f um\n', depthBinEdges(2), depthBinEdges(3)); - fprintf(' Bin 3 (deep) : %.0f - %.0f um\n', depthBinEdges(3), depthBinEdges(4)); + 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 path +% Build save directory path % ------------------------------------------------------------------------- -NP_first = loadNPclassFromTable(exList(1)); +NP_first = loadNPclassFromTable(exList(1)); vs_first = linearlyMovingBallAnalysis(NP_first); -p = extractBefore(vs_first.getAnalysisFileName, 'lizards'); -p = [p 'lizards']; -if ~exist([p '\Combined_lizard_analysis'], 'dir') - cd(p) - mkdir Combined_lizard_analysis +basePath = extractBefore(vs_first.getAnalysisFileName, 'lizards'); +basePath = [basePath 'lizards']; +saveDir = fullfile(basePath, 'Combined_lizard_analysis'); +if ~exist(saveDir, 'dir') + mkdir(saveDir); end -saveDir = [p '\Combined_lizard_analysis']; -stimLabel = strjoin(params.stimTypes, '-'); -depthSuffix = ''; -if params.byDepth; depthSuffix = '_byDepth'; end -nameOfFile = sprintf('\\Ex_%d-%d_Combined_PSTHs_%s%s.mat', ... - exList(1), exList(end), stimLabel, depthSuffix); +% ---- 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 whether to recompute or load +% Decide: recompute or load from disk % ------------------------------------------------------------------------- -if exist([saveDir nameOfFile], 'file') == 2 && ~params.overwrite - S = load([saveDir nameOfFile]); +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', [saveDir nameOfFile]); + fprintf('Loading saved PSTHs from:\n %s\n', fullSavePath); forloop = false; else fprintf('Experiment list mismatch — recomputing.\n'); - forloop = true; end -else - forloop = true; end % ========================================================================= -% EXPERIMENT LOOP +% MAIN COMPUTATION LOOP (skip if loaded from disk) % ========================================================================= if forloop nStim = numel(params.stimTypes); nExp = numel(exList); - % psthAll{s,b} — s = stim type, b = depth bin (1 if byDepth is off) - psthAll = cell(nStim, nDepthBins); + % ===================================================================== + % 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 ===\n', ex); + 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); - for s = 1:nStim - for b = 1:nDepthBins - if ~isempty(psthAll{s,b}) - psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; - end - end - end + 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 - switch stimType - case "rectGrid" - obj = rectGridAnalysis(NP); - case "linearlyMovingBall" - obj = linearlyMovingBallAnalysis(NP); - case 'StaticGrating' - obj = StaticDriftingGratingAnalysis(NP); - case 'MovingGrating' - obj = StaticDriftingGratingAnalysis(NP); - otherwise - error('Unknown stimType: %s', stimType); - end + obj = buildStimObject(NP, stimType, sess); catch ME - warning('Could not build %s for exp %d: %s', stimType, ex, ME.message); - for b = 1:nDepthBins - if ~isempty(psthAll{s,b}) - psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; - end - end + 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 - NeuronResp = obj.ResponseWindow; - - if params.statType == "BootstrapPerNeuron" - Stats = obj.BootstrapPerNeuron; - elseif params.statType == "maxPermuteTest" - Stats = obj.StatisticsPerNeuron; - else - Stats = obj.ShufflingAnalysis; + if isempty(obj) + appendNaNRowForStim(psthAll, s, nDepthBins, catLabelsAll{s}, lockedNBins); + continue end - if params.speed ~= "max" && isequal(obj.stimName,'linearlyMovingBall') - fieldName = 'Speed2'; startStim = 0; - elseif isequal(obj.stimName,'linearlyMovingBall') - fieldName = 'Speed1'; startStim = 0; - elseif isequal(params.stimTypes,'StaticGrating') - fieldName = 'Static'; startStim = 0; - elseif isequal(params.stimTypes,'MovingGrating') - startStim = obj.VST.static_time*1000; fieldName = 'Moving'; - else - startStim = 0; + % ---- 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 - p_sort = obj.dataObj.convertPhySorting2tIc(obj.spikeSortingFolder); - label = string(p_sort.label'); - goodU = p_sort.ic(:, label == 'good'); - + % ---- Ensure ResponseWindow is computed ---------------------- try - pvals = Stats.(fieldName).pvalsResponse; - catch - pvals = Stats.pvalsResponse; + 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 - C = NeuronResp.(fieldName).C; - catch - C = NeuronResp.C; + 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; @@ -181,39 +342,92 @@ function plotPSTH_MultiExp(exList, params) lockedPreBase = preBase; lockedEdges = 0 : params.binWidth : windowTotal; lockedNBins = numel(lockedEdges) - 1; - tAxis = lockedEdges(1:end-1); fprintf(' Locked window: preBase=%d ms, postStim=%d ms, nBins=%d\n', ... lockedPreBase, params.postStim, lockedNBins); end - eNeurons = find(pvals < params.alpha); + % ---- 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 - if isempty(eNeurons) - fprintf(' [%s] No responsive neurons in exp %d.\n', stimType, ex); - for b = 1:nDepthBins - if ~isempty(psthAll{s,b}) - psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; + % 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 - fprintf(' [%s] %d responsive neuron(s) in exp %d.\n', stimType, ex, numel(eNeurons)); + 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 - % ---------------------------------------------------------- - % Build PSTH per neuron - % ---------------------------------------------------------- - psthRateNeurons = zeros(numel(eNeurons), lockedNBins); + % ============================================================== + % 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 + % ---- Assign depth bin ----------------------------------- if params.byDepth depthRow = depthTable.Experiment == ex & depthTable.Unit == u; if ~any(depthRow) - neuronBinIdx(ni) = 0; % unknown depth — skip + neuronBinIdx(ni) = 0; continue end unitDepth = depthTable.Depth_um(depthRow); @@ -225,140 +439,209 @@ function plotPSTH_MultiExp(exList, params) neuronBinIdx(ni) = 3; end else - neuronBinIdx(ni) = 1; % all neurons in single bin + neuronBinIdx(ni) = 1; end - MRhist = BuildBurstMatrix( ... - goodU(:, u), ... - round(p_sort.t), ... - round(directimesSorted - lockedPreBase), ... - round(windowTotal)); - MRhist = squeeze(MRhist); - - if ~isempty(params.TakeTopPercentTrials) - MeanTrial = mean(MRhist, 2); - [~, ind] = sort(MeanTrial, 'descend'); - takeTrials = ind(1:round(numel(MeanTrial)*params.TakeTopPercentTrials)); - MRhist = MRhist(takeTrials, :); - 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); - nTrials = size(MRhist, 1); - spikeTimes = repmat((1:size(MRhist,2)), nTrials, 1); - spikeTimes = spikeTimes(logical(MRhist)); - counts = histcounts(spikeTimes, lockedEdges); - psthRateNeurons(ni, :) = (counts / (params.binWidth * nTrials)) * 1000; + 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 per depth bin and append - % ---------------------------------------------------------- + % ============================================================== + % Average across neurons per depth bin × category, append + % ============================================================== for b = 1:nDepthBins binNeurons = neuronBinIdx == b; + if ~any(binNeurons) - fprintf(' [%s] No neurons in depth bin %d for exp %d.\n', stimType, b, ex); - if ~isempty(psthAll{s,b}) - psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; + for ci = 1:nCats + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, NaN(1, lockedNBins)); end continue end - psthExp = mean(psthRateNeurons(binNeurons, :), 1, 'omitnan'); - - if params.zScore - baselineBins = tAxis < lockedPreBase; - baselineMean = mean(psthExp(baselineBins)); - baselineStd = std(psthExp(baselineBins)); - if baselineStd > 0 - psthExp = (psthExp - baselineMean) / baselineStd; + 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 - warning(' [%s] Bin %d: baseline std is zero for exp %d.', stimType, b, ex); - if ~isempty(psthAll{s,b}) - psthAll{s,b} = [psthAll{s,b}; NaN(1, lockedNBins)]; - end - continue + psthExp = mean(catData, 1, 'omitnan'); + psthAll{s, b, ci} = appendOrInit(psthAll{s, b, ci}, psthExp(:)'); end end - psthAll{s,b} = [psthAll{s,b}; psthExp(:)']; - fprintf(' [%s] Bin %d: %d neuron(s) in exp %d.\n', stimType, b, sum(binNeurons), ex); + fprintf(' [%s] Depth bin %d: %d neuron(s) in exp %d.\n', ... + stimType, b, sum(binNeurons), ex); end - end % stim loop + end % stim-type loop end % experiment loop - % ------------------------------------------------------------------ - % Save - % ------------------------------------------------------------------ + % ===================================================================== + % 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 - S.(sprintf('%s_bin%d', stimField, b)) = psthAll{s,b}; + 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([saveDir nameOfFile], '-struct', 'S'); - fprintf('\nSaved PSTHs to:\n %s\n', [saveDir nameOfFile]); + save(fullSavePath, '-struct', 'S'); + fprintf('\nSaved PSTHs to:\n %s\n', fullSavePath); else - % Load psthAll from disk + % ================================================================= + % 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); - psthAll = cell(numel(params.stimTypes), nDepthBins); for s = 1:numel(params.stimTypes) stimField = matlab.lang.makeValidName(params.stimTypes(s)); for b = 1:nDepthBins - fieldKey = sprintf('%s_bin%d', stimField, b); - if isfield(S, fieldKey) - psthAll{s,b} = S.(fieldKey); - else - warning('Field "%s" not found in saved file.', fieldKey); - psthAll{s,b} = []; + 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 % ========================================================================= -% PLOT +% PLOTTING % ========================================================================= tAxis = lockedEdges(1:end-1); tAxisPlot = tAxis - lockedPreBase; -baseColors = lines(numel(params.stimTypes)); -depthShades = [0.05, 0.45, 0.78]; % light → dark for shallow → deep -binLabels = {'shallow', 'middle', 'deep'}; +% ---- Colour palette and label maps ------------------------------------- +nStim = numel(params.stimTypes); +baseColors = lines(nStim); -stimLegendMap = containers.Map(... - {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... - {'MB', 'SB', 'MG', 'SG'}); +stimLegendMap = containers.Map( ... + {'MB', 'MBR', 'RG', 'SDGm', 'SDGs', 'NV', 'NI', 'FFF'}, ... + {'MB', 'MBR', 'RG', 'SDGm', 'SDGs', 'NV', 'NI', 'FFF'}); -% ------------------------------------------------------------------ -% First pass: global ylim -% ------------------------------------------------------------------ -yMax = 0; -yMin = inf; +depthShades = [0.05, 0.45, 0.78]; +binLabels = {'shallow', 'middle', 'deep'}; -meanAll = cell(numel(params.stimTypes), nDepthBins); -semAll = cell(numel(params.stimTypes), nDepthBins); +% ---- First pass: smooth traces, compute mean & SEM, find global ylim --- +yMax = -Inf; +yMin = Inf; -for s = 1:numel(params.stimTypes) +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 - data = psthAll{s,b}; - if isempty(data); continue; end - validRows = ~all(isnan(data), 2); - data = data(validRows, :); - if isempty(data); continue; end - meanAll{s,b} = mean(data, 1, 'omitnan'); - semAll{s,b} = std(data, 0, 1, 'omitnan') / sqrt(sum(~isnan(data(:,1)))); - yMax = max(yMax, max(meanAll{s,b} + semAll{s,b})); - yMin = min(yMin, min(meanAll{s,b} - semAll{s,b})); + 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 @@ -369,18 +652,34 @@ function plotPSTH_MultiExp(exList, params) yLims = [max(0, yMin - yPad), yMax + yPad]; end -% ------------------------------------------------------------------ -% Plot -% ------------------------------------------------------------------ +% ---- Create figure ------------------------------------------------------ fig = figure; -set(fig, 'Units', 'centimeters', 'Position', [5 5 9 10]); -ax = axes(fig); +set(fig, 'Units', 'centimeters', 'Position', [5 5 10 7]); +ax = axes(fig); hold(ax, 'on'); legendHandles = []; legendLabels = {}; -for s = 1:numel(params.stimTypes) +% ---- 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) @@ -390,78 +689,402 @@ function plotPSTH_MultiExp(exList, params) 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); - data = psthAll{s,b}; - if isempty(data); continue; end - validRows = ~all(isnan(data), 2); - data = data(validRows, :); - if isempty(data); continue; end +ax.FontName = 'Helvetica'; +ax.FontSize = 8; +ax.YAxis.FontSize = 8; +ax.XAxis.FontSize = 8; +hold(ax, 'off'); - meanPSTH = meanAll{s,b}; - semPSTH = semAll{s,b}; +title(ax, sprintf('N = %d experiments', numel(exList)), ... + 'FontName', 'Helvetica', 'FontSize', 10); - % Smooth if requested - if params.smooth > 0 - smoothBins = round(params.smooth / params.binWidth); % convert ms to bins - meanPSTH = smoothdata(meanPSTH, 'gaussian', smoothBins); - semPSTH = smoothdata(semPSTH, 'gaussian', smoothBins); + + +% ---- 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 = {}; - % Color and label depend on mode - if params.byDepth - lineColor = baseColors(s,:) * (1 - depthShades(b)); - legendLabel = sprintf('%s %s (%.0f-%.0f um)', ... - shortName, binLabels{b}, depthBinEdges(b), depthBinEdges(b+1)); +% 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 - lineColor = baseColors(s,:); - legendLabel = shortName; + 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 - % SEM shading - if params.shadeSTD && size(data,1) > 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'); + case "SDGs" + if isfield(rw, 'C') + C = rw.C; + elseif isfield(rw, 'Static') && isfield(rw.Static, 'C') + C = rw.Static.C; end - % Mean line - h = plot(ax, tAxisPlot(:)', meanPSTH(:)', ... - 'Color', lineColor, 'LineWidth', 1.5); + otherwise + if isfield(rw, 'C') + C = rw.C; + end +end +end - legendHandles(end+1) = h; %#ok - legendLabels{end+1} = legendLabel; %#ok - fprintf(' [%s] n=%d experiments in plot.\n', legendLabel, sum(validRows)); +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 -xline(ax, 0, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); -xline(ax, params.postStim, 'k--', 'LineWidth', 1.2, 'HandleVisibility', 'off'); -if params.zScore; yLabel = 'Z-score'; else; yLabel = '[spk/s]'; 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 -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); +function C = getConditionMatrix(obj, stimType, speedParam) +% getConditionMatrix Extract condition matrix C from ResponseWindow. -ax.FontName = 'helvetica'; -ax.FontSize = 8; -ax.YAxis.FontSize = 8; -ax.XAxis.FontSize = 8; -hold(ax, 'off'); +[fieldName, ~] = getFieldAndOffset(obj, stimType, speedParam); +NeuronResp = obj.ResponseWindow; -sgtitle(sprintf('N = %d', numel(exList)), 'FontName', 'helvetica', 'FontSize', 11); -set(fig, 'Units', 'centimeters', 'Position', [20 20 7 4]); +try + C = NeuronResp.(fieldName).C; +catch + C = NeuronResp.C; +end +end -if params.PaperFig - vs_first.printFig(fig, sprintf('PSTH-depth-%s-%s', ... - params.stimTypes(1), params.stimTypes(2)), PaperFig = params.PaperFig) + +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 From c08721646bd5465701e4379cfba2de9113045216 Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Wed, 20 May 2026 00:50:48 +0300 Subject: [PATCH 18/19] Adding changes to plotallexp raster --- .../plotSwarmBootstrapWithComparisons.asv | 567 ++++++------- .../plotSwarmBootstrapWithComparisons.m | 573 ++++++------- .../plotRaster.asv | 570 +++++++++++++ .../@linearlyMovingBallAnalysis/plotRaster.m | 18 +- .../@rectGridAnalysis/plotRaster.m | 2 + visualStimulationAnalysis/AllExpAnalysis.asv | 20 +- visualStimulationAnalysis/AllExpAnalysis.m | 30 +- .../RunAnalysisClass.asv | 101 ++- visualStimulationAnalysis/RunAnalysisClass.m | 89 +- .../plotRaster_MultiExp.m | 776 +++++++++++++++--- 10 files changed, 1967 insertions(+), 779 deletions(-) create mode 100644 visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.asv diff --git a/general functions/plotSwarmBootstrapWithComparisons.asv b/general functions/plotSwarmBootstrapWithComparisons.asv index 1361f7a..401d70e 100644 --- a/general functions/plotSwarmBootstrapWithComparisons.asv +++ b/general functions/plotSwarmBootstrapWithComparisons.asv @@ -1,9 +1,9 @@ -function [fig, randiColors,figAllDiffs] = 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] = plotSwarmBootstrapWithComparisons(tbl, pairs, ... -% pValues, valueField, params) +% [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 @@ -20,31 +20,26 @@ function [fig, randiColors,figAllDiffs] = plotSwarmBootstrapWithComparisons(tbl, % 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' (default) or 'percentile' (95% bootstrap CI) +% 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 and the random dot-draw permutation. +% 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. For -% insertion-level data with grouping {'animal','insertion'}, the within- -% insertion step resamples a single observation, so the procedure naturally -% reduces to an animal->insertion bootstrap without special-casing. -% Categorical grouping columns are coerced to numeric category codes before -% hierBoot is called (hierBoot pre-allocates intermediate levels as -% nan(size(data)), so it requires numeric inputs). +% levels are present, resampling each level with replacement in turn. % ------------------------------------------------------------------------- -% Argument validation block. MATLAB enforces types/sizes before the body runs. +% Argument validation block % ------------------------------------------------------------------------- arguments tbl table % observation table pairs cell = {} % stim pairs to test - pValues double = [] % p-value per pair (NaN allowed) + 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) @@ -55,49 +50,46 @@ arguments 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.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.ciMethod char = 'percentile'% 'sem' | 'percentile' params.bootGroupVars cell = {'__auto__'}% hierarchical bootstrap levels end % ------------------------------------------------------------------------- -% Up-front input validation. +% Up-front input validation % ------------------------------------------------------------------------- -% Either raw mode (1 field) or fraction mode (2 fields). Fail loudly otherwise. +% 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.'); + assert(~isempty(valueField), 'valueField must contain at least one column name.'); end -% colorByZScore can only work if the column exists; downgrade with a warning. +% 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; honor the two-tile mode. +% 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 for the entire call so bootstraps and dot-draw orders are -% deterministic. Critical for figure reproducibility in a paper. +% Seed RNG once so bootstraps and dot-draw orders are deterministic rng(params.rngSeed); % ------------------------------------------------------------------------- -% Resolve bootstrap grouping variables. -% Sentinel '__auto__' means "auto-fill from columns present in the table". -% Explicit {} from the caller forces a flat (non-hierarchical) bootstrap. +% Resolve bootstrap grouping variables % ------------------------------------------------------------------------- if isscalar(params.bootGroupVars) && strcmp(params.bootGroupVars{1}, '__auto__') - cands = {'animal','insertion'}; + cands = {'animal','insertion'}; % candidate hierarchy columns params.bootGroupVars = cands(ismember(cands, tbl.Properties.VariableNames)); else missing = ~ismember(params.bootGroupVars, tbl.Properties.VariableNames); @@ -106,51 +98,47 @@ else end % ------------------------------------------------------------------------- -% Detect data granularity. -% If every (insertion, stimulus) pair appears at most once, the table is -% insertion-level (e.g., one number-of-responsive-units value per insertion). -% Otherwise it is neuron-level (many neurons per insertion per stimulus). -% Drives buildDiffTable's pairing strategy AND plotRawSwarm's line-grouping. +% 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. +% Padding / spacing constants derived from the y-axis cap % ------------------------------------------------------------------------- yMaxVis = params.yMaxVis; -bracketPad = yMaxVis * 0.05; -stackPad = yMaxVis * 0.05; -textPad = yMaxVis * 0.01; -semAlpha = 0.6; +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 stimulus labels and compute the value column. +% Pre-process tbl: rename legacy labels, reorder categories, compute value % ------------------------------------------------------------------------- -tbl = renameStimulusLabels(tbl); -pairs = renamePairLabels(pairs); -tbl = reorderStimulusByLevel(tbl); % sort categorical by trailing numeric 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 - % Element-wise ratio. NaN/Inf may arise if denominator has zeros — they - % are filtered downstream by the bootstrap/plotting code. - tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); + tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); % element-wise ratio else - tbl.value = tbl.(valueField{1}); + tbl.value = tbl.(valueField{1}); % raw value end -% Drop unused categorical levels so colormaps and category counts are accurate. +% 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: either single axes or a 1x2 tiledlayout depending on mode. +% Build figure: single axes or 1x2 tiledlayout % ------------------------------------------------------------------------- fig = figure; -set(fig, 'Color', 'w'); % white background for publication +set(fig, 'Color', 'w'); % white background for publication if params.showBothAndDiff - % Left tile: every stimulus shown raw. Right tile: most-significant pair's diff. + % 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); @@ -160,7 +148,7 @@ if params.showBothAndDiff % Pick the most significant pair for the diff tile if ~isempty(pValues) - [~, sigIdx] = min(pValues); + [~, sigIdx] = min(pValues); else sigIdx = 1; end @@ -171,10 +159,10 @@ if params.showBothAndDiff plotDiffSwarm(axDiff, tblDiff, pairForDiff, pValForDiff, params, ... yMaxVis, bracketPad, textPad); else - % Single-axes mode: either the raw swarm or the difference, not both. - ax = axes(fig); + % Single-axes mode: either the raw swarm or the difference + ax = axes(fig); hold(ax, 'on'); - set(ax, 'Clipping', 'off'); % allow brackets/text outside ylim + set(ax, 'Clipping', 'off'); if params.diff tblDiff = buildDiffTable(tbl, pairs, params, isInsertionLevel); @@ -187,7 +175,7 @@ else end % ------------------------------------------------------------------------- -% Additional figure: one tile per pairwise difference (only if multi-pair). +% Additional figure: one tile per pairwise difference (only when >1 pair) % ------------------------------------------------------------------------- if size(pairs, 1) > 1 figAllDiffs = plotAllPairDiffs(tbl, pairs, pValues, params, ... @@ -203,58 +191,55 @@ end % main function % ========================================================================= % LOCAL FUNCTION: plotRawSwarm -% Plots all observations grouped by stimulus, with optional connecting lines -% between paired neurons across stim types. Returns the random draw permutation. % ========================================================================= 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 +set(ax, 'Clipping', 'off'); % brackets may sit above yMaxVis -stimuli = categories(tbl.stimulus); % ordered category list -tblPlot = tbl; % alias to keep names short +stimuli = categories(tbl.stimulus); % ordered category list +tblPlot = tbl; -% Random permutation of dot indices => overlapping colors don't form layers. -% rng() was seeded once in the main function, so this is reproducible. +% Random permutation for dot draw order (seeded in main) randiColors = randperm(height(tblPlot)); -% Choose dot color source: continuous zScore (diverging) or categorical animal. +% Choose dot color source if params.colorByZScore colorData = tblPlot.zScore(randiColors); else colorData = tblPlot.animal(randiColors); end -% Draw the swarm. swarmchart accepts a categorical x-axis directly. +% Draw swarm if params.filled s = swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... params.dotSize, colorData, 'filled', ... 'MarkerFaceAlpha', params.Alpha); else - % SizeData=30 below intentionally overrides params.dotSize for legibility - % of open markers; consider exposing as its own param if you want full control. s = swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... params.dotSize, colorData, ... 'MarkerEdgeAlpha', params.Alpha, 'LineWidth', 1, 'SizeData', 30); end s.XJitter = params.Xjitter; -Str = string(unique(tbl.stimulus)); +% Build short tick labels from encoded category names +Str = string(stimuli); +out = buildTickLabels(Str); -% Extract first number (integer or decimal, positive/negative) +% 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,numStr); + xticklabels(ax, out); end -% Configure the colormap to match the chosen color source. +% Configure colormap if params.colorByZScore colormap(ax, buildRdBuColormap(256)); maxZ = max(abs(tblPlot.zScore), [], 'omitnan'); - if isempty(maxZ) || maxZ == 0, maxZ = 1; end % degenerate safety - clim(ax, [-maxZ maxZ]); % symmetric around zero + if isempty(maxZ) || maxZ == 0, maxZ = 1; end + clim(ax, [-maxZ maxZ]); cb = colorbar(ax); cb.Label.String = 'Z-score'; else @@ -262,36 +247,28 @@ else end % ------------------------------------------------------------------------- -% Optional: draw a thin line for each unit across stimulus columns. -% Choice of unit identifier depends on data granularity: -% * insertion-level: insertion *is* the unit, so group by insertion. -% * neuron-level : need NeurID; insertion would erroneously merge units -% from the same penetration into a single line. +% Optional connecting lines between paired observations % ------------------------------------------------------------------------- if params.drawLines && numel(stimuli) <= 2 if isInsertionLevel - unitIDvar = 'insertion'; + unitIDvar = 'insertion'; % insertion IS the unit elseif ismember('NeurID', tblPlot.Properties.VariableNames) - unitIDvar = 'NeurID'; + unitIDvar = 'NeurID'; % neuron is the unit else unitIDvar = ''; warning(['drawLines=true on neuron-level data without NeurID; ', ... - 'skipping connecting lines (insertion would merge ', ... - 'multiple units into one line).']); + 'skipping connecting lines.']); end if ~isempty(unitIDvar) cats = categories(tblPlot.stimulus); - % Map stimulus categories to numeric x positions for line() calls. - xMap = containers.Map(cats, 1:numel(cats)); + xMap = containers.Map(cats, 1:numel(cats)); % stimulus -> x-position xNum = arrayfun(@(i) xMap(char(tblPlot.stimulus(i))), 1:height(tblPlot)); - % Iterate over actual unit values (categorical or numeric); equality - % comparison works on both, so no type-specific branching is needed. unitIDs = unique(tblPlot.(unitIDvar)); for u = 1:numel(unitIDs) idx = tblPlot.(unitIDvar) == unitIDs(u); - if nnz(idx) < 2, continue; end % need >=2 stim columns to draw + if nnz(idx) < 2, continue; end line(ax, xNum(idx), tblPlot.value(idx), ... 'Color', [0 0 0 0.1], 'LineWidth', 0.1); end @@ -300,21 +277,22 @@ end ylabel(ax, params.yLegend); ax.Box = 'off'; -ax.Layer = 'top'; % axis ticks above swarm dots +ax.Layer = 'top'; -% Hierarchical bootstrap mean ± SE (or 95% CI) per stimulus column. +% Hierarchical bootstrap mean +/- SE (or 95% CI) if params.plotMeanSem plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha); end -% Pairwise significance brackets (only if pairs and pValues are aligned). -if ~isempty(pairs) && numel(pValues) == size(pairs,1) +% 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); + yMaxVis, bracketPad, stackPad, textPad, adjacentOnly); end -% Cap visible y-range. Brackets/text use Clipping=off, so they remain visible -% even above this cap (intentional for tight figures). +% Cap visible y-range (brackets use Clipping=off so they remain visible) ylim(ax, [ax.YLim(1) yMaxVis]); end % plotRawSwarm @@ -322,8 +300,6 @@ end % plotRawSwarm % ========================================================================= % LOCAL FUNCTION: plotDiffSwarm -% One swarm column showing per-neuron (or per-insertion) (stimA - stimB), -% with a 4-tier significance annotation matching plotBrackets. % ========================================================================= function randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... yMaxVis, bracketPad, textPad) @@ -331,10 +307,18 @@ function randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... hold(ax, 'on'); set(ax, 'Clipping', 'off'); -% Reproducible draw order; rng() set once in main. +% 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)); -% Same color-source logic as raw plot, but only if zScore made it through buildDiffTable. +% Color source if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) colorData = tblDiff.zScore(randiColors); else @@ -352,14 +336,11 @@ else end s.XJitter = params.Xjitter; -Str = string(unique(tblDiff.stimulus)); - -numStr = regexp(Str, '-?\d+\.?\d*', 'match', 'once'); - -if any(~cellfun(@isempty, numStr)) - xticklabels(ax, numStr); -end +% 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'); @@ -371,168 +352,147 @@ else colormap(ax, lines(numel(categories(tblDiff.animal)))); end -% Visual reference at zero so the sign of differences is obvious at a glance. +% 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 scheme matching plotBrackets). +% Significance annotation (four-tier: ***, **, *, or nothing) % ------------------------------------------------------------------------- ylims = ylim(ax); if ~isempty(pValues) && numel(pValues) >= 1 - - fprintf('=== DIFF MODE SIGNIFICANCE ===\n'); - fprintf('p-value: %.4e\n', pValues(1)); + pVal = pValues(1); % scalar for this diff panel + fprintf('Diff significance: p = %.4e\n', pVal); vals = tblDiff.value; - if isempty(vals) - fprintf('No values to annotate.\n'); - ylim(ax, [ylims(1) yMaxVis]); - return - end - - % Place the annotation just above the highest visible (capped) value. maxVisible = max(min(vals(:), yMaxVis(1))); if isempty(maxVisible), maxVisible = yMaxVis; end yText = maxVisible + bracketPad; - % Skip stars for non-significant - if isnan(pValues(1)) || pValues(1) >= 0.05 - % no stars drawn - else - if pValues(1) < 0.001, txt = '***'; - elseif pValues(1) < 0.01, txt = '**'; - else, txt = '*'; + % 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 - % Hard-coded x=1: the diff plot has exactly one column. text(ax, 1, yText, txt, ... 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); end - % % Comparison label (e.g. "SB > SG"), placed above the stars. - % compTextPad = 10 * textPad; - % stimA = pairs{1,1}; - % stimB = pairs{1,2}; - % compText = sprintf('%s > %s', stimA, stimB); - % yCompText = yText + compTextPad; - % - % text(ax, 1, yCompText, compText, ... - % 'HorizontalAlignment', 'center', 'FontSize', 10, 'Clipping', 'off'); - % - % % Expand y-limits if the comparison label needs more room than yMaxVis allows. - % requiredHeight = yCompText + compTextPad; - % if requiredHeight > yMaxVis - % ylim(ax, [ylims(1) requiredHeight]); - % else ylim(ax, [ylims(1) yMaxVis]); - % end else ylim(ax, [ylims(1) yMaxVis]); end -fprintf('=== END DIFF MODE SIGNIFICANCE ===\n'); - end % plotDiffSwarm % ========================================================================= % LOCAL FUNCTION: buildDiffTable -% Per-unit (stimA - stimB) within insertion. Pairing strategy is chosen by -% data granularity: -% * insertion-level (one row per insertion-stimulus): direct subtraction. -% * neuron-level + NeurID present: match by NeurID (intersect). -% * neuron-level without NeurID: row-order fallback with warning. +% 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, ... +assert(~isempty(pairs) && size(pairs, 1) >= 1, ... 'diff mode requires at least one stimulus pair.'); -stimA = pairs{1,1}; -stimB = pairs{1,2}; +stimA = strtrim(pairs{1,1}); % trim whitespace for safety +stimB = strtrim(pairs{1,2}); hasNeurID = ismember('NeurID', tbl.Properties.VariableNames); -% Only warn when NeurID is genuinely needed (neuron-level data) and missing. if ~hasNeurID && ~isInsertionLevel - warning(['buildDiffTable: NeurID column not present and table appears to ', ... - 'be neuron-level (multiple rows per insertion-stimulus pair). ', ... - 'Pairing by row order — fragile if rows are reordered. Add NeurID.']); + warning(['buildDiffTable: NeurID column absent for neuron-level data. ', ... + 'Pairing by row order — fragile if rows are reordered.']); end -ins = categories(tbl.insertion); -diffVals = []; % accumulators for the output table -animals = categorical.empty(0, 1); % MUST be categorical so vertcat preserves type -insers = []; -zScores = []; % only filled if colorByZScore +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; - if ~any(idxA) || ~any(idxB), continue; end + + % Skip insertions where either stimulus is absent + if ~any(idxA) || ~any(idxB) + continue + end if isInsertionLevel - % One row per side guaranteed by the granularity check. + % 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. + % 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 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 for neuron-level data without NeurID. + % Row-order fallback (fragile) vA = tbl.value(idxA); vB = tbl.value(idxB); if numel(vA) ~= numel(vB) - warning('Insertion %s: %d stimA rows but %d stimB rows; skipping.', ... + 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); + 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; repmat(i, numel(vA), 1)]; %#ok + diffVals = [diffVals; vA - vB]; %#ok + animals = [animals; an]; %#ok + insers = [insers; insCat]; %#ok if useZ - zScores = [zScores; zPair]; %#ok + zScores = [zScores; zPair]; %#ok end end -% Drop NaN differences (e.g., from zero-denominator fractions). +% Drop NaN differences (e.g. from zero-denominator fractions) valid = ~isnan(diffVals); stimName = sprintf('%s-%s', stimA, stimB); tblDiff = table(); -tblDiff.insertion = categorical(insers(valid)); +tblDiff.insertion = insers(valid); % categorical insertion labels tblDiff.stimulus = categorical(repmat({stimName}, sum(valid), 1)); tblDiff.animal = animals(valid); tblDiff.value = diffVals(valid); @@ -546,17 +506,8 @@ end % buildDiffTable % ========================================================================= % LOCAL FUNCTION: plotMeanSemBars -% Hierarchical-bootstrap central tendency and uncertainty per stimulus column. -% -% Reports the SAMPLE mean as the point estimate (consistent with conventional -% reporting; mean(bootMean) converges to it as nBoot->Inf but is unconventional). -% Uncertainty bar uses params.ciMethod: -% 'sem' -> ±1 SE from std(bootMean) -% 'percentile' -> [2.5, 97.5] percentile CI (recommended for skewed data) -% -% Resampling is hierarchical via hierBoot (Saravanan et al. 2020), with one -% level per entry of params.bootGroupVars (typically {'animal','insertion'}). -% Falls back to a flat bootstrap (bootstrp) when no levels are configured. +% Hierarchical-bootstrap central tendency and uncertainty per stimulus. +% Uses hierBoot (Saravanan et al. 2020) for hierarchical resampling. % ========================================================================= function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) @@ -564,10 +515,7 @@ for i = 1:numel(stimuli) idx = tblPlot.stimulus == stimuli{i}; if ~any(idx), continue; end - % Pull values + matching cluster IDs, dropping NaNs from all together. - % Without this step bootstrp(@mean, vals) returns NaN whenever any sample - % contains a NaN, while the analytical SE silently omits NaNs — the - % original code switched between these two policies at n=500. + % Pull values and drop NaNs vals = tblPlot.value(idx); keep = ~isnan(vals); vals = vals(keep); @@ -578,42 +526,30 @@ for i = 1:numel(stimuli) continue end - % Sample mean as point estimate. - %mu = mean(vals); - - % Pull each grouping column for this stimulus, aligned with the NaN drop. - % NOTE: hierBoot pre-allocates intermediate level arrays via nan(size(data)) - % (i.e., as double), so any categorical grouping column must be coerced to - % its underlying integer category code before being passed in. The codes - % preserve group identity for the equality comparisons hierBoot performs. + % 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); % numeric-only contract + col = double(col); % hierBoot requires numeric end groupVals{g} = col; end - % Hierarchical bootstrap of the mean. Empty group list => flat bootstrap. - % For insertion-level data with groups {'animal','insertion'}, the - % within-insertion resampling step picks the same single observation each - % time, so the procedure naturally collapses to an animal->insertion - % bootstrap without any special-casing here. + % Hierarchical or flat bootstrap if isempty(groupVars) bootMean = bootstrp(params.nBoot, @mean, vals); else bootMean = hierBoot(vals, params.nBoot, groupVals{:}); end - % Hierarchical-bootstrap point estimate (consistent with the CI). - % mean(bootMean) converges to the hierarchical mean, which weights - % animals/insertions equally — matching the mixed-model logic. + % Point estimate: mean of the bootstrap distribution + % (weights animals/insertions equally, matching the mixed model) mu = mean(bootMean); - % Uncertainty bar from the bootstrap distribution. + % Uncertainty bar switch lower(params.ciMethod) case 'sem' se = std(bootMean); @@ -626,18 +562,16 @@ for i = 1:numel(stimuli) error('Unknown ciMethod: %s. Use ''sem'' or ''percentile''.', params.ciMethod); end - % Vertical uncertainty line. + % Vertical uncertainty line line(ax, [i i], [yLo yHi], ... 'Color', [0 0 0 semAlpha], 'LineWidth', 2); - - % End caps. + % 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 — slightly wider than caps so the point estimate stands out. + % Mean line dx = 0.15; plot(ax, [i-dx i+dx], [mu mu], 'k-', 'LineWidth', 1.2); end @@ -647,68 +581,60 @@ end % plotMeanSemBars % ========================================================================= % LOCAL FUNCTION: plotBrackets -% Pairwise significance brackets above the swarm. Four-tier annotation -% (***, **, *, ns), consistent with plotDiffSwarm. +% 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) - -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), ', ')); - -% Track y-positions of already-placed brackets so subsequent ones stack -% rather than overlap. -usedHeights = zeros(size(pairs,1), 1); + yMaxVis, bracketPad, stackPad, textPad, adjacentOnly) -for k = 1:size(pairs,1) +% Track y-positions of placed brackets to prevent overlap +usedHeights = zeros(size(pairs, 1), 1); - fprintf('\n--- Pair %d: %s vs %s ---\n', k, pairs{k,1}, pairs{k,2}); +for k = 1:size(pairs, 1) - % Skip non-significant pairs entirely (no bracket, no text) + % Skip non-significant (no bracket, no text) if isnan(pValues(k)) || pValues(k) >= 0.05 - fprintf('SKIPPING: non-significant (p=%.4g).\n', pValues(k)); 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})); - fprintf('x1 index: %d, x2 index: %d\n', x1, x2); - - if isempty(x1) || isempty(x2) - fprintf('SKIPPING: One or both stimuli not found in plot.\n'); + 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}); - fprintf('vals1 count: %d, vals2 count: %d\n', numel(vals1), numel(vals2)); - - % Cap each value at yMaxVis so the bracket is anchored to what's visible. maxVisible = max(min([vals1; vals2], yMaxVis)); yBase = maxVisible + bracketPad; - % Vertical stacking against previously placed brackets. + % 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; - fprintf('Bracket y position: %.3f\n', y); - fprintf('p-value: %.4e\n', pValues(k)); - - % Bracket horizontal + two short verticals. - 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'); + % 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 - fprintf('Drawing text: %s\n', txt); text(ax, mean([x1 x2]), y + textPad, txt, ... 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); end @@ -716,9 +642,51 @@ 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 -% Replaces legacy stimulus abbreviations in tbl.stimulus. % ========================================================================= function tbl = renameStimulusLabels(tbl) s = string(tbl.stimulus); @@ -731,14 +699,13 @@ end % ========================================================================= % LOCAL FUNCTION: renamePairLabels -% Same legacy substitutions, applied element-wise to the pairs cell array. % ========================================================================= 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"); % must come before SDGm — strict prefix + p = replace(p, "SDGs", "SG"); p = replace(p, "SDGm", "MG"); pairs{i} = char(p); end @@ -746,30 +713,49 @@ end % ========================================================================= -% LOCAL FUNCTION: buildRdBuColormap -% n-row diverging Red-Blue colormap centred on white. -% Blue = negative, White = zero, Red = positive. +% 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 cmap = buildRdBuColormap(n) -half = floor(n/2); +function out = buildTickLabels(Str) +out = strings(size(Str)); +for i = 1:numel(Str) + s = Str(i); -blueToWhite = [linspace(0.02, 1, half)', ... - linspace(0.44, 1, half)', ... - linspace(0.69, 1, half)']; + % Extract uppercase prefix (MB, MG, etc.) + prefix = regexp(s, '^[A-Z]+', 'match', 'once'); -whiteToRed = [linspace(1, 0.70, half)', ... - linspace(1, 0.09, half)', ... - linspace(1, 0.09, half)']; + % Extract number-like string (possibly negative or with 'p' for decimal) + numStr = regexp(s, '-?\d+(?:[p\.]\d+)?', 'match', 'once'); -cmap = [blueToWhite; whiteToRed]; + 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 (e.g. mode-1 labels like 'MB','SDGm'). +% 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) @@ -778,24 +764,24 @@ 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 + if numel(parts) < 2, continue; end % no underscore => no level token - last = parts{end}; % decode trailing token - last = strrep(last, 'p', '.'); % 'p' -> '.' (decimal) - last = strrep(last, 'neg', '-'); % 'neg' -> '-' (negative) + 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. +% 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. +% Two-step stable sort: primary numeric ascending, secondary alphabetical [catsAlpha, idxAlpha] = sort(cats); numsAlpha = nums(idxAlpha); [~, idxNum] = sort(numsAlpha, 'ascend', 'MissingPlacement', 'last'); @@ -803,44 +789,19 @@ catsFinal = catsAlpha(idxNum); tbl.stimulus = reordercats(tbl.stimulus, catsFinal); -end +end % reorderStimulusByLevel + % ========================================================================= -% LOCAL FUNCTION: plotAllPairDiffs -% Stand-alone figure with one tile per pairwise difference. Each tile is a -% diff swarm + significance annotation, matching the format of plotDiffSwarm. +% LOCAL FUNCTION: buildRdBuColormap % ========================================================================= -function figAll = plotAllPairDiffs(tbl, pairs, pValues, params, ... - isInsertionLevel, yMaxVis, bracketPad, textPad) - -nPairs = size(pairs, 1); - -figAll = figure; -set(figAll, 'Color', 'w'); - -% 'flow' layout adapts to any pair count without manual rows/cols tuning. -tl = tiledlayout(figAll, 'flow', 'TileSpacing', 'compact', 'Padding', 'compact'); -title(tl, 'All pairwise differences'); - -for k = 1:nPairs - ax = nexttile(tl); - - pairK = pairs(k, :); - pValK = pValues(k); - - tblDiff = buildDiffTable(tbl, pairK, params, isInsertionLevel); - - % Empty diff table (e.g. no overlapping insertions) – leave tile blank - if height(tblDiff) == 0 - title(ax, sprintf('%s − %s (no data)', pairK{1}, pairK{2}), ... - 'FontSize', 8); - continue - end - - plotDiffSwarm(ax, tblDiff, pairK, pValK, params, ... - yMaxVis, bracketPad, textPad); - - title(ax, sprintf('%s − %s', pairK{1}, pairK{2}), 'FontSize', 8); -end - +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 53b7c8c..daee790 100644 --- a/general functions/plotSwarmBootstrapWithComparisons.m +++ b/general functions/plotSwarmBootstrapWithComparisons.m @@ -1,9 +1,9 @@ -function [fig, randiColors,figAllDiffs] = 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] = plotSwarmBootstrapWithComparisons(tbl, pairs, ... -% pValues, valueField, params) +% [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 @@ -20,31 +20,26 @@ % 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' (default) or 'percentile' (95% bootstrap CI) +% 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 and the random dot-draw permutation. +% 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. For -% insertion-level data with grouping {'animal','insertion'}, the within- -% insertion step resamples a single observation, so the procedure naturally -% reduces to an animal->insertion bootstrap without special-casing. -% Categorical grouping columns are coerced to numeric category codes before -% hierBoot is called (hierBoot pre-allocates intermediate levels as -% nan(size(data)), so it requires numeric inputs). +% levels are present, resampling each level with replacement in turn. % ------------------------------------------------------------------------- -% Argument validation block. MATLAB enforces types/sizes before the body runs. +% Argument validation block % ------------------------------------------------------------------------- arguments tbl table % observation table pairs cell = {} % stim pairs to test - pValues double = [] % p-value per pair (NaN allowed) + 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) @@ -55,49 +50,46 @@ 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.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.ciMethod char = 'percentile'% 'sem' | 'percentile' params.bootGroupVars cell = {'__auto__'}% hierarchical bootstrap levels end % ------------------------------------------------------------------------- -% Up-front input validation. +% Up-front input validation % ------------------------------------------------------------------------- -% Either raw mode (1 field) or fraction mode (2 fields). Fail loudly otherwise. +% 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.'); + assert(~isempty(valueField), 'valueField must contain at least one column name.'); end -% colorByZScore can only work if the column exists; downgrade with a warning. +% 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; honor the two-tile mode. +% 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 for the entire call so bootstraps and dot-draw orders are -% deterministic. Critical for figure reproducibility in a paper. +% Seed RNG once so bootstraps and dot-draw orders are deterministic rng(params.rngSeed); % ------------------------------------------------------------------------- -% Resolve bootstrap grouping variables. -% Sentinel '__auto__' means "auto-fill from columns present in the table". -% Explicit {} from the caller forces a flat (non-hierarchical) bootstrap. +% Resolve bootstrap grouping variables % ------------------------------------------------------------------------- if isscalar(params.bootGroupVars) && strcmp(params.bootGroupVars{1}, '__auto__') - cands = {'animal','insertion'}; + cands = {'animal','insertion'}; % candidate hierarchy columns params.bootGroupVars = cands(ismember(cands, tbl.Properties.VariableNames)); else missing = ~ismember(params.bootGroupVars, tbl.Properties.VariableNames); @@ -106,51 +98,47 @@ end % ------------------------------------------------------------------------- -% Detect data granularity. -% If every (insertion, stimulus) pair appears at most once, the table is -% insertion-level (e.g., one number-of-responsive-units value per insertion). -% Otherwise it is neuron-level (many neurons per insertion per stimulus). -% Drives buildDiffTable's pairing strategy AND plotRawSwarm's line-grouping. +% 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. +% Padding / spacing constants derived from the y-axis cap % ------------------------------------------------------------------------- yMaxVis = params.yMaxVis; -bracketPad = yMaxVis * 0.05; -stackPad = yMaxVis * 0.05; -textPad = yMaxVis * 0.01; -semAlpha = 0.6; +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 stimulus labels and compute the value column. +% Pre-process tbl: rename legacy labels, reorder categories, compute value % ------------------------------------------------------------------------- -tbl = renameStimulusLabels(tbl); -pairs = renamePairLabels(pairs); -tbl = reorderStimulusByLevel(tbl); % sort categorical by trailing numeric 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 - % Element-wise ratio. NaN/Inf may arise if denominator has zeros — they - % are filtered downstream by the bootstrap/plotting code. - tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); + tbl.value = tbl.(valueField{1}) ./ tbl.(valueField{2}); % element-wise ratio else - tbl.value = tbl.(valueField{1}); + tbl.value = tbl.(valueField{1}); % raw value end -% Drop unused categorical levels so colormaps and category counts are accurate. +% 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: either single axes or a 1x2 tiledlayout depending on mode. +% Build figure: single axes or 1x2 tiledlayout % ------------------------------------------------------------------------- fig = figure; -set(fig, 'Color', 'w'); % white background for publication +set(fig, 'Color', 'w'); % white background for publication if params.showBothAndDiff - % Left tile: every stimulus shown raw. Right tile: most-significant pair's diff. + % 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); @@ -160,7 +148,7 @@ % Pick the most significant pair for the diff tile if ~isempty(pValues) - [~, sigIdx] = min(pValues); + [~, sigIdx] = min(pValues); else sigIdx = 1; end @@ -171,10 +159,10 @@ plotDiffSwarm(axDiff, tblDiff, pairForDiff, pValForDiff, params, ... yMaxVis, bracketPad, textPad); else - % Single-axes mode: either the raw swarm or the difference, not both. - ax = axes(fig); + % Single-axes mode: either the raw swarm or the difference + ax = axes(fig); hold(ax, 'on'); - set(ax, 'Clipping', 'off'); % allow brackets/text outside ylim + set(ax, 'Clipping', 'off'); if params.diff tblDiff = buildDiffTable(tbl, pairs, params, isInsertionLevel); @@ -187,7 +175,7 @@ end % ------------------------------------------------------------------------- -% Additional figure: one tile per pairwise difference (only if multi-pair). +% Additional figure: one tile per pairwise difference (only when >1 pair) % ------------------------------------------------------------------------- if size(pairs, 1) > 1 figAllDiffs = plotAllPairDiffs(tbl, pairs, pValues, params, ... @@ -203,58 +191,55 @@ % ========================================================================= % LOCAL FUNCTION: plotRawSwarm -% Plots all observations grouped by stimulus, with optional connecting lines -% between paired neurons across stim types. Returns the random draw permutation. % ========================================================================= 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 +set(ax, 'Clipping', 'off'); % brackets may sit above yMaxVis -stimuli = categories(tbl.stimulus); % ordered category list -tblPlot = tbl; % alias to keep names short +stimuli = categories(tbl.stimulus); % ordered category list +tblPlot = tbl; -% Random permutation of dot indices => overlapping colors don't form layers. -% rng() was seeded once in the main function, so this is reproducible. +% Random permutation for dot draw order (seeded in main) randiColors = randperm(height(tblPlot)); -% Choose dot color source: continuous zScore (diverging) or categorical animal. +% Choose dot color source if params.colorByZScore colorData = tblPlot.zScore(randiColors); else colorData = tblPlot.animal(randiColors); end -% Draw the swarm. swarmchart accepts a categorical x-axis directly. +% Draw swarm if params.filled s = swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... params.dotSize, colorData, 'filled', ... 'MarkerFaceAlpha', params.Alpha); else - % SizeData=30 below intentionally overrides params.dotSize for legibility - % of open markers; consider exposing as its own param if you want full control. s = swarmchart(ax, tblPlot.stimulus(randiColors), tblPlot.value(randiColors), ... params.dotSize, colorData, ... 'MarkerEdgeAlpha', params.Alpha, 'LineWidth', 1, 'SizeData', 30); end s.XJitter = params.Xjitter; -Str = string(unique(tbl.stimulus)); +% Build short tick labels from encoded category names +Str = string(stimuli); +out = buildTickLabels(Str); -% Extract first number (integer or decimal, positive/negative) +% 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,numStr); + xticklabels(ax, out); end -% Configure the colormap to match the chosen color source. +% Configure colormap if params.colorByZScore colormap(ax, buildRdBuColormap(256)); maxZ = max(abs(tblPlot.zScore), [], 'omitnan'); - if isempty(maxZ) || maxZ == 0, maxZ = 1; end % degenerate safety - clim(ax, [-maxZ maxZ]); % symmetric around zero + if isempty(maxZ) || maxZ == 0, maxZ = 1; end + clim(ax, [-maxZ maxZ]); cb = colorbar(ax); cb.Label.String = 'Z-score'; else @@ -262,36 +247,28 @@ end % ------------------------------------------------------------------------- -% Optional: draw a thin line for each unit across stimulus columns. -% Choice of unit identifier depends on data granularity: -% * insertion-level: insertion *is* the unit, so group by insertion. -% * neuron-level : need NeurID; insertion would erroneously merge units -% from the same penetration into a single line. +% Optional connecting lines between paired observations % ------------------------------------------------------------------------- if params.drawLines && numel(stimuli) <= 2 if isInsertionLevel - unitIDvar = 'insertion'; + unitIDvar = 'insertion'; % insertion IS the unit elseif ismember('NeurID', tblPlot.Properties.VariableNames) - unitIDvar = 'NeurID'; + unitIDvar = 'NeurID'; % neuron is the unit else unitIDvar = ''; warning(['drawLines=true on neuron-level data without NeurID; ', ... - 'skipping connecting lines (insertion would merge ', ... - 'multiple units into one line).']); + 'skipping connecting lines.']); end if ~isempty(unitIDvar) cats = categories(tblPlot.stimulus); - % Map stimulus categories to numeric x positions for line() calls. - xMap = containers.Map(cats, 1:numel(cats)); + xMap = containers.Map(cats, 1:numel(cats)); % stimulus -> x-position xNum = arrayfun(@(i) xMap(char(tblPlot.stimulus(i))), 1:height(tblPlot)); - % Iterate over actual unit values (categorical or numeric); equality - % comparison works on both, so no type-specific branching is needed. unitIDs = unique(tblPlot.(unitIDvar)); for u = 1:numel(unitIDs) idx = tblPlot.(unitIDvar) == unitIDs(u); - if nnz(idx) < 2, continue; end % need >=2 stim columns to draw + if nnz(idx) < 2, continue; end line(ax, xNum(idx), tblPlot.value(idx), ... 'Color', [0 0 0 0.1], 'LineWidth', 0.1); end @@ -300,21 +277,22 @@ ylabel(ax, params.yLegend); ax.Box = 'off'; -ax.Layer = 'top'; % axis ticks above swarm dots +ax.Layer = 'top'; -% Hierarchical bootstrap mean ± SE (or 95% CI) per stimulus column. +% Hierarchical bootstrap mean +/- SE (or 95% CI) if params.plotMeanSem plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha); end -% Pairwise significance brackets (only if pairs and pValues are aligned). -if ~isempty(pairs) && numel(pValues) == size(pairs,1) +% 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); + yMaxVis, bracketPad, stackPad, textPad, adjacentOnly); end -% Cap visible y-range. Brackets/text use Clipping=off, so they remain visible -% even above this cap (intentional for tight figures). +% Cap visible y-range (brackets use Clipping=off so they remain visible) ylim(ax, [ax.YLim(1) yMaxVis]); end % plotRawSwarm @@ -322,8 +300,6 @@ % ========================================================================= % LOCAL FUNCTION: plotDiffSwarm -% One swarm column showing per-neuron (or per-insertion) (stimA - stimB), -% with a 4-tier significance annotation matching plotBrackets. % ========================================================================= function randiColors = plotDiffSwarm(ax, tblDiff, pairs, pValues, params, ... yMaxVis, bracketPad, textPad) @@ -331,10 +307,18 @@ hold(ax, 'on'); set(ax, 'Clipping', 'off'); -% Reproducible draw order; rng() set once in main. +% 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)); -% Same color-source logic as raw plot, but only if zScore made it through buildDiffTable. +% Color source if params.colorByZScore && ismember('zScore', tblDiff.Properties.VariableNames) colorData = tblDiff.zScore(randiColors); else @@ -352,16 +336,11 @@ end s.XJitter = params.Xjitter; -Str = string(unique(tblDiff.stimulus)); - -nums = regexp(Str, '-?\d+\.?\d*', 'match'); - -numStr = strjoin(nums, '-'); - -if any(~cellfun(@isempty, nums)) - xticklabels(ax, numStr); -end +% 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'); @@ -373,168 +352,147 @@ colormap(ax, lines(numel(categories(tblDiff.animal)))); end -% Visual reference at zero so the sign of differences is obvious at a glance. +% 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 scheme matching plotBrackets). +% Significance annotation (four-tier: ***, **, *, or nothing) % ------------------------------------------------------------------------- ylims = ylim(ax); if ~isempty(pValues) && numel(pValues) >= 1 - - fprintf('=== DIFF MODE SIGNIFICANCE ===\n'); - fprintf('p-value: %.4e\n', pValues(1)); + pVal = pValues(1); % scalar for this diff panel + fprintf('Diff significance: p = %.4e\n', pVal); vals = tblDiff.value; - if isempty(vals) - fprintf('No values to annotate.\n'); - ylim(ax, [ylims(1) yMaxVis]); - return - end - - % Place the annotation just above the highest visible (capped) value. maxVisible = max(min(vals(:), yMaxVis(1))); if isempty(maxVisible), maxVisible = yMaxVis; end yText = maxVisible + bracketPad; - % Skip stars for non-significant - if isnan(pValues(1)) || pValues(1) >= 0.05 - % no stars drawn - else - if pValues(1) < 0.001, txt = '***'; - elseif pValues(1) < 0.01, txt = '**'; - else, txt = '*'; + % 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 - % Hard-coded x=1: the diff plot has exactly one column. text(ax, 1, yText, txt, ... 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); end - % % Comparison label (e.g. "SB > SG"), placed above the stars. - % compTextPad = 10 * textPad; - % stimA = pairs{1,1}; - % stimB = pairs{1,2}; - % compText = sprintf('%s > %s', stimA, stimB); - % yCompText = yText + compTextPad; - % - % text(ax, 1, yCompText, compText, ... - % 'HorizontalAlignment', 'center', 'FontSize', 10, 'Clipping', 'off'); - % - % % Expand y-limits if the comparison label needs more room than yMaxVis allows. - % requiredHeight = yCompText + compTextPad; - % if requiredHeight > yMaxVis - % ylim(ax, [ylims(1) requiredHeight]); - % else ylim(ax, [ylims(1) yMaxVis]); - % end else ylim(ax, [ylims(1) yMaxVis]); end -fprintf('=== END DIFF MODE SIGNIFICANCE ===\n'); - end % plotDiffSwarm % ========================================================================= % LOCAL FUNCTION: buildDiffTable -% Per-unit (stimA - stimB) within insertion. Pairing strategy is chosen by -% data granularity: -% * insertion-level (one row per insertion-stimulus): direct subtraction. -% * neuron-level + NeurID present: match by NeurID (intersect). -% * neuron-level without NeurID: row-order fallback with warning. +% 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, ... +assert(~isempty(pairs) && size(pairs, 1) >= 1, ... 'diff mode requires at least one stimulus pair.'); -stimA = pairs{1,1}; -stimB = pairs{1,2}; +stimA = strtrim(pairs{1,1}); % trim whitespace for safety +stimB = strtrim(pairs{1,2}); hasNeurID = ismember('NeurID', tbl.Properties.VariableNames); -% Only warn when NeurID is genuinely needed (neuron-level data) and missing. if ~hasNeurID && ~isInsertionLevel - warning(['buildDiffTable: NeurID column not present and table appears to ', ... - 'be neuron-level (multiple rows per insertion-stimulus pair). ', ... - 'Pairing by row order — fragile if rows are reordered. Add NeurID.']); + warning(['buildDiffTable: NeurID column absent for neuron-level data. ', ... + 'Pairing by row order — fragile if rows are reordered.']); end -ins = categories(tbl.insertion); -diffVals = []; % accumulators for the output table -animals = categorical.empty(0, 1); % MUST be categorical so vertcat preserves type -insers = []; -zScores = []; % only filled if colorByZScore +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; - if ~any(idxA) || ~any(idxB), continue; end + + % Skip insertions where either stimulus is absent + if ~any(idxA) || ~any(idxB) + continue + end if isInsertionLevel - % One row per side guaranteed by the granularity check. + % 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. + % 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 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 for neuron-level data without NeurID. + % Row-order fallback (fragile) vA = tbl.value(idxA); vB = tbl.value(idxB); if numel(vA) ~= numel(vB) - warning('Insertion %s: %d stimA rows but %d stimB rows; skipping.', ... + 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); + 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; repmat(i, numel(vA), 1)]; %#ok + diffVals = [diffVals; vA - vB]; %#ok + animals = [animals; an]; %#ok + insers = [insers; insCat]; %#ok if useZ - zScores = [zScores; zPair]; %#ok + zScores = [zScores; zPair]; %#ok end end -% Drop NaN differences (e.g., from zero-denominator fractions). +% Drop NaN differences (e.g. from zero-denominator fractions) valid = ~isnan(diffVals); stimName = sprintf('%s-%s', stimA, stimB); tblDiff = table(); -tblDiff.insertion = categorical(insers(valid)); +tblDiff.insertion = insers(valid); % categorical insertion labels tblDiff.stimulus = categorical(repmat({stimName}, sum(valid), 1)); tblDiff.animal = animals(valid); tblDiff.value = diffVals(valid); @@ -548,17 +506,8 @@ % ========================================================================= % LOCAL FUNCTION: plotMeanSemBars -% Hierarchical-bootstrap central tendency and uncertainty per stimulus column. -% -% Reports the SAMPLE mean as the point estimate (consistent with conventional -% reporting; mean(bootMean) converges to it as nBoot->Inf but is unconventional). -% Uncertainty bar uses params.ciMethod: -% 'sem' -> ±1 SE from std(bootMean) -% 'percentile' -> [2.5, 97.5] percentile CI (recommended for skewed data) -% -% Resampling is hierarchical via hierBoot (Saravanan et al. 2020), with one -% level per entry of params.bootGroupVars (typically {'animal','insertion'}). -% Falls back to a flat bootstrap (bootstrp) when no levels are configured. +% Hierarchical-bootstrap central tendency and uncertainty per stimulus. +% Uses hierBoot (Saravanan et al. 2020) for hierarchical resampling. % ========================================================================= function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) @@ -566,10 +515,7 @@ function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) idx = tblPlot.stimulus == stimuli{i}; if ~any(idx), continue; end - % Pull values + matching cluster IDs, dropping NaNs from all together. - % Without this step bootstrp(@mean, vals) returns NaN whenever any sample - % contains a NaN, while the analytical SE silently omits NaNs — the - % original code switched between these two policies at n=500. + % Pull values and drop NaNs vals = tblPlot.value(idx); keep = ~isnan(vals); vals = vals(keep); @@ -580,42 +526,30 @@ function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) continue end - % Sample mean as point estimate. - %mu = mean(vals); - - % Pull each grouping column for this stimulus, aligned with the NaN drop. - % NOTE: hierBoot pre-allocates intermediate level arrays via nan(size(data)) - % (i.e., as double), so any categorical grouping column must be coerced to - % its underlying integer category code before being passed in. The codes - % preserve group identity for the equality comparisons hierBoot performs. + % 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); % numeric-only contract + col = double(col); % hierBoot requires numeric end groupVals{g} = col; end - % Hierarchical bootstrap of the mean. Empty group list => flat bootstrap. - % For insertion-level data with groups {'animal','insertion'}, the - % within-insertion resampling step picks the same single observation each - % time, so the procedure naturally collapses to an animal->insertion - % bootstrap without any special-casing here. + % Hierarchical or flat bootstrap if isempty(groupVars) bootMean = bootstrp(params.nBoot, @mean, vals); else bootMean = hierBoot(vals, params.nBoot, groupVals{:}); end - % Hierarchical-bootstrap point estimate (consistent with the CI). - % mean(bootMean) converges to the hierarchical mean, which weights - % animals/insertions equally — matching the mixed-model logic. + % Point estimate: mean of the bootstrap distribution + % (weights animals/insertions equally, matching the mixed model) mu = mean(bootMean); - % Uncertainty bar from the bootstrap distribution. + % Uncertainty bar switch lower(params.ciMethod) case 'sem' se = std(bootMean); @@ -628,18 +562,16 @@ function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) error('Unknown ciMethod: %s. Use ''sem'' or ''percentile''.', params.ciMethod); end - % Vertical uncertainty line. + % Vertical uncertainty line line(ax, [i i], [yLo yHi], ... 'Color', [0 0 0 semAlpha], 'LineWidth', 2); - - % End caps. + % 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 — slightly wider than caps so the point estimate stands out. + % Mean line dx = 0.15; plot(ax, [i-dx i+dx], [mu mu], 'k-', 'LineWidth', 1.2); end @@ -649,68 +581,60 @@ function plotMeanSemBars(ax, tblPlot, stimuli, params, semAlpha) % ========================================================================= % LOCAL FUNCTION: plotBrackets -% Pairwise significance brackets above the swarm. Four-tier annotation -% (***, **, *, ns), consistent with plotDiffSwarm. +% 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) + yMaxVis, bracketPad, stackPad, textPad, adjacentOnly) -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), ', ')); +% Track y-positions of placed brackets to prevent overlap +usedHeights = zeros(size(pairs, 1), 1); -% Track y-positions of already-placed brackets so subsequent ones stack -% rather than overlap. -usedHeights = zeros(size(pairs,1), 1); +for k = 1:size(pairs, 1) -for k = 1:size(pairs,1) - - fprintf('\n--- Pair %d: %s vs %s ---\n', k, pairs{k,1}, pairs{k,2}); - - % Skip non-significant pairs entirely (no bracket, no text) + % Skip non-significant (no bracket, no text) if isnan(pValues(k)) || pValues(k) >= 0.05 - fprintf('SKIPPING: non-significant (p=%.4g).\n', pValues(k)); 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})); - fprintf('x1 index: %d, x2 index: %d\n', x1, x2); - - if isempty(x1) || isempty(x2) - fprintf('SKIPPING: One or both stimuli not found in plot.\n'); + 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}); - fprintf('vals1 count: %d, vals2 count: %d\n', numel(vals1), numel(vals2)); - - % Cap each value at yMaxVis so the bracket is anchored to what's visible. maxVisible = max(min([vals1; vals2], yMaxVis)); yBase = maxVisible + bracketPad; - % Vertical stacking against previously placed brackets. + % 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; - fprintf('Bracket y position: %.3f\n', y); - fprintf('p-value: %.4e\n', pValues(k)); - - % Bracket horizontal + two short verticals. - 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'); + % 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 - fprintf('Drawing text: %s\n', txt); text(ax, mean([x1 x2]), y + textPad, txt, ... 'HorizontalAlignment', 'center', 'FontSize', 7, 'Clipping', 'off'); end @@ -718,9 +642,51 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... 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 -% Replaces legacy stimulus abbreviations in tbl.stimulus. % ========================================================================= function tbl = renameStimulusLabels(tbl) s = string(tbl.stimulus); @@ -733,14 +699,13 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... % ========================================================================= % LOCAL FUNCTION: renamePairLabels -% Same legacy substitutions, applied element-wise to the pairs cell array. % ========================================================================= 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"); % must come before SDGm — strict prefix + p = replace(p, "SDGs", "SG"); p = replace(p, "SDGm", "MG"); pairs{i} = char(p); end @@ -748,30 +713,53 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... % ========================================================================= -% LOCAL FUNCTION: buildRdBuColormap -% n-row diverging Red-Blue colormap centred on white. -% Blue = negative, White = zero, Red = positive. +% 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 cmap = buildRdBuColormap(n) -half = floor(n/2); +function out = buildTickLabels(Str) +out = strings(size(Str)); +for i = 1:numel(Str) + s = Str(i); -blueToWhite = [linspace(0.02, 1, half)', ... - linspace(0.44, 1, half)', ... - linspace(0.69, 1, half)']; + % Extract uppercase prefix (MB, MG, etc.) + prefix = regexp(s, '^[A-Z]+', 'match', 'once'); -whiteToRed = [linspace(1, 0.70, half)', ... - linspace(1, 0.09, half)', ... - linspace(1, 0.09, half)']; + % Extract number-like string (possibly negative or with 'p' for decimal) + numStr = regexp(s, '-?\d+(?:[p\.]\d+)?', 'match', 'once'); -cmap = [blueToWhite; whiteToRed]; + 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 + numFormatted = compose("%.2f", numVal); + end + 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 (e.g. mode-1 labels like 'MB','SDGm'). +% 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) @@ -780,24 +768,24 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... for i = 1:numel(cats) parts = strsplit(cats{i}, '_'); - if numel(parts) < 2, continue; end % no underscore => no level token + if numel(parts) < 2, continue; end % no underscore => no level token - last = parts{end}; % decode trailing token - last = strrep(last, 'p', '.'); % 'p' -> '.' (decimal) - last = strrep(last, 'neg', '-'); % 'neg' -> '-' (negative) + 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. +% 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. +% Two-step stable sort: primary numeric ascending, secondary alphabetical [catsAlpha, idxAlpha] = sort(cats); numsAlpha = nums(idxAlpha); [~, idxNum] = sort(numsAlpha, 'ascend', 'MissingPlacement', 'last'); @@ -805,44 +793,19 @@ function plotBrackets(ax, tblPlot, stimuli, pairs, pValues, ... tbl.stimulus = reordercats(tbl.stimulus, catsFinal); -end +end % reorderStimulusByLevel + % ========================================================================= -% LOCAL FUNCTION: plotAllPairDiffs -% Stand-alone figure with one tile per pairwise difference. Each tile is a -% diff swarm + significance annotation, matching the format of plotDiffSwarm. +% LOCAL FUNCTION: buildRdBuColormap % ========================================================================= -function figAll = plotAllPairDiffs(tbl, pairs, pValues, params, ... - isInsertionLevel, yMaxVis, bracketPad, textPad) - -nPairs = size(pairs, 1); - -figAll = figure; -set(figAll, 'Color', 'w'); - -% 'flow' layout adapts to any pair count without manual rows/cols tuning. -tl = tiledlayout(figAll, 'flow', 'TileSpacing', 'compact', 'Padding', 'compact'); -title(tl, 'All pairwise differences'); - -for k = 1:nPairs - ax = nexttile(tl); - - pairK = pairs(k, :); - pValK = pValues(k); - - tblDiff = buildDiffTable(tbl, pairK, params, isInsertionLevel); - - % Empty diff table (e.g. no overlapping insertions) – leave tile blank - if height(tblDiff) == 0 - title(ax, sprintf('%s − %s (no data)', pairK{1}, pairK{2}), ... - 'FontSize', 8); - continue - end - - plotDiffSwarm(ax, tblDiff, pairK, pValK, params, ... - yMaxVis, bracketPad, textPad); - - title(ax, sprintf('%s − %s', pairK{1}, pairK{2}), 'FontSize', 8); -end - +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/@linearlyMovingBallAnalysis/plotRaster.asv b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.asv new file mode 100644 index 0000000..39bfd65 --- /dev/null +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.asv @@ -0,0 +1,570 @@ +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 + +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 + + +[C indexS] = sortrows(C,[2 6 3 4 5]); +directimesSorted = C(:,1)'; + +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 +uDir = unique(C(:,2)); +uOffset = unique(C(:,3)); +uSize = unique(C(:,4)); +uSpeed = unique(C(:,5)); +uLums= unique(C(:,6)); + + +%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))); + %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 + 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 + + 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 fc14584..4763b3a 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m @@ -23,6 +23,8 @@ function plotRaster(obj,params) params.OneLuminosity string = "all" params.PaperFig logical = false params.statType string = "maxPermuteTest" + params.sortingOrder string = ["direction","luminosity","offset","size","speed"] + end @@ -106,7 +108,21 @@ function plotRaster(obj,params) 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 diff --git a/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m b/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m index e07f8ec..187f0c6 100644 --- a/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@rectGridAnalysis/plotRaster.m @@ -101,6 +101,8 @@ function plotRaster(obj,params) %Mr = ConvBurstMatrix(Mr,fspecial('gaussian',[1 params.GaussianLength],3),'same'); +%%%%Sort + ur =1; diff --git a/visualStimulationAnalysis/AllExpAnalysis.asv b/visualStimulationAnalysis/AllExpAnalysis.asv index 8a1d7e0..2069251 100644 --- a/visualStimulationAnalysis/AllExpAnalysis.asv +++ b/visualStimulationAnalysis/AllExpAnalysis.asv @@ -442,10 +442,23 @@ if runLoop % 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 - fName = levelToFieldName(cat, lv); % key in catStats + % 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(:); @@ -697,6 +710,11 @@ 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'}, ... diff --git a/visualStimulationAnalysis/AllExpAnalysis.m b/visualStimulationAnalysis/AllExpAnalysis.m index 881885f..1fcb856 100644 --- a/visualStimulationAnalysis/AllExpAnalysis.m +++ b/visualStimulationAnalysis/AllExpAnalysis.m @@ -689,6 +689,25 @@ 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 @@ -796,6 +815,8 @@ % 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( ... @@ -809,6 +830,9 @@ % --- 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) @@ -833,9 +857,9 @@ animal = S.TableRespNeurs.animal(idx1); % animal for this insertion - diffs = [diffs; d]; %#ok - insLabels = [insLabels; double(ins)]; %#ok - animLabels = [animLabels; double(animal)]; %#ok + diffs = [diffs; d]; + insLabels = [insLabels; double(ins)]; + animLabels = [animLabels; double(animal)]; end end diff --git a/visualStimulationAnalysis/RunAnalysisClass.asv b/visualStimulationAnalysis/RunAnalysisClass.asv index 7a82483..f0aa5bf 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.asv +++ b/visualStimulationAnalysis/RunAnalysisClass.asv @@ -29,9 +29,9 @@ end %% Moving ball -for ex =[84]%97 74:84 (Neurons, 96_74, ) +for ex =[84,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 @@ -40,19 +40,20 @@ for ex =[84]%97 74:84 (Neurons, 96_74, ) % 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('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); + % 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 @@ -68,14 +69,40 @@ end %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); + 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 categoryis, 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'},CompareCategory="directions",PaperFig=true,... - overwriteResponse=false,overwriteStats=true); +%% 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); @@ -95,8 +122,46 @@ plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=false, zScore=true,TakeTo %% 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"] -%% -plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50, postStim= 1500, stimTypes={"MB"},splitBy="directions",useCategoryPvals = true); %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) diff --git a/visualStimulationAnalysis/RunAnalysisClass.m b/visualStimulationAnalysis/RunAnalysisClass.m index 1aa9ce9..d8795e8 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -29,9 +29,9 @@ %% Moving ball -for ex =[84]%97 74:84 (Neurons, 96_74, ) +for ex =[84,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 @@ -40,19 +40,20 @@ % 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('AllResponsiveNeurons',true,'overwrite',true,'MergeNtrials',5,'bin',50,'GaussianLength',30,'MaxVal_1', false, ... + 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); + % 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 @@ -69,25 +70,39 @@ %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); + 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 categories, z-scores are computed with a moving window and responsive units are selected on the per category p value. +%% 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=true,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); +[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); @@ -107,8 +122,46 @@ %% 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"] -%% -plotPSTH_MultiExp([49:54,64:66,68:85 87:97], overwrite=true, zScore=true,TakeTopPercentTrials=[], PaperFig=true, byDepth=false, smooth=50, postStim= 1500, stimTypes={"MB"},splitBy="directions",useCategoryPvals = true); %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) diff --git a/visualStimulationAnalysis/plotRaster_MultiExp.m b/visualStimulationAnalysis/plotRaster_MultiExp.m index bf5065a..f6eac26 100644 --- a/visualStimulationAnalysis/plotRaster_MultiExp.m +++ b/visualStimulationAnalysis/plotRaster_MultiExp.m @@ -8,7 +8,8 @@ function plotRaster_MultiExp(exList, params) % 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, or leaves them unsorted. +% 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. % @@ -29,10 +30,14 @@ function plotRaster_MultiExp(exList, params) % 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 = ["rectGrid","linearlyMovingBall"] % stimulus types — one tile each + 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 @@ -41,9 +46,9 @@ function plotRaster_MultiExp(exList, params) params.postStim double = 0 % post-stimulus window in ms params.preBase double = 200 % pre-stimulus baseline in ms params.overwrite logical = false % if true, recompute even if cache exists - params.TakeTopPercentTrials double = 0.3 % fraction (0,1] of trials to keep; [] or 0 = keep all + 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" | "none" + 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 @@ -54,15 +59,57 @@ function plotRaster_MultiExp(exList, params) 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, ... +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 % ------------------------------------------------------------------------- @@ -85,14 +132,23 @@ function plotRaster_MultiExp(exList, params) p = [p 'lizards']; % include 'lizards' folder if ~exist([p '\Combined_lizard_analysis'], 'dir') % create output dir if absent - cd(p) - mkdir Combined_lizard_analysis + 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. "rectGrid-linearlyMovingBall" -nameOfFile = sprintf('\\Ex_%d-%d_Raster_%s.mat', ... - exList(1), exList(end), stimLabel); % cache filename +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 @@ -119,21 +175,26 @@ function plotRaster_MultiExp(exList, params) 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 + 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 - depthAll{s} = []; - expAll{s} = []; - phyAll{s} = []; + 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 @@ -150,31 +211,32 @@ function plotRaster_MultiExp(exList, params) for s = 1:nStim try NPtmp = loadNPclassFromTable(exList(ei)); % load NP object - switch params.stimTypes(s) - case "rectGrid"; objTmp = rectGridAnalysis(NPtmp); - case "linearlyMovingBall"; objTmp = linearlyMovingBallAnalysis(NPtmp); - case "StaticGrating"; objTmp = StaticDriftingGratingAnalysis(NPtmp); - case "MovingGrating"; objTmp = StaticDriftingGratingAnalysis(NPtmp); + 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) - if params.speed ~= "max" && isequal(objTmp.stimName, 'linearlyMovingBall') - fn = 'Speed2'; - elseif isequal(objTmp.stimName, 'linearlyMovingBall') - fn = 'Speed1'; - elseif isequal(params.stimTypes(s), 'StaticGrating') - fn = 'Static'; - elseif isequal(params.stimTypes(s), 'MovingGrating') - fn = 'Moving'; - else - fn = ''; % rectGrid: flat struct - end + 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 + dur = NRtmp.stimDur; % flat struct fallback end minStimDur(s) = min(minStimDur(s), dur); % keep shortest for this stim @@ -202,33 +264,41 @@ function plotRaster_MultiExp(exList, params) NP = loadNPclassFromTable(ex); % load Neuropixels data object catch ME warning('Could not load experiment %d: %s', ex, ME.message); - continue % skip on load failure + continue % skip on load failure end for s = 1:nStim - stimType = params.stimTypes(s); % current stimulus string + 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 "rectGrid" - obj = rectGridAnalysis(NP); - case "linearlyMovingBall" - obj = linearlyMovingBallAnalysis(NP); - case "StaticGrating" - obj = StaticDriftingGratingAnalysis(NP); - case "MovingGrating" - obj = StaticDriftingGratingAnalysis(NP); + 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: %s', stimType); + 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 + continue % skip this stim/exp end - NeuronResp = obj.ResponseWindow; % response-window struct + % --- Select statistics struct --- if params.statType == "BootstrapPerNeuron" @@ -238,41 +308,153 @@ function plotRaster_MultiExp(exList, params) end % --- Resolve sub-field name and stim-onset offset --- - fieldName = ''; % sub-field key + fieldName = resolveFieldName(stimType, params.speed); % sub-field key for this stim type startStim = 0; % ms offset for stim onset - if params.speed ~= "max" && isequal(obj.stimName, 'linearlyMovingBall') - fieldName = 'Speed2'; % slower speed condition - elseif isequal(obj.stimName, 'linearlyMovingBall') - fieldName = 'Speed1'; % fastest speed condition - elseif isequal(stimType, 'StaticGrating') - fieldName = 'Static'; % static grating sub-field - elseif isequal(stimType, 'MovingGrating') - fieldName = 'Moving'; % moving grating sub-field - startStim = obj.VST.static_time * 1000; % moving phase onset (s -> ms) + 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 % Phy cluster ID per good unit + goodPhyIDs = p_sort.phy_ID(label == 'good'); % Phy cluster IDs matching goodU columns - % --- Response p-values --- + % --- 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 + pvals = Stats.(fieldName).pvalsResponse; % stim-specific p-values catch pvals = Stats.pvalsResponse; % flat struct fallback end - % --- Stimulus onset times --- + % --- Stimulus onset times and condition matrix --- try - C = NeuronResp.(fieldName).C; % condition matrix + C = NeuronResp.(fieldName).C; % condition matrix (sub-field) catch - C = NeuronResp.C; % fallback + C = NeuronResp.C; % condition matrix (flat) end - directimesSorted = C(:, 1)' + startStim; % onset times in ms + 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 @@ -287,21 +469,28 @@ function plotRaster_MultiExp(exList, params) % Lock baseline on first experiment if isempty(lockedPreBase) - lockedPreBase = preBase; % shared across all stimuli + 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) + 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 --- - eNeurons = find(pvals < params.alpha); % indices of significant 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); @@ -314,17 +503,130 @@ function plotRaster_MultiExp(exList, params) % ---------------------------------------------------------- % 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 - % 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 + % ============================================================== + % 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) && ... @@ -350,33 +652,42 @@ function plotRaster_MultiExp(exList, params) % --- Z-score using pre-stimulus baseline --- if params.zScore baselineBins = tAxis{s} < lockedPreBase; % mask for baseline bins - bMean = mean(neuronPSTH(baselineBins)); % baseline mean - bStd = std(neuronPSTH(baselineBins)); % baseline SD + + 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 + 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 + 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); + 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 + 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); + depthAll{s}(end+1) = depthTable.Depth_um(depthRow); % matched depth else depthAll{s}(end+1) = NaN; % not found end @@ -386,6 +697,13 @@ function plotRaster_MultiExp(exList, params) 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 @@ -397,12 +715,23 @@ function plotRaster_MultiExp(exList, params) 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); - 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); + 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 % ------------------------------------------------------------------ @@ -416,13 +745,14 @@ function plotRaster_MultiExp(exList, params) for s = 1:numel(params.stimTypes) stimField = matlab.lang.makeValidName(params.stimTypes(s)); % valid struct field name - S.(sprintf('%s_raster', stimField)) = rasterAll{s}; - S.(sprintf('%s_depth', stimField)) = depthAll{s}; - S.(sprintf('%s_exp', stimField)) = expAll{s}; - S.(sprintf('%s_phy', stimField)) = phyAll{s}; % NEW: Phy cluster IDs + 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'); + save([saveDir nameOfFile], '-struct', 'S'); % write cache to disk fprintf('\nSaved raster data to:\n %s\n', [saveDir nameOfFile]); else @@ -433,27 +763,39 @@ function plotRaster_MultiExp(exList, params) lockedPreBase = S.lockedPreBase; % restore baseline stimDurAll = S.stimDurAll; % restore stim durations - rasterAll = cell(1, numel(params.stimTypes)); - depthAll = cell(1, numel(params.stimTypes)); - expAll = cell(1, numel(params.stimTypes)); - phyAll = cell(1, numel(params.stimTypes)); + 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)); - rasterAll{s} = S.(sprintf('%s_raster', stimField)); - depthAll{s} = S.(sprintf('%s_depth', stimField)); - expAll{s} = S.(sprintf('%s_exp', stimField)); + 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" + 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 @@ -531,8 +873,11 @@ function plotRaster_MultiExp(exList, params) if nNeu == 0; continue; end % nothing to do - % Pre-filter to this stimulus for speed - stimMask = string(tuningTable.stimulus) == params.stimTypes(s); + % 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 @@ -540,7 +885,7 @@ function plotRaster_MultiExp(exList, params) row = subT.experimentNum == expAll{s}(k) & ... subT.phyID == phyAll{s}(k); if any(row) - tuningAll{s}(k) = subT.(params.tuningIndexCol)(find(row, 1)); + tuningAll{s}(k) = subT.(params.tuningIndexCol)(find(row, 1)); % tuning index value end end @@ -556,6 +901,9 @@ function plotRaster_MultiExp(exList, params) % ========================================================================= % 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 @@ -568,10 +916,10 @@ function plotRaster_MultiExp(exList, params) % 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'); + 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'); + data, fspecial('gaussian', [1 params.GaussianLength], 2), 'same'); % narrow kernel for short windows end [~, peakBin] = max(dataForSort(:, postStimBins), [], 2); % peak column per neuron @@ -585,18 +933,80 @@ function plotRaster_MultiExp(exList, params) % --- Sort by spatial-tuning index --- % MissingPlacement='last' sends NaN (unmatched) neurons to the bottom [~, sortIdx] = sort(tuningAll{s}, params.tuningSortOrder, ... - 'MissingPlacement', 'last'); + '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); + 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 + 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 @@ -608,10 +1018,11 @@ function plotRaster_MultiExp(exList, params) % PLOT % ========================================================================= -% Short display labels for each stimulus type +% Short display labels for each stimulus type (abbreviations are already +% concise, but the map allows custom labels if desired). stimLegendMap = containers.Map( ... - {'linearlyMovingBall', 'rectGrid', 'MovingGrating', 'StaticGrating'}, ... - {'MB', 'SB', 'MG', 'SG'}); + {'RG', 'MB', 'MBR', 'SDGs', 'SDGm', 'NI', 'NV', 'FFF'}, ... + {'RG', 'MB', 'MBR', 'SDGs', 'SDGm', 'NI', 'NV', 'FFF'}); nStim = numel(params.stimTypes); @@ -721,6 +1132,39 @@ function plotRaster_MultiExp(exList, params) '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 @@ -732,18 +1176,23 @@ function plotRaster_MultiExp(exList, params) 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); - xticklabels(ax, arrayfun(@(v) sprintf('%.2g', v), ticksSec, 'UniformOutput', false)); + xticks(ax, ticksSec); % apply x tick positions + xticklabels(ax, arrayfun(@(v) sprintf('%.2g', v), ticksSec, 'UniformOutput', false)); % formatted labels if s == 1 - ylabel(ax, 'Neuron #', 'FontName', 'helvetica', 'FontSize', 8); % y-label on leftmost tile + 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); + 'FontName', 'helvetica', 'FontSize', 8); % tile title with neuron count - ax.FontName = 'helvetica'; - ax.FontSize = 8; + 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 @@ -761,21 +1210,88 @@ function plotRaster_MultiExp(exList, params) % ------------------------------------------------------------------ cb = colorbar(axAll(end)); % attach to last axes if params.zScore - cb.Label.String = 'Z-score'; + cb.Label.String = 'Z-score'; % label for z-scored data else - cb.Label.String = 'Firing rate (spk/s)'; + cb.Label.String = 'Firing rate (spk/s)'; % label for raw rates end -cb.Label.FontName = 'helvetica'; +cb.Label.FontName = 'helvetica'; % colorbar label font cb.Label.FontSize = 8; -cb.FontName = 'helvetica'; +cb.FontName = 'helvetica'; % tick font cb.FontSize = 8; -set(fig, 'Units', 'centimeters', 'Position', [20 20 9 12]); +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 + 'FontName', 'helvetica', 'FontSize', 10); % super-title if params.PaperFig - vs_first.printFig(fig, sprintf('Raster-%s', stimLabel), PaperFig=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 From b71bb0325d41d8cc2a779177a876541660be5d6d Mon Sep 17 00:00:00 2001 From: simon37robledo Date: Wed, 20 May 2026 01:41:38 +0300 Subject: [PATCH 19/19] changes to plot raster in mB --- .../plotRaster.asv | 211 +++++++++++++--- .../@linearlyMovingBallAnalysis/plotRaster.m | 229 +++++++++++++++--- visualStimulationAnalysis/RunAnalysisClass.m | 18 +- 3 files changed, 389 insertions(+), 69 deletions(-) diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.asv b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.asv index 39bfd65..a892950 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.asv +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.asv @@ -23,7 +23,8 @@ arguments (Input) params.OneLuminosity string = "all" params.PaperFig logical = false params.statType string = "maxPermuteTest" - params.sortingOrder string + params.sortingOrder string = ["direction","luminosity","offset","size","speed"] + end @@ -107,9 +108,6 @@ if params.OneLuminosity ~= "all" end -[C indexS] = sortrows(C,[2 6 3 4 5]); -directimesSorted = C(:,1)'; - sortMap = struct( ... 'direction', 2, ... 'offset', 3, ... @@ -127,12 +125,117 @@ end directimesSorted = C(:,1)'; -%Unique parmeters of the different categories -uDir = unique(C(:,2)); -uOffset = unique(C(:,3)); -uSize = unique(C(:,4)); -uSpeed = unique(C(:,5)); -uLums= unique(C(:,6)); + + +% ========================================================== +% 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 @@ -232,6 +335,7 @@ for u = eNeuron 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: @@ -241,30 +345,75 @@ for u = eNeuron 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([]) diff --git a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m index 4763b3a..1cc9929 100644 --- a/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m +++ b/visualStimulationAnalysis/@linearlyMovingBallAnalysis/plotRaster.m @@ -132,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); @@ -144,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)]; @@ -174,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 @@ -230,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: @@ -239,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([]) diff --git a/visualStimulationAnalysis/RunAnalysisClass.m b/visualStimulationAnalysis/RunAnalysisClass.m index d8795e8..b56d366 100644 --- a/visualStimulationAnalysis/RunAnalysisClass.m +++ b/visualStimulationAnalysis/RunAnalysisClass.m @@ -29,7 +29,7 @@ %% Moving ball -for ex =[84,88]%97 74:84 (Neurons, 96_74, ) +for ex =[88]%97 74:84 (Neurons, 96_74, ) NP = loadNPclassFromTable(ex); %73 81 vs = linearlyMovingBallAnalysis(NP,Multiplesizes=true); % vs.getSessionTime("overwrite",true); @@ -40,7 +40,7 @@ % 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, ... + 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) @@ -85,6 +85,20 @@ %% 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 %%%%%%%%