OSCR

Brain Network Dynamics of Local and Global Predictive Processing in Aging.

Code ↔ Paper

30 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 30 matches · 1 of them tie a paragraph to a whole file, not to given lines: a weak match, whose lines are not tinted
  1. [1] § Methods › Statistical Analysis of ERF Responses ↔ Main_Analysis.m, lines 935–975 · score 0.88 · 0–800 ms, cluster forming threshold, error rate, maximum cluster, family, mixed
  2. [2] § Methods › Statistical Analysis of ERF Responses ↔ Analysis_Group_and_Condition_Seperated_Data.m, lines 1229–1270 · score 0.88 · 0–800 ms, cluster forming threshold, error rate, maximum cluster, family, mixed
  3. [3] § Methods › Investigating BROAD‐NESS Networks in Group and Condition Separated Data ↔ Analysis_Group_and_Condition_Seperated_Data.m, lines 1229–1270 · score 0.85 · 0–800 ms, cluster forming threshold, maximum cluster, timepoint, principal component, Family
  4. [4] § Methods › Investigating BROAD‐NESS Networks in Group and Condition Separated Data ↔ Main_Analysis.m, lines 935–975 · score 0.85 · 0–800 ms, cluster forming threshold, maximum cluster, timepoint, principal component, Family
  5. [5] § Methods › Source Reconstruction ↔ sources_3D_plot_LBPD.m, lines 1–38 · score 0.73 · head model, MNI152 T1, MEG sensors, brain source, active, activity
  6. [6] § Methods › Experimental Paradigm ↔ Experimental_Paradigm.py, lines 16–56 · score 0.72 · incongruent blocks, target tones, probability, randomized, deviant, paradigm
  7. [7] § Results › Brain Network Modularity Analysis ↔ Main_Analysis.m, lines 5227–5348 · score 0.71 · Anderson Darling, mixed ANOVA, Bonferroni correction, interaction, Older, Global
  8. [8] § Methods › Phase Space and Recurrence Quantification Analysis of PCA‐Derived Networks ↔ Main_Analysis.m, lines 3935–4041 · score 0.69 · Benjamini Hochberg, random intercept, Satterthwaite, FDR, metric, interaction
  9. [9] § Methods › Source Reconstruction ↔ MEG_SR_Beam_LBPD.m, lines 1–96 · score 0.69 · single shell, MNI152 T1, MEG sensors, brain source, model, weighted
  10. [10] § Methods › Experimental Paradigm ↔ Experimental_Paradigm.py, lines 16–56 · score 0.68 · incongruent blocks, target tone, sound, sequences, stimuli, Paradigm
  11. [11] § Methods › MEG Data Pre‐Processing ↔ BroadNess_APR2020.m, lines 26–63 · score 0.67 · HPI coils, movement compensation, raw, MaxFilter, MEG
  12. [12] § Methods › Source Reconstruction ↔ MEG_SR_Beam_LBPD.m, lines 1–96 · score 0.67 · FieldTrip, beamforming algorithms, house, OSL, SPM, model
  13. [13] § Methods › MEG Data Pre‐Processing ↔ Preprocessing_SourceReconstruction_BROADNESSHalfSplit.m, lines 6–53 · score 0.66 · HPI coils, movement compensation, raw, MaxFilter, MEG
  14. [14] § Methods › Neural Data Acquisition ↔ MEG_sensors_MCS_plottingclusters_LBPD_D.m, lines 58–128 · score 0.65 · Elekta Neuromag TRIUX, pre processing, positions, MEG
  15. [15] § Methods › Source Reconstruction ↔ sources_3D_plot_LBPD.m, lines 1–38 · score 0.60 · FieldTrip, neural signals, house, head, activity, model
  16. [16] § Results › Deriving Brain Networks via PCA ↔ BROADNESS_Toolbox/BROADNESS_Functions/BROADNESS_NetworkEstimation.m, lines 1–84 · score 0.60 · broadband brain networks, spatial activation pattern, brain voxels, eigenvalue, eigenvector, PCs
  17. [17] § Methods › Principal Component Analysis (PCA) ↔ PCA_LBPD.m, lines 80–148 · score 0.60 · maximum variance, covariance matrix, brain sources, eigenvalues, eigenvectors, PCA
  18. [18] § Methods › Phase Space and Recurrence Quantification Analysis of PCA‐Derived Networks ↔ Main_Analysis.m, lines 2470–2612 · score 0.60 · Euclidean distance, phase space coordinates, shuffled, trajectory, younger, permutation
  19. [19] § Results › Overview of the Experimental Design and MEG Source Reconstruction ↔ BROADNESS_Toolbox/BROADNESS_Functions/BROADNESS_NetworkEstimation.m, lines 1–84 · score 0.59 · spatial activation patterns, variance explained, brain voxels, BROAD NESS, occurrences, eigenvalues
  20. [20] § Methods › Principal Component Analysis (PCA) ↔ BROADNESS_Toolbox/BROADNESS_Functions/BROADNESS_NetworkEstimation.m, lines 175–228 · score 0.59 · maximum variance, covariance matrix, brain sources, eigenvalues, eigenvectors, dimensionality
  21. [21] § Methods › MEG Data Pre‐Processing ↔ BROADNESS_Toolbox/BROADNESS_Functions/BROADNESS_Visualizer.m, lines 1–121 · score 0.59 · Brain Activity, FSL, Centre, Human, Oxford, Mapping
  22. [22] § Results › Spatial Gradient Embedding of BROAD‐NESS‐Derived Network Topographies ↔ BROADNESS_Toolbox/BROADNESS_Functions/BROADNESS_SpatialActivationClustering.m, lines 104–178 · score 0.58 · spatial activation patterns, Brain template, clustering solution, silhouette, embedding, optimal
  23. [23] § Methods › Brain Network Modularity Analysis ↔ Main_Analysis.m, lines 5227–5348 · score 0.57 · mixed ANOVA, Bonferroni corrected, older, global
  24. [24] § Methods › Spatial Gradient Embedding and Clustering Analysis of Network Topographies ↔ BROADNESS_Toolbox/BROADNESS_Functions/BROADNESS_Visualizer.m, lines 1–121 · score 0.57 · network topographies, BROAD NESS, uncover, map, quantify, brain networks
  25. [25] § Results › Spatial Gradient Embedding of BROAD‐NESS‐Derived Network Topographies ↔ BROADNESS_Toolbox/BROADNESS_Functions/BROADNESS_SpatialActivationClustering.m, lines 104–178 · score 0.57 · brain templates, cluster solution, silhouette, embedded, repetitions, optimal
  26. [26] § Methods › Investigating BROAD‐NESS Networks in Group and Condition Separated Data ↔ BROADNESS_Toolbox/BROADNESS_Functions/BROADNESS_EffectiveDimensionality.m, the whole file · a weak match · score 0.53 · effective dimensionality, variance explained, BROAD NESS, principal components, eigenspectrum, brain network
  27. [27] § Methods › Principal Component Analysis (PCA) ↔ PCA_LBPD.m, lines 80–148 · score 0.52 · diagonal matrix, variance explained, eigenvalues, eigenvector, component, PCA
  28. [28] § Methods › Source Reconstruction ↔ MEG_SR_Beam_LBPD.m, lines 421–531 · score 0.52 · covariance matrix, MEG sensors, concatenating, dipole, weights, signal
  29. [29] § Methods › Phase Space and Recurrence Quantification Analysis of PCA‐Derived Networks ↔ Main_Analysis.m, lines 2614–2662 · score 0.51 · Euclidean distance, older participants, phase space, trajectories, age
  30. [30] § Methods › Source Reconstruction ↔ MEG_SR_Beam_LBPD.m, lines 288–357 · score 0.50 · leadfield model, MEG sensors, dipole, beamforming

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

MATLAB · 5,427 lines · 195 KB · no license · 7 matches

  1. % ========================================================================
  2. % Network reconfiguration preserves prediction error signalling in the aging brain
  3. %
  4. % Please cite the preprint:
  5. % Mathias Houe Andersen, Gemma Fernández-Rubio, David R. Quiroga-Martinez, Mattia Rosso, Mathias Klarlund,
  6. % Kit Melissa Larsen, Hartwig Roman Siebner, Morten L. Kringelbach, Peter Vuust, Leonardo Bonetti. bioRxiv.
  7. % Network reconfiguration preserves prediction error signalling in the aging brain.
  8. % https://doi.org/10.64898/2026.04.11.717798
  9. %
  10. % ========================================================================
  11. %
  12. % This script demonstrates one application of BROADNESS
  13. % (Broadband Brain Network Estimation via Source Separation) on
  14. % source-reconstructed MEG data from the auditory local-global paradigm.
  15. %
  16. % When using the BROADBAND BRAIN NETWORK ESTIMATION VIA SOURCE SEPARATION (BROADNESS) TOOLBOX
  17. % please cite the first BROAD-NESS paper:
  18. %
  19. % Bonetti, L., Fernandez-Rubio, G., Andersen, M. H., Malvaso, C., Carlomagno,
  20. % F., Testa, C., Vuust, P, Kringelbach, M.L., & Rosso, M. (2025). Advanced Science.
  21. % BROAD-NESS Uncovers Dual-Stream Mechanisms Underlying Predictive Coding in Auditory Memory Networks.
  22. % https://doi.org/10.1002/advs.202507878
  23. %
  24. % ========================================================================
  25. % The dataset used in the present script is available at the following link:
  26. % https://doi.org/10.5281/zenodo.18231641
  27. %
  28. % The code for the experimental paradigm, the auditory local-global paradigm, used in
  29. % this study is available at the following link:
  30. % https://doi.org/10.5281/zenodo.18231506
  31. %
  32. % PLEASE NOTE
  33. % The current BROADNESS pipeline takes as input a single 4D matrix that
  34. % includes all individual participants’ data, computes the group average
  35. % for network estimation (using PCA), and then outputs the corresponding
  36. % time series for each participant for statistical analysis.
  37. % The spatial activation patterns of the networks is instead provided for
  38. % the group level.
  39. % Note that if you wish to only have a quick computation on the data averaged across
  40. % participants, you can use the data averaged across participants available
  41. % in "MMNSubtracted_Average_SignFixed.mat".
  42. %
  43. % ========================================================================
  44. %
  45. % The script loads the MEG dataset into the MATLAB workspace and
  46. % calls four core functions plus a start-up:
  47. %
  48. % ------------------------------------------------------------------------
  49. % FUNCTIONS OVERVIEW:
  50. % ------------------------------------------------------------------------
  51. % - 0) BROADNESS_Startup()
  52. % Initializes the environment.
  53. %
  54. % - 1) BROADNESS_NetworkEstimation()
  55. % Performs PCA on the event-related field/potentials to derive the
  56. % underlying brain networks.
  57. %
  58. % - 2) BROADNESS_Visualizer()
  59. % Takes as input selected outputs from `BROADNESS_NetworkEstimation`
  60. % to visualize a number of features such as brain network time series
  61. % and topographies.
  62. %
  63. % - 3) BROADNESS_PhaseSpace_RQA()
  64. % Computes phase space embedding and RQA on brain networks time
  65. % series [it works for both the time series extracted by
  66. % BROADNESS_NetworkEstimation (PCA) and BROADNESS_AlternativeNetworkEstimation_ICA (ICA)]
  67. %
  68. % - 4) BROADNESS_SpatialGradients()
  69. % Computes spatial gradients embedding and clustering on the spatial
  70. % activation patterns of the brain networks
  71. % -------------------------------------------------------------------------
  72. % AUTHOR OF THIS SCRIPT:
  73. % Mathias Houe Andersen
  74. % [email hidden]
  75. % Danish Research Centre for Magnetic Resonance, Hvidovre University
  76. % Hospital
  77. % Faculty of Health and Medical Sciences, University of Copenhagen,
  78. % Copenhagen, Denmark.
  79. % Updated version 28/01/2026
  80. % ========================================================================
  81. % ------------------------------------------------------------------------
  82. % AUTHORS OF THE BROADNESS TOOLBOX:
  83. % Leonardo Bonetti, Chiara Malvaso, Mathias Houe Andersen & Mattia Rosso
  84. % [email hidden]; [email hidden]
  85. % [email hidden]
  86. % [email hidden]
  87. % [email hidden]
  88. % Center for Music in the Brain, Aarhus University
  89. % Centre for Eudaimonia and Human Flourishing, Linacre College, University of Oxford
  90. % Danish Research Centre for Magnetic Resonance, Hvidovre University
  91. % Hospital
  92. % Faculty of Health and Medical Sciences, University of Copenhagen
  93. % Department of Physics, University of Bologna
  94. % Aarhus (DK), Oxford (UK), Copenhagen (DK), Bologna (Italy)
  95. % ========================================================================
  96. %% 0) STARTUP
  97. % Simply download the BROADNESS Toolbox folder and place it in the working directory,
  98. % Make sure not to alter the structure of its functions, subfolders, or files.
  99. clear
  100. close all
  101. clc
  102. % Setup directories
  103. path_home = '/mainpath/BROADNESS_MEG_AuditoryRecognition-main/BROADNESS_Toolbox';
  104. addpath(path_home)
  105. BROADNESS_Startup(path_home);
  106. addpath(fullfile(matlabroot,'toolbox','stats','stats'),'-begin') % makes sure the pca function is the standard function in matlab
  107. %%
  108. %% 1a) PERFORM BROADNESS
  109. %%% ------------------- USER SETTINGS ------------------- %%%
  110. % Load data
  111. load('/mainpath/MMNSubtracted_Average_SignFixed.mat');
  112. DATA = dum;
  113. % Remove first participant and define time vector with a 100 ms baseline
  114. time = -0.100:0.004:0.8;
  115. DATA = DATA(:,101:326,:,2:78);
  116. size(DATA)
  117. % Load groups and adjust subid after removing first participant
  118. load('/mainpath/groups.mat');
  119. older = older - 1;
  120. young = young - 1;
  121. older_subj = older(:); % row-wise versions
  122. young_subj = young(:);
  123. %%% ------------------ COMPUTATION --------------------- %%%
  124. % Run BROADNESS network estimation (default parameters)
  125. BROADNESS = BROADNESS_NetworkEstimation(DATA, time);
  126. %% 1b) Split data into 4 different groups
  127. cond_global = 1;
  128. cond_local = 2;
  129. % Copy the struct
  130. BROADNESS_young_global = BROADNESS;
  131. BROADNESS_young_local = BROADNESS;
  132. BROADNESS_older_global = BROADNESS;
  133. BROADNESS_older_local = BROADNESS;
  134. % Slice TimeSeries
  135. BROADNESS_young_global.TimeSeries_BrainNetworks = BROADNESS.TimeSeries_BrainNetworks(:, :, cond_global, young_subj);
  136. BROADNESS_young_local.TimeSeries_BrainNetworks = BROADNESS.TimeSeries_BrainNetworks(:, :, cond_local, young_subj);
  137. BROADNESS_older_global.TimeSeries_BrainNetworks = BROADNESS.TimeSeries_BrainNetworks(:, :, cond_global, older_subj);
  138. BROADNESS_older_local.TimeSeries_BrainNetworks = BROADNESS.TimeSeries_BrainNetworks(:, :, cond_local, older_subj);
  139. % Slice OriginalData to keep dimensions consistent
  140. BROADNESS_young_global.OriginalData = BROADNESS.OriginalData(:, :, cond_global, young_subj);
  141. BROADNESS_young_local.OriginalData = BROADNESS.OriginalData(:, :, cond_local, young_subj);
  142. BROADNESS_older_global.OriginalData = BROADNESS.OriginalData(:, :, cond_global, older_subj);
  143. BROADNESS_older_local.OriginalData = BROADNESS.OriginalData(:, :, cond_local, older_subj);
  144. % Sanity checks by displaying sizes
  145. assert(size(BROADNESS_young_local.TimeSeries_BrainNetworks,3) == size(BROADNESS_young_local.OriginalData,3), 'cond mismatch');
  146. assert(size(BROADNESS_young_local.TimeSeries_BrainNetworks,4) == size(BROADNESS_young_local.OriginalData,4), 'subj mismatch');
  147. % Display sizes
  148. disp(size(BROADNESS.TimeSeries_BrainNetworks))
  149. disp(size(BROADNESS_young_global.TimeSeries_BrainNetworks))
  150. disp(size(BROADNESS_young_local.TimeSeries_BrainNetworks))
  151. disp(size(BROADNESS_older_global.TimeSeries_BrainNetworks))
  152. disp(size(BROADNESS_older_local.TimeSeries_BrainNetworks))
  153. %% 1c) %% Calculate ED on BROADNESS, PCA applied to all data
  154. % Assumes:
  155. % BROADNESS.Variance_BrainNetworks : 225 x 1 double
  156. % Extract eigenvalue / variance vector
  157. lam = BROADNESS.Variance_BrainNetworks(:);
  158. % Enforce non-negativity
  159. lam(lam < 0) = 0;
  160. % Compute effective dimensionality
  161. ED = (sum(lam)^2) / max(sum(lam.^2), realmin);
  162. % ---- Sanity checks ----
  163. fprintf('Effective Dimensionality (ED): %.3f\n', ED);
  164. % Theoretical bounds
  165. nComp = numel(lam);
  166. if ED < 1 || ED > nComp
  167. warning('ED = %.3f outside theoretical bounds [1, %d]. Check eigenvalues.', ED, nComp);
  168. end
  169. % Effective Dimensionality (ED) = 2.835
  170. %% 1d) Plot variance explained
  171. % Plot variance explained for first 10 PCs from BROADNESS.Variance_BrainNetworks
  172. % Styling:
  173. % - dark purple line + markers
  174. % - black dotted vertical line at x = 2.835
  175. % - Helvetica everywhere
  176. % - boxed legend inside plot
  177. % -----------------------------
  178. % Data prep (robust to row/col)
  179. % -----------------------------
  180. % -----------------------------
  181. % Styling controls
  182. % -----------------------------
  183. legendFontSize = 10; % resize legend text
  184. gridAlpha = 0.15; % transparency of grid lines (0–1)
  185. gridColor = [0 0 0]; % grid color (black, light via alpha)
  186. ve = BROADNESS.Variance_BrainNetworks(:); % force column vector
  187. nPC = min(10, numel(ve));
  188. x = 1:nPC;
  189. y = ve(1:nPC);
  190. % If values are proportions and one wants percentages:
  191. % y = 100*y;
  192. % -----------------------------
  193. % Define dark purple color
  194. % -----------------------------
  195. darkPurple = [88 24 124] / 255; % perceptually dark, print-safe purple
  196. % -----------------------------
  197. % Figure + axes (Helvetica)
  198. % -----------------------------
  199. fig = figure('Color','w');
  200. ax = axes('Parent',fig);
  201. hold(ax,'on');
  202. set(fig, 'DefaultTextFontName','Helvetica');
  203. set(fig, 'DefaultAxesFontName','Helvetica');
  204. % -----------------------------
  205. % Plot variance explained
  206. % -----------------------------
  207. grid(ax,'on');
  208. ax.GridLineStyle = '-';
  209. ax.GridAlpha = gridAlpha;
  210. ax.GridColor = gridColor;
  211. % Optional: subtle minor grid (often too busy—use cautiously)
  212. % ax.MinorGridLineStyle = ':';
  213. % ax.MinorGridAlpha = gridAlpha * 0.7;
  214. % ax.XMinorGrid = 'on';
  215. % ax.YMinorGrid = 'on';
  216. hVar = plot(ax, x, y, '-o', ...
  217. 'Color', darkPurple, ...
  218. 'MarkerFaceColor', darkPurple, ...
  219. 'MarkerEdgeColor', darkPurple, ...
  220. 'LineWidth', 1.8, ...
  221. 'MarkerSize', 6, ...
  222. 'DisplayName', 'Variance Explained');
  223. % -----------------------------
  224. % Vertical dotted line
  225. % -----------------------------
  226. xEff = 2.835;
  227. hEff = xline(ax, xEff, ':k', ...
  228. 'LineWidth', 1.5, ...
  229. 'DisplayName', 'Effective Dimensionality');
  230. % -----------------------------
  231. % Labels / axes styling
  232. % -----------------------------
  233. xlabel(ax, 'Principal Component', 'FontName','Helvetica');
  234. ylabel(ax, 'Variance Explained', 'FontName','Helvetica');
  235. xlim(ax, [0.5, nPC + 0.5]);
  236. xticks(ax, 1:nPC);
  237. set(ax, ...
  238. 'FontName','Helvetica', ...
  239. 'Box','on', ...
  240. 'LineWidth', 1.0, ...
  241. 'TickDir','out');
  242. % Optional y-limits
  243. % ylim(ax, [min(0, min(y)*0.95), max(y)*1.05]);
  244. % -----------------------------
  245. % Legend (boxed, inside plot)
  246. % -----------------------------
  247. lgd = legend(ax, hEff, 'Effective Dimensionality', ...
  248. 'Location','northeast');
  249. set(lgd, ...
  250. 'Box','on', ...
  251. 'FontName','Helvetica', ...
  252. 'FontSize', legendFontSize, ...
  253. 'ItemTokenSize',[12 8]); % controls marker/line spacing
  254. hold(ax,'off');
  255. %% 2a) BROADNESS VISUALIZATION
  256. % NOTE: Either 2a) or 2b) should run, as 2b) is simply an extended version
  257. % of 2a) with additional settings.
  258. % This section generates 5 plots, described as follows:
  259. % #1) Dynamic brain activity map of the original data
  260. % #2) Variance explained by the networks
  261. % #3) Time series of the networks
  262. % #4) Activation patterns of the networks (3D)
  263. % #5) Activation patterns of the networks (nifti images)
  264. %%% ------------------- USER SETTINGS ------------------- %%%
  265. % Common options
  266. Options = [];
  267. Options.name_nii = '/mainpath/Output';
  268. load([path_home '/BROADNESS_External/MNI152_8mm_coord_dyi.mat']);
  269. Options.MNI_coords = MNI8;
  270. % Run each of the 5 lines below in seperate
  271. % All data with label
  272. % Options.Labels = {'All data'}; BROADNESS_Visualizer(BROADNESS, Options);
  273. % 4 Groups with different labels
  274. Options.Labels = {'Younger adults - Local effect'}; BROADNESS_Visualizer(BROADNESS_young_local, Options);
  275. %Options.Labels = {'Older adults - Local effect'}; BROADNESS_Visualizer(BROADNESS_older_local, Options);
  276. %Options.Labels = {'Younger adults - Global effect'}; BROADNESS_Visualizer(BROADNESS_young_global, Options);
  277. %Options.Labels = {'Older adults - Global effect'}; BROADNESS_Visualizer(BROADNESS_older_global, Options);
  278. %% 2b) BROADNESS VISUALIZATION (ALTERNATIVE SCENARIO WITH OPTIONAL INPUTS)
  279. % This section demonstrates the same function as above,
  280. % but with optional settings provided. Any missing arguments
  281. % will automatically use their default values.
  282. %%% ------------------- USER SETTINGS ------------------- %%%
  283. % Minimal user settings: output folder
  284. Options = [];
  285. Options.name_nii = '/mainpath/Output'; %output folder
  286. load([path_home '/BROADNESS_External/MNI152_8mm_coord_dyi.mat']); %all voxels MNI coordinates
  287. Options.MNI_coords = MNI8;
  288. Options.WhichPlots = [1 1 1 1 1]; %which plots to be generated
  289. Options.ncomps = [1 2 3]; %indices of PCs to be plotted (all plots)
  290. Options.ncomps_var = 60; %number of PCs to be plotted (only in Variance plot)
  291. Options.Labels = {'Global', 'Local'}; %experimental condition labels
  292. Options.color_PCs = [
  293. 0.4, 0.761, 0.647; % teal-green
  294. 0.988, 0.553, 0.384; % coral
  295. 0.553, 0.627, 0.796; % periwinkle
  296. 0.906, 0.541, 0.765; % pink-purple
  297. 0.651, 0.847, 0.329; % green
  298. 1.000, 0.851, 0.184; % yellow
  299. 0.898, 0.769, 0.580; % beige
  300. 0.702, 0.702, 0.702 % gray
  301. ]; %RGB code colors for PCs
  302. Options.color_conds = [
  303. 0.106, 0.620, 0.467; % green
  304. 0.851, 0.373, 0.008; % orange
  305. 0.459, 0.439, 0.702; % purple
  306. 0.906, 0.161, 0.541; % magenta
  307. 0.400, 0.651, 0.118; % lime green
  308. 0.902, 0.671, 0.008; % gold
  309. 0.651, 0.463, 0.114; % brown
  310. 0.4, 0.4, 0.4 % gray
  311. ]; %RGB code colors for experimental conditions
  312. % If one wishes to remove the cerebellum voxels (not included in 3D brain template (#4)), please set 'remove_cerebellum_label' to 1
  313. % NOTE: This removal works only for 8mm brain
  314. remove_cerebellum_label = 0;
  315. if remove_cerebellum_label == 1
  316. load([path_home '/BROADNESS_External/cerebellum_coords.mat']); %only cerebellar voxels
  317. % Remove cerebellar voxels since they are not included in the 3D brain template (#4)
  318. [~, idx_cerebellum] = ismember(MNI8, cerebellum_coords, 'rows'); % find cerebellum indexes in MNI coordinates matrix (all voxels)
  319. MNI8(idx_cerebellum~=0,:) = nan; %assigning nans to MNI coordinates matrix
  320. Options.MNI_coords = MNI8; %assigning the MNI coordinates of the data for visualization purposes (both 3D main template (#4) and nifti images (#5))
  321. end
  322. %%% ------------------ COMPUTATION --------------------- %%%
  323. % Run each of the 5 lines below in seperate
  324. % Visualize brain networks features
  325. BROADNESS_Visualizer(BROADNESS,Options)
  326. %Options.Labels = {'Younger adults - Local effect'}; BROADNESS_Visualizer(BROADNESS_young_local, Options);
  327. %Options.Labels = {'Older adults - Local effect'}; BROADNESS_Visualizer(BROADNESS_older_local, Options);
  328. %Options.Labels = {'Younger adults - Global effect'}; BROADNESS_Visualizer(BROADNESS_young_global, Options);
  329. %Options.Labels = {'Older adults - Global effect'}; BROADNESS_Visualizer(BROADNESS_older_global, Options);
  330. %% 2c) - Convert MNI coords into brain labels for positively and negatively contributing voxels independently
  331. % For each network ActivationTable_*.xlsx:
  332. % 1) split voxels into positive vs negative contributions
  333. % 2) map each voxel's MNI (mm) coordinate to an atlas label
  334. % 3) count voxels per label and output sorted lists
  335. % 4) ALSO: if a voxel lands on background (0) or unlabeled, assign it the
  336. % nearest label within a growing voxel-radius (0..maxSearchRadius)
  337. % 5) Output per-area counts split by the radius used (exact, 1-away, 2-away, ...)
  338. %
  339. % IMPORTANT NOTES / LIMITATIONS (stress-test):
  340. % - This forces a label by snapping to the nearest non-zero atlas label within a voxel radius.
  341. % - Distance here is in *atlas voxel units* using a Chebyshev “ring radius”
  342. % (max(|dx|,|dy|,|dz|)). This matches cubic neighborhood shells:
  343. % r=1 -> 26-neighborhood shell, r=2 -> 5x5x5 shell boundary, etc.
  344. % - If many voxels snap at large radii, interpret with caution: you’re labeling
  345. % “nearest parcel”, not necessarily the true anatomical assignment.
  346. %
  347. % FIXES INCLUDED:
  348. % - Robust LUT parser for AAL-style .txt files with lines like: "1 Precentral_L 1"
  349. % (handles arbitrary whitespace and even missing last numeric id)
  350. % - Fixed operator precedence bug in guessMNIColumns()
  351. clear; clc;
  352. % ----------------------- USER SETTINGS -----------------------
  353. networkFiles = {
  354. '/mainpath/Output/BROADNESS_Output/BROADNESS_nifti/ActivationTable_BrainNetwork_1.xlsx'
  355. '/mainpath/Output/BROADNESS_Output/BROADNESS_nifti/ActivationTable_BrainNetwork_2.xlsx'
  356. '/mainpath/Output/BROADNESS_Output/BROADNESS_nifti/ActivationTable_BrainNetwork_3.xlsx'
  357. };
  358. % Choose an atlas in MNI space (NIfTI) + a label lookup (CSV/TXT).
  359. atlasNiftiPath = '/mainpath/AAL3/AAL3v1.nii';
  360. atlasLabelsPath = '/mainpath/AAL3/AAL3v1.nii.txt';
  361. % Otherwise leave as "" to let the script guess.
  362. contributionColumnOverride = ""; % e.g., "Contribution", "Weight", "Loading", "Beta", "Z", etc.
  363. % What to do with atlas voxels that map to 0 / background / unknown
  364. unknownLabelName = "Unknown/Background";
  365. % How far (in ATLAS VOXELS) to search for nearest non-zero label
  366. maxSearchRadius = 3; % 0 = exact only; 1 = allow shell radius 1; etc.
  367. % Output folder (will be created if missing)
  368. outDir = fullfile('/mainpath/', 'voxel_counts_by_area');
  369. if ~exist(outDir, 'dir'); mkdir(outDir); end
  370. % ------------------------------------------------------------
  371. % Load atlas volume
  372. assert(exist(atlasNiftiPath, 'file')==2, 'Atlas NIfTI not found: %s', atlasNiftiPath);
  373. V = spm_vol(atlasNiftiPath);
  374. A = spm_read_vols(V);
  375. % Load atlas labels LUT (robust for AAL-style txt)
  376. lut = loadAtlasLUT(atlasLabelsPath, unknownLabelName);
  377. % Process each network file
  378. for iF = 1:numel(networkFiles)
  379. xlsxPath = networkFiles{iF};
  380. assert(exist(xlsxPath, 'file')==2, 'Network table not found: %s', xlsxPath);
  381. T = readtable(xlsxPath);
  382. % Find MNI coordinate columns + contribution column
  383. [xCol, yCol, zCol] = guessMNIColumns(T);
  384. contribCol = guessContributionColumn(T, xCol, yCol, zCol, contributionColumnOverride);
  385. XYZ = [T.(xCol), T.(yCol), T.(zCol)];
  386. contrib = T.(contribCol);
  387. % Basic sanity checks
  388. if ~isnumeric(XYZ) || size(XYZ,2)~=3
  389. error('MNI columns are not numeric 3D coords. Detected columns: %s, %s, %s', xCol, yCol, zCol);
  390. end
  391. if ~isnumeric(contrib)
  392. error('Contribution column is not numeric. Detected column: %s', contribCol);
  393. end
  394. % Map each MNI coord -> atlas label (WITH radius-based snapping)
  395. [labels, usedRadius] = mniToAtlasLabelWithSnap(XYZ, V, A, lut, unknownLabelName, maxSearchRadius);
  396. % Split positive/negative (zero excluded)
  397. posMask = contrib > 0;
  398. negMask = contrib < 0;
  399. % Count labels by distance radius used
  400. posCountsByR = countLabelsByRadius(labels(posMask), usedRadius(posMask), maxSearchRadius);
  401. negCountsByR = countLabelsByRadius(labels(negMask), usedRadius(negMask), maxSearchRadius);
  402. % Convert to tables (Area + Dist0..DistR + Total)
  403. posTableByR = countsByRadiusToTable(posCountsByR, maxSearchRadius, "Area");
  404. negTableByR = countsByRadiusToTable(negCountsByR, maxSearchRadius, "Area");
  405. % Also keep the old simple totals (optional)
  406. posSimple = countLabels(labels(posMask));
  407. negSimple = countLabels(labels(negMask));
  408. posTableSimple = countsToTable(posSimple, "Area", "VoxelCount");
  409. negTableSimple = countsToTable(negSimple, "Area", "VoxelCount");
  410. % Summary: how many voxels labeled at each radius overall (not per area)
  411. posRadiusSummary = radiusSummaryTable(usedRadius(posMask), maxSearchRadius, "Positive");
  412. negRadiusSummary = radiusSummaryTable(usedRadius(negMask), maxSearchRadius, "Negative");
  413. % Save results
  414. [~, baseName, ~] = fileparts(xlsxPath);
  415. outXlsx = fullfile(outDir, sprintf('%s_voxelCountsByArea.xlsx', baseName));
  416. writetable(posTableByR, outXlsx, 'Sheet', 'Positive_ByRadius', 'WriteMode', 'overwritesheet');
  417. writetable(negTableByR, outXlsx, 'Sheet', 'Negative_ByRadius', 'WriteMode', 'overwritesheet');
  418. writetable(posRadiusSummary, outXlsx, 'Sheet', 'Positive_RadiusSummary', 'WriteMode', 'overwritesheet');
  419. writetable(negRadiusSummary, outXlsx, 'Sheet', 'Negative_RadiusSummary', 'WriteMode', 'overwritesheet');
  420. % Optional: keep simple totals as additional sheets
  421. writetable(posTableSimple, outXlsx, 'Sheet', 'Positive_SimpleTotals', 'WriteMode', 'overwritesheet');
  422. writetable(negTableSimple, outXlsx, 'Sheet', 'Negative_SimpleTotals', 'WriteMode', 'overwritesheet');
  423. % Print key info
  424. fprintf('\n=== %s ===\n', baseName);
  425. fprintf('MNI columns: %s %s %s | Contribution: %s\n', xCol, yCol, zCol, contribCol);
  426. fprintf('Atlas snapping max radius: %d voxels\n', maxSearchRadius);
  427. fprintf('\n+ Positive radius summary\n');
  428. disp(posRadiusSummary);
  429. fprintf('\n- Negative radius summary\n');
  430. disp(negRadiusSummary);
  431. fprintf('\n+ Positive areas by radius (top 15 by Total)\n');
  432. disp(posTableByR(1:min(15,height(posTableByR)),:));
  433. fprintf('\n- Negative areas by radius (top 15 by Total)\n');
  434. disp(negTableByR(1:min(15,height(negTableByR)),:));
  435. end
  436. fprintf('\nDone. Outputs saved to: %s\n', outDir);
  437. %% ----------------------- FUNCTIONS -----------------------
  438. function lut = loadAtlasLUT(labelsPath, unknownLabelName)
  439. % Robust LUT loader that supports:
  440. % 1) CSV with headers (id + name/label)
  441. % 2) AAL-style text where each line is whitespace-delimited, e.g.:
  442. % 1 Precentral_L 1
  443. % 2 Precentral_R 2
  444. % and possibly some lines missing the final numeric id:
  445. % 35 Cingulate_Ant_L
  446. assert(exist(labelsPath,'file')==2, 'Atlas label file not found: %s', labelsPath);
  447. [~,~,ext] = fileparts(labelsPath);
  448. if strcmpi(ext,'.csv')
  449. L = readtable(labelsPath, 'TextType','string');
  450. varNames = lower(string(L.Properties.VariableNames));
  451. idIdx = find(contains(varNames,"id") | contains(varNames,"index") | contains(varNames,"value"), 1);
  452. nameIdx = find(contains(varNames,"name") | contains(varNames,"label") | contains(varNames,"region") | contains(varNames,"structure"), 1);
  453. if isempty(idIdx) || isempty(nameIdx)
  454. error('Could not identify id/name columns in CSV LUT. Need columns like id,name.');
  455. end
  456. ids = L{:, idIdx};
  457. names = L{:, nameIdx};
  458. if ~isnumeric(ids)
  459. ids = str2double(string(ids));
  460. end
  461. if any(isnan(ids))
  462. error('Atlas LUT ids contain NaNs after parsing. Check the CSV LUT format.');
  463. end
  464. lut = containers.Map('KeyType','double','ValueType','char');
  465. for k = 1:numel(ids)
  466. lut(double(ids(k))) = char(names(k));
  467. end
  468. if ~isKey(lut, 0)
  469. lut(0) = char(unknownLabelName);
  470. end
  471. return;
  472. end
  473. lines = readlines(labelsPath);
  474. lut = containers.Map('KeyType','double','ValueType','char');
  475. for i = 1:numel(lines)
  476. s = strtrim(lines(i));
  477. if strlength(s)==0
  478. continue;
  479. end
  480. parts = regexp(s, '\s+', 'split');
  481. if numel(parts) < 2
  482. continue;
  483. end
  484. id1 = str2double(parts{1});
  485. if isnan(id1)
  486. continue;
  487. end
  488. idLast = str2double(parts{end});
  489. if ~isnan(idLast) && numel(parts) >= 3
  490. atlasId = idLast;
  491. nameTokens = parts(2:end-1);
  492. else
  493. atlasId = id1;
  494. nameTokens = parts(2:end);
  495. end
  496. if isempty(nameTokens)
  497. continue;
  498. end
  499. regionName = strjoin(nameTokens, ' ');
  500. lut(double(atlasId)) = char(regionName);
  501. end
  502. if ~isKey(lut, 0)
  503. lut(0) = char(unknownLabelName);
  504. end
  505. if lut.Count <= 1
  506. warning('Atlas LUT parsing produced very few entries. Check LUT file formatting: %s', labelsPath);
  507. end
  508. end
  509. function [xCol, yCol, zCol] = guessMNIColumns(T)
  510. vn = string(T.Properties.VariableNames);
  511. vnl = lower(vn);
  512. xCandidates = vn( ((contains(vnl,"mni") & contains(vnl,"x"))) | strcmp(vnl,"x") | endsWith(vnl,"_x") );
  513. yCandidates = vn( ((contains(vnl,"mni") & contains(vnl,"y"))) | strcmp(vnl,"y") | endsWith(vnl,"_y") );
  514. zCandidates = vn( ((contains(vnl,"mni") & contains(vnl,"z"))) | strcmp(vnl,"z") | endsWith(vnl,"_z") );
  515. if isempty(xCandidates), xCandidates = vn(strcmp(vnl,"x") | contains(vnl,"coordx") | contains(vnl,"x_mm") | contains(vnl,"xmm")); end
  516. if isempty(yCandidates), yCandidates = vn(strcmp(vnl,"y") | contains(vnl,"coordy") | contains(vnl,"y_mm") | contains(vnl,"ymm")); end
  517. if isempty(zCandidates), zCandidates = vn(strcmp(vnl,"z") | contains(vnl,"coordz") | contains(vnl,"z_mm") | contains(vnl,"zmm")); end
  518. if isempty(xCandidates) || isempty(yCandidates) || isempty(zCandidates)
  519. numMask = varfun(@isnumeric, T, 'OutputFormat','uniform');
  520. numCols = vn(numMask);
  521. if numel(numCols) < 3
  522. error('Could not find MNI columns. No obvious X/Y/Z or >=3 numeric columns.');
  523. end
  524. xCol = numCols(1); yCol = numCols(2); zCol = numCols(3);
  525. return;
  526. end
  527. xCol = xCandidates(1);
  528. yCol = yCandidates(1);
  529. zCol = zCandidates(1);
  530. end
  531. function contribCol = guessContributionColumn(T, xCol, yCol, zCol, override)
  532. if strlength(override) > 0
  533. assert(any(strcmp(string(T.Properties.VariableNames), override)), ...
  534. 'Override contribution column not found: %s', override);
  535. contribCol = override;
  536. return;
  537. end
  538. vn = string(T.Properties.VariableNames);
  539. vnl = lower(vn);
  540. exclude = ismember(vn, [xCol,yCol,zCol]);
  541. preferred = contains(vnl,"contrib") | contains(vnl,"weight") | contains(vnl,"loading") | ...
  542. contains(vnl,"beta") | strcmp(vnl,"z") | contains(vnl,"zscore") | ...
  543. contains(vnl,"t") | contains(vnl,"stat") | contains(vnl,"value") | contains(vnl,"coef");
  544. idx = find(preferred & ~exclude, 1);
  545. if ~isempty(idx)
  546. contribCol = vn(idx);
  547. return;
  548. end
  549. numMask = varfun(@isnumeric, T, 'OutputFormat','uniform') & ~exclude;
  550. numCols = vn(numMask);
  551. if isempty(numCols)
  552. error('No numeric column found for contribution (besides coordinates).');
  553. end
  554. vars = zeros(numel(numCols),1);
  555. for k = 1:numel(numCols)
  556. x = T.(numCols(k));
  557. vars(k) = var(x(~isnan(x)));
  558. end
  559. [~,kmax] = max(vars);
  560. contribCol = numCols(kmax);
  561. end
  562. function [labels, usedRadius] = mniToAtlasLabelWithSnap(XYZmm, V, A, lut, unknownLabelName, maxR)
  563. % Returns:
  564. % labels: assigned region name for each coordinate
  565. % usedRadius:
  566. % 0..maxR = snapped using that voxel shell radius (0 means exact voxel non-zero label)
  567. % -1 = could not find any non-zero label within maxR or out-of-bounds
  568. n = size(XYZmm,1);
  569. labels = strings(n,1);
  570. usedRadius = -1 * ones(n,1);
  571. M = V.mat; % voxel->mm
  572. iM = inv(M); % mm->voxel
  573. volSize = size(A);
  574. for r = 1:n
  575. mm = [XYZmm(r,:), 1]';
  576. vox = iM * mm;
  577. ijk0 = round(vox(1:3))';
  578. % If out of bounds, keep Unknown
  579. if any(isnan(ijk0)) || any(ijk0 < 1) || ...
  580. ijk0(1) > volSize(1) || ijk0(2) > volSize(2) || ijk0(3) > volSize(3)
  581. labels(r) = unknownLabelName;
  582. usedRadius(r) = -1;
  583. continue;
  584. end
  585. val0 = A(ijk0(1), ijk0(2), ijk0(3));
  586. if isnan(val0); val0 = 0; end
  587. key0 = double(round(val0));
  588. % Exact hit must be non-zero AND in LUT
  589. if key0 ~= 0 && isKey(lut, key0)
  590. labels(r) = string(lut(key0));
  591. usedRadius(r) = 0;
  592. continue;
  593. end
  594. % Otherwise, search shells r=1..maxR for nearest non-zero labels
  595. found = false;
  596. for rad = 1:maxR
  597. keysInShell = collectNonzeroKeysInShell(A, ijk0, rad);
  598. if ~isempty(keysInShell)
  599. % Choose the most frequent label key in this shell (mode)
  600. chosenKey = modeTiebreakSmallest(keysInShell);
  601. if isKey(lut, chosenKey)
  602. labels(r) = string(lut(chosenKey));
  603. else
  604. % If LUT is missing this key (unexpected), mark unknown
  605. labels(r) = unknownLabelName;
  606. end
  607. usedRadius(r) = rad;
  608. found = true;
  609. break;
  610. end
  611. end
  612. if ~found
  613. labels(r) = unknownLabelName;
  614. usedRadius(r) = -1;
  615. end
  616. end
  617. end
  618. function keys = collectNonzeroKeysInShell(A, ijk0, rad)
  619. % Collect non-zero atlas integer keys from the Chebyshev shell at radius rad.
  620. % Shell means max(|dx|,|dy|,|dz|) == rad (not <= rad).
  621. volSize = size(A);
  622. xs = (ijk0(1)-rad):(ijk0(1)+rad);
  623. ys = (ijk0(2)-rad):(ijk0(2)+rad);
  624. zs = (ijk0(3)-rad):(ijk0(3)+rad);
  625. % Clip to bounds
  626. xs = xs(xs>=1 & xs<=volSize(1));
  627. ys = ys(ys>=1 & ys<=volSize(2));
  628. zs = zs(zs>=1 & zs<=volSize(3));
  629. vals = [];
  630. for xi = 1:numel(xs)
  631. for yi = 1:numel(ys)
  632. for zi = 1:numel(zs)
  633. dx = abs(xs(xi) - ijk0(1));
  634. dy = abs(ys(yi) - ijk0(2));
  635. dz = abs(zs(zi) - ijk0(3));
  636. if max([dx,dy,dz]) ~= rad
  637. continue; % not in shell boundary
  638. end
  639. v = A(xs(xi), ys(yi), zs(zi));
  640. if isnan(v); v = 0; end
  641. k = double(round(v));
  642. if k ~= 0
  643. vals(end+1,1) = k; %#ok<AGROW>
  644. end
  645. end
  646. end
  647. end
  648. keys = vals;
  649. end
  650. function chosen = modeTiebreakSmallest(x)
  651. % Returns the most frequent value in x; ties broken by choosing smallest key.
  652. if isempty(x)
  653. chosen = [];
  654. return;
  655. end
  656. ux = unique(x);
  657. counts = zeros(size(ux));
  658. for i = 1:numel(ux)
  659. counts(i) = sum(x == ux(i));
  660. end
  661. maxc = max(counts);
  662. candidates = ux(counts == maxc);
  663. chosen = min(candidates);
  664. end
  665. function countsMap = countLabels(lbls)
  666. countsMap = containers.Map('KeyType','char','ValueType','double');
  667. for i = 1:numel(lbls)
  668. k = char(lbls(i));
  669. if isKey(countsMap, k)
  670. countsMap(k) = countsMap(k) + 1;
  671. else
  672. countsMap(k) = 1;
  673. end
  674. end
  675. end
  676. function countsByR = countLabelsByRadius(lbls, usedRadius, maxR)
  677. % countsByR: containers.Map where each key is an area name (char),
  678. % value is a 1x(maxR+2) vector:
  679. % [dist0, dist1, ..., distMaxR, distUnresolved]
  680. %
  681. % unresolved is usedRadius == -1 (out-of-bounds or no label found within maxR)
  682. assert(numel(lbls) == numel(usedRadius), 'lbls and usedRadius must have same length.');
  683. countsByR = containers.Map('KeyType','char','ValueType','any');
  684. for i = 1:numel(lbls)
  685. area = char(lbls(i));
  686. r = usedRadius(i);
  687. if ~isKey(countsByR, area)
  688. countsByR(area) = zeros(1, (maxR+1) + 1); % dist0..distMaxR plus unresolved
  689. end
  690. vec = countsByR(area);
  691. if r >= 0 && r <= maxR
  692. vec(r+1) = vec(r+1) + 1;
  693. else
  694. % unresolved bucket
  695. vec(end) = vec(end) + 1;
  696. end
  697. countsByR(area) = vec;
  698. end
  699. end
  700. function T = countsByRadiusToTable(countsByR, maxR, areaColName)
  701. % Output table columns:
  702. % Area | Dist0 | Dist1 | ... | DistMaxR | Unresolved | Total
  703. keysC = string(countsByR.keys);
  704. nK = numel(keysC);
  705. distMat = zeros(nK, (maxR+1) + 1); % dist0..distMaxR + Unresolved
  706. for i = 1:nK
  707. v = countsByR(char(keysC(i)));
  708. distMat(i,:) = v(:)';
  709. end
  710. total = sum(distMat, 2);
  711. varNames = strings(1, 1 + (maxR+1) + 1 + 1); % area + dists + unresolved + total
  712. varNames(1) = areaColName;
  713. for r = 0:maxR
  714. varNames(1 + (r+1)) = "Dist" + string(r);
  715. end
  716. varNames(1 + (maxR+1) + 1) = "Unresolved";
  717. varNames(end) = "Total";
  718. T = table(keysC(:), 'VariableNames', varNames(1));
  719. for r = 0:maxR
  720. T.("Dist"+string(r)) = distMat(:, r+1);
  721. end
  722. T.Unresolved = distMat(:, end);
  723. T.Total = total;
  724. T = sortrows(T, "Total", "descend");
  725. end
  726. function T = radiusSummaryTable(usedRadius, maxR, labelPrefix)
  727. % Overall counts by radius (how many voxels were exact vs snapped by r=1..maxR vs unresolved)
  728. % Columns: Type | Radius | Count
  729. radii = [0:maxR, -1];
  730. counts = zeros(size(radii));
  731. for i = 1:numel(radii)
  732. rr = radii(i);
  733. counts(i) = sum(usedRadius == rr);
  734. end
  735. radiusLabel = strings(numel(radii),1);
  736. for i = 1:numel(radii)
  737. if radii(i) == -1
  738. radiusLabel(i) = "Unresolved";
  739. else
  740. radiusLabel(i) = "Dist" + string(radii(i));
  741. end
  742. end
  743. Type = repmat(string(labelPrefix), numel(radii), 1);
  744. T = table(Type, radiusLabel, counts(:), 'VariableNames', ["Type","Radius","Count"]);
  745. end
  746. function T = countsToTable(countsMap, nameCol, countCol)
  747. keysC = string(countsMap.keys);
  748. valsC = cell2mat(countsMap.values);
  749. T = table(keysC(:), valsC(:), 'VariableNames',[nameCol, countCol]);
  750. T = sortrows(T, countCol, 'descend');
  751. end
  752. %% 2d) TIMESERIES ANALYSIS: Statistics — Timepoint-wise mixed ANOVA via CLUSTER-BASED permutation (over time)
  753. % We test whether the time courses of a single principal component (PC) differ:
  754. % - between age groups (Young vs Old) --> "Group main effect"
  755. % - between conditions (Global vs Local) within subjects --> "Condition main effect"
  756. % - in their difference across groups --> "Interaction"
  757. %
  758. % We do this separately for PC = 1, 2, 3 in the 0–0.8 s time window.
  759. % Significance is controlled with a cluster-based permutation test:
  760. % 1) Form clusters of adjacent time samples whose effect size exceeds a
  761. % "cluster-forming threshold" (CFT).
  762. % 2) For each permutation, compute the maximum cluster "mass" (sum of |effect| in cluster).
  763. % 3) An observed cluster is significant if its mass exceeds the (1 - alpha) quantile
  764. % of that max-mass null distribution (FWER control).
  765. %
  766. % Inputs expected in the workspace (each is a struct with a 4D array):
  767. % BROADNESS_young_local .TimeSeries_BrainNetworks : [time x PC x cond x subj]
  768. % BROADNESS_young_global.TimeSeries_BrainNetworks : [time x PC x cond x subj]
  769. % BROADNESS_older_local .TimeSeries_BrainNetworks : [time x PC x cond x subj]
  770. % BROADNESS_older_global.TimeSeries_BrainNetworks : [time x PC x cond x subj]
  771. % NOTE: We only use cond index 1 (time series for this PC), so effectively [time x PC x 1 x subj].
  772. %
  773. % Also expected:
  774. % time : vector of time stamps in seconds (must cover 0–0.8 s)
  775. %
  776. % Outputs (per PC):
  777. % A .mat file with RES struct containing observed effects, thresholds, significant masks,
  778. % cluster lists, and convenience metadata; plus several diagnostic PNG plots.
  779. stats_outdir = '/mainpath/Output/ANOVA_Stats';
  780. if ~exist(stats_outdir,'dir'); mkdir(stats_outdir); end % ensure output folder exists
  781. % ---- Parameters ----
  782. alpha = 0.05; % family-wise error rate target for clusters (final significance level)
  783. cluster_alpha = 0.05; % per-sample cluster-forming threshold (liberal; used only to build clusters)
  784. n_perm = 5000; % number of permutations for null estimation (increase = more stable, slower)
  785. pcs_to_use = 1:3; % PCs to analyze in this script
  786. % ---- Determine dims: we expect [time x PC x cond x subj] ----
  787. sz = size(BROADNESS_young_local.TimeSeries_BrainNetworks);
  788. nT_bn = sz(1); % number of time samples available in the data (from "broadness" arrays)
  789. nPC = sz(2); % number of principal components available
  790. % ---- align external 'time' vector to nT_bn ----
  791. % We need a time vector aligned to the first dimension of the data arrays.
  792. if ~exist('time','var') || isempty(time)
  793. error('Variable "time" not found. Define the time vector in seconds.');
  794. end
  795. if numel(time) < nT_bn
  796. error('Provided time vector (%d) is shorter than BROADNESS time dimension (%d).', numel(time), nT_bn);
  797. end
  798. time_use = time(1:nT_bn); % trim any extra time points just in case
  799. % ---- analysis window ----
  800. % Restrict the analysis to [0, 0.8] seconds, as specified in the design.
  801. t_mask = (time_use >= 0) & (time_use <= 0.8);
  802. if ~any(t_mask)
  803. error('No timepoints within [0, 0.8] s after alignment.');
  804. end
  805. t_idx = find(t_mask); % indices of the selected time points
  806. t_win = time_use(t_idx); % time stamps used in the stats
  807. Twin = numel(t_idx); % number of samples in the window
  808. dt_win = median(diff(t_win));% time step (used for info and plotting titles)
  809. % ---- PC availability check ----
  810. if max(pcs_to_use) > nPC
  811. error('Requested PC index exceeds available PCs (%d).', nPC);
  812. end
  813. % ===================== MAIN LOOP OVER PCs =====================
  814. for pc = pcs_to_use
  815. % ---- Extract subject x time matrices for this PC ----
  816. % We want matrices with rows = subjects, columns = time.
  817. % squeeze(... )' transposes [time x subj] -> [subj x time].
  818. % Note: we use cond index 1 (the only condition dim used in these arrays).
  819. YL = squeeze(BROADNESS_young_local. TimeSeries_BrainNetworks(t_idx, pc, 1, :))'; % [N_younger x T]
  820. YG = squeeze(BROADNESS_young_global.TimeSeries_BrainNetworks(t_idx, pc, 1, :))'; % [N_younger x T]
  821. OL = squeeze(BROADNESS_older_local. TimeSeries_BrainNetworks(t_idx, pc, 1, :))'; % [N_older x T]
  822. OG = squeeze(BROADNESS_older_global.TimeSeries_BrainNetworks(t_idx, pc, 1, :))'; % [N_older x T]
  823. % ---- Defensive shape checks (fail early if misaligned) ----
  824. if any([size(YL,2), size(YG,2), size(OL,2), size(OG,2)] ~= Twin)
  825. error('Time dimension mismatch after slicing for PC %d.', pc);
  826. end
  827. if size(YL,1) ~= size(YG,1)
  828. error('Younger group Local/Global subject count mismatch for PC %d.', pc);
  829. end
  830. if size(OL,1) ~= size(OG,1)
  831. error('Older group Local/Global subject count mismatch for PC %d.', pc);
  832. end
  833. % ---- Compute OBSERVED effects (each is a 1 x T vector) ----
  834. % Group main (between-subject): average Local+Global within each subject, then Young minus Old.
  835. Y_mean = (YL + YG) ./ 2; % [N_younger x T]
  836. O_mean = (OL + OG) ./ 2; % [N_older x T]
  837. obs_G = mean(Y_mean, 1, 'omitnan') - mean(O_mean, 1, 'omitnan'); % 1 x T
  838. % Condition main (within-subject): (Global - Local) pooled across both groups.
  839. D_y = YG - YL; % [N_younger x T] within-subject difference in Young
  840. D_o = OG - OL; % [N_older x T] within-subject difference in Old
  841. D_all = [D_y; D_o]; % [N_total x T] stack both groups
  842. obs_C = mean(D_all, 1, 'omitnan');% 1 x T pooled within-subject effect
  843. % Interaction: "difference of differences" = (Younger (G-L)) - (Older (G-L)).
  844. obs_I = mean(D_y, 1, 'omitnan') - mean(D_o, 1, 'omitnan'); % 1 x T
  845. % ---- Cluster-based permutation tests (each returns a Boolean mask over time, thresholds, and clusters) ----
  846. % Between-subject permutation (reshuffle group labels) for Group main and Interaction:
  847. [sig_G, thr_G, clu_G] = perm_between_time(Y_mean, O_mean, n_perm, cluster_alpha, alpha); % Group main
  848. [sig_I, thr_I, clu_I] = perm_between_time(D_y, D_o, n_perm, cluster_alpha, alpha); % Interaction
  849. % Within-subject permutation (sign flips) for Condition main:
  850. [sig_C, thr_C, clu_C] = perm_within_time(D_all, n_perm, cluster_alpha, alpha); % Condition main
  851. % ---- Convert significant masks into readable time windows [start, end] in seconds ----
  852. % These are contiguous segments of significant samples.
  853. win_G = mask_to_windows(sig_G, t_win);
  854. win_C = mask_to_windows(sig_C, t_win);
  855. win_I = mask_to_windows(sig_I, t_win);
  856. % ---- Save structured results for this PC ----
  857. RES = struct();
  858. RES.PC = pc; % which PC we analyzed
  859. RES.time = t_win; % time support used in the test
  860. RES.dt = dt_win; % median sample spacing
  861. RES.alpha = alpha; % final FWER alpha
  862. RES.cluster_alpha = cluster_alpha; % cluster-forming per-sample alpha
  863. RES.n_perm = n_perm; % number of permutations
  864. RES.obs = struct('Group', obs_G, 'Condition', obs_C, 'Interaction', obs_I); % observed effect curves
  865. RES.thresholds = struct('Group', thr_G, 'Condition', thr_C, 'Interaction', thr_I); % CFT and critical mass
  866. RES.sig = struct('Group', sig_G, 'Condition', sig_C, 'Interaction', sig_I); % significant masks
  867. RES.windows = struct('Group', win_G, 'Condition', win_C, 'Interaction', win_I); % [start,end] windows
  868. RES.N = struct('Young', size(YL,1), 'Old', size(OL,1)); % sample sizes
  869. RES.clusters = struct('Group', clu_G, 'Condition', clu_C, 'Interaction', clu_I); % raw cluster lists
  870. save(fullfile(stats_outdir, sprintf('PC%d_Timewise_ANOVA_ClusterPerm.mat', pc)), 'RES', '-v7.3');
  871. % ---- Quick diagnostic plots (saved per PC) ----
  872. % Each plot draws the observed curve and shades significant clusters to help visual QC.
  873. try
  874. make_effect_plot(t_win, obs_G, sig_G, thr_G, sprintf('PC%d — Group main (Young-Old)', pc), ...
  875. fullfile(stats_outdir, sprintf('PC%d_GroupMain.png', pc)));
  876. make_effect_plot(t_win, obs_C, sig_C, thr_C, sprintf('PC%d — Condition main (Global-Local)', pc), ...
  877. fullfile(stats_outdir, sprintf('PC%d_ConditionMain.png', pc)));
  878. make_effect_plot(t_win, obs_I, sig_I, thr_I, sprintf('PC%d — Interaction (YoungΔ-OldΔ)', pc), ...
  879. fullfile(stats_outdir, sprintf('PC%d_Interaction.png', pc)));
  880. catch ME
  881. warning('Plotting failed for PC %d: %s', pc, ME.message);
  882. end
  883. % ===================== INSERTED PLOTS OF ALL RELEVANT COMPUTED ITEMS =====================
  884. % 1) Raw condition means (YL, YG, OL, OG), with ANY-effect significant windows shaded.
  885. try
  886. fig1 = figure('Visible','off','Color','w','Units','pixels','Position',[100 100 1100 450]);
  887. ax1 = axes(fig1); hold(ax1,'on'); grid(ax1,'on'); box(ax1,'on');
  888. % Group-level mean time courses per condition
  889. muYL = mean(YL,1,'omitnan'); muYG = mean(YG,1,'omitnan');
  890. muOL = mean(OL,1,'omitnan'); muOG = mean(OG,1,'omitnan');
  891. % Colored lines for each condition/group combo
  892. plot(ax1, t_win, muYL, 'LineWidth',1.2, 'Color',[0.10 0.20 0.60]); % Young-Local
  893. plot(ax1, t_win, muYG, 'LineWidth',1.2, 'Color',[0.35 0.70 0.95]); % Young-Global
  894. plot(ax1, t_win, muOL, 'LineWidth',1.2, 'Color',[0.55 0.10 0.70]); % Old-Local
  895. plot(ax1, t_win, muOG, 'LineWidth',1.2, 'Color',[0.95 0.45 0.70]); % Old-Global
  896. yl1 = ylim(ax1);
  897. % Gray shading where ANY of the three effects is significant (union of masks)
  898. sig_any = sig_G | sig_C | sig_I;
  899. shade_clusters_union(ax1, t_win, sig_any, yl1, [0.7 0.7 0.7], 0.12);
  900. title(ax1, sprintf('PC%d — Raw condition means (YL,YG,OL,OG) with significant windows (gray)', pc), 'Interpreter','none');
  901. xlabel(ax1,'Time (s)'); ylabel(ax1,'Activation (a.u.)');
  902. legend(ax1, {'Younger-Local','Younger-Global','Older-Local','Older-Global'}, 'Location','northwest');
  903. print(fig1, fullfile(stats_outdir, sprintf('PC%d_RawConditionMeans.png', pc)), '-dpng','-r200');
  904. close(fig1);
  905. catch ME
  906. warning('Raw means plotting failed for PC %d: %s', pc, ME.message);
  907. end
  908. % 2) Group means (mean of Local+Global) with Group-effect clusters shaded.
  909. try
  910. fig2 = figure('Visible','off','Color','w','Units','pixels','Position',[100 100 1100 450]);
  911. ax2 = axes(fig2); hold(ax2,'on'); grid(ax2,'on'); box(ax2,'on');
  912. plot(ax2, t_win, mean(Y_mean,1,'omitnan'), 'LineWidth',1.5, 'Color',[0.2 0.2 0.8]); % Younger mean
  913. plot(ax2, t_win, mean(O_mean,1,'omitnan'), 'LineWidth',1.5, 'Color',[0.8 0.2 0.2]); % Older mean
  914. yl2 = ylim(ax2);
  915. shade_clusters_union(ax2, t_win, sig_G, yl2, [0.7 0.7 0.7], 0.15);
  916. title(ax2, sprintf('PC%d — Group means (Young vs Old) with Group-effect clusters (gray)', pc), 'Interpreter','none');
  917. xlabel(ax2,'Time (s)'); ylabel(ax2,'Activation (a.u.)');
  918. legend(ax2, {'Young (mean of Local+Global)','Older (mean of Local+Global)'}, 'Location','northwest');
  919. print(fig2, fullfile(stats_outdir, sprintf('PC%d_GroupMeans_ClusterGray.png', pc)), '-dpng','-r200');
  920. close(fig2);
  921. catch ME
  922. warning('Group means plotting failed for PC %d: %s', pc, ME.message);
  923. end
  924. % 3) Condition differences (G-L) per group and pooled, with appropriate shading for Interaction/Condition.
  925. try
  926. fig3 = figure('Visible','off','Color','w','Units','pixels','Position',[100 100 1100 500]);
  927. tlo3 = tiledlayout(fig3, 2,1, 'Padding','compact','TileSpacing','compact');
  928. % (a) G-L per group with Interaction shading
  929. ax3a = nexttile(tlo3,1); hold(ax3a,'on'); grid(ax3a,'on'); box(ax3a,'on');
  930. dY = mean(D_y,1,'omitnan'); % Younger (G-L)
  931. dO = mean(D_o,1,'omitnan'); % Older (G-L)
  932. plot(ax3a, t_win, dY, 'LineWidth',1.4, 'Color',[0.2 0.6 0.9]);
  933. plot(ax3a, t_win, dO, 'LineWidth',1.4, 'Color',[0.9 0.4 0.2]);
  934. yl3a = ylim(ax3a);
  935. shade_clusters_union(ax3a, t_win, sig_I, yl3a, [0.7 0.7 0.7], 0.15);
  936. title(ax3a, sprintf('PC%d — (G-L) per group with Interaction clusters (gray)', pc), 'Interpreter','none');
  937. xlabel(ax3a,'Time (s)'); ylabel(ax3a,'Δ (G-L)');
  938. legend(ax3a, {'Young: G-L','Old: G-L'}, 'Location','northwest');
  939. % (b) Pooled Condition effect with Condition shading
  940. ax3b = nexttile(tlo3,2); hold(ax3b,'on'); grid(ax3b,'on'); box(ax3b,'on');
  941. plot(ax3b, t_win, obs_C, 'LineWidth',1.6, 'Color',[0.0 0.45 0.74]);
  942. yl3b = ylim(ax3b);
  943. shade_clusters_union(ax3b, t_win, sig_C, yl3b, [0.7 0.7 0.7], 0.15);
  944. title(ax3b, sprintf('PC%d — Condition main effect (pooled G-L) with clusters (gray)', pc), 'Interpreter','none');
  945. xlabel(ax3b,'Time (s)'); ylabel(ax3b,'Δ (G-L) pooled');
  946. print(fig3, fullfile(stats_outdir, sprintf('PC%d_ConditionAndInteractionDetails.png', pc)), '-dpng','-r200');
  947. close(fig3);
  948. catch ME
  949. warning('Condition/Interaction plotting failed for PC %d: %s', pc, ME.message);
  950. end
  951. % 4) Binary significance masks (three rows: Group, Condition, Interaction) to see where each effect is on.
  952. try
  953. fig4 = figure('Visible','off','Color','w','Units','pixels','Position',[100 100 1100 400]);
  954. ax4 = axes(fig4); hold(ax4,'on'); grid(ax4,'on'); box(ax4,'on');
  955. y0 = 0;
  956. plot_sig_mask(ax4, t_win, sig_G, y0+2, 'Group');
  957. plot_sig_mask(ax4, t_win, sig_C, y0+1, 'Condition');
  958. plot_sig_mask(ax4, t_win, sig_I, y0+0, 'Interaction');
  959. yticks(ax4, y0+[0 1 2]); yticklabels(ax4, {'Interaction','Condition','Group'});
  960. xlabel(ax4,'Time (s)'); ylim(ax4,[-0.5 2.5]); xlim(ax4,[t_win(1) t_win(end)]);
  961. title(ax4, sprintf('PC%d — Binary significance masks (cluster-corrected)', pc), 'Interpreter','none');
  962. print(fig4, fullfile(stats_outdir, sprintf('PC%d_SignificanceMasks.png', pc)), '-dpng','-r200');
  963. close(fig4);
  964. catch ME
  965. warning('Significance mask plotting failed for PC %d: %s', pc, ME.message);
  966. end
  967. % ===================== END INSERTED PLOTS =====================
  968. end
  969. fprintf('6 updated: cluster-based stats saved in %s\n', stats_outdir);
  970. % ===================== LOCAL HELPERS =====================
  971. % The helper functions below implement the permutation logic, cluster detection,
  972. % mask->window conversion, robust quantiles, and plotting utilities.
  973. function [sig_mask, thr, clusters] = perm_between_time(A, B, nperm, c_alpha, fwer_alpha)
  974. % Between-subject cluster permutation across time on mean differences.
  975. % INPUTS:
  976. % A : [N_A x T] group A subjects x time
  977. % B : [N_B x T] group B subjects x time
  978. % nperm : number of permutations
  979. % c_alpha : pointwise alpha to define clusters (CFT)
  980. % fwer_alpha : final cluster-level alpha (FWER)
  981. % OUTPUTS:
  982. % sig_mask : 1 x T logical, true where any significant cluster spans
  983. % thr : struct with fields 'cluster_forming' (CFT) and 'cluster_mass' (critical mass)
  984. % clusters : struct with cluster indices, masses, and which ones are significant (sig_flags)
  985. Na = size(A,1); Nb = size(B,1); T = size(A,2);
  986. if size(B,2) ~= T; error('Time dim mismatch A/B.'); end
  987. % Observed difference (Younger-Older or D_y - D_o depending on call site)
  988. obs = mean(A,1,'omitnan') - mean(B,1,'omitnan'); % 1 x T
  989. % Concatenate to reshuffle labels in permutations
  990. X = [A; B];
  991. % --- Build the cluster-forming threshold (CFT) ---
  992. % Strategy: pool the absolute per-sample effects from permuted splits.
  993. % Then take the (1 - c_alpha) quantile as CFT.
  994. pooled_abs = nan(nperm*T,1);
  995. for p = 1:nperm
  996. idx = randperm(Na+Nb);
  997. Ap = X(idx(1:Na), :);
  998. Bp = X(idx(Na+1:end), :);
  999. dpp = mean(Ap,1,'omitnan') - mean(Bp,1,'omitnan');
  1000. pooled_abs((p-1)*T+1 : p*T) = abs(dpp(:));
  1001. end
  1002. cft = quantile_fast(pooled_abs, 1 - c_alpha);
  1003. % --- Build the null of MAX cluster mass (FWER control) ---
  1004. % For each permutation, compute the largest cluster mass.
  1005. max_masses = nan(nperm,1);
  1006. for p = 1:nperm
  1007. idx = randperm(Na+Nb);
  1008. Ap = X(idx(1:Na), :);
  1009. Bp = X(idx(Na+1:end), :);
  1010. dpp = mean(Ap,1,'omitnan') - mean(Bp,1,'omitnan');
  1011. max_masses(p) = max_cluster_mass(abs(dpp), cft);
  1012. end
  1013. crit_mass = quantile_fast(max_masses, 1 - fwer_alpha);
  1014. % --- Observed clusters and significance decision ---
  1015. [clu_idx, clu_mass] = find_clusters(abs(obs), cft);
  1016. sig_flags = clu_mass > crit_mass;
  1017. % Convert significant clusters into a 1 x T boolean mask
  1018. sig_mask = false(1,T);
  1019. for k = 1:numel(clu_idx)
  1020. if sig_flags(k), sig_mask(clu_idx{k}) = true; end
  1021. end
  1022. thr = struct('cluster_forming', cft, 'cluster_mass', crit_mass);
  1023. clusters = struct('idx_list', {clu_idx}, 'masses', clu_mass, 'sig_flags', sig_flags);
  1024. end
  1025. function [sig_mask, thr, clusters] = perm_within_time(D, nperm, c_alpha, fwer_alpha)
  1026. % Within-subject cluster permutation by random sign flips (paired design).
  1027. % INPUT:
  1028. % D : [N_subj x T] per-subject within-contrast (e.g., Global - Local)
  1029. % OUTPUT:
  1030. % same as perm_between_time
  1031. [Ns, T] = size(D);
  1032. obs = mean(D,1,'omitnan'); % observed pooled within-subject effect
  1033. % Pre-generate ±1 flip matrix (Ns x nperm) for speed.
  1034. flips = (randi(2, Ns, nperm)*2 - 3); % entries are +1 or -1
  1035. % Build CFT via pooled absolute permuted effects
  1036. pooled_abs = nan(nperm*T,1);
  1037. for p = 1;nperm
  1038. Dp = D .* flips(:,p); % random sign flip per subject
  1039. dpp = mean(Dp,1,'omitnan'); % permuted effect
  1040. pooled_abs((p-1)*T+1 : p*T) = abs(dpp(:));
  1041. end
  1042. cft = quantile_fast(pooled_abs, 1 - c_alpha);
  1043. % Build max cluster mass null (FWER)
  1044. max_masses = nan(nperm,1);
  1045. for p = 1:nperm
  1046. Dp = D .* flips(:,p);
  1047. dpp = mean(Dp,1,'omitnan');
  1048. max_masses(p) = max_cluster_mass(abs(dpp), cft);
  1049. end
  1050. crit_mass = quantile_fast(max_masses, 1 - fwer_alpha);
  1051. % Observed clusters
  1052. [clu_idx, clu_mass] = find_clusters(abs(obs), cft);
  1053. sig_flags = clu_mass > crit_mass;
  1054. % Convert to mask
  1055. sig_mask = false(1,T);
  1056. for k = 1:numel(clu_idx)
  1057. if sig_flags(k), sig_mask(clu_idx{k}) = true; end
  1058. end
  1059. thr = struct('cluster_forming', cft, 'cluster_mass', crit_mass);
  1060. clusters = struct('idx_list', {clu_idx}, 'masses', clu_mass, 'sig_flags', sig_flags);
  1061. end
  1062. function m = max_cluster_mass(abs_series, cft)
  1063. % Return the largest cluster mass in a 1 x T absolute effect series.
  1064. % If no clusters form, return 0.
  1065. [~, masses] = find_clusters(abs_series, cft);
  1066. if isempty(masses), m = 0; else, m = max(masses); end
  1067. end
  1068. function [idx_list, masses] = find_clusters(abs_series, cft)
  1069. % Find contiguous indices where abs_series > cft and compute their masses.
  1070. % INPUTS:
  1071. % abs_series : 1 x T nonnegative vector (absolute effect)
  1072. % cft : scalar cluster-forming threshold
  1073. % OUTPUTS:
  1074. % idx_list : cell array; each cell is a vector of indices for one cluster
  1075. % masses : vector; sum(abs_series) within each cluster
  1076. mask = abs_series(:)' > cft; % suprathreshold boolean vector
  1077. idx = find(mask);
  1078. idx_list = {}; masses = [];
  1079. if isempty(idx), return; end
  1080. % Break points where clusters separate (difference > 1)
  1081. br = [1, find(diff(idx) > 1)+1, numel(idx)+1];
  1082. for b = 1:numel(br)-1
  1083. seg = idx(br(b):br(b+1)-1);
  1084. idx_list{end+1} = seg; %#ok<AGROW>
  1085. masses(end+1) = sum(abs_series(seg)); %#ok<AGROW>
  1086. end
  1087. end
  1088. function W = mask_to_windows(mask, tvec)
  1089. % Convert a 1 x T boolean mask into [nWin x 2] time windows using tvec.
  1090. % Each row: [start_time, end_time] in seconds for a contiguous "true" segment.
  1091. mask = mask(:)'; on = find(mask);
  1092. W = [];
  1093. if isempty(on), return; end
  1094. br = [1, find(diff(on) > 1)+1, numel(on)+1];
  1095. for b = 1:numel(br)-1
  1096. seg = on(br(b):br(b+1)-1);
  1097. W(end+1, :) = [tvec(seg(1)), tvec(seg(end))]; %#ok<AGROW>
  1098. end
  1099. end
  1100. function q = quantile_fast(x, p)
  1101. % Robust quantile that ignores NaNs using prctile.
  1102. % INPUTS:
  1103. % x : vector of samples
  1104. % p : desired quantile in [0,1]
  1105. % OUTPUT:
  1106. % q : p-quantile of x
  1107. x = x(isfinite(x));
  1108. if isempty(x), q = NaN; return; end
  1109. q = prctile(x, p*100);
  1110. end
  1111. function make_effect_plot(t, obs, sig, thr, ttl, outpng)
  1112. % Simple 1D effect plot:
  1113. % - line = observed effect over time
  1114. % - shaded areas = significant clusters (FWER controlled)
  1115. % - title shows CFT and critical mass for transparency
  1116. h = figure('Visible','off','Color','w','Units','pixels','Position',[100 100 1100 900]);
  1117. plot(t, obs, 'LineWidth', 1.5); hold on; grid on; box on;
  1118. yl = ylim;
  1119. shade_clusters(t, sig, yl);
  1120. title(sprintf('%s | CFT=%.3g | crit mass=%.3g', ttl, thr.cluster_forming, thr.cluster_mass), 'Interpreter','none');
  1121. xlabel('Time (s)'); ylabel('\Delta');
  1122. print(h, outpng, '-dpng', '-r200');
  1123. close(h);
  1124. end
  1125. function shade_clusters(t, sig, yl)
  1126. % Fill rectangles over contiguous significant segments using y-limits "yl".
  1127. on = find(sig(:)');
  1128. if isempty(on), return; end
  1129. br = [1, find(diff(on) > 1)+1, numel(on)+1];
  1130. for b = 1:numel(br)-1
  1131. seg = on(br(b):br(b+1)-1);
  1132. x1 = t(seg(1)); x2 = t(seg(end));
  1133. area([x1 x2], [yl(2) yl(2)], yl(1), 'FaceAlpha', 0.12, 'EdgeColor','none');
  1134. end
  1135. end
  1136. % ===================== INSERTED LOCAL HELPERS FOR PLOTTING =====================
  1137. function shade_clusters_union(ax, t, sig_mask, yl, face_rgb, face_alpha)
  1138. % Shade regions where sig_mask==true on provided axes "ax".
  1139. % Used to show "any effect" or effect-specific masks.
  1140. on = find(sig_mask(:)');
  1141. if isempty(on), return; end
  1142. br = [1, find(diff(on) > 1)+1, numel(on)+1];
  1143. for b = 1:numel(br)-1
  1144. seg = on(br(b):br(b+1)-1);
  1145. x1 = t(seg(1)); x2 = t(seg(end));
  1146. patch('XData',[x1 x2 x2 x1], 'YData',[yl(1) yl(1) yl(2) yl(2)], ...
  1147. 'FaceColor',face_rgb, 'FaceAlpha',face_alpha, 'EdgeColor','none', ...
  1148. 'Parent',ax, 'HitTest','off');
  1149. end
  1150. end
  1151. function plot_sig_mask(ax, t, mask, ybase, labeltxt)
  1152. % Draw a thick line for each contiguous "true" region at vertical level ybase.
  1153. % Useful for a compact "barcode" visualization of significance.
  1154. on = find(mask(:)');
  1155. if isempty(on)
  1156. % draw a faint reference line if there is no significance
  1157. plot(ax, t([1 end]), [ybase ybase], ':', 'Color',[0.5 0.5 0.5], 'HandleVisibility','off');
  1158. return;
  1159. end
  1160. br = [1, find(diff(on) > 1)+1, numel(on)+1];
  1161. for b = 1:numel(br)-1
  1162. seg = on(br(b):br(b+1)-1);
  1163. x1 = t(seg(1)); x2 = t(seg(end));
  1164. plot(ax, [x1 x2], [ybase ybase], '-', 'LineWidth', 3);
  1165. end
  1166. % label the row in the margin
  1167. text(mean(t), ybase+0.15, labeltxt, 'HorizontalAlignment','center', 'VerticalAlignment','bottom', ...
  1168. 'FontSize',9, 'Color',[0 0 0], 'Interpreter','none');
  1169. end
  1170. %% 2e) TIMESERIES ANALYSIS: Plotting — 2×3 PC-WISE SUMMARY PLOT using ANOVA results from 2d)
  1171. % Layout:
  1172. % [ PC1 | PC2 | PC3
  1173. % | LEGEND ]
  1174. % Uses:
  1175. % - BROADNESS_young_local/global, BROADNESS_older_local/global
  1176. % - stats_outdir and RES files created in 6a)
  1177. % - 'time' vector in workspace
  1178. % Output:
  1179. % - PNG saved in stats_outdir
  1180. stats_outdir = '/mainpath/Output/ANOVA_Stats';
  1181. % --------- sanity on 6) outputs ---------
  1182. need_files = {
  1183. fullfile(stats_outdir,'PC1_Timewise_ANOVA_ClusterPerm.mat')
  1184. fullfile(stats_outdir,'PC2_Timewise_ANOVA_ClusterPerm.mat')
  1185. fullfile(stats_outdir,'PC3_Timewise_ANOVA_ClusterPerm.mat')
  1186. };
  1187. have_files = cellfun(@(p) exist(p,'file')==2, need_files);
  1188. if ~all(have_files)
  1189. warning('7: Missing some 6 result files. Found=%s', mat2str(have_files));
  1190. end
  1191. % --------- require 'time' ---------
  1192. if ~exist('time','var') || isempty(time)
  1193. error('7: time vector not found in workspace.');
  1194. end
  1195. % --------- figure aesthetics ---------
  1196. tags = {'Young_Local','Old_Local','Young_Global','Old_Global'};
  1197. labels = {'Younger - Local','Older - Local','Younger - Global','Older - Global'};
  1198. combo_colors = [ ...
  1199. 0.1 0.2 0.5; % Young-Local (blue)
  1200. 0.5 0.1 0.1; % Old-Local (red)
  1201. 0.4 0.6 0.85; % Young-Global (light blue)
  1202. 0.8 0.2 0.2]; % Old-Global (light red)
  1203. % Gray shaded areas for any significant effect
  1204. shade_gray = [0.7 0.7 0.7];
  1205. shade_alpha = 0.15;
  1206. ribbon_alpha = 0.20; % transparency for SE ribbons
  1207. % Bottom indicator colors per effect
  1208. clr_group = [0 0.8 0.2]; % green
  1209. clr_cond = [0.6 0 0.8]; % purple
  1210. clr_int = [1 0.6 0]; % orange
  1211. % Y-limits (adjust as required)
  1212. ymin = -1100; ymax = 1000;
  1213. % --------- requested full x-window + dotted vertical reference lines ---------
  1214. xlim_target = [-0.1 0.8];
  1215. vlines = [0.0];
  1216. % Indices and time vector for plotting (full requested window)
  1217. t_plot_idx = find(time >= xlim_target(1) & time <= xlim_target(2));
  1218. t_plot = time(t_plot_idx);
  1219. if isempty(t_plot)
  1220. error('7: No samples within [%g, %g] in the time vector.', xlim_target(1), xlim_target(2));
  1221. end
  1222. dt_plot = median(diff(t_plot));
  1223. % Pack the four BROADNESS structs in the same order as labels
  1224. BMAP = containers.Map( ...
  1225. tags, ...
  1226. {BROADNESS_young_local, BROADNESS_older_local, BROADNESS_young_global, BROADNESS_older_global} ...
  1227. );
  1228. % --------- precompute mu/se for each combo and each PC lazily (on FULL window) ---------
  1229. get_mu_se = @(tag, pc) extract_mu_se_proper_7(BMAP(tag), t_plot_idx, pc);
  1230. % --------- plotting ---------
  1231. pc_list = [1 2 3];
  1232. h = figure('Visible','off','Color','w','Units','pixels','Position',[100 100 1400 1000]);
  1233. set(h, 'DefaultAxesFontName', 'Helvetica', ...
  1234. 'DefaultTextFontName', 'Helvetica', ...
  1235. 'DefaultLegendFontName', 'Helvetica', ...
  1236. 'DefaultAxesFontSize', 8, ...
  1237. 'DefaultTextFontSize', 8, ...
  1238. 'DefaultLegendFontSize', 8, ...
  1239. 'DefaultAxesTitleFontSizeMultiplier', 1, ...
  1240. 'DefaultAxesLabelFontSizeMultiplier', 1);
  1241. tlo = tiledlayout(2,3, 'Padding','compact','TileSpacing','compact');
  1242. for ip = 1:numel(pc_list)
  1243. pc = pc_list(ip);
  1244. % Load stats (for shading/indicators only)
  1245. matfile = fullfile(stats_outdir, sprintf('PC%d_Timewise_ANOVA_ClusterPerm.mat', pc));
  1246. if exist(matfile,'file')~=2
  1247. warning('7: Missing %s. Skipping PC%d.', matfile, pc);
  1248. nexttile; axis off; title(sprintf('PC%d (missing results)', pc));
  1249. continue;
  1250. end
  1251. S = load(matfile, 'RES');
  1252. Rt = S.RES; % has RES.time, RES.sig.*
  1253. ax = nexttile(tlo, ip); hold(ax,'on');
  1254. % === A) Build grid-aligned masks on t_plot (guarantees perfect alignment) ===
  1255. maskG_plot = sig_to_plot_mask(Rt.sig.Group, Rt.time, t_plot);
  1256. maskC_plot = sig_to_plot_mask(Rt.sig.Condition, Rt.time, t_plot);
  1257. maskI_plot = sig_to_plot_mask(Rt.sig.Interaction,Rt.time, t_plot);
  1258. % Convert masks to windows on t_plot, expanding half-bin to cover last sample
  1259. wins_G = mask_to_windows_on_grid(maskG_plot, t_plot, dt_plot, xlim_target);
  1260. wins_C = mask_to_windows_on_grid(maskC_plot, t_plot, dt_plot, xlim_target);
  1261. wins_I = mask_to_windows_on_grid(maskI_plot, t_plot, dt_plot, xlim_target);
  1262. % === B) Draw shading (behind curves) using the grid-aligned windows ===
  1263. draw_gray_windows_7(ax, wins_G, ymin, ymax, shade_gray, shade_alpha);
  1264. draw_gray_windows_7(ax, wins_C, ymin, ymax, shade_gray, shade_alpha);
  1265. draw_gray_windows_7(ax, wins_I, ymin, ymax, shade_gray, shade_alpha);
  1266. % === C) Mean + SE ribbon for each combo (draw ribbon first, then line) ===
  1267. for j = 1:numel(tags)
  1268. [mu, se] = get_mu_se(tags{j}, pc);
  1269. if isempty(mu) || all(isnan(mu)), continue; end
  1270. mu = mu(:); se = se(:); % ensure column vectors
  1271. lo = mu - se; hi = mu + se; % SE band
  1272. % Ribbon (shaded SE)
  1273. patch(ax, [t_plot(:); flipud(t_plot(:))], ...
  1274. [lo; flipud(hi)], ...
  1275. combo_colors(j,:), ...
  1276. 'FaceAlpha', ribbon_alpha, ...
  1277. 'EdgeColor', 'none', ...
  1278. 'HandleVisibility', 'off', ...
  1279. 'Clipping', 'on');
  1280. % Mean line on top
  1281. plot(ax, t_plot, mu, 'LineWidth', 1.2, 'Color', combo_colors(j,:));
  1282. end
  1283. % === D) Bottom indicator lines using the same grid-aligned windows ===
  1284. draw_bottom_lines_7(ax, {wins_G, wins_C, wins_I}, {clr_group, clr_cond, clr_int}, ...
  1285. xlim_target, ymin, ymax);
  1286. % Cosmetics
  1287. ylim(ax, [ymin ymax]);
  1288. xlim(ax, xlim_target);
  1289. grid(ax,'on'); box(ax,'on');
  1290. xlabel(ax, 'Time (s)'); ylabel(ax, 'Activation (a.u.)');
  1291. title(ax, sprintf('PC%d', pc));
  1292. % Dotted vertical reference lines
  1293. for vv = 1:numel(vlines)
  1294. if vlines(vv) >= xlim_target(1) && vlines(vv) <= xlim_target(2)
  1295. xline(ax, vlines(vv), ':', 'Color', [0 0 0], 'LineWidth', 0.75, 'HandleVisibility','off');
  1296. end
  1297. end
  1298. end
  1299. % Legend tile
  1300. axL = nexttile(tlo, 4); cla(axL); hold(axL,'on'); axis(axL,'off');
  1301. % curve entries (4)
  1302. h_leg = gobjects(1, numel(tags));
  1303. for j = 1:numel(tags)
  1304. h_leg(j) = plot(axL, NaN, NaN, '-', 'LineWidth', 3, 'Color', combo_colors(j,:));
  1305. end
  1306. % effect line entries (3)
  1307. hE = gobjects(1,3);
  1308. hE(1) = plot(axL, NaN, NaN, '-', 'LineWidth', 3.0, 'Color', clr_group);
  1309. hE(2) = plot(axL, NaN, NaN, '-', 'LineWidth', 3.0, 'Color', clr_cond);
  1310. hE(3) = plot(axL, NaN, NaN, '-', 'LineWidth', 3.0, 'Color', clr_int);
  1311. % ----------- KEY CHANGE: ordering + NumColumns = 4 -----------
  1312. lgd = legend(axL, ...
  1313. [h_leg, hE], ...
  1314. [labels, {'Group main effect','Condition main effect','Interaction effect'}], ...
  1315. 'NumColumns', 4, ...
  1316. 'Orientation','horizontal',...
  1317. 'Location','northwest');
  1318. set(lgd, 'Interpreter','none', 'Units','normalized', 'Location','none');
  1319. set(lgd, 'Position', [0.53 0.10 0.40 0.30]);
  1320. set(lgd, 'ItemTokenSize', [15, 12]);
  1321. lgd.TextColor = [0 0 0];
  1322. lgd.Color = [1 1 1];
  1323. lgd.EdgeColor = 'none';
  1324. sgtitle(tlo, 'Brain Network Time Series — cluster-perm windows (gray), effect ticks (colored)');
  1325. out_png = fullfile(stats_outdir, 'Summary_1x3_PCwise_Using_ANOVA6_GRAY.png');
  1326. print(h, out_png, '-dpng','-r200');
  1327. close(h);
  1328. fprintf('7 saved: %s\n', out_png);
  1329. % ===================== LOCAL HELPERS (7) =====================
  1330. function [mu, se] = extract_mu_se_proper_7(B, t_idx, pc)
  1331. % Returns column vectors [numel(t_idx) x 1] for mu and se
  1332. % Expect data layout: [time x PC x cond x subj]
  1333. X = B.TimeSeries_BrainNetworks(t_idx, pc, 1, :); % [T x 1 x 1 x Nsubj]
  1334. T = numel(t_idx);
  1335. N = size(X, 4);
  1336. X = reshape(X, T, N); % [T x Nsubj]
  1337. mu = mean(X, 2, 'omitnan'); % [T x 1]
  1338. if N <= 1
  1339. se = zeros(T,1);
  1340. else
  1341. se = std(X, 0, 2, 'omitnan') ./ sqrt(N); % [T x 1]
  1342. end
  1343. end
  1344. function mask_plot = sig_to_plot_mask(sig_mask, t_stat, t_plot)
  1345. % Map a logical mask defined on t_stat onto the plotting grid t_plot.
  1346. % We set a t_plot sample true if the nearest t_stat sample is significant,
  1347. % provided the distance <= half a t_plot bin.
  1348. t_stat = t_stat(:);
  1349. sig_mask = logical(sig_mask(:))';
  1350. if isempty(t_stat) || isempty(t_plot) || numel(sig_mask) ~= numel(t_stat)
  1351. mask_plot = false(size(t_plot));
  1352. return;
  1353. end
  1354. dt_plot = median(diff(t_plot));
  1355. mask_plot = false(size(t_plot));
  1356. % nearest neighbor assign
  1357. for k = 1:numel(t_stat)
  1358. if ~sig_mask(k), continue; end
  1359. [d, j] = min(abs(t_plot - t_stat(k)));
  1360. if d <= dt_plot/2 + 10*eps(max(abs(t_plot))) % robust tolerance
  1361. mask_plot(j) = true;
  1362. end
  1363. end
  1364. % optional: bridge tiny gaps due to roundoff (two isolated falses between trues)
  1365. % (kept conservative; comment out if undesired)
  1366. % mask_plot = imclose(mask_plot, ones(1,3)); % requires Image Processing Toolbox
  1367. end
  1368. function wins = mask_to_windows_on_grid(mask_plot, t_plot, dt_plot, xlim_ok)
  1369. % Convert a logical mask on t_plot to [n x 2] windows in seconds,
  1370. % expanding by half-bin so the last marked sample is fully covered.
  1371. wins = [];
  1372. if ~any(mask_plot), return; end
  1373. on = find(mask_plot(:)');
  1374. br = [1, find(diff(on) > 1)+1, numel(on)+1];
  1375. for b = 1:numel(br)-1
  1376. seg = on(br(b):br(b+1)-1);
  1377. t1 = t_plot(seg(1)) - dt_plot/2;
  1378. t2 = t_plot(seg(end)) + dt_plot/2;
  1379. % clip to requested x-limits
  1380. t1 = max(t1, xlim_ok(1));
  1381. t2 = min(t2, xlim_ok(2));
  1382. if t2 > t1
  1383. wins(end+1,:) = [t1, t2]; %#ok<AGROW>
  1384. end
  1385. end
  1386. end
  1387. function draw_gray_windows_7(ax, wins, ymin, ymax, shade_gray, shade_alpha)
  1388. % wins: [n x 2] in seconds on the plotting grid.
  1389. if isempty(wins), return; end
  1390. for i=1:size(wins,1)
  1391. t1 = wins(i,1); t2 = wins(i,2);
  1392. X = [t1 t2 t2 t1];
  1393. Y = [ymin ymin ymax ymax];
  1394. patch('XData',X,'YData',Y,'FaceColor',shade_gray,'FaceAlpha',shade_alpha, ...
  1395. 'EdgeColor','none','Parent',ax,'HitTest','off','Clipping','on');
  1396. end
  1397. end
  1398. function draw_bottom_lines_7(ax, wins_cell, effect_colors, xlim_ok, ymin, ymax)
  1399. % wins_cell = {wins_GROUP, wins_COND, wins_INT}; each [n x 2] on the plotting grid.
  1400. oldUnits = get(ax,'Units'); set(ax,'Units','pixels');
  1401. axPos = get(ax,'Position'); set(ax,'Units',oldUnits);
  1402. yRange = ymax - ymin;
  1403. spacing_factor = 5;
  1404. dy_data = (axPos(4) > 0) * (spacing_factor / max(axPos(4),1)) * yRange;
  1405. if dy_data==0, dy_data = 0.003*yRange; end
  1406. y_base = ymin + 0.03 * yRange; % bottom baseline
  1407. n_levels = 3; % top→bottom: Group, Condition, Interaction
  1408. for lvl = 1:3
  1409. wins = wins_cell{lvl};
  1410. if isempty(wins), continue; end
  1411. y_line = y_base + (n_levels - lvl)*dy_data; % stack lines
  1412. for i=1:size(wins,1)
  1413. t1 = max(wins(i,1), xlim_ok(1));
  1414. t2 = min(wins(i,2), xlim_ok(2));
  1415. if t2 <= t1, continue; end
  1416. plot(ax, [t1 t2], [y_line y_line], '-', 'LineWidth', 3.0, ...
  1417. 'Color', effect_colors{lvl}, 'HitTest','off');
  1418. end
  1419. end
  1420. end
  1421. %% 2f) Investigating ERFs: Latency, order and amplitude
  1422. % We compute peaks on the MEAN time series (mu) returned by extract_mu_se_proper_7
  1423. % for each PC and each plot (Young/Older x Local/Global).
  1424. % Local:
  1425. % P50 : most positive in [0.05, 0.10]
  1426. % MMN : most negative in [0.10, 0.20]
  1427. % P300 : most positive in [0.15, 0.30]
  1428. % RON : most negative in [0.25, 0.40]
  1429. % Global:
  1430. % MMN : most negative in [0.10, 0.20]
  1431. % P300 : most positive in [0.10, 0.25]
  1432. % Helper: get peak (latency, amplitude) in a window from (time, signal)
  1433. % polarity: 'pos' (max) or 'neg' (min)
  1434. get_peak = @(tt, xx, t1, t2, polarity) local_get_peak(tt, xx, t1, t2, polarity);
  1435. % Windows definition
  1436. local_windows = { ...
  1437. 'P50', 0.05, 0.10, 'pos'; ...
  1438. 'MMN', 0.10, 0.20, 'neg'; ...
  1439. 'P300', 0.15, 0.30, 'pos'; ...
  1440. 'RON', 0.25, 0.40, 'neg' ...
  1441. };
  1442. global_windows = { ...
  1443. 'MMN', 0.10, 0.20, 'neg'; ...
  1444. 'P300', 0.15, 0.25, 'pos'; ...
  1445. 'RON', 0.25, 0.40, 'neg' ...
  1446. };
  1447. % --------- NEW: store detected peaks for later plotting ---------
  1448. PEAKS = struct();
  1449. fprintf('\n================ PEAK SUMMARY (mu) ================\n');
  1450. for it = 1:numel(tags)
  1451. tag = tags{it};
  1452. isLocal = contains(tag, 'Local', 'IgnoreCase', true);
  1453. isGlobal = contains(tag, 'Global', 'IgnoreCase', true);
  1454. if ~(isLocal || isGlobal)
  1455. continue;
  1456. end
  1457. fprintf('\n---- %s ----\n', tag);
  1458. for ip = 1:numel(pc_list)
  1459. pc = pc_list(ip);
  1460. [mu, ~] = get_mu_se(tag, pc);
  1461. if isempty(mu) || all(isnan(mu))
  1462. fprintf('PC%d: mu empty/NaN -> skipping\n', pc);
  1463. continue;
  1464. end
  1465. mu = mu(:);
  1466. fprintf('PC%d\n', pc);
  1467. if isLocal
  1468. W = local_windows;
  1469. else
  1470. W = global_windows;
  1471. end
  1472. for iw = 1:size(W,1)
  1473. name = W{iw,1};
  1474. t1 = W{iw,2};
  1475. t2 = W{iw,3};
  1476. pol = W{iw,4};
  1477. % --------- NEW: skip RON for (Young_Local, PC2) and (Old_Local, PC2) ---------
  1478. if strcmpi(name, 'RON') && pc == 2 && (strcmpi(tag, 'Young_Local') || strcmpi(tag, 'Old_Local'))
  1479. continue;
  1480. end
  1481. % --------- NEW: skip P300 for (Young_Global, PC2) and (Old_Global, PC2) ---------
  1482. if strcmpi(name, 'P300') && pc == 2 && (strcmpi(tag, 'Young_Global') || strcmpi(tag, 'Old_Global'))
  1483. continue;
  1484. end
  1485. % --------- NEW: Global RON only for PC1 ---------
  1486. if strcmpi(name, 'RON') && isGlobal && pc ~= 1
  1487. continue;
  1488. end
  1489. [lat, amp] = get_peak(t_plot(:), mu, t1, t2, pol);
  1490. fprintf('%s %.6f %.6f\n', name, lat, amp);
  1491. % --------- NEW: keep for plotting (per tag, PC, component) ---------
  1492. if ~isfield(PEAKS, tag)
  1493. PEAKS.(tag) = struct();
  1494. end
  1495. pc_field = sprintf('PC%d', pc);
  1496. if ~isfield(PEAKS.(tag), pc_field)
  1497. PEAKS.(tag).(pc_field) = struct();
  1498. end
  1499. PEAKS.(tag).(pc_field).(name) = [lat, amp];
  1500. end
  1501. end
  1502. end
  1503. fprintf('===================================================\n');
  1504. % --------- EXPORT PEAKS TO EXCEL (4 sheets) ---------
  1505. out_xlsx = fullfile(pwd, 'Peak_Summary.xlsx');
  1506. sheet_tags = {'Young_Local','Old_Local','Young_Global','Old_Global'};
  1507. for is = 1:numel(sheet_tags)
  1508. tag = sheet_tags{is};
  1509. isLocal = contains(tag, 'Local', 'IgnoreCase', true);
  1510. isGlobal = contains(tag, 'Global', 'IgnoreCase', true);
  1511. if isLocal
  1512. erf_list = {'P50','MMN','P300','RON'};
  1513. elseif isGlobal
  1514. erf_list = {'MMN','P300','RON'};
  1515. else
  1516. erf_list = {};
  1517. end
  1518. nPC = numel(pc_list);
  1519. varNames = cell(1, 1 + 3*nPC);
  1520. varNames{1} = 'ERF';
  1521. c = 2;
  1522. for k = 1:nPC
  1523. if k == 1
  1524. pcName = 'First_PC';
  1525. elseif k == 2
  1526. pcName = 'Second_PC';
  1527. elseif k == 3
  1528. pcName = 'Third_PC';
  1529. else
  1530. pcName = sprintf('%dth_PC', k);
  1531. end
  1532. varNames{c} = pcName; c = c + 1;
  1533. varNames{c} = sprintf('Latency_%d', k); c = c + 1;
  1534. varNames{c} = sprintf('Amplitude_%d', k); c = c + 1;
  1535. end
  1536. nERF = numel(erf_list);
  1537. rows = cell(nERF, 1 + 3*nPC);
  1538. for ie = 1:nERF
  1539. erf = erf_list{ie};
  1540. lat_all = nan(nPC,1);
  1541. amp_all = nan(nPC,1);
  1542. pc_all = nan(nPC,1);
  1543. for ip = 1:nPC
  1544. pc = pc_list(ip);
  1545. pc_field = sprintf('PC%d', pc);
  1546. lat = NaN; amp = NaN;
  1547. if isfield(PEAKS, tag) && isfield(PEAKS.(tag), pc_field) && isfield(PEAKS.(tag).(pc_field), erf)
  1548. v = PEAKS.(tag).(pc_field).(erf);
  1549. if numel(v) >= 2
  1550. lat = v(1);
  1551. amp = v(2);
  1552. end
  1553. end
  1554. lat_all(ip) = lat;
  1555. amp_all(ip) = amp;
  1556. pc_all(ip) = pc;
  1557. end
  1558. valid = ~isnan(lat_all);
  1559. pc_v = pc_all(valid);
  1560. lat_v = lat_all(valid);
  1561. amp_v = amp_all(valid);
  1562. if ~isempty(lat_v)
  1563. M = [lat_v(:), -amp_v(:)];
  1564. [~, idx] = sortrows(M, [1 2]);
  1565. pc_sorted = pc_v(idx);
  1566. lat_sorted = lat_v(idx);
  1567. amp_sorted = amp_v(idx);
  1568. else
  1569. pc_sorted = [];
  1570. lat_sorted = [];
  1571. amp_sorted = [];
  1572. end
  1573. row = cell(1, 1 + 3*nPC);
  1574. row{1} = erf;
  1575. col = 2;
  1576. for k = 1:numel(pc_sorted)
  1577. row{col} = sprintf('PC%d', pc_sorted(k)); col = col + 1;
  1578. row{col} = lat_sorted(k); col = col + 1;
  1579. row{col} = amp_sorted(k); col = col + 1;
  1580. end
  1581. while col <= (1 + 3*nPC)
  1582. row{col} = ''; col = col + 1;
  1583. if col <= (1 + 3*nPC); row{col} = NaN; col = col + 1; end
  1584. if col <= (1 + 3*nPC); row{col} = NaN; col = col + 1; end
  1585. end
  1586. rows(ie,:) = row;
  1587. end
  1588. T = cell2table(rows, 'VariableNames', varNames);
  1589. writetable(T, out_xlsx, 'Sheet', tag);
  1590. end
  1591. % --------- local function ---------
  1592. function [lat, amp] = local_get_peak(tt, xx, t1, t2, polarity)
  1593. idx = find(tt >= t1 & tt <= t2);
  1594. if isempty(idx) || isempty(xx)
  1595. lat = NaN; amp = NaN;
  1596. return;
  1597. end
  1598. xw = xx(idx);
  1599. if all(isnan(xw))
  1600. lat = NaN; amp = NaN;
  1601. return;
  1602. end
  1603. switch lower(polarity)
  1604. case 'pos'
  1605. [amp, k] = max(xw);
  1606. case 'neg'
  1607. [amp, k] = min(xw);
  1608. otherwise
  1609. error('local_get_peak: polarity must be ''pos'' or ''neg''.');
  1610. end
  1611. lat = tt(idx(k));
  1612. end
  1613. stats_outdir = '/mainpath/Output/ANOVA_Stats';
  1614. if ~exist('time','var') || isempty(time)
  1615. error('7: time vector not found in workspace.');
  1616. end
  1617. tags = {'Young_Local','Old_Local','Young_Global','Old_Global'};
  1618. labels = {'Younger - Local','Older - Local','Younger - Global','Older - Global'};
  1619. combo_colors = [ ...
  1620. 0.1 0.2 0.5;
  1621. 0.5 0.1 0.1;
  1622. 0.4 0.6 0.85;
  1623. 0.8 0.2 0.2];
  1624. pc_list = [1 2 3];
  1625. pc_styles = {'-','--',':'};
  1626. pc_names = {'PC #1','PC #2','PC #3'};
  1627. ymin = -1100; ymax = 1000;
  1628. xlim_target = [-0.1 0.8];
  1629. vlines = [0.0];
  1630. t_plot_idx = find(time >= xlim_target(1) & time <= xlim_target(2));
  1631. t_plot = time(t_plot_idx);
  1632. if isempty(t_plot)
  1633. error('7: No samples within [%g, %g] in the time vector.', xlim_target(1), xlim_target(2));
  1634. end
  1635. BMAP = containers.Map( ...
  1636. tags, ...
  1637. {BROADNESS_young_local, BROADNESS_older_local, BROADNESS_young_global, BROADNESS_older_global} ...
  1638. );
  1639. get_mu_se = @(tag, pc) extract_mu_se_proper_7(BMAP(tag), t_plot_idx, pc);
  1640. marker_map = containers.Map( ...
  1641. {'P50','MMN','P300','RON'}, ...
  1642. {'x','o','^','s'} ...
  1643. );
  1644. h = figure('Visible','off','Color','w','Units','pixels','Position',[100 100 1400 1000]);
  1645. tlo = tiledlayout(2,2, 'Padding','compact','TileSpacing','compact');
  1646. axPos = cell(1,4);
  1647. for k = 1:4
  1648. axPos{k} = nexttile(tlo, k);
  1649. end
  1650. for k = 1:4
  1651. cla(axPos{k}); hold(axPos{k},'on');
  1652. end
  1653. left_col_x = 0.07;
  1654. right_col_x = 0.39;
  1655. col_w = 0.24;
  1656. top_row_y = 0.56;
  1657. bot_row_y = 0.18;
  1658. row_h = 0.30;
  1659. set(axPos{1}, 'Position', [left_col_x, top_row_y, col_w, row_h]);
  1660. set(axPos{2}, 'Position', [right_col_x, top_row_y, col_w, row_h]);
  1661. set(axPos{3}, 'Position', [left_col_x, bot_row_y, col_w, row_h]);
  1662. set(axPos{4}, 'Position', [right_col_x, bot_row_y, col_w, row_h]);
  1663. ribbon_alpha = 0.20;
  1664. lgd_lw = 3.0;
  1665. lgd_fs = 14;
  1666. lgd_loc = 'southeast';
  1667. for it = 1:numel(tags)
  1668. tag = tags{it};
  1669. ax = axPos{it};
  1670. hold(ax,'on');
  1671. isLocal = contains(tag, 'Local', 'IgnoreCase', true);
  1672. isGlobal = contains(tag, 'Global', 'IgnoreCase', true);
  1673. for ip = 1:numel(pc_list)
  1674. pc = pc_list(ip);
  1675. [mu, se] = get_mu_se(tag, pc);
  1676. if isempty(mu) || all(isnan(mu)), continue; end
  1677. mu = mu(:);
  1678. se = se(:);
  1679. lo = mu - se;
  1680. hi = mu + se;
  1681. patch(ax, [t_plot(:); flipud(t_plot(:))], ...
  1682. [lo; flipud(hi)], ...
  1683. combo_colors(it,:), ...
  1684. 'FaceAlpha', ribbon_alpha, ...
  1685. 'EdgeColor', 'none', ...
  1686. 'HandleVisibility', 'off', ...
  1687. 'Clipping', 'on');
  1688. plot(ax, t_plot, mu, pc_styles{ip}, ...
  1689. 'LineWidth', 1.2, ...
  1690. 'Color', combo_colors(it,:));
  1691. if isLocal
  1692. W = local_windows;
  1693. else
  1694. W = global_windows;
  1695. end
  1696. pc_field = sprintf('PC%d', pc);
  1697. if isfield(PEAKS, tag) && isfield(PEAKS.(tag), pc_field)
  1698. for iw = 1:size(W,1)
  1699. nm = W{iw,1};
  1700. if strcmpi(nm, 'RON') && pc == 2 && (strcmpi(tag, 'Young_Local') || strcmpi(tag, 'Old_Local'))
  1701. continue;
  1702. end
  1703. if strcmpi(nm, 'P300') && pc == 2 && (strcmpi(tag, 'Young_Global') || strcmpi(tag, 'Old_Global'))
  1704. continue;
  1705. end
  1706. if isfield(PEAKS.(tag).(pc_field), nm)
  1707. pa = PEAKS.(tag).(pc_field).(nm);
  1708. lat = pa(1);
  1709. amp = pa(2);
  1710. if ~isnan(lat) && ~isnan(amp)
  1711. mk = 'x';
  1712. if isKey(marker_map, nm)
  1713. mk = marker_map(nm);
  1714. end
  1715. if strcmp(mk, 'x')
  1716. plot(ax, lat, amp, mk, ...
  1717. 'Color', [0 0 0], ...
  1718. 'LineWidth', 1.2, ...
  1719. 'MarkerSize', 7, ...
  1720. 'HandleVisibility', 'off', ...
  1721. 'Clipping', 'on');
  1722. else
  1723. plot(ax, lat, amp, mk, ...
  1724. 'Color', [0 0 0], ...
  1725. 'MarkerFaceColor', 'none', ...
  1726. 'LineWidth', 1.2, ...
  1727. 'MarkerSize', 7, ...
  1728. 'HandleVisibility', 'off', ...
  1729. 'Clipping', 'on');
  1730. end
  1731. end
  1732. end
  1733. end
  1734. end
  1735. end
  1736. ylim(ax, [ymin ymax]);
  1737. xlim(ax, xlim_target);
  1738. grid(ax,'on'); box(ax,'on');
  1739. xlabel(ax, 'Time (s)'); ylabel(ax, 'Activation (a.u.)');
  1740. title(ax, labels{it}, 'Interpreter','none');
  1741. for vv = 1:numel(vlines)
  1742. if vlines(vv) >= xlim_target(1) && vlines(vv) <= xlim_target(2)
  1743. xline(ax, vlines(vv), ':', 'Color', [0 0 0], 'LineWidth', 0.75, 'HandleVisibility','off');
  1744. end
  1745. end
  1746. h1 = plot(ax, nan, nan, '-', 'LineWidth', lgd_lw, 'Color', combo_colors(it,:));
  1747. h2 = plot(ax, nan, nan, '--', 'LineWidth', lgd_lw, 'Color', combo_colors(it,:));
  1748. h3 = plot(ax, nan, nan, ':', 'LineWidth', lgd_lw, 'Color', combo_colors(it,:));
  1749. hP50 = plot(ax, nan, nan, 'x', 'Color', [0 0 0], 'LineWidth', 1.2, 'MarkerSize', 7);
  1750. hMMN = plot(ax, nan, nan, 'o', 'Color', [0 0 0], 'LineWidth', 1.2, 'MarkerSize', 7, 'MarkerFaceColor', 'none');
  1751. hP300 = plot(ax, nan, nan, '^', 'Color', [0 0 0], 'LineWidth', 1.2, 'MarkerSize', 7, 'MarkerFaceColor', 'none');
  1752. hRON = plot(ax, nan, nan, 's', 'Color', [0 0 0], 'LineWidth', 1.2, 'MarkerSize', 7, 'MarkerFaceColor', 'none');
  1753. % Bottom row (plots 3 and 4): remove "P50" from legend
  1754. if it == 3 || it == 4
  1755. lgd = legend(ax, [h1 h2 h3 hMMN hP300 hRON], ...
  1756. {'PC #1','PC #2','PC #3','MMN','P300','RON'}, 'Location', lgd_loc);
  1757. else
  1758. lgd = legend(ax, [h1 h2 h3 hP50 hMMN hP300 hRON], ...
  1759. {'PC #1','PC #2','PC #3','P50','MMN','P300','RON'}, 'Location', lgd_loc);
  1760. end
  1761. % ========================================================
  1762. set(lgd, 'FontName', 'Helvetica', 'FontSize', lgd_fs);
  1763. end
  1764. sgtitle(tlo, 'Brain Network Time Series — PC1/PC2/PC3 as line styles (no stats)');
  1765. out_png = fullfile(stats_outdir, 'Summary_2x2_ComboWise_PCstyles_NoStats.png');
  1766. print(h, out_png, '-dpng','-r200');
  1767. close(h);
  1768. fprintf('7 saved: %s\n', out_png);
  1769. %% 3a) PHASE SPACE EMBEDDING AND RECURRENCE QUANTIFICATION ANALYSIS
  1770. %%% ------------------- USER SETTINGS ------------------- %%%
  1771. % Simply use the structure outputted by the BROADNESS_NetworkEstimation function
  1772. % Additional optional inputs can be provided, as described in the function.
  1773. % For 3D visualization: 'principalcomps',[1:3] below
  1774. % For 2D visualization: 'principalcomps',[1:2] below
  1775. % For switching between individual x, y, z limits and limits standard across all plots, edit 224-225 (2D) or line
  1776. % 257-259 (3D) in BROADNESS_PhaseSpace_RQA.m: Insert commented lines for
  1777. % individual limits, and defined limits for standard limits across all plots.
  1778. %%% ------------------ COMPUTATION --------------------- %%%
  1779. % Shift here from 3D to 2D illustrations, by commenting out the unwanted
  1780. % section of the dimensionality
  1781. % 3D - Overall
  1782. RQA_BROADNESS = BROADNESS_PhaseSpace_RQA(BROADNESS,'principalcomps',[1:3],'threshold',0.1,'video','on','figure','on', 'plotlabel', 'All data');
  1783. % 3D - Group/condition specific
  1784. RQA_BROADNESS_young_local = BROADNESS_PhaseSpace_RQA(BROADNESS_young_local,'principalcomps',[1:3],'threshold',0.1,'video','on','figure','on', 'plotlabel', 'Yl');
  1785. RQA_BROADNESS_older_local = BROADNESS_PhaseSpace_RQA(BROADNESS_older_local,'principalcomps',[1:3],'threshold',0.1,'video','on','figure','on', 'plotlabel', 'Ol');
  1786. RQA_BROADNESS_young_global = BROADNESS_PhaseSpace_RQA(BROADNESS_young_global,'principalcomps',[1:3],'threshold',0.1,'video','on','figure','on', 'plotlabel', 'Yg');
  1787. RQA_BROADNESS_older_global = BROADNESS_PhaseSpace_RQA(BROADNESS_older_global,'principalcomps',[1:3],'threshold',0.1,'video','on','figure','on', 'plotlabel', 'Og');
  1788. % 2D - Overall
  1789. % RQA_BROADNESS = BROADNESS_PhaseSpace_RQA(BROADNESS,'principalcomps',[1:2],'threshold',0.1,'video','on','figure','on', 'plotlabel', 'All data');
  1790. % 2D - Group/condition specific
  1791. % RQA_BROADNESS_young_local = BROADNESS_PhaseSpace_RQA(BROADNESS_young_local,'principalcomps',[1:2],'threshold',0.1,'video','on','figure','on', 'plotlabel', 'Yl');
  1792. % RQA_BROADNESS_older_local = BROADNESS_PhaseSpace_RQA(BROADNESS_older_local,'principalcomps',[1:2],'threshold',0.1,'video','on','figure','on', 'plotlabel', 'Ol');
  1793. % RQA_BROADNESS_young_global = BROADNESS_PhaseSpace_RQA(BROADNESS_young_global,'principalcomps',[1:2],'threshold',0.1,'video','on','figure','on', 'plotlabel', 'Yg');
  1794. % RQA_BROADNESS_older_global = BROADNESS_PhaseSpace_RQA(BROADNESS_older_global,'principalcomps',[1:2],'threshold',0.1,'video','on','figure','on', 'plotlabel', 'Og');
  1795. %% 3b) Export average phase space values
  1796. % After running 3D plots in 3a):
  1797. % Put items in a list with filenames
  1798. items = { ...
  1799. RQA_BROADNESS_young_local, 'young_local_phase_space.csv'; ...
  1800. RQA_BROADNESS_older_local, 'older_local_phase_space.csv'; ...
  1801. RQA_BROADNESS_young_global, 'young_global_phase_space.csv'; ...
  1802. RQA_BROADNESS_older_global, 'older_global_phase_space.csv' ...
  1803. };
  1804. for k = 1:size(items,1)
  1805. RQA = items{k,1};
  1806. fout = items{k,2};
  1807. t = RQA.PhaseSpace.time(:)'; % 1 x T
  1808. XYZ = RQA.PhaseSpace.coords{1}; % [T x 3] (assumes condition = 1)
  1809. % Basic sanity checks (avoid silent wrong output)
  1810. if size(XYZ,2) ~= 3
  1811. error('Expected 3 PCs for %s, but got %d columns.', fout, size(XYZ,2));
  1812. end
  1813. if size(XYZ,1) ~= numel(t)
  1814. error('Time length mismatch for %s (time=%d, coords=%d).', fout, numel(t), size(XYZ,1));
  1815. end
  1816. out = [ ...
  1817. t; ...
  1818. XYZ(:,1)'; ...
  1819. XYZ(:,2)'; ...
  1820. XYZ(:,3)' ...
  1821. ]; % 4 x T
  1822. writematrix(out, fout);
  1823. end
  1824. %% 3c) Plot differences in 3D phase-space coordinates
  1825. % 1) LOCAL: Young_local - Older_local
  1826. % 2) GLOBAL: Young_global - Older_global
  1827. % 3) LOCAL vs GLOBAL (group-averaged): mean(Young_local,Older_local) - mean(Young_global,Older_global)
  1828. %
  1829. % Within each plot, x/y/z differences are overlaid:
  1830. % - x: solid
  1831. % - y: dashed
  1832. % - z: dotted
  1833. %
  1834. % Segment coloring (UPDATED):
  1835. % Colors no longer encode "young/local vs old/global".
  1836. % Instead, each time point uses the same jet-based time coloring as the
  1837. % phase space video: color progresses with time index (early->late).
  1838. %
  1839. % X-axis forced to [-0.1 0.8] seconds (data clipped to what exists).
  1840. % Manually set y-axis limits via ylims_user (set [] for auto).
  1841. % ------------------- USER SETTINGS -------------------
  1842. RQA_y_local = RQA_BROADNESS_young_local;
  1843. RQA_o_local = RQA_BROADNESS_older_local;
  1844. RQA_y_global = RQA_BROADNESS_young_global;
  1845. RQA_o_global = RQA_BROADNESS_older_global;
  1846. cond_to_plot = 1; % which condition to use (1..nCond)
  1847. xlims = [-0.1 0.8]; % requested time window (s)
  1848. ylims_user = [-800 800]; % <<< MANUAL Y LIMITS (set [] to auto)
  1849. lw = 2; % line width
  1850. % ------------------- GLOBAL FIGURE FONT SETTINGS -------------------
  1851. set(groot, 'defaultAxesFontName', 'Helvetica');
  1852. set(groot, 'defaultTextFontName', 'Helvetica');
  1853. set(groot, 'defaultLegendFontName', 'Helvetica');
  1854. % ------------------- FIGURE 1: LOCAL (Young - Older) -------------------
  1855. figure('Color','w');
  1856. plot_one_context(RQA_y_local, RQA_o_local, cond_to_plot, xlims, ylims_user, ...
  1857. lw, 'LOCAL (Young - Older)');
  1858. % ------------------- FIGURE 2: GLOBAL (Young - Older) ------------------
  1859. figure('Color','w');
  1860. plot_one_context(RQA_y_global, RQA_o_global, cond_to_plot, xlims, ylims_user, ...
  1861. lw, 'GLOBAL (Young - Older)');
  1862. % ------------------- FIGURE 3: LOCAL vs GLOBAL (avg(Y,O)) --------------
  1863. figure('Color','w');
  1864. plot_local_vs_global_avg(RQA_y_local, RQA_o_local, RQA_y_global, RQA_o_global, ...
  1865. cond_to_plot, xlims, ylims_user, lw);
  1866. % =================== LOCAL FUNCTIONS ===================
  1867. function plot_one_context(RQA_y, RQA_o, cond_to_plot, xlims, ylims_user, lw, plotTitle)
  1868. % ----- Validate -----
  1869. require_phasespace(RQA_y, 'RQA_y');
  1870. require_phasespace(RQA_o, 'RQA_o');
  1871. coords_y = RQA_y.PhaseSpace.coords;
  1872. coords_o = RQA_o.PhaseSpace.coords;
  1873. if cond_to_plot > numel(coords_y) || cond_to_plot > numel(coords_o)
  1874. error('cond_to_plot exceeds available conditions.');
  1875. end
  1876. PS_y = coords_y{cond_to_plot}; % [T x 3]
  1877. PS_o = coords_o{cond_to_plot}; % [T x 3]
  1878. if size(PS_y,2) < 3 || size(PS_o,2) < 3
  1879. error('Phase-space coordinates do not have 3 columns. Did you run with ''principalcomps'',[1:3]?');
  1880. end
  1881. t_y = RQA_y.PhaseSpace.time(:);
  1882. t_o = RQA_o.PhaseSpace.time(:);
  1883. % ----- Align time bases -----
  1884. [t, PS_o_aligned, PS_y_aligned] = align_to_young_time(t_y, PS_y, t_o, PS_o);
  1885. % ----- FULL colormap (EXACTLY like phase space video: jet(full_length)) -----
  1886. cmapFull = jet(numel(t));
  1887. % ----- Clip to requested time window -----
  1888. % Compare magnitudes by treating numbers as positive
  1889. % i.e., diff = abs(Young) - abs(Older)
  1890. [tW, diffPS, cmapW] = clip_and_diff(t, abs(PS_y_aligned), abs(PS_o_aligned), xlims, plotTitle, cmapFull);
  1891. % ----- Plot -----
  1892. hold on; grid on; box on;
  1893. plot_time_colored_segments(tW, diffPS(:,1), '-', lw, cmapW); % PC1 solid
  1894. plot_time_colored_segments(tW, diffPS(:,2), '--', lw, cmapW); % PC2 dashed
  1895. plot_time_colored_segments(tW, diffPS(:,3), ':', lw, cmapW); % PC3 dotted
  1896. xlim(xlims);
  1897. apply_ylims(ylims_user);
  1898. xlabel('Time (s)');
  1899. ylabel('Difference');
  1900. title(sprintf('%s (cond %d)', plotTitle, cond_to_plot), 'Interpreter','none');
  1901. % ----- Legend (bottom-right, large, Helvetica) -----
  1902. h1 = plot(nan, nan, '-', 'LineWidth', lw, 'Color', [0 0 0]);
  1903. h2 = plot(nan, nan, '--', 'LineWidth', lw, 'Color', [0 0 0]);
  1904. h3 = plot(nan, nan, ':', 'LineWidth', lw, 'Color', [0 0 0]);
  1905. lgd = legend([h1 h2 h3], {'PC #1', 'PC #2', 'PC #3'}, 'Location', 'southeast');
  1906. set(lgd, 'FontName', 'Helvetica', 'FontSize', 14);
  1907. drawnow;
  1908. end
  1909. function plot_local_vs_global_avg(RQA_y_local, RQA_o_local, RQA_y_global, RQA_o_global, ...
  1910. cond_to_plot, xlims, ylims_user, lw)
  1911. require_phasespace(RQA_y_local, 'RQA_y_local');
  1912. require_phasespace(RQA_o_local, 'RQA_o_local');
  1913. require_phasespace(RQA_y_global, 'RQA_y_global');
  1914. require_phasespace(RQA_o_global, 'RQA_o_global');
  1915. % Grab phase space coords for the chosen condition
  1916. PS_yL = RQA_y_local.PhaseSpace.coords{cond_to_plot};
  1917. PS_oL = RQA_o_local.PhaseSpace.coords{cond_to_plot};
  1918. t_yL = RQA_y_local.PhaseSpace.time(:);
  1919. t_oL = RQA_o_local.PhaseSpace.time(:);
  1920. PS_yG = RQA_y_global.PhaseSpace.coords{cond_to_plot};
  1921. PS_oG = RQA_o_global.PhaseSpace.coords{cond_to_plot};
  1922. t_yG = RQA_y_global.PhaseSpace.time(:);
  1923. t_oG = RQA_o_global.PhaseSpace.time(:);
  1924. if size(PS_yL,2) < 3 || size(PS_oL,2) < 3 || size(PS_yG,2) < 3 || size(PS_oG,2) < 3
  1925. error('Local/Global phase-space coordinates do not have 3 columns. Did you run with ''principalcomps'',[1:3]?');
  1926. end
  1927. % Build LOCAL average on LOCAL young timebase
  1928. [tL, PS_oL_aligned, PS_yL_aligned] = align_to_young_time(t_yL, PS_yL, t_oL, PS_oL);
  1929. local_avg = 0.5*(PS_yL_aligned + PS_oL_aligned);
  1930. % Build GLOBAL average, interpolated onto LOCAL timebase (so subtraction is aligned)
  1931. [tG, PS_oG_aligned, PS_yG_aligned] = align_to_young_time(t_yG, PS_yG, t_oG, PS_oG);
  1932. global_avg_Gtime = 0.5*(PS_yG_aligned + PS_oG_aligned);
  1933. global_avg = zeros(size(local_avg));
  1934. global_avg(:,1) = interp1(tG, global_avg_Gtime(:,1), tL, 'linear', 'extrap');
  1935. global_avg(:,2) = interp1(tG, global_avg_Gtime(:,2), tL, 'linear', 'extrap');
  1936. global_avg(:,3) = interp1(tG, global_avg_Gtime(:,3), tL, 'linear', 'extrap');
  1937. % ----- FULL colormap based on the LOCAL timebase length (matches video indexing) -----
  1938. cmapFull = jet(numel(tL));
  1939. % Clip + diff
  1940. % Compare magnitudes by treating numbers as positive
  1941. % i.e., diff = abs(Local_avg) - abs(Global_avg)
  1942. plotTitle = 'LOCAL vs GLOBAL (avg(Y,O))';
  1943. [tW, diffPS, cmapW] = clip_and_diff(tL, abs(local_avg), abs(global_avg), xlims, plotTitle, cmapFull); % Local - Global (magnitudes)
  1944. % Plot
  1945. hold on; grid on; box on;
  1946. plot_time_colored_segments(tW, diffPS(:,1), '-', lw, cmapW); % PC1 solid
  1947. plot_time_colored_segments(tW, diffPS(:,2), '--', lw, cmapW); % PC2 dashed
  1948. plot_time_colored_segments(tW, diffPS(:,3), ':', lw, cmapW); % PC3 dotted
  1949. xlim(xlims);
  1950. apply_ylims(ylims_user);
  1951. xlabel('Time (s)');
  1952. ylabel('Difference');
  1953. title(sprintf('%s (cond %d)', plotTitle, cond_to_plot), 'Interpreter','none');
  1954. % ----- Legend (bottom-right, large, Helvetica) -----
  1955. h1 = plot(nan, nan, '-', 'LineWidth', lw, 'Color', [0 0 0]);
  1956. h2 = plot(nan, nan, '--', 'LineWidth', lw, 'Color', [0 0 0]);
  1957. h3 = plot(nan, nan, ':', 'LineWidth', lw, 'Color', [0 0 0]);
  1958. lgd = legend([h1 h2 h3], {'PC #1', 'PC #2', 'PC #3'}, 'Location', 'southeast');
  1959. set(lgd, 'FontName', 'Helvetica', 'FontSize', 14);
  1960. drawnow;
  1961. end
  1962. function require_phasespace(RQA, nameStr)
  1963. if ~isfield(RQA,'PhaseSpace') || ~isfield(RQA.PhaseSpace,'coords') || ~isfield(RQA.PhaseSpace,'time')
  1964. error('%s is missing PhaseSpace.coords or PhaseSpace.time. Run the modified BROADNESS_PhaseSpace_RQA first.', nameStr);
  1965. end
  1966. end
  1967. function [t, PS_o_aligned, PS_y_aligned] = align_to_young_time(t_y, PS_y, t_o, PS_o)
  1968. % Align older onto young time base (or keep if identical)
  1969. same_time = (numel(t_y) == numel(t_o)) && all(abs(t_y - t_o) < 1e-12);
  1970. if same_time
  1971. t = t_y;
  1972. PS_o_aligned = PS_o;
  1973. PS_y_aligned = PS_y;
  1974. else
  1975. t = t_y;
  1976. PS_y_aligned = PS_y;
  1977. PS_o_aligned = zeros(size(PS_y,1), 3);
  1978. PS_o_aligned(:,1) = interp1(t_o, PS_o(:,1), t, 'linear', 'extrap');
  1979. PS_o_aligned(:,2) = interp1(t_o, PS_o(:,2), t, 'linear', 'extrap');
  1980. PS_o_aligned(:,3) = interp1(t_o, PS_o(:,3), t, 'linear', 'extrap');
  1981. end
  1982. % Enforce same length safely
  1983. n = min([numel(t), size(PS_y_aligned,1), size(PS_o_aligned,1)]);
  1984. t = t(1:n);
  1985. PS_y_aligned = PS_y_aligned(1:n,:);
  1986. PS_o_aligned = PS_o_aligned(1:n,:);
  1987. end
  1988. function [tW, diffPS, cmapW] = clip_and_diff(t, A, B, xlims, contextLabel, cmapFull)
  1989. % Clip to xlims, then compute A-B.
  1990. % ALSO return cmapW as the subset of a FULL-length jet colormap,
  1991. % so colors match the phase-space video exactly per timepoint index.
  1992. if nargin < 6 || isempty(cmapFull)
  1993. cmapFull = jet(numel(t));
  1994. end
  1995. if xlims(1) < min(t) || xlims(2) > max(t)
  1996. warning('%s: requested x-limits [%.3f %.3f] exceed available data range [%.3f %.3f]. Data will only appear where available.', ...
  1997. contextLabel, xlims(1), xlims(2), min(t), max(t));
  1998. end
  1999. idx = (t >= xlims(1)) & (t <= xlims(2));
  2000. if ~any(idx)
  2001. error('%s: no data points fall within xlims = [%.3f %.3f].', contextLabel, xlims(1), xlims(2));
  2002. end
  2003. tW = t(idx);
  2004. diffPS = A(idx,:) - B(idx,:);
  2005. cmapW = cmapFull(idx,:);
  2006. end
  2007. function apply_ylims(ylims_user)
  2008. if ~isempty(ylims_user)
  2009. if ~isnumeric(ylims_user) || numel(ylims_user) ~= 2 || any(~isfinite(ylims_user)) || ylims_user(1) >= ylims_user(2)
  2010. error('ylims_user must be [] or a finite 1x2 vector with ylims_user(1) < ylims_user(2).');
  2011. end
  2012. ylim(ylims_user);
  2013. end
  2014. end
  2015. function plot_time_colored_segments(x, y, lineStyle, lw, cmap)
  2016. % Draw line segment-by-segment, color decided by time index (like phase space video).
  2017. % If there is only a single sample in the clipped window, plotting segments would
  2018. % draw nothing (because there are 0 segments). In that case, draw a visible point.
  2019. if isempty(x) || isempty(y)
  2020. return;
  2021. end
  2022. hold on;
  2023. % Ensure cmap length matches number of points
  2024. if size(cmap,1) < numel(x)
  2025. cmap = jet(numel(x));
  2026. end
  2027. if numel(x) == 1
  2028. % No segments possible -> draw a point so the figure isn't empty
  2029. plot(x, y, 'LineStyle', 'none', 'Marker', '.', 'MarkerSize', 18, ...
  2030. 'Color', cmap(1,:));
  2031. return;
  2032. end
  2033. nSeg = numel(x) - 1;
  2034. for i = 1:nSeg
  2035. % Use the color of the starting point (consistent with per-timepoint coloring)
  2036. col = cmap(i,:);
  2037. plot(x(i:i+1), y(i:i+1), 'LineStyle', lineStyle, 'LineWidth', lw, 'Color', col);
  2038. end
  2039. end
  2040. %% 3d) PHASE-SPACE AGE-GROUP STATISTICS
  2041. % Tests whether younger and older adults differ in 3D phase-space mean trajectory
  2042. % separation within each condition: LOCAL and GLOBAL.
  2043. %
  2044. % Logic:
  2045. % 1) Use PC1/PC2/PC3 phase-space coordinates for every participant.
  2046. % 2) For each condition and time point, compute the young group mean
  2047. % trajectory and the older group mean trajectory.
  2048. % 3) For every time point from 0 to 800 ms, compute:
  2049. %
  2050. % D(t) = || mean_young(t) - mean_older(t) ||
  2051. %
  2052. % where the norm is Euclidean distance in 3D phase space.
  2053. % 4) Create a null distribution by shuffling age-group labels across
  2054. % participants while preserving the original group sizes.
  2055. % 5) Use cluster-based permutation testing over time with 5000 permutations.
  2056. % 6) Plot 3D mean trajectories. Non-significant segments are gray;
  2057. % significant segments use the same jet time-coloring as the phase-space plots.
  2058. %
  2059. % IMPORTANT INTERPRETATION:
  2060. % This tests whether the average young and older trajectories are separated
  2061. % in 3D phase space. It does not test within-group dispersion around a pooled
  2062. % centroid. Because D(t) is non-negative, the test is inherently directional
  2063. % in the sense of larger-than-null separation; opts.tail is retained only for
  2064. % compatibility with earlier code and is not used by the distance test.
  2065. % ------------------- USER SETTINGS -------------------
  2066. phase_stats_opts = struct();
  2067. phase_stats_opts.nPerm = 5000;
  2068. phase_stats_opts.alpha = 0.05;
  2069. phase_stats_opts.clusterAlpha = 0.05;
  2070. phase_stats_opts.testWindow = [-0.100 0.800];
  2071. phase_stats_opts.condToUse = 1;
  2072. phase_stats_opts.tail = 'two';
  2073. phase_stats_opts.randomSeed = 42;
  2074. phase_stats_opts.outDir = fullfile(pwd, 'PhaseSpace_AgeGroup_Stats');
  2075. phase_stats_opts.saveFigures = true;
  2076. phase_stats_opts.showFigures = true;
  2077. phase_stats_opts.lineWidth = 2.5;
  2078. phase_stats_opts.grayColor = [0.65 0.65 0.65];
  2079. phase_stats_opts.youngMarker = 'none';
  2080. phase_stats_opts.olderMarker = 'none';
  2081. phase_stats_opts.savePermutationXlsx = true;
  2082. if ~exist(phase_stats_opts.outDir, 'dir')
  2083. mkdir(phase_stats_opts.outDir);
  2084. end
  2085. RQA_BROADNESS_young_local = BROADNESS_PhaseSpace_RQA(BROADNESS_young_local, 'principalcomps',[1:3], 'threshold',0.1, 'video','off', 'figure','off', 'plotlabel','Yl');
  2086. RQA_BROADNESS_older_local = BROADNESS_PhaseSpace_RQA(BROADNESS_older_local, 'principalcomps',[1:3], 'threshold',0.1, 'video','off', 'figure','off', 'plotlabel','Ol');
  2087. RQA_BROADNESS_young_global = BROADNESS_PhaseSpace_RQA(BROADNESS_young_global, 'principalcomps',[1:3], 'threshold',0.1, 'video','off', 'figure','off', 'plotlabel','Yg');
  2088. RQA_BROADNESS_older_global = BROADNESS_PhaseSpace_RQA(BROADNESS_older_global, 'principalcomps',[1:3], 'threshold',0.1, 'video','off', 'figure','off', 'plotlabel','Og');
  2089. PhaseStats_Local = run_phase_space_age_stats( ...
  2090. RQA_BROADNESS_young_local, RQA_BROADNESS_older_local, ...
  2091. 'Local', phase_stats_opts);
  2092. PhaseStats_Global = run_phase_space_age_stats( ...
  2093. RQA_BROADNESS_young_global, RQA_BROADNESS_older_global, ...
  2094. 'Global', phase_stats_opts);
  2095. save(fullfile(phase_stats_opts.outDir, 'PhaseSpace_AgeGroup_ClusterStats.mat'), ...
  2096. 'PhaseStats_Local', 'PhaseStats_Global', 'phase_stats_opts');
  2097. writetable(PhaseStats_Local.clusterTable, fullfile(phase_stats_opts.outDir, 'PhaseSpace_AgeGroup_Clusters.xlsx'), 'Sheet','Local');
  2098. writetable(PhaseStats_Global.clusterTable, fullfile(phase_stats_opts.outDir, 'PhaseSpace_AgeGroup_Clusters.xlsx'), 'Sheet','Global');
  2099. fprintf('\nSaved phase-space age-group statistics to:\n%s\n', phase_stats_opts.outDir);
  2100. %% 3e) PHASE-SPACE LOCAL-vs-GLOBAL STATISTICS ACROSS ALL PARTICIPANTS
  2101. % Tests whether the LOCAL and GLOBAL 3D phase-space mean trajectories differ
  2102. % across all participants, collapsing across age group.
  2103. %
  2104. % Logic:
  2105. % 1) Use PC1/PC2/PC3 phase-space coordinates for every participant.
  2106. % 2) Combine younger and older participants into one all-participants sample.
  2107. % 3) For every time point from 0 to 800 ms, compute:
  2108. %
  2109. % D(t) = || mean_local(t) - mean_global(t) ||
  2110. %
  2111. % where the norm is Euclidean distance in 3D phase space.
  2112. % 4) Create a null distribution by randomly swapping LOCAL/GLOBAL labels
  2113. % within each participant while preserving participant pairing.
  2114. % 5) Use cluster-based permutation testing over time with the same settings
  2115. % as above.
  2116. % 6) Plot 3D mean trajectories. Non-significant segments are gray;
  2117. % significant segments use the same jet time-coloring as the phase-space plots.
  2118. %
  2119. % IMPORTANT INTERPRETATION:
  2120. % This tests whether the average LOCAL and GLOBAL trajectories are separated
  2121. % in 3D phase space across all participants. It assumes participant order is
  2122. % matched between LOCAL and GLOBAL within each age group.
  2123. PhaseStats_LocalVsGlobal_AllParticipants = run_phase_space_local_global_stats_all_participants( ...
  2124. RQA_BROADNESS_young_local, RQA_BROADNESS_older_local, ...
  2125. RQA_BROADNESS_young_global, RQA_BROADNESS_older_global, ...
  2126. phase_stats_opts);
  2127. save(fullfile(phase_stats_opts.outDir, 'PhaseSpace_LocalVsGlobal_AllParticipants_ClusterStats.mat'), ...
  2128. 'PhaseStats_LocalVsGlobal_AllParticipants', 'phase_stats_opts');
  2129. writetable(PhaseStats_LocalVsGlobal_AllParticipants.clusterTable, ...
  2130. fullfile(phase_stats_opts.outDir, 'PhaseSpace_LocalVsGlobal_AllParticipants_Clusters.xlsx'), ...
  2131. 'Sheet','LocalVsGlobal_AllParticipants');
  2132. fprintf('\nSaved phase-space local-vs-global all-participants statistics to:\n%s\n', phase_stats_opts.outDir);
  2133. %% ======================= LOCAL FUNCTIONS =======================
  2134. function OUT = run_phase_space_age_stats(RQA_young, RQA_older, conditionLabel, opts)
  2135. require_participant_phase_space(RQA_young, 'RQA_young');
  2136. require_participant_phase_space(RQA_older, 'RQA_older');
  2137. cond = opts.condToUse;
  2138. [tY, Y] = rqa_to_3d_array(RQA_young, cond);
  2139. [tO, O] = rqa_to_3d_array(RQA_older, cond);
  2140. [t, Y, O] = align_group_phase_spaces(tY, Y, tO, O);
  2141. if size(Y,2) ~= 3 || size(O,2) ~= 3
  2142. error('%s: expected 3D phase space. Re-run RQA with ''principalcomps'',[1:3].', conditionLabel);
  2143. end
  2144. winMask = t >= opts.testWindow(1) & t <= opts.testWindow(2);
  2145. if ~any(winMask)
  2146. error('%s: no time samples found in requested test window [%.3f %.3f] s.', ...
  2147. conditionLabel, opts.testWindow(1), opts.testWindow(2));
  2148. end
  2149. tW = t(winMask);
  2150. YW = Y(winMask,:,:);
  2151. OW = O(winMask,:,:);
  2152. meanY = mean(YW, 3, 'omitnan');
  2153. meanO = mean(OW, 3, 'omitnan');
  2154. observedDistance = euclidean_distance_between_trajectories(meanY, meanO);
  2155. stats = cluster_perm_mean_trajectory_distance(YW, OW, opts.nPerm, opts.clusterAlpha, opts.alpha, opts.randomSeed);
  2156. OUT = struct();
  2157. OUT.conditionLabel = conditionLabel;
  2158. OUT.time = tW(:);
  2159. OUT.youngMeanTrajectory = meanY;
  2160. OUT.olderMeanTrajectory = meanO;
  2161. OUT.observedDistance = observedDistance(:)';
  2162. OUT.youngParticipantTrajectories = YW;
  2163. OUT.olderParticipantTrajectories = OW;
  2164. OUT.stats = stats;
  2165. OUT.clusterTable = make_cluster_table(stats, tW, conditionLabel);
  2166. if isfield(opts, 'savePermutationXlsx') && opts.savePermutationXlsx
  2167. save_phase_space_permutation_details_xlsx(OUT, opts);
  2168. end
  2169. plot_phase_space_age_stats_3d(OUT, opts);
  2170. end
  2171. function require_participant_phase_space(RQA, nameStr)
  2172. if ~isfield(RQA, 'PhaseSpace') || ~isfield(RQA.PhaseSpace, 'participant_coords') || ~isfield(RQA.PhaseSpace, 'time')
  2173. error(['%s lacks RQA.PhaseSpace.participant_coords. Replace BROADNESS_PhaseSpace_RQA.m ', ...
  2174. 'with the modified helper code and re-run the RQA calls.'], nameStr);
  2175. end
  2176. end
  2177. function [t, A] = rqa_to_3d_array(RQA, cond)
  2178. t = RQA.PhaseSpace.time(:);
  2179. pcells = RQA.PhaseSpace.participant_coords;
  2180. if cond > size(pcells,1)
  2181. error('Requested condition %d, but RQA output only has %d condition(s).', cond, size(pcells,1));
  2182. end
  2183. nP = size(pcells,2);
  2184. first = pcells{cond,1};
  2185. nT = size(first,1);
  2186. nD = size(first,2);
  2187. A = nan(nT, nD, nP);
  2188. for p = 1:nP
  2189. X = pcells{cond,p};
  2190. if size(X,1) ~= nT || size(X,2) ~= nD
  2191. error('Participant %d has inconsistent phase-space size.', p);
  2192. end
  2193. A(:,:,p) = X;
  2194. end
  2195. n = min(numel(t), size(A,1));
  2196. t = t(1:n);
  2197. A = A(1:n,:,:);
  2198. end
  2199. function [t, Y, O] = align_group_phase_spaces(tY, Y, tO, O)
  2200. sameTime = numel(tY) == numel(tO) && all(abs(tY(:)-tO(:)) < 1e-12);
  2201. if sameTime
  2202. t = tY(:);
  2203. return;
  2204. end
  2205. t = tY(:);
  2206. O2 = nan(numel(t), size(O,2), size(O,3));
  2207. for p = 1:size(O,3)
  2208. for d = 1:size(O,2)
  2209. O2(:,d,p) = interp1(tO(:), O(:,d,p), t, 'linear', 'extrap');
  2210. end
  2211. end
  2212. O = O2;
  2213. end
  2214. function D = euclidean_distance_between_trajectories(A, B)
  2215. dif = A - B;
  2216. D = sqrt(sum(dif.^2, 2))';
  2217. end
  2218. function S = cluster_perm_mean_trajectory_distance(Y, O, nPerm, clusterAlpha, alpha, randomSeed)
  2219. rng(randomSeed, 'twister');
  2220. nY = size(Y,3);
  2221. nT = size(Y,1);
  2222. meanY = mean(Y, 3, 'omitnan');
  2223. meanO = mean(O, 3, 'omitnan');
  2224. distanceObs = euclidean_distance_between_trajectories(meanY, meanO);
  2225. allData = cat(3, Y, O);
  2226. nAll = size(allData,3);
  2227. permDistance = zeros(nPerm, nT);
  2228. for ip = 1:nPerm
  2229. idx = randperm(nAll);
  2230. Yp = allData(:,:,idx(1:nY));
  2231. Op = allData(:,:,idx(nY+1:end));
  2232. meanYp = mean(Yp, 3, 'omitnan');
  2233. meanOp = mean(Op, 3, 'omitnan');
  2234. permDistance(ip,:) = euclidean_distance_between_trajectories(meanYp, meanOp);
  2235. end
  2236. pObs_uncorrected = zeros(1,nT);
  2237. for tt = 1:nT
  2238. pObs_uncorrected(tt) = (1 + sum(permDistance(:,tt) >= distanceObs(tt))) / (nPerm + 1);
  2239. end
  2240. distanceCrit = prctile(permDistance, 100 * (1 - clusterAlpha), 1);
  2241. supraObs = distanceObs > distanceCrit;
  2242. obsClusters = logical_to_clusters(supraObs);
  2243. obsMass = cluster_masses(distanceObs, obsClusters);
  2244. maxMassNull = zeros(nPerm,1);
  2245. permClusterCounts = zeros(nPerm,1);
  2246. for ip = 1:nPerm
  2247. supraPerm = permDistance(ip,:) > distanceCrit;
  2248. cl = logical_to_clusters(supraPerm);
  2249. permClusterCounts(ip) = numel(cl);
  2250. mass = cluster_masses(permDistance(ip,:), cl);
  2251. if isempty(mass)
  2252. maxMassNull(ip) = 0;
  2253. else
  2254. maxMassNull(ip) = max(mass);
  2255. end
  2256. end
  2257. pCluster = ones(numel(obsMass),1);
  2258. for c = 1:numel(obsMass)
  2259. pCluster(c) = (1 + sum(maxMassNull >= obsMass(c))) / (nPerm + 1);
  2260. end
  2261. sigMask = false(1,nT);
  2262. obsClusterIndex = zeros(1,nT);
  2263. obsClusterP_time = nan(1,nT);
  2264. obsClusterMass_time = nan(1,nT);
  2265. for c = 1:numel(obsClusters)
  2266. obsClusterIndex(obsClusters{c}) = c;
  2267. obsClusterP_time(obsClusters{c}) = pCluster(c);
  2268. obsClusterMass_time(obsClusters{c}) = obsMass(c);
  2269. if pCluster(c) < alpha
  2270. sigMask(obsClusters{c}) = true;
  2271. end
  2272. end
  2273. S = struct();
  2274. S.distanceObs = distanceObs;
  2275. S.distanceCrit = distanceCrit;
  2276. S.pObs_uncorrected = pObs_uncorrected;
  2277. S.clusterAlpha = clusterAlpha;
  2278. S.alpha = alpha;
  2279. S.nPerm = nPerm;
  2280. S.test = 'MeanTrajectoryEuclideanDistance_LabelShuffle';
  2281. S.obsClusters = obsClusters;
  2282. S.obsClusterMass = obsMass;
  2283. S.obsClusterP = pCluster;
  2284. S.obsClusterIndex = obsClusterIndex;
  2285. S.obsClusterP_time = obsClusterP_time;
  2286. S.obsClusterMass_time = obsClusterMass_time;
  2287. S.maxMassNull = maxMassNull;
  2288. S.permClusterCounts = permClusterCounts;
  2289. S.permDistance = permDistance;
  2290. S.significantMask = sigMask;
  2291. end
  2292. function clusters = logical_to_clusters(mask)
  2293. mask = mask(:)';
  2294. idx = find(mask);
  2295. clusters = {};
  2296. if isempty(idx), return; end
  2297. breaks = [1, find(diff(idx) > 1) + 1, numel(idx) + 1];
  2298. for b = 1:numel(breaks)-1
  2299. clusters{end+1} = idx(breaks(b):breaks(b+1)-1); %#ok<AGROW>
  2300. end
  2301. end
  2302. function mass = cluster_masses(statVals, clusters)
  2303. mass = zeros(numel(clusters),1);
  2304. for c = 1:numel(clusters)
  2305. idx = clusters{c};
  2306. mass(c) = sum(statVals(idx));
  2307. end
  2308. end
  2309. function T = make_cluster_table(S, t, conditionLabel)
  2310. nC = numel(S.obsClusters);
  2311. Condition = strings(nC,1);
  2312. Cluster = (1:nC)';
  2313. Start_s = nan(nC,1);
  2314. End_s = nan(nC,1);
  2315. Duration_ms = nan(nC,1);
  2316. ClusterMass = nan(nC,1);
  2317. P_cluster = nan(nC,1);
  2318. Significant = false(nC,1);
  2319. for c = 1:nC
  2320. idx = S.obsClusters{c};
  2321. Condition(c) = string(conditionLabel);
  2322. Start_s(c) = t(idx(1));
  2323. End_s(c) = t(idx(end));
  2324. Duration_ms(c) = 1000 * (End_s(c) - Start_s(c));
  2325. ClusterMass(c) = S.obsClusterMass(c);
  2326. P_cluster(c) = S.obsClusterP(c);
  2327. Significant(c) = P_cluster(c) < S.alpha;
  2328. end
  2329. T = table(Condition, Cluster, Start_s, End_s, Duration_ms, ClusterMass, P_cluster, Significant);
  2330. end
  2331. function save_phase_space_permutation_details_xlsx(OUT, opts)
  2332. if ~exist(opts.outDir, 'dir')
  2333. mkdir(opts.outDir);
  2334. end
  2335. safeLabel = regexprep(OUT.conditionLabel, '[^A-Za-z0-9_]+', '_');
  2336. outXlsx = fullfile(opts.outDir, sprintf('PhaseSpace_%s_PermutationDetails.xlsx', safeLabel));
  2337. if exist(outXlsx, 'file')
  2338. delete(outXlsx);
  2339. end
  2340. S = OUT.stats;
  2341. t = OUT.time(:);
  2342. nT = numel(t);
  2343. nY = size(OUT.youngParticipantTrajectories,3);
  2344. nO = size(OUT.olderParticipantTrajectories,3);
  2345. Setting = string({ ...
  2346. 'Condition'; ...
  2347. 'Test'; ...
  2348. 'DistanceDefinition'; ...
  2349. 'ComparedGroups'; ...
  2350. 'nYoung'; ...
  2351. 'nOlder'; ...
  2352. 'nTimepoints'; ...
  2353. 'TestWindowStart_s'; ...
  2354. 'TestWindowEnd_s'; ...
  2355. 'nPermutations'; ...
  2356. 'RandomSeed'; ...
  2357. 'ClusterFormingAlpha'; ...
  2358. 'ClusterCorrectedAlpha'; ...
  2359. 'ClusterFormingCriticalValue'; ...
  2360. 'NullDefinition'; ...
  2361. 'ImportantInterpretation'});
  2362. Value = string({ ...
  2363. OUT.conditionLabel; ...
  2364. 'Euclidean distance between young and older group mean trajectories + label-shuffling cluster permutation correction over time'; ...
  2365. 'D(t) = norm(mean_young_3D(t) - mean_older_3D(t))'; ...
  2366. 'Younger adults vs older adults'; ...
  2367. num2str(nY); ...
  2368. num2str(nO); ...
  2369. num2str(nT); ...
  2370. sprintf('%.6f', min(t)); ...
  2371. sprintf('%.6f', max(t)); ...
  2372. num2str(S.nPerm); ...
  2373. num2str(opts.randomSeed); ...
  2374. sprintf('%.6f', S.clusterAlpha); ...
  2375. sprintf('%.6f', S.alpha); ...
  2376. 'Time-point-specific 95th percentile of shuffled-label D(t) when clusterAlpha = 0.05'; ...
  2377. 'Shuffle age-group labels across participant trajectories while preserving original group sizes'; ...
  2378. 'This tests whether average group trajectories are separated in phase space, not whether individuals differ in distance from a pooled centroid'});
  2379. SettingsTable = table(Setting, Value);
  2380. writetable(SettingsTable, outXlsx, 'Sheet', 'Settings');
  2381. TimeIndex = (1:nT)';
  2382. Time_s = t;
  2383. Time_ms = 1000 .* t;
  2384. DistanceObserved = S.distanceObs(:);
  2385. DistanceCrit = S.distanceCrit(:);
  2386. P_uncorrected = S.pObs_uncorrected(:);
  2387. SupraThreshold = S.obsClusterIndex(:) > 0;
  2388. ClusterID = S.obsClusterIndex(:);
  2389. ClusterMass = S.obsClusterMass_time(:);
  2390. ClusterP_corrected = S.obsClusterP_time(:);
  2391. SignificantClusterCorrected = S.significantMask(:);
  2392. TimepointStats = table(TimeIndex, Time_s, Time_ms, DistanceObserved, DistanceCrit, ...
  2393. P_uncorrected, SupraThreshold, ClusterID, ClusterMass, ClusterP_corrected, ...
  2394. SignificantClusterCorrected, ...
  2395. 'VariableNames', {'TimeIndex','Time_s','Time_ms','DistanceObserved_YoungMeanVsOlderMean', ...
  2396. 'DistanceCritical_PointwisePermutation','P_uncorrected','SupraThreshold','ClusterID', ...
  2397. 'ClusterMass','ClusterP_corrected','SignificantClusterCorrected'});
  2398. writetable(TimepointStats, outXlsx, 'Sheet', 'TimepointStats');
  2399. YoungNorm = sqrt(sum(OUT.youngMeanTrajectory.^2, 2));
  2400. OlderNorm = sqrt(sum(OUT.olderMeanTrajectory.^2, 2));
  2401. YoungMinusOlderNorm = YoungNorm - OlderNorm;
  2402. YoungerGreaterMask = S.significantMask(:) & (YoungNorm > OlderNorm);
  2403. OlderGreaterMask = S.significantMask(:) & (OlderNorm > YoungNorm);
  2404. DirectionNorms = table(Time_ms, YoungNorm, OlderNorm, YoungMinusOlderNorm, ...
  2405. YoungerGreaterMask, OlderGreaterMask, ...
  2406. 'VariableNames', {'Time_ms','YoungNorm','OlderNorm','YoungMinusOlderNorm', ...
  2407. 'YoungerGreaterMask','OlderGreaterMask'});
  2408. writetable(DirectionNorms, outXlsx, 'Sheet', 'DirectionNorms');
  2409. writetable(OUT.clusterTable, outXlsx, 'Sheet', 'ObservedClusters');
  2410. Permutation = (1:S.nPerm)';
  2411. MaxClusterMass = S.maxMassNull(:);
  2412. NumClusters = S.permClusterCounts(:);
  2413. NullDistribution = table(Permutation, MaxClusterMass, NumClusters);
  2414. writetable(NullDistribution, outXlsx, 'Sheet', 'NullDistribution');
  2415. PermutationDistances = array2table(S.permDistance);
  2416. PermutationDistances.Properties.VariableNames = make_time_varnames(t, 'Dperm');
  2417. PermutationDistances = addvars(PermutationDistances, Permutation, 'Before', 1);
  2418. writetable(PermutationDistances, outXlsx, 'Sheet', 'PermutationDistances');
  2419. Trajectories = table(TimeIndex, Time_s, Time_ms, ...
  2420. OUT.youngMeanTrajectory(:,1), OUT.youngMeanTrajectory(:,2), OUT.youngMeanTrajectory(:,3), ...
  2421. OUT.olderMeanTrajectory(:,1), OUT.olderMeanTrajectory(:,2), OUT.olderMeanTrajectory(:,3), ...
  2422. S.distanceObs(:), ...
  2423. 'VariableNames', {'TimeIndex','Time_s','Time_ms', ...
  2424. 'YoungMean_PC1','YoungMean_PC2','YoungMean_PC3', ...
  2425. 'OlderMean_PC1','OlderMean_PC2','OlderMean_PC3', ...
  2426. 'DistanceObserved_YoungMeanVsOlderMean'});
  2427. writetable(Trajectories, outXlsx, 'Sheet', 'MeanTrajectories');
  2428. YoungCoords = participant_coords_to_table(OUT.youngParticipantTrajectories, t, 'Young');
  2429. OlderCoords = participant_coords_to_table(OUT.olderParticipantTrajectories, t, 'Older');
  2430. writetable(YoungCoords, outXlsx, 'Sheet', 'YoungParticipantCoords');
  2431. writetable(OlderCoords, outXlsx, 'Sheet', 'OlderParticipantCoords');
  2432. fprintf('%s detailed permutation XLSX saved: %s\n', OUT.conditionLabel, outXlsx);
  2433. end
  2434. function T = participant_coords_to_table(A, t, groupLabel)
  2435. nT = size(A,1);
  2436. nP = size(A,3);
  2437. Group = strings(nT*nP,1);
  2438. Participant = nan(nT*nP,1);
  2439. TimeIndex = nan(nT*nP,1);
  2440. Time_s = nan(nT*nP,1);
  2441. Time_ms = nan(nT*nP,1);
  2442. PC1 = nan(nT*nP,1);
  2443. PC2 = nan(nT*nP,1);
  2444. PC3 = nan(nT*nP,1);
  2445. row = 0;
  2446. for p = 1:nP
  2447. for tt = 1:nT
  2448. row = row + 1;
  2449. Group(row) = string(groupLabel);
  2450. Participant(row) = p;
  2451. TimeIndex(row) = tt;
  2452. Time_s(row) = t(tt);
  2453. Time_ms(row) = 1000 * t(tt);
  2454. PC1(row) = A(tt,1,p);
  2455. PC2(row) = A(tt,2,p);
  2456. PC3(row) = A(tt,3,p);
  2457. end
  2458. end
  2459. T = table(Group, Participant, TimeIndex, Time_s, Time_ms, PC1, PC2, PC3);
  2460. end
  2461. function names = make_time_varnames(t, prefix)
  2462. tMs = round(1000 .* t(:));
  2463. names = strings(1, numel(tMs));
  2464. for i = 1:numel(tMs)
  2465. if tMs(i) < 0
  2466. names(i) = sprintf('%s_minus%dms', prefix, abs(tMs(i)));
  2467. else
  2468. names(i) = sprintf('%s_%dms', prefix, tMs(i));
  2469. end
  2470. end
  2471. names = matlab.lang.makeValidName(cellstr(names));
  2472. names = string(matlab.lang.makeUniqueStrings(names));
  2473. end
  2474. function plot_phase_space_age_stats_3d(OUT, opts)
  2475. meanY = OUT.youngMeanTrajectory;
  2476. meanO = OUT.olderMeanTrajectory;
  2477. sigMask = OUT.stats.significantMask(:)';
  2478. youngNorm = sqrt(sum(meanY.^2, 2))';
  2479. olderNorm = sqrt(sum(meanO.^2, 2))';
  2480. youngerGreaterMask = sigMask & (youngNorm > olderNorm);
  2481. olderGreaterMask = sigMask & (olderNorm > youngNorm);
  2482. axisLims = get_common_xyz_limits(meanY, meanO);
  2483. plot_phase_space_age_stats_3d_single_group(OUT, opts, meanY, youngerGreaterMask, ...
  2484. 'Younger', 'Younger > Older', 'Younger', axisLims);
  2485. plot_phase_space_age_stats_3d_single_group(OUT, opts, meanO, olderGreaterMask, ...
  2486. 'Older', 'Older > Younger', 'Older', axisLims);
  2487. plot_average_age_direction_trajectory(OUT, opts, youngerGreaterMask, olderGreaterMask, axisLims);
  2488. end
  2489. function axisLims = get_common_xyz_limits(meanY, meanO)
  2490. allXYZ = [meanY; meanO];
  2491. xLim = [min(allXYZ(:,1),[],'omitnan') max(allXYZ(:,1),[],'omitnan')];
  2492. yLim = [min(allXYZ(:,2),[],'omitnan') max(allXYZ(:,2),[],'omitnan')];
  2493. zLim = [min(allXYZ(:,3),[],'omitnan') max(allXYZ(:,3),[],'omitnan')];
  2494. xLim = pad_axis_limits(xLim);
  2495. yLim = pad_axis_limits(yLim);
  2496. zLim = pad_axis_limits(zLim);
  2497. axisLims = struct();
  2498. axisLims.xLim = xLim;
  2499. axisLims.yLim = yLim;
  2500. axisLims.zLim = zLim;
  2501. end
  2502. function lims = pad_axis_limits(lims)
  2503. if any(~isfinite(lims))
  2504. lims = [-1 1];
  2505. return;
  2506. end
  2507. if lims(1) == lims(2)
  2508. padVal = max(abs(lims(1)) * 0.05, 1e-6);
  2509. else
  2510. padVal = 0.05 * diff(lims);
  2511. end
  2512. lims = [lims(1)-padVal lims(2)+padVal];
  2513. end
  2514. function [youngColor, olderColor] = get_condition_age_colors(conditionLabel)
  2515. if strcmpi(conditionLabel, 'Local')
  2516. youngColor = [0.1 0.2 0.5];
  2517. olderColor = [0.5 0.1 0.1];
  2518. else
  2519. youngColor = [0.4 0.6 0.85];
  2520. olderColor = [0.8 0.2 0.2];
  2521. end
  2522. end
  2523. function plot_phase_space_age_stats_3d_single_group(OUT, opts, XYZ, directionMask, groupLabel, directionLabel, groupFileLabel, axisLims)
  2524. t = OUT.time(:);
  2525. cmap = jet(numel(t));
  2526. figVis = 'on';
  2527. if ~opts.showFigures
  2528. figVis = 'off';
  2529. end
  2530. h = figure('Visible',figVis, 'Color','w', ...
  2531. 'Name',['Phase-space stats - ' OUT.conditionLabel ' - ' groupLabel]);
  2532. pos = get(h,'Position');
  2533. pos(3) = pos(3) + 60; % increase width by 20 points
  2534. set(h,'Position',pos);
  2535. ax = axes('Parent', h);
  2536. hold(ax, 'on');
  2537. grid(ax, 'on');
  2538. box(ax, 'on');
  2539. view(ax, 3);
  2540. axis(ax, 'vis3d');
  2541. plot_colored_sig_trajectory(ax, XYZ, directionMask, cmap, opts.grayColor, opts.lineWidth);
  2542. %scatter3(ax, XYZ(1,1), XYZ(1,2), XYZ(1,3), 45, [0 0 0], 'filled', 'DisplayName','Start');
  2543. %scatter3(ax, XYZ(end,1), XYZ(end,2), XYZ(end,3), 45, [1 1 1], 'filled', 'MarkerEdgeColor',[0 0 0], 'DisplayName','End');
  2544. xlim(ax, axisLims.xLim);
  2545. ylim(ax, axisLims.yLim);
  2546. zlim(ax, axisLims.zLim);
  2547. xlabel(ax, 'Brain network 1');
  2548. ylabel(ax, 'Brain network 2');
  2549. zlabel(ax, 'Brain network 3');
  2550. title(ax, sprintf('%s: %s mean trajectory (%s)', ...
  2551. OUT.conditionLabel, groupLabel, directionLabel), 'Interpreter','none');
  2552. sigCmap = make_significance_time_colormap(cmap, directionMask, opts.grayColor);
  2553. colormap(ax, sigCmap);
  2554. cb = colorbar(ax);
  2555. cb.Label.String = 'Time (s)';
  2556. desiredTimes = -0.1:0.1:0.8;
  2557. desiredTimes = desiredTimes(desiredTimes >= min(t) & desiredTimes <= max(t));
  2558. cb.Ticks = (desiredTimes - min(t)) ./ (max(t) - min(t));
  2559. cb.TickLabels = arrayfun(@format_phase_space_time_tick, desiredTimes, 'UniformOutput', false);
  2560. hGray = plot3(ax, nan, nan, nan, '-', 'Color', opts.grayColor, 'LineWidth', opts.lineWidth);
  2561. hSig = plot_multicolor_dummy_legend_line_3d(ax, cmap, opts.lineWidth);
  2562. legend(ax, [hGray hSig], {'Non-significant segment','Significant segment'}, ...
  2563. 'Location','southoutside', 'Orientation','horizontal');
  2564. set(ax, 'FontName','Helvetica');
  2565. set(findall(h,'Type','text'), 'FontName','Helvetica');
  2566. if opts.saveFigures
  2567. safeLabel = regexprep(OUT.conditionLabel, '[^A-Za-z0-9_]+', '_');
  2568. outPng = fullfile(opts.outDir, sprintf('PhaseSpace_%s_%s_AgeGroupClusterStats_3D.png', ...
  2569. safeLabel, groupFileLabel));
  2570. print(h, outPng, '-dpng', '-r300');
  2571. fprintf('%s %s 3D figure saved: %s\n', OUT.conditionLabel, groupLabel, outPng);
  2572. end
  2573. end
  2574. function plot_significance_time_line_single_group(OUT, opts, directionMask, groupLabel, directionLabel, groupFileLabel)
  2575. t = OUT.time(:);
  2576. tMs = 1000 .* t;
  2577. cmap = jet(numel(t));
  2578. figVis = 'on';
  2579. if ~opts.showFigures
  2580. figVis = 'off';
  2581. end
  2582. h = figure('Visible',figVis, 'Color','w', ...
  2583. 'Name',['Significant time points - ' OUT.conditionLabel ' - ' groupLabel]);
  2584. pos = get(h,'Position');
  2585. pos(3) = pos(3) + 60; % increase width by 20 points
  2586. set(h,'Position',pos);
  2587. axLine = axes('Parent', h);
  2588. hold(axLine, 'on');
  2589. box(axLine, 'off');
  2590. plot_significance_time_line(axLine, tMs, directionMask, cmap, opts.grayColor, opts.lineWidth);
  2591. xlim(axLine, [min(tMs) max(tMs)]);
  2592. ylim(axLine, [-1 1]);
  2593. yticks(axLine, []);
  2594. xlabel(axLine, 'Time (ms)');
  2595. title(axLine, sprintf('%s: %s significant time points (%s)', ...
  2596. OUT.conditionLabel, groupLabel, directionLabel), 'Interpreter','none');
  2597. colormap(axLine, cmap);
  2598. cb = colorbar(axLine);
  2599. cb.Label.String = 'Time (s)';
  2600. desiredTimes = -0.1:0.1:0.8;
  2601. desiredTimes = desiredTimes(desiredTimes >= min(t) & desiredTimes <= max(t));
  2602. cb.Ticks = (desiredTimes - min(t)) ./ (max(t) - min(t));
  2603. cb.TickLabels = arrayfun(@format_phase_space_time_tick, desiredTimes, 'UniformOutput', false);
  2604. hGray = plot(axLine, nan, nan, '-', 'Color', opts.grayColor, 'LineWidth', opts.lineWidth);
  2605. hSig = plot_multicolor_dummy_legend_line_2d(axLine, cmap, opts.lineWidth);
  2606. legend(axLine, [hGray hSig], {'Non-significant segment','Significant segment'}, 'Location','best');
  2607. set(axLine, 'FontName','Helvetica');
  2608. set(findall(h,'Type','text'), 'FontName','Helvetica');
  2609. if opts.saveFigures
  2610. safeLabel = regexprep(OUT.conditionLabel, '[^A-Za-z0-9_]+', '_');
  2611. outPng = fullfile(opts.outDir, sprintf('PhaseSpace_%s_%s_SignificantTimeLine.png', ...
  2612. safeLabel, groupFileLabel));
  2613. print(h, outPng, '-dpng', '-r300');
  2614. fprintf('%s %s significance-line figure saved: %s\n', OUT.conditionLabel, groupLabel, outPng);
  2615. end
  2616. end
  2617. function plot_average_age_direction_trajectory(OUT, opts, youngerGreaterMask, olderGreaterMask, axisLims)
  2618. meanY = OUT.youngMeanTrajectory;
  2619. meanO = OUT.olderMeanTrajectory;
  2620. avgXYZ = (meanY + meanO) ./ 2;
  2621. [youngColor, olderColor] = get_condition_age_colors(OUT.conditionLabel);
  2622. figVis = 'on';
  2623. if ~opts.showFigures
  2624. figVis = 'off';
  2625. end
  2626. h = figure('Visible',figVis, 'Color','w', ...
  2627. 'Name',['Average age-direction trajectory - ' OUT.conditionLabel]);
  2628. pos = get(h,'Position');
  2629. pos(3) = pos(3) + 60; % increase width by 20 points
  2630. set(h,'Position',pos);
  2631. ax = axes('Parent', h);
  2632. hold(ax, 'on');
  2633. grid(ax, 'on');
  2634. box(ax, 'on');
  2635. view(ax, 3);
  2636. axis(ax, 'vis3d');
  2637. plot_age_direction_trajectory(ax, avgXYZ, youngerGreaterMask, olderGreaterMask, ...
  2638. opts.grayColor, youngColor, olderColor, opts.lineWidth);
  2639. %scatter3(ax, avgXYZ(1,1), avgXYZ(1,2), avgXYZ(1,3), 45, [0 0 0], 'filled', 'DisplayName','Start');
  2640. %scatter3(ax, avgXYZ(end,1), avgXYZ(end,2), avgXYZ(end,3), 45, [1 1 1], 'filled', 'MarkerEdgeColor',[0 0 0], 'DisplayName','End');
  2641. xlim(ax, axisLims.xLim);
  2642. ylim(ax, axisLims.yLim);
  2643. zlim(ax, axisLims.zLim);
  2644. xlabel(ax, 'Brain network 1');
  2645. ylabel(ax, 'Brain network 2');
  2646. zlabel(ax, 'Brain network 3');
  2647. title(ax, sprintf('%s: average mean trajectory', OUT.conditionLabel), 'Interpreter','none');
  2648. avgCmap = make_age_direction_time_colormap(numel(OUT.time), youngerGreaterMask, olderGreaterMask, ...
  2649. opts.grayColor, youngColor, olderColor);
  2650. colormap(ax, avgCmap);
  2651. cb = colorbar(ax);
  2652. cb.Label.String = 'Time (s)';
  2653. t = OUT.time(:);
  2654. desiredTimes = -0.1:0.1:0.8;
  2655. desiredTimes = desiredTimes(desiredTimes >= min(t) & desiredTimes <= max(t));
  2656. cb.Ticks = (desiredTimes - min(t)) ./ (max(t) - min(t));
  2657. cb.TickLabels = arrayfun(@format_phase_space_time_tick, desiredTimes, 'UniformOutput', false);
  2658. hGray = plot3(ax, nan, nan, nan, '-', 'Color', opts.grayColor, 'LineWidth', opts.lineWidth);
  2659. hYoung = plot3(ax, nan, nan, nan, '-', 'Color', youngColor, 'LineWidth', opts.lineWidth);
  2660. hOld = plot3(ax, nan, nan, nan, '-', 'Color', olderColor, 'LineWidth', opts.lineWidth);
  2661. legend(ax, [hGray hYoung hOld], {'Non-significant segment','Younger > Older','Older > Younger'}, ...
  2662. 'Location','southoutside', 'Orientation','horizontal');
  2663. set(ax, 'FontName','Helvetica');
  2664. set(findall(h,'Type','text'), 'FontName','Helvetica');
  2665. if opts.saveFigures
  2666. safeLabel = regexprep(OUT.conditionLabel, '[^A-Za-z0-9_]+', '_');
  2667. outPng = fullfile(opts.outDir, sprintf('PhaseSpace_%s_AverageAgeDirectionTrajectory_3D.png', safeLabel));
  2668. print(h, outPng, '-dpng', '-r300');
  2669. fprintf('%s average age-direction 3D figure saved: %s\n', OUT.conditionLabel, outPng);
  2670. end
  2671. end
  2672. function plot_average_age_direction_time_line(OUT, opts, youngerGreaterMask, olderGreaterMask)
  2673. t = OUT.time(:);
  2674. tMs = 1000 .* t;
  2675. [youngColor, olderColor] = get_condition_age_colors(OUT.conditionLabel);
  2676. figVis = 'on';
  2677. if ~opts.showFigures
  2678. figVis = 'off';
  2679. end
  2680. h = figure('Visible',figVis, 'Color','w', ...
  2681. 'Name',['Average age-direction significant time line - ' OUT.conditionLabel]);
  2682. pos = get(h,'Position');
  2683. pos(3) = pos(3) + 60; % increase width by 20 points
  2684. set(h,'Position',pos);
  2685. axLine = axes('Parent', h);
  2686. hold(axLine, 'on');
  2687. box(axLine, 'off');
  2688. plot_age_direction_time_line(axLine, tMs, youngerGreaterMask, olderGreaterMask, ...
  2689. opts.grayColor, youngColor, olderColor, opts.lineWidth);
  2690. xlim(axLine, [min(tMs) max(tMs)]);
  2691. ylim(axLine, [-1 1]);
  2692. yticks(axLine, []);
  2693. xlabel(axLine, 'Time (ms)');
  2694. title(axLine, sprintf('%s: age-direction significant time points', OUT.conditionLabel), 'Interpreter','none');
  2695. hGray = plot(axLine, nan, nan, '-', 'Color', opts.grayColor, 'LineWidth', opts.lineWidth);
  2696. hYoung = plot(axLine, nan, nan, '-', 'Color', youngColor, 'LineWidth', opts.lineWidth);
  2697. hOld = plot(axLine, nan, nan, '-', 'Color', olderColor, 'LineWidth', opts.lineWidth);
  2698. legend(axLine, [hGray hYoung hOld], {'Non-significant segment','Younger > Older','Older > Younger'}, 'Location','best');
  2699. set(axLine, 'FontName','Helvetica');
  2700. set(findall(h,'Type','text'), 'FontName','Helvetica');
  2701. if opts.saveFigures
  2702. safeLabel = regexprep(OUT.conditionLabel, '[^A-Za-z0-9_]+', '_');
  2703. outPng = fullfile(opts.outDir, sprintf('PhaseSpace_%s_AverageAgeDirectionTimeLine.png', safeLabel));
  2704. print(h, outPng, '-dpng', '-r300');
  2705. fprintf('%s average age-direction timeline figure saved: %s\n', OUT.conditionLabel, outPng);
  2706. end
  2707. end
  2708. function sigCmap = make_significance_time_colormap(cmap, sigMask, grayColor)
  2709. sigMask = sigMask(:);
  2710. sigCmap = repmat(grayColor, size(cmap,1), 1);
  2711. n = min(size(cmap,1), numel(sigMask));
  2712. sigCmap(1:n,:) = repmat(grayColor, n, 1);
  2713. sigCmap(sigMask(1:n),:) = cmap(sigMask(1:n),:);
  2714. end
  2715. function avgCmap = make_age_direction_time_colormap(nT, youngerGreaterMask, olderGreaterMask, grayColor, youngColor, olderColor)
  2716. youngerGreaterMask = youngerGreaterMask(:);
  2717. olderGreaterMask = olderGreaterMask(:);
  2718. avgCmap = repmat(grayColor, nT, 1);
  2719. nY = min(nT, numel(youngerGreaterMask));
  2720. nO = min(nT, numel(olderGreaterMask));
  2721. avgCmap(youngerGreaterMask(1:nY),:) = repmat(youngColor, sum(youngerGreaterMask(1:nY)), 1);
  2722. avgCmap(olderGreaterMask(1:nO),:) = repmat(olderColor, sum(olderGreaterMask(1:nO)), 1);
  2723. end
  2724. function tickLabel = format_phase_space_time_tick(x)
  2725. if abs(x) < 1e-12
  2726. tickLabel = '0s';
  2727. elseif abs(x - round(x)) < 1e-12
  2728. tickLabel = sprintf('%ds', round(x));
  2729. else
  2730. tickLabel = sprintf('%.1f s', x);
  2731. end
  2732. end
  2733. function plot_colored_sig_trajectory(ax, XYZ, sigMask, cmap, grayColor, lw)
  2734. n = size(XYZ,1);
  2735. for i = 1:(n-1)
  2736. if sigMask(i) || sigMask(i+1)
  2737. col = cmap(i,:);
  2738. else
  2739. col = grayColor;
  2740. end
  2741. plot3(ax, XYZ(i:i+1,1), XYZ(i:i+1,2), XYZ(i:i+1,3), '-', ...
  2742. 'Color', col, 'LineWidth', lw, 'HandleVisibility','off');
  2743. end
  2744. end
  2745. function plot_significance_time_line(ax, tMs, sigMask, cmap, grayColor, lw)
  2746. n = numel(tMs);
  2747. y = zeros(size(tMs));
  2748. for i = 1:(n-1)
  2749. if sigMask(i) || sigMask(i+1)
  2750. col = cmap(i,:);
  2751. else
  2752. col = grayColor;
  2753. end
  2754. plot(ax, tMs(i:i+1), y(i:i+1), '-', ...
  2755. 'Color', col, 'LineWidth', lw, 'HandleVisibility','off');
  2756. end
  2757. end
  2758. function plot_age_direction_trajectory(ax, XYZ, youngerGreaterMask, olderGreaterMask, grayColor, youngColor, olderColor, lw)
  2759. n = size(XYZ,1);
  2760. for i = 1:(n-1)
  2761. if youngerGreaterMask(i) || youngerGreaterMask(i+1)
  2762. col = youngColor;
  2763. elseif olderGreaterMask(i) || olderGreaterMask(i+1)
  2764. col = olderColor;
  2765. else
  2766. col = grayColor;
  2767. end
  2768. plot3(ax, XYZ(i:i+1,1), XYZ(i:i+1,2), XYZ(i:i+1,3), '-', ...
  2769. 'Color', col, 'LineWidth', lw, 'HandleVisibility','off');
  2770. end
  2771. end
  2772. function plot_age_direction_time_line(ax, tMs, youngerGreaterMask, olderGreaterMask, grayColor, youngColor, olderColor, lw)
  2773. n = numel(tMs);
  2774. y = zeros(size(tMs));
  2775. for i = 1:(n-1)
  2776. if youngerGreaterMask(i) || youngerGreaterMask(i+1)
  2777. col = youngColor;
  2778. elseif olderGreaterMask(i) || olderGreaterMask(i+1)
  2779. col = olderColor;
  2780. else
  2781. col = grayColor;
  2782. end
  2783. plot(ax, tMs(i:i+1), y(i:i+1), '-', ...
  2784. 'Color', col, 'LineWidth', lw, 'HandleVisibility','off');
  2785. end
  2786. end
  2787. function hGroup = plot_multicolor_dummy_legend_line_3d(ax, cmap, lw)
  2788. hGroup = hggroup('Parent', ax);
  2789. legendColors = [0 0 0.5; 0 0.5 0; 0.5 0 0];
  2790. for ii = 1:3
  2791. x = [ii-1 ii] ./ 3;
  2792. y = [0 0];
  2793. z = [0 0];
  2794. plot3(ax, x, y, z, '-', ...
  2795. 'Color', legendColors(ii,:), ...
  2796. 'LineWidth', lw, ...
  2797. 'Parent', hGroup, ...
  2798. 'HandleVisibility','off');
  2799. end
  2800. set(get(get(hGroup,'Annotation'),'LegendInformation'), 'IconDisplayStyle','on');
  2801. end
  2802. function hGroup = plot_multicolor_dummy_legend_line_2d(ax, cmap, lw)
  2803. hGroup = hggroup('Parent', ax);
  2804. legendColors = [0 0 0.5; 0 0.5 0; 0.5 0 0];
  2805. for ii = 1:3
  2806. x = [ii-1 ii] ./ 3;
  2807. y = [0 0];
  2808. plot(ax, x, y, '-', ...
  2809. 'Color', legendColors(ii,:), ...
  2810. 'LineWidth', lw, ...
  2811. 'Parent', hGroup, ...
  2812. 'HandleVisibility','off');
  2813. end
  2814. set(get(get(hGroup,'Annotation'),'LegendInformation'), 'IconDisplayStyle','on');
  2815. end
  2816. function OUT = run_phase_space_local_global_stats_all_participants(RQA_young_local, RQA_older_local, RQA_young_global, RQA_older_global, opts)
  2817. require_participant_phase_space(RQA_young_local, 'RQA_young_local');
  2818. require_participant_phase_space(RQA_older_local, 'RQA_older_local');
  2819. require_participant_phase_space(RQA_young_global, 'RQA_young_global');
  2820. require_participant_phase_space(RQA_older_global, 'RQA_older_global');
  2821. cond = opts.condToUse;
  2822. [tYL, YL] = rqa_to_3d_array(RQA_young_local, cond);
  2823. [tOL, OL] = rqa_to_3d_array(RQA_older_local, cond);
  2824. [tYG, YG] = rqa_to_3d_array(RQA_young_global, cond);
  2825. [tOG, OG] = rqa_to_3d_array(RQA_older_global, cond);
  2826. [t, YL, OL, YG, OG] = align_four_group_phase_spaces(tYL, YL, tOL, OL, tYG, YG, tOG, OG);
  2827. LocalAll = cat(3, YL, OL);
  2828. GlobalAll = cat(3, YG, OG);
  2829. if size(LocalAll,3) ~= size(GlobalAll,3)
  2830. error('Local-vs-global comparison requires the same number of local and global participant trajectories.');
  2831. end
  2832. if size(LocalAll,2) ~= 3 || size(GlobalAll,2) ~= 3
  2833. error('Local-vs-global: expected 3D phase space. Re-run RQA with ''principalcomps'',[1:3].');
  2834. end
  2835. winMask = t >= opts.testWindow(1) & t <= opts.testWindow(2);
  2836. if ~any(winMask)
  2837. error('Local-vs-global: no time samples found in requested test window [%.3f %.3f] s.', ...
  2838. opts.testWindow(1), opts.testWindow(2));
  2839. end
  2840. tW = t(winMask);
  2841. LW = LocalAll(winMask,:,:);
  2842. GW = GlobalAll(winMask,:,:);
  2843. meanL = mean(LW, 3, 'omitnan');
  2844. meanG = mean(GW, 3, 'omitnan');
  2845. observedDistance = euclidean_distance_between_trajectories(meanL, meanG);
  2846. stats = cluster_perm_paired_condition_trajectory_distance(LW, GW, opts.nPerm, opts.clusterAlpha, opts.alpha, opts.randomSeed);
  2847. OUT = struct();
  2848. OUT.conditionLabel = 'LocalVsGlobal_AllParticipants';
  2849. OUT.time = tW(:);
  2850. OUT.localMeanTrajectory = meanL;
  2851. OUT.globalMeanTrajectory = meanG;
  2852. OUT.observedDistance = observedDistance(:)';
  2853. OUT.localParticipantTrajectories = LW;
  2854. OUT.globalParticipantTrajectories = GW;
  2855. OUT.stats = stats;
  2856. OUT.clusterTable = make_cluster_table(stats, tW, 'LocalVsGlobal_AllParticipants');
  2857. if isfield(opts, 'savePermutationXlsx') && opts.savePermutationXlsx
  2858. save_phase_space_local_global_permutation_details_xlsx(OUT, opts);
  2859. end
  2860. plot_phase_space_local_global_stats_3d(OUT, opts);
  2861. end
  2862. function [t, A1, A2, A3, A4] = align_four_group_phase_spaces(t1, A1, t2, A2, t3, A3, t4, A4)
  2863. t = t1(:);
  2864. A2 = interpolate_phase_space_to_time(t2, A2, t);
  2865. A3 = interpolate_phase_space_to_time(t3, A3, t);
  2866. A4 = interpolate_phase_space_to_time(t4, A4, t);
  2867. end
  2868. function Aout = interpolate_phase_space_to_time(tIn, Ain, tOut)
  2869. sameTime = numel(tIn) == numel(tOut) && all(abs(tIn(:)-tOut(:)) < 1e-12);
  2870. if sameTime
  2871. Aout = Ain;
  2872. return;
  2873. end
  2874. Aout = nan(numel(tOut), size(Ain,2), size(Ain,3));
  2875. for p = 1:size(Ain,3)
  2876. for d = 1:size(Ain,2)
  2877. Aout(:,d,p) = interp1(tIn(:), Ain(:,d,p), tOut(:), 'linear', 'extrap');
  2878. end
  2879. end
  2880. end
  2881. function S = cluster_perm_paired_condition_trajectory_distance(L, G, nPerm, clusterAlpha, alpha, randomSeed)
  2882. rng(randomSeed, 'twister');
  2883. nP = size(L,3);
  2884. nT = size(L,1);
  2885. meanL = mean(L, 3, 'omitnan');
  2886. meanG = mean(G, 3, 'omitnan');
  2887. distanceObs = euclidean_distance_between_trajectories(meanL, meanG);
  2888. permDistance = zeros(nPerm, nT);
  2889. for ip = 1:nPerm
  2890. Lp = L;
  2891. Gp = G;
  2892. swapMask = rand(1,nP) > 0.5;
  2893. for p = find(swapMask)
  2894. tmp = Lp(:,:,p);
  2895. Lp(:,:,p) = Gp(:,:,p);
  2896. Gp(:,:,p) = tmp;
  2897. end
  2898. meanLp = mean(Lp, 3, 'omitnan');
  2899. meanGp = mean(Gp, 3, 'omitnan');
  2900. permDistance(ip,:) = euclidean_distance_between_trajectories(meanLp, meanGp);
  2901. end
  2902. pObs_uncorrected = zeros(1,nT);
  2903. for tt = 1:nT
  2904. pObs_uncorrected(tt) = (1 + sum(permDistance(:,tt) >= distanceObs(tt))) / (nPerm + 1);
  2905. end
  2906. distanceCrit = prctile(permDistance, 100 * (1 - clusterAlpha), 1);
  2907. supraObs = distanceObs > distanceCrit;
  2908. obsClusters = logical_to_clusters(supraObs);
  2909. obsMass = cluster_masses(distanceObs, obsClusters);
  2910. maxMassNull = zeros(nPerm,1);
  2911. permClusterCounts = zeros(nPerm,1);
  2912. for ip = 1:nPerm
  2913. supraPerm = permDistance(ip,:) > distanceCrit;
  2914. cl = logical_to_clusters(supraPerm);
  2915. permClusterCounts(ip) = numel(cl);
  2916. mass = cluster_masses(permDistance(ip,:), cl);
  2917. if isempty(mass)
  2918. maxMassNull(ip) = 0;
  2919. else
  2920. maxMassNull(ip) = max(mass);
  2921. end
  2922. end
  2923. pCluster = ones(numel(obsMass),1);
  2924. for c = 1:numel(obsMass)
  2925. pCluster(c) = (1 + sum(maxMassNull >= obsMass(c))) / (nPerm + 1);
  2926. end
  2927. sigMask = false(1,nT);
  2928. obsClusterIndex = zeros(1,nT);
  2929. obsClusterP_time = nan(1,nT);
  2930. obsClusterMass_time = nan(1,nT);
  2931. for c = 1:numel(obsClusters)
  2932. obsClusterIndex(obsClusters{c}) = c;
  2933. obsClusterP_time(obsClusters{c}) = pCluster(c);
  2934. obsClusterMass_time(obsClusters{c}) = obsMass(c);
  2935. if pCluster(c) < alpha
  2936. sigMask(obsClusters{c}) = true;
  2937. end
  2938. end
  2939. S = struct();
  2940. S.distanceObs = distanceObs;
  2941. S.distanceCrit = distanceCrit;
  2942. S.pObs_uncorrected = pObs_uncorrected;
  2943. S.clusterAlpha = clusterAlpha;
  2944. S.alpha = alpha;
  2945. S.nPerm = nPerm;
  2946. S.test = 'MeanTrajectoryEuclideanDistance_PairedLocalGlobalWithinParticipantSwap';
  2947. S.obsClusters = obsClusters;
  2948. S.obsClusterMass = obsMass;
  2949. S.obsClusterP = pCluster;
  2950. S.obsClusterIndex = obsClusterIndex;
  2951. S.obsClusterP_time = obsClusterP_time;
  2952. S.obsClusterMass_time = obsClusterMass_time;
  2953. S.maxMassNull = maxMassNull;
  2954. S.permClusterCounts = permClusterCounts;
  2955. S.permDistance = permDistance;
  2956. S.significantMask = sigMask;
  2957. end
  2958. function save_phase_space_local_global_permutation_details_xlsx(OUT, opts)
  2959. if ~exist(opts.outDir, 'dir')
  2960. mkdir(opts.outDir);
  2961. end
  2962. outXlsx = fullfile(opts.outDir, 'PhaseSpace_LocalVsGlobal_AllParticipants_PermutationDetails.xlsx');
  2963. if exist(outXlsx, 'file')
  2964. delete(outXlsx);
  2965. end
  2966. S = OUT.stats;
  2967. t = OUT.time(:);
  2968. nT = numel(t);
  2969. nP = size(OUT.localParticipantTrajectories,3);
  2970. Setting = string({ ...
  2971. 'Condition'; ...
  2972. 'Test'; ...
  2973. 'DistanceDefinition'; ...
  2974. 'ComparedConditions'; ...
  2975. 'nParticipants'; ...
  2976. 'nTimepoints'; ...
  2977. 'TestWindowStart_s'; ...
  2978. 'TestWindowEnd_s'; ...
  2979. 'nPermutations'; ...
  2980. 'RandomSeed'; ...
  2981. 'ClusterFormingAlpha'; ...
  2982. 'ClusterCorrectedAlpha'; ...
  2983. 'ClusterFormingCriticalValue'; ...
  2984. 'NullDefinition'; ...
  2985. 'ImportantInterpretation'});
  2986. Value = string({ ...
  2987. OUT.conditionLabel; ...
  2988. 'Euclidean distance between local and global mean trajectories + paired within-participant local/global label-swap cluster permutation correction over time'; ...
  2989. 'D(t) = norm(mean_local_3D(t) - mean_global_3D(t))'; ...
  2990. 'Local vs Global across all participants'; ...
  2991. num2str(nP); ...
  2992. num2str(nT); ...
  2993. sprintf('%.6f', min(t)); ...
  2994. sprintf('%.6f', max(t)); ...
  2995. num2str(S.nPerm); ...
  2996. num2str(opts.randomSeed); ...
  2997. sprintf('%.6f', S.clusterAlpha); ...
  2998. sprintf('%.6f', S.alpha); ...
  2999. 'Time-point-specific 95th percentile of paired swapped-label D(t) when clusterAlpha = 0.05'; ...
  3000. 'Randomly swap LOCAL/GLOBAL labels within each participant while preserving participant pairing'; ...
  3001. 'This tests whether average LOCAL and GLOBAL trajectories are separated in phase space across all participants'});
  3002. SettingsTable = table(Setting, Value);
  3003. writetable(SettingsTable, outXlsx, 'Sheet', 'Settings');
  3004. TimeIndex = (1:nT)';
  3005. Time_s = t;
  3006. Time_ms = 1000 .* t;
  3007. DistanceObserved = S.distanceObs(:);
  3008. DistanceCrit = S.distanceCrit(:);
  3009. P_uncorrected = S.pObs_uncorrected(:);
  3010. SupraThreshold = S.obsClusterIndex(:) > 0;
  3011. ClusterID = S.obsClusterIndex(:);
  3012. ClusterMass = S.obsClusterMass_time(:);
  3013. ClusterP_corrected = S.obsClusterP_time(:);
  3014. SignificantClusterCorrected = S.significantMask(:);
  3015. TimepointStats = table(TimeIndex, Time_s, Time_ms, DistanceObserved, DistanceCrit, ...
  3016. P_uncorrected, SupraThreshold, ClusterID, ClusterMass, ClusterP_corrected, ...
  3017. SignificantClusterCorrected, ...
  3018. 'VariableNames', {'TimeIndex','Time_s','Time_ms','DistanceObserved_LocalMeanVsGlobalMean', ...
  3019. 'DistanceCritical_PointwisePermutation','P_uncorrected','SupraThreshold','ClusterID', ...
  3020. 'ClusterMass','ClusterP_corrected','SignificantClusterCorrected'});
  3021. writetable(TimepointStats, outXlsx, 'Sheet', 'TimepointStats');
  3022. LocalNorm = sqrt(sum(OUT.localMeanTrajectory.^2, 2));
  3023. GlobalNorm = sqrt(sum(OUT.globalMeanTrajectory.^2, 2));
  3024. LocalMinusGlobalNorm = LocalNorm - GlobalNorm;
  3025. LocalGreaterMask = S.significantMask(:) & (LocalNorm > GlobalNorm);
  3026. GlobalGreaterMask = S.significantMask(:) & (GlobalNorm > LocalNorm);
  3027. DirectionNorms = table(Time_ms, LocalNorm, GlobalNorm, LocalMinusGlobalNorm, ...
  3028. LocalGreaterMask, GlobalGreaterMask, ...
  3029. 'VariableNames', {'Time_ms','LocalNorm','GlobalNorm','LocalMinusGlobalNorm', ...
  3030. 'LocalGreaterMask','GlobalGreaterMask'});
  3031. writetable(DirectionNorms, outXlsx, 'Sheet', 'DirectionNorms');
  3032. writetable(OUT.clusterTable, outXlsx, 'Sheet', 'ObservedClusters');
  3033. Permutation = (1:S.nPerm)';
  3034. MaxClusterMass = S.maxMassNull(:);
  3035. NumClusters = S.permClusterCounts(:);
  3036. NullDistribution = table(Permutation, MaxClusterMass, NumClusters);
  3037. writetable(NullDistribution, outXlsx, 'Sheet', 'NullDistribution');
  3038. PermutationDistances = array2table(S.permDistance);
  3039. PermutationDistances.Properties.VariableNames = make_time_varnames(t, 'Dperm');
  3040. PermutationDistances = addvars(PermutationDistances, Permutation, 'Before', 1);
  3041. writetable(PermutationDistances, outXlsx, 'Sheet', 'PermutationDistances');
  3042. Trajectories = table(TimeIndex, Time_s, Time_ms, ...
  3043. OUT.localMeanTrajectory(:,1), OUT.localMeanTrajectory(:,2), OUT.localMeanTrajectory(:,3), ...
  3044. OUT.globalMeanTrajectory(:,1), OUT.globalMeanTrajectory(:,2), OUT.globalMeanTrajectory(:,3), ...
  3045. S.distanceObs(:), ...
  3046. 'VariableNames', {'TimeIndex','Time_s','Time_ms', ...
  3047. 'LocalMean_PC1','LocalMean_PC2','LocalMean_PC3', ...
  3048. 'GlobalMean_PC1','GlobalMean_PC2','GlobalMean_PC3', ...
  3049. 'DistanceObserved_LocalMeanVsGlobalMean'});
  3050. writetable(Trajectories, outXlsx, 'Sheet', 'MeanTrajectories');
  3051. LocalCoords = condition_participant_coords_to_table(OUT.localParticipantTrajectories, t, 'Local');
  3052. GlobalCoords = condition_participant_coords_to_table(OUT.globalParticipantTrajectories, t, 'Global');
  3053. writetable(LocalCoords, outXlsx, 'Sheet', 'LocalParticipantCoords');
  3054. writetable(GlobalCoords, outXlsx, 'Sheet', 'GlobalParticipantCoords');
  3055. fprintf('Local-vs-global detailed permutation XLSX saved: %s\', outXlsx);
  3056. end
  3057. function T = condition_participant_coords_to_table(A, t, conditionLabel)
  3058. nT = size(A,1);
  3059. nP = size(A,3);
  3060. Condition = strings(nT*nP,1);
  3061. Participant = nan(nT*nP,1);
  3062. TimeIndex = nan(nT*nP,1);
  3063. Time_s = nan(nT*nP,1);
  3064. Time_ms = nan(nT*nP,1);
  3065. PC1 = nan(nT*nP,1);
  3066. PC2 = nan(nT*nP,1);
  3067. PC3 = nan(nT*nP,1);
  3068. row = 0;
  3069. for p = 1:nP
  3070. for tt = 1:nT
  3071. row = row + 1;
  3072. Condition(row) = string(conditionLabel);
  3073. Participant(row) = p;
  3074. TimeIndex(row) = tt;
  3075. Time_s(row) = t(tt);
  3076. Time_ms(row) = 1000 * t(tt);
  3077. PC1(row) = A(tt,1,p);
  3078. PC2(row) = A(tt,2,p);
  3079. PC3(row) = A(tt,3,p);
  3080. end
  3081. end
  3082. T = table(Condition, Participant, TimeIndex, Time_s, Time_ms, PC1, PC2, PC3);
  3083. end
  3084. function plot_phase_space_local_global_stats_3d(OUT, opts)
  3085. meanL = OUT.localMeanTrajectory;
  3086. meanG = OUT.globalMeanTrajectory;
  3087. sigMask = OUT.stats.significantMask(:)';
  3088. localNorm = sqrt(sum(meanL.^2, 2))';
  3089. globalNorm = sqrt(sum(meanG.^2, 2))';
  3090. localGreaterMask = sigMask & (localNorm > globalNorm);
  3091. globalGreaterMask = sigMask & (globalNorm > localNorm);
  3092. axisLims = get_common_xyz_limits(meanL, meanG);
  3093. plot_phase_space_local_global_stats_3d_single_condition(OUT, opts, meanL, localGreaterMask, ...
  3094. 'Local', 'Local > Global', 'Local', axisLims);
  3095. plot_phase_space_local_global_stats_3d_single_condition(OUT, opts, meanG, globalGreaterMask, ...
  3096. 'Global', 'Global > Local', 'Global', axisLims);
  3097. plot_average_local_global_direction_trajectory(OUT, opts, localGreaterMask, globalGreaterMask, axisLims);
  3098. end
  3099. function [localColor, globalColor] = get_local_global_colors()
  3100. localColor = [0.1 0.2 0.5];
  3101. globalColor = [0.8 0.2 0.2];
  3102. end
  3103. function plot_phase_space_local_global_stats_3d_single_condition(OUT, opts, XYZ, directionMask, conditionLabel, directionLabel, conditionFileLabel, axisLims)
  3104. t = OUT.time(:);
  3105. cmap = jet(numel(t));
  3106. figVis = 'on';
  3107. if ~opts.showFigures
  3108. figVis = 'off';
  3109. end
  3110. h = figure('Visible',figVis, 'Color','w', ...
  3111. 'Name',['Phase-space stats - LocalVsGlobal - ' conditionLabel]);
  3112. pos = get(h,'Position');
  3113. pos(3) = pos(3) + 60; % increase width by 20 points
  3114. set(h,'Position',pos);
  3115. ax = axes('Parent', h);
  3116. hold(ax, 'on');
  3117. grid(ax, 'on');
  3118. box(ax, 'on');
  3119. view(ax, 3);
  3120. axis(ax, 'vis3d');
  3121. plot_colored_sig_trajectory(ax, XYZ, directionMask, cmap, opts.grayColor, opts.lineWidth);
  3122. xlim(ax, axisLims.xLim);
  3123. ylim(ax, axisLims.yLim);
  3124. zlim(ax, axisLims.zLim);
  3125. xlabel(ax, 'Brain network 1');
  3126. ylabel(ax, 'Brain network 2');
  3127. zlabel(ax, 'Brain network 3');
  3128. title(ax, sprintf('Local vs Global: %s mean trajectory (%s)', ...
  3129. conditionLabel, directionLabel), 'Interpreter','none');
  3130. sigCmap = make_significance_time_colormap(cmap, directionMask, opts.grayColor);
  3131. colormap(ax, sigCmap);
  3132. cb = colorbar(ax);
  3133. cb.Label.String = 'Time (s)';
  3134. desiredTimes = -0.1:0.1:0.8;
  3135. desiredTimes = desiredTimes(desiredTimes >= min(t) & desiredTimes <= max(t));
  3136. cb.Ticks = (desiredTimes - min(t)) ./ (max(t) - min(t));
  3137. cb.TickLabels = arrayfun(@format_phase_space_time_tick, desiredTimes, 'UniformOutput', false);
  3138. hGray = plot3(ax, nan, nan, nan, '-', 'Color', opts.grayColor, 'LineWidth', opts.lineWidth);
  3139. hSig = plot_multicolor_dummy_legend_line_3d(ax, cmap, opts.lineWidth);
  3140. legend(ax, [hGray hSig], {'Non-significant segment','Significant segment'}, ...
  3141. 'Location','southoutside', 'Orientation','horizontal');
  3142. set(ax, 'FontName','Helvetica');
  3143. set(findall(h,'Type','text'), 'FontName','Helvetica');
  3144. if opts.saveFigures
  3145. outPng = fullfile(opts.outDir, sprintf('PhaseSpace_LocalVsGlobal_AllParticipants_%s_ConditionClusterStats_3D.png', ...
  3146. conditionFileLabel));
  3147. print(h, outPng, '-dpng', '-r300');
  3148. fprintf('Local-vs-global %s 3D figure saved: %s\', conditionLabel, outPng);
  3149. end
  3150. end
  3151. function plot_average_local_global_direction_trajectory(OUT, opts, localGreaterMask, globalGreaterMask, axisLims)
  3152. meanL = OUT.localMeanTrajectory;
  3153. meanG = OUT.globalMeanTrajectory;
  3154. avgXYZ = (meanL + meanG) ./ 2;
  3155. [localColor, globalColor] = get_local_global_colors();
  3156. figVis = 'on';
  3157. if ~opts.showFigures
  3158. figVis = 'off';
  3159. end
  3160. h = figure('Visible',figVis, 'Color','w', ...
  3161. 'Name','Average local-global direction trajectory - all participants');
  3162. pos = get(h,'Position');
  3163. pos(3) = pos(3) + 60;
  3164. set(h,'Position',pos);
  3165. ax = axes('Parent', h);
  3166. hold(ax, 'on');
  3167. grid(ax, 'on');
  3168. box(ax, 'on');
  3169. view(ax, 3);
  3170. axis(ax, 'vis3d');
  3171. plot_local_global_direction_trajectory(ax, avgXYZ, localGreaterMask, globalGreaterMask, ...
  3172. opts.grayColor, localColor, globalColor, opts.lineWidth);
  3173. xlim(ax, axisLims.xLim);
  3174. ylim(ax, axisLims.yLim);
  3175. zlim(ax, axisLims.zLim);
  3176. xlabel(ax, 'Brain network 1');
  3177. ylabel(ax, 'Brain network 2');
  3178. zlabel(ax, 'Brain network 3');
  3179. title(ax, 'Local vs Global: average mean trajectory', 'Interpreter','none');
  3180. avgCmap = make_local_global_direction_time_colormap(numel(OUT.time), localGreaterMask, globalGreaterMask, ...
  3181. opts.grayColor, localColor, globalColor);
  3182. colormap(ax, avgCmap);
  3183. cb = colorbar(ax);
  3184. cb.Label.String = 'Time (s)';
  3185. t = OUT.time(:);
  3186. desiredTimes = -0.1:0.1:0.8;
  3187. desiredTimes = desiredTimes(desiredTimes >= min(t) & desiredTimes <= max(t));
  3188. cb.Ticks = (desiredTimes - min(t)) ./ (max(t) - min(t));
  3189. cb.TickLabels = arrayfun(@format_phase_space_time_tick, desiredTimes, 'UniformOutput', false);
  3190. hGray = plot3(ax, nan, nan, nan, '-', 'Color', opts.grayColor, 'LineWidth', opts.lineWidth);
  3191. hLocal = plot3(ax, nan, nan, nan, '-', 'Color', localColor, 'LineWidth', opts.lineWidth);
  3192. hGlobal = plot3(ax, nan, nan, nan, '-', 'Color', globalColor, 'LineWidth', opts.lineWidth);
  3193. legend(ax, [hGray hLocal hGlobal], {'Non-significant segment','Local > Global','Global > Local'}, ...
  3194. 'Location','southoutside', 'Orientation','horizontal');
  3195. set(ax, 'FontName','Helvetica');
  3196. set(findall(h,'Type','text'), 'FontName','Helvetica');
  3197. if opts.saveFigures
  3198. outPng = fullfile(opts.outDir, 'PhaseSpace_LocalVsGlobal_AllParticipants_AverageConditionDirectionTrajectory_3D.png');
  3199. print(h, outPng, '-dpng', '-r300');
  3200. fprintf('Local-vs-global average 3D figure saved: %s\', outPng);
  3201. end
  3202. end
  3203. function avgCmap = make_local_global_direction_time_colormap(nT, localGreaterMask, globalGreaterMask, grayColor, localColor, globalColor)
  3204. localGreaterMask = localGreaterMask(:);
  3205. globalGreaterMask = globalGreaterMask(:);
  3206. avgCmap = repmat(grayColor, nT, 1);
  3207. nL = min(nT, numel(localGreaterMask));
  3208. nG = min(nT, numel(globalGreaterMask));
  3209. avgCmap(localGreaterMask(1:nL),:) = repmat(localColor, sum(localGreaterMask(1:nL)), 1);
  3210. avgCmap(globalGreaterMask(1:nG),:) = repmat(globalColor, sum(globalGreaterMask(1:nG)), 1);
  3211. end
  3212. function plot_local_global_direction_trajectory(ax, XYZ, localGreaterMask, globalGreaterMask, grayColor, localColor, globalColor, lw)
  3213. n = size(XYZ,1);
  3214. for i = 1:(n-1)
  3215. if localGreaterMask(i) || localGreaterMask(i+1)
  3216. col = localColor;
  3217. elseif globalGreaterMask(i) || globalGreaterMask(i+1)
  3218. col = globalColor;
  3219. else
  3220. col = grayColor;
  3221. end
  3222. plot3(ax, XYZ(i:i+1,1), XYZ(i:i+1,2), XYZ(i:i+1,3), '-', ...
  3223. 'Color', col, 'LineWidth', lw, 'HandleVisibility','off');
  3224. end
  3225. end
  3226. %% 3f) STATS - RQA ANOVA TEST
  3227. % Requires:
  3228. % - RQA_BROADNESS.RQA_metrics : {Nsubj x 1} cell, each a 2x8 table (1=Global, 2=Local)
  3229. % - young_subj, older_subj : subject indices for each group (1..Nsubj)
  3230. RQAcell = RQA_BROADNESS.RQA_metrics;
  3231. N = numel(RQAcell);
  3232. % Group vector per subject
  3233. Group = strings(N,1);
  3234. Group(young_subj) = "Young";
  3235. Group(older_subj) = "Old";
  3236. Group = categorical(Group, ["Young","Old"]);
  3237. % Metric names (take from first non-empty entry)
  3238. idx1 = find(~cellfun(@isempty,RQAcell),1,'first');
  3239. metricNames = RQAcell{idx1}.Properties.VariableNames;
  3240. % Container for results (now with q-values)
  3241. Res = table('Size',[numel(metricNames) 7], ...
  3242. 'VariableTypes',{'string','double','double','double','double','double','double'}, ...
  3243. 'VariableNames',{'Metric','p_Group','p_Condition','p_Interaction', ...
  3244. 'q_Group','q_Condition','q_Interaction'});
  3245. fprintf('\n=== Mixed ANOVA (Group × Condition) per metric ===\n');
  3246. for m = 1:numel(metricNames)
  3247. met = metricNames{m};
  3248. % Extract Global/Local values for all subjects
  3249. G = nan(N,1); L = nan(N,1);
  3250. for s = 1:N
  3251. T = RQAcell{s};
  3252. if ~isempty(T) && all(ismember(met, T.Properties.VariableNames))
  3253. G(s) = T{1,met}; % row 1 = Global
  3254. L(s) = T{2,met}; % row 2 = Local
  3255. end
  3256. end
  3257. % Build long table: Subject, Group, Condition, Value
  3258. Subject = repelem((1:N).',2);
  3259. GroupLong = [Group; Group];
  3260. Condition = categorical([repmat("Global",N,1); repmat("Local",N,1)], ["Global","Local"]);
  3261. Value = [G; L];
  3262. % Drop missing rows (robust to NaNs)
  3263. keep = isfinite(Value) & (GroupLong~="");
  3264. Tlong = table(Subject(keep), GroupLong(keep), Condition(keep), Value(keep), ...
  3265. 'VariableNames',{'Subject','Group','Condition','Value'});
  3266. % Mixed effects model: fixed Group, Condition, Interaction effects. Random intercept per subject
  3267. lme = fitlme(Tlong, 'Value ~ Group*Condition + (1|Subject)');
  3268. % F-tests for fixed effects (Satterthwaite df)
  3269. A = anova(lme,'DFMethod','Satterthwaite');
  3270. pG = A.pValue(strcmp(A.Term,'Group'));
  3271. pC = A.pValue(strcmp(A.Term,'Condition'));
  3272. pIx = A.pValue(strcmp(A.Term,'Group:Condition'));
  3273. Res.Metric(m) = string(met);
  3274. Res.p_Group(m) = pG;
  3275. Res.p_Condition(m) = pC;
  3276. Res.p_Interaction(m) = pIx;
  3277. fprintf('%-8s Group p=%.4g | Condition p=%.4g | Interaction p=%.4g\n', ...
  3278. met, pG, pC, pIx);
  3279. end
  3280. %% 3g) Joint FDR correction (BH) across all tests (Group/Condition/Interaction × metrics)
  3281. pG = Res.p_Group;
  3282. pC = Res.p_Condition;
  3283. pIx = Res.p_Interaction;
  3284. % Stack all p-values
  3285. pAll = [pG; pC; pIx];
  3286. % Benjamini–Hochberg (custom)
  3287. qAll = bh_fdr(pAll);
  3288. M = numel(metricNames);
  3289. Res.q_Group = qAll(1:M);
  3290. Res.q_Condition = qAll(M+1:2*M);
  3291. Res.q_Interaction = qAll(2*M+1:3*M);
  3292. disp(Res);
  3293. %% FDR correction helper
  3294. function q = bh_fdr(p)
  3295. % Benjamini–Hochberg FDR correction (vectorized, supports NaNs)
  3296. q = nan(size(p));
  3297. [ps, idx] = sort(p(:), 'ascend', 'MissingPlacement','last');
  3298. m = sum(~isnan(ps));
  3299. ranks = (1:m)';
  3300. adj = ps(1:m) .* m ./ ranks;
  3301. adj = cummin(flipud(adj));
  3302. adj = flipud(adj);
  3303. adj(adj>1) = 1;
  3304. q(idx(1:m)) = adj;
  3305. end
  3306. %% 3h) Plotting RQA metrics — ONE FIGURE (2x4 grid), VIOLINS per metric (using draw_violin_mean_SD)
  3307. plot_outdir = '/mainpath/Output/RQA';
  3308. if ~exist(plot_outdir,'dir')
  3309. mkdir(plot_outdir);
  3310. end
  3311. % ---------- USER-ADJUSTABLE PARAMETER ----------
  3312. % Proportion of the data range added as extra empty space above the highest
  3313. % data point.
  3314. EXTRA_TOP_PAD = 0.30; % e.g. 0.2 (little extra), 0.3 (default), 0.5 (lots)
  3315. % Colors per combo (consistent with Piece 1)
  3316. % Order here matches conditions:
  3317. % 1: Young-Local, 2: Old-Local, 3: Young-Global, 4: Old-Global
  3318. comb_colors = [ ...
  3319. 0.1 0.2 0.5; % Young-Local (blue)
  3320. 0.4 0.6 0.85; % Young-Global (light blue)
  3321. 0.5 0.1 0.1; % Old-Local (red)
  3322. 0.8 0.2 0.2]; % Old-Global (light red)
  3323. % X-axis / legend order inside each panel:
  3324. condLabelsOrder = {'Young-Local','Old-Local','Young-Global','Old-Global'};
  3325. % -------------------------------------------------------------------------
  3326. % 1) Get basic info and metric names
  3327. % -------------------------------------------------------------------------
  3328. RQA_YL = RQA_BROADNESS_young_local.RQA_metrics; % young, local (cell array)
  3329. RQA_OL = RQA_BROADNESS_older_local.RQA_metrics; % old, local (cell array)
  3330. RQA_YG = RQA_BROADNESS_young_global.RQA_metrics; % young, global
  3331. RQA_OG = RQA_BROADNESS_older_global.RQA_metrics; % old, global
  3332. nY = numel(RQA_YL);
  3333. nO = numel(RQA_OL);
  3334. % Sanity checks (same as before)
  3335. if numel(RQA_YG) ~= nY
  3336. error('Young-Global and Young-Local have different number of subjects.');
  3337. end
  3338. if numel(RQA_OG) ~= nO
  3339. error('Old-Global and Old-Local have different number of subjects.');
  3340. end
  3341. % Extract metric names from first non-empty table
  3342. AllCells = [RQA_YL(:); RQA_OL(:); RQA_YG(:); RQA_OG(:)];
  3343. idx1 = find(~cellfun(@isempty, AllCells), 1, 'first');
  3344. if isempty(idx1)
  3345. error('No non-empty RQA tables found.');
  3346. end
  3347. metricNames = AllCells{idx1}.Properties.VariableNames; % e.g. RR, L, DET, ...
  3348. % --- CUSTOM METRIC PLOTTING ORDER ---
  3349. desiredOrder = {'RR','L','TT','V_max','ENTR','DET','LAM','DIV'};
  3350. % Keep only metrics that actually exist, and append any leftovers at the end
  3351. desiredOrder = desiredOrder(ismember(desiredOrder, metricNames));
  3352. leftovers = metricNames(~ismember(metricNames, desiredOrder));
  3353. metricNames = [desiredOrder leftovers];
  3354. % -------------------------------------------------------------------------
  3355. % 2) Prepare figure: 2x4 tiles (for 8 metrics)
  3356. % -------------------------------------------------------------------------
  3357. nMetrics = numel(metricNames); % should be 8
  3358. fig = figure('Color','w','Units','pixels','Position',[30 30 2600 1700]);
  3359. tl = tiledlayout(fig, 2, 4, 'TileSpacing','compact','Padding','compact');
  3360. % -------------------------------------------------------------------------
  3361. % 3) Loop over metrics and create violins per metric using draw_violin_mean_SD
  3362. % -------------------------------------------------------------------------
  3363. for m = 1:nMetrics
  3364. metName = metricNames{m};
  3365. ax = nexttile(tl, m);
  3366. hold(ax,'on');
  3367. % -------------------------------------------------------------
  3368. % Collect values for this metric across subjects and conditions
  3369. % -------------------------------------------------------------
  3370. YL_vals = nan(nY,1);
  3371. YG_vals = nan(nY,1);
  3372. OL_vals = nan(nO,1);
  3373. OG_vals = nan(nO,1);
  3374. % Young
  3375. for s = 1:nY
  3376. Tloc = RQA_YL{s};
  3377. Tglob = RQA_YG{s};
  3378. if isempty(Tloc) || ~istable(Tloc) || ...
  3379. isempty(Tglob) || ~istable(Tglob) || ...
  3380. ~ismember(metName, Tloc.Properties.VariableNames) || ...
  3381. ~ismember(metName, Tglob.Properties.VariableNames)
  3382. continue;
  3383. end
  3384. YL_vals(s) = Tloc{1, metName};
  3385. YG_vals(s) = Tglob{1, metName};
  3386. end
  3387. % Old
  3388. for s = 1:nO
  3389. Tloc = RQA_OL{s};
  3390. Tglob = RQA_OG{s};
  3391. if isempty(Tloc) || ~istable(Tloc) || ...
  3392. isempty(Tglob) || ~istable(Tglob) || ...
  3393. ~ismember(metName, Tloc.Properties.VariableNames) || ...
  3394. ~ismember(metName, Tglob.Properties.VariableNames)
  3395. continue;
  3396. end
  3397. OL_vals(s) = Tloc{1, metName};
  3398. OG_vals(s) = Tglob{1, metName};
  3399. end
  3400. % Group into a cell array for easier looping (index meaning fixed):
  3401. % 1: Young-Local, 2: Young-Global, 3: Old-Local, 4: Old-Global
  3402. groupVals = {
  3403. YL_vals(~isnan(YL_vals)); % 1: Young-Local
  3404. YG_vals(~isnan(YG_vals)); % 2: Young-Global
  3405. OL_vals(~isnan(OL_vals)); % 3: Old-Local
  3406. OG_vals(~isnan(OG_vals)); % 4: Old-Global
  3407. };
  3408. % Check if there is any data at all
  3409. if all(cellfun(@isempty, groupVals))
  3410. title(ax, metName, 'Interpreter','none');
  3411. ylabel(ax, 'Value');
  3412. text(ax, 0.5, 0.5, 'No data', 'HorizontalAlignment','center');
  3413. box(ax, 'on');
  3414. continue;
  3415. end
  3416. % -------------------------------------------------------------
  3417. % Draw violins using the same helper as in Piece 1
  3418. % Order on x-axis: Young-Local, Old-Local, Young-Global, Old-Global
  3419. % -------------------------------------------------------------
  3420. xpositions = 1:4;
  3421. plot_order = [1 3 2 4]; % indices into groupVals / colors
  3422. for ip = 1:numel(plot_order)
  3423. g = plot_order(ip); % which condition to plot
  3424. thisVals = groupVals{g};
  3425. if isempty(thisVals)
  3426. continue;
  3427. end
  3428. % draw_violin_mean_SD should be on the path (same as Piece 1)
  3429. draw_violin_mean_SD(ax, thisVals, xpositions(ip), comb_colors(g,:));
  3430. end
  3431. % -------------------------------------------------------------
  3432. % Get data-driven y-range (robustly), same logic as Piece 1
  3433. % -------------------------------------------------------------
  3434. kids = findall(ax); % all children under this axis
  3435. Y = [];
  3436. for h = reshape(kids,1,[])
  3437. if isprop(h,'YData')
  3438. y = get(h,'YData');
  3439. if ~isempty(y)
  3440. y = y(:);
  3441. if isnumeric(y)
  3442. Y = [Y; y]; %#ok<AGROW>
  3443. end
  3444. end
  3445. end
  3446. end
  3447. finiteY = Y(isfinite(Y));
  3448. if isempty(finiteY)
  3449. ymin = 0;
  3450. ymax = 1;
  3451. else
  3452. ymin = min(finiteY);
  3453. ymax = max(finiteY);
  3454. end
  3455. if ymax == ymin
  3456. ymax = ymin + 1;
  3457. end
  3458. yr = ymax - ymin;
  3459. % ---------- Apply padding ----------
  3460. pad_lower = 0.10 * yr; % fixed bottom padding
  3461. pad_upper = EXTRA_TOP_PAD * yr; % user-controlled top padding
  3462. ylim(ax, [ymin - pad_lower, ymax + pad_upper]);
  3463. xlim(ax, [0.5 4.5]);
  3464. % ---------- Cosmetics (axes) ----------
  3465. set(ax, 'XTick', xpositions, 'XTickLabel', condLabelsOrder, ...
  3466. 'TickLabelInterpreter','none');
  3467. xtickangle(ax, 30);
  3468. ylabel(ax, metName, 'Interpreter','none');
  3469. title(ax, metName, 'FontWeight','bold','Interpreter','none');
  3470. grid(ax,'on');
  3471. box(ax,'on');
  3472. end
  3473. % -------------------------------------------------------------------------
  3474. % Legend tile (same style as Piece 1)
  3475. % -------------------------------------------------------------------------
  3476. axL = nexttile(tl, 9);
  3477. cla(axL);
  3478. hold(axL,'on');
  3479. axis(axL,'off');
  3480. legend_items = gobjects(0);
  3481. legend_labels = strings(0);
  3482. for k = 1:4
  3483. legend_items(end+1) = plot(axL, NaN, NaN, 'o', 'MarkerSize', 10, ...
  3484. 'MarkerFaceColor', comb_colors(k,:), 'MarkerEdgeColor', 'k'); %#ok<AGROW>
  3485. legend_labels(end+1) = condLabelsOrder{k}; %#ok<AGROW>
  3486. end
  3487. lg = legend(axL, legend_items, legend_labels, 'Location', 'northwest');
  3488. set(lg, 'Interpreter','none', 'FontSize',13, 'Box','off');
  3489. % -------------------------------------------------------------------------
  3490. % Title, fonts, save specification
  3491. % -------------------------------------------------------------------------
  3492. sgtitle(tl, 'RQA Metrics: Violin plots per condition', 'FontWeight','bold');
  3493. % Set global font
  3494. set(findall(fig, '-property', 'FontName'), 'FontName', 'Helvetica');
  3495. out_png = fullfile(plot_outdir, 'RQA_violin_like_2x4_metrics.png');
  3496. out_fig = fullfile(plot_outdir, 'RQA_violin_like_2x4_metrics.fig');
  3497. set(fig, 'PaperPositionMode','auto');
  3498. print(fig, out_png, '-dpng', '-r300');
  3499. saveas(fig, out_fig);
  3500. fprintf('RQA violin-like plot saved to:\n %s\n %s\n', out_png, out_fig);
  3501. %% 4a) SPATIAL GRADIENTS EMBEDDING AND CLUSTERING ANALYSIS
  3502. %%% ------------------- USER SETTINGS ------------------- %%%
  3503. % Simply use the structure outputted by the BROADNESS_NetworkEstimation function
  3504. % Additional optional inputs can be provided, as described in the function.
  3505. % Delete 'scatterplots','all' to only visualize optimal k in the cluster plot.
  3506. load([path_home '/BROADNESS_External/MNI152_8mm_coord_dyi.mat']); %all voxels MNI coordinates
  3507. Options.MNI_coords = MNI8;
  3508. %%% ------------------ COMPUTATION --------------------- %%%
  3509. % Run 1 of these 5 lines in seperate
  3510. % Overall
  3511. %SPATIAL_GRADIENTS_BROADNESS = BROADNESS_SpatialGradients(BROADNESS,'principalcomps',[1:3],'evalclusters',1,'mni_coords', Options.MNI_coords,'outpath', '/mainpath/Output');
  3512. % Group/condition specific
  3513. %SPATIAL_GRADIENTS_BROADNESS_young_local = BROADNESS_SpatialGradients(BROADNESS_young_local,'principalcomps',[1:3],'evalclusters',1,'mni_coords', Options.MNI_coords);
  3514. %SPATIAL_GRADIENTS_BROADNESS_older_local = BROADNESS_SpatialGradients(BROADNESS_older_local,'principalcomps',[1:3],'evalclusters',1,'mni_coords', Options.MNI_coords);
  3515. %SPATIAL_GRADIENTS_BROADNESS_young_global = BROADNESS_SpatialGradients(BROADNESS_young_global,'principalcomps',[1:3],'evalclusters',1,'mni_coords', Options.MNI_coords);
  3516. %SPATIAL_GRADIENTS_BROADNESS_older_global = BROADNESS_SpatialGradients(BROADNESS_older_global,'principalcomps',[1:3],'evalclusters',1,'mni_coords', Options.MNI_coords);
  3517. %% 4b) Finding the optimal cluster solution with 5000 iterations for k = 2:40
  3518. load([path_home '/BROADNESS_External/MNI152_8mm_coord_dyi.mat']); %all voxels MNI coordinates
  3519. Options.MNI_coords = MNI8;
  3520. S_GRAD_Final = BROADNESS_SpatialGradients(BROADNESS,'principalcomps',[1:3],'evalclusters', 5000, 'nclusters', 2:40, 'mni_coords', Options.MNI_coords,'outpath', '/mainpath/Output','scatterplots', 'all');
  3521. %% 4c) Plot Sillouhete plot of optimal k frequency count for all iterations
  3522. freqTbl = S_GRAD_Final.optimalK_Frequency;
  3523. figure;
  3524. b = bar(freqTbl.K, freqTbl.Count, 'FaceColor', [0 0.45 0]);
  3525. xlabel('Optimal number of clusters (K)');
  3526. ylabel('Frequency across evalclusters runs');
  3527. title('Distribution of silhouette-optimal K');
  3528. grid on;
  3529. %% 4d) Plot 3 stacked jittered data points for PC1/PC2/PC3 values across all clusters
  3530. % Reads CSVs: OptimalK_14_Cluster_XX_PC_points.csv
  3531. % Folder: /mainpath/Output/BROADNESS_Output/ClusterPCcoords
  3532. clear; close all; clc;
  3533. dataDir = '/mainpath/Output/BROADNESS_Output/ClusterPCcoords';
  3534. optimalK = 16; % Inserted manually after obtaining results from 4b and 4c
  3535. % Limits
  3536. xLimits = [-0.1 0.1];
  3537. % Visual settings
  3538. markerSize = 18; % dot size
  3539. jitterAmp = 0.08; % vertical jitter within each box (0 = no jitter)
  3540. boxHeight = 0.55; % height of each box in y-units (visual only)
  3541. lineCenters = [3, 2, 1]; % top-to-bottom: PC1, PC2, PC3 (stacked)
  3542. % Color map must match scatter: jet(k)*0.9
  3543. cmap = jet(optimalK) * 0.9;
  3544. % Which PCs to plot (must match the CSV column names)
  3545. pcNames = {'PC1','PC2','PC3'};
  3546. % ---------------- LOAD DATA ----------------
  3547. clusterVals = cell(optimalK, 3); % {cluster, pcIndex} -> vector of values
  3548. for cl = 1:optimalK
  3549. fn = fullfile(dataDir, sprintf('OptimalK_%d_Cluster_%02d_PC_points.csv', optimalK, cl));
  3550. if ~exist(fn,'file')
  3551. error('Missing file: %s', fn);
  3552. end
  3553. T = readtable(fn);
  3554. % Validate required columns exist
  3555. for p = 1:3
  3556. if ~ismember(pcNames{p}, T.Properties.VariableNames)
  3557. error('File %s does not contain column "%s". Columns are: %s', ...
  3558. fn, pcNames{p}, strjoin(T.Properties.VariableNames, ', '));
  3559. end
  3560. end
  3561. clusterVals{cl,1} = T.(pcNames{1});
  3562. clusterVals{cl,2} = T.(pcNames{2});
  3563. clusterVals{cl,3} = T.(pcNames{3});
  3564. end
  3565. %% One image per cluster: 3 stacked 1D point-rugs
  3566. clear; close all; clc;
  3567. dataDir = '/mainpath/Output/BROADNESS_Output/ClusterPCcoords';
  3568. optimalK = 16; % Inserted after running 4b and 4c to identify optimal number of clusters
  3569. % Match limits
  3570. lims = [-0.1 0.1];
  3571. % Match cluster colors
  3572. cmap = jet(optimalK) * 0.9;
  3573. % Output folder
  3574. outDir = fullfile(dataDir, 'Cluster1DPointRugs');
  3575. if ~exist(outDir,'dir'); mkdir(outDir); end
  3576. % -------------------- USER-EDITABLE LAYOUT VARIABLES --------------------
  3577. lineSep = 0.0060; % baseline-to-baseline spacing
  3578. laneY = [2 1 0] * lineSep; % BN1 top, BN2 middle, BN3 bottom
  3579. leftLabels = {'BN1','BN2','BN3'};
  3580. labelX = lims(1) - 0.0040;
  3581. labelFS = 12;
  3582. yPadTop = 0.020;
  3583. yPadBottom = 0.0040;
  3584. vTickHalfHeight = 0.0018; % <<< half-height of vertical ticks on bars
  3585. % -----------------------------------------------------------------------
  3586. % Plot settings
  3587. yJitter = 0.0017;
  3588. ptSize = 42;
  3589. xTickPositions = [lims(1), 0, lims(2)];
  3590. for cl = 1:optimalK
  3591. fn = fullfile(dataDir, sprintf('OptimalK_%d_Cluster_%02d_PC_points.csv', optimalK, cl));
  3592. if ~exist(fn,'file')
  3593. warning('Missing file: %s (skipping)', fn);
  3594. continue;
  3595. end
  3596. T = readtable(fn);
  3597. needed = {'PC1','PC2','PC3'};
  3598. if ~all(ismember(needed, T.Properties.VariableNames))
  3599. warning('File %s missing PC columns.', fn);
  3600. continue;
  3601. end
  3602. X = T.PC1(:);
  3603. Y = T.PC2(:);
  3604. Z = T.PC3(:);
  3605. keep = (X>=lims(1) & X<=lims(2)) & ...
  3606. (Y>=lims(1) & Y<=lims(2)) & ...
  3607. (Z>=lims(1) & Z<=lims(2));
  3608. Xp = X(keep);
  3609. Yp = Y(keep);
  3610. Zp = Z(keep);
  3611. % --- Figure ---
  3612. fig = figure('Color','w', 'Visible','off');
  3613. ax = axes(fig); hold(ax,'on');
  3614. % Baseline lanes
  3615. for k = 1:3
  3616. plot(ax, lims, [laneY(k) laneY(k)], 'k-', 'LineWidth', 1.0);
  3617. end
  3618. % Vertical ticks on each baseline
  3619. for k = 1:3
  3620. for xt = xTickPositions
  3621. plot(ax, [xt xt], ...
  3622. laneY(k) + [-vTickHalfHeight vTickHalfHeight], ...
  3623. 'k-', 'LineWidth', 1.0);
  3624. end
  3625. end
  3626. % Left labels (Helvetica, not bold)
  3627. for k = 1:3
  3628. text(ax, labelX, laneY(k), leftLabels{k}, ...
  3629. 'HorizontalAlignment','right', ...
  3630. 'VerticalAlignment','middle', ...
  3631. 'FontName','Helvetica', ...
  3632. 'FontSize', labelFS, ...
  3633. 'FontWeight','normal');
  3634. end
  3635. % Point rugs
  3636. if ~isempty(Xp)
  3637. scatter(ax, Xp, laneY(1) + (rand(size(Xp))-0.5)*2*yJitter, ...
  3638. ptSize, 'MarkerFaceColor', cmap(cl,:), 'MarkerEdgeColor','none');
  3639. end
  3640. if ~isempty(Yp)
  3641. scatter(ax, Yp, laneY(2) + (rand(size(Yp))-0.5)*2*yJitter, ...
  3642. ptSize, 'MarkerFaceColor', cmap(cl,:), 'MarkerEdgeColor','none');
  3643. end
  3644. if ~isempty(Zp)
  3645. scatter(ax, Zp, laneY(3) + (rand(size(Zp))-0.5)*2*yJitter, ...
  3646. ptSize, 'MarkerFaceColor', cmap(cl,:), 'MarkerEdgeColor','none');
  3647. end
  3648. % Axes formatting
  3649. xlim(ax, lims);
  3650. ylim(ax, [min(laneY)-yPadBottom, max(laneY)+yPadTop]);
  3651. xticks(ax, xTickPositions);
  3652. box(ax,'off'); grid(ax,'off');
  3653. % Hide x-axis line but keep numbers
  3654. set(ax, 'XColor', 'none'); % hides axis line & default ticks
  3655. set(ax, 'XTickLabelMode','auto'); % keep numeric labels
  3656. set(ax,'YColor','none','FontName','Helvetica');
  3657. title(ax, sprintf('Cluster %02d (OptimalK=%d, n=%d)', ...
  3658. cl, optimalK, numel(Xp)), 'FontWeight','bold');
  3659. % Save
  3660. outPng = fullfile(outDir, ...
  3661. sprintf('OptimalK_%d_Cluster_%02d_point_rugs.png', optimalK, cl));
  3662. exportgraphics(fig, outPng, 'Resolution', 300);
  3663. close(fig);
  3664. fprintf('Saved: %s\n', outPng);
  3665. end
  3666. %% 4e) Export MNI coordinates of all clusters
  3667. % Drop-in block (not a function). Requires MATLAB's niftiinfo/niftiread.
  3668. % Exports: Index, X, Y, Z, Activation
  3669. %
  3670. % NOTES:
  3671. % - Uses the NIfTI affine: info.Transform.T
  3672. % - If Transform is wrong/missing, coordinates will be wrong.
  3673. % ---- USER SETTINGS (edit these) ----
  3674. optimalK = 16; % Inserted after running 4b and 4c to identify optimal number of clusters
  3675. niiDir = '/mainpath/Output/BROADNESS_Output/BROADNESS_nifti/Clusters_k=16_Final_With_Niftis/' % where SpatialGradients_*.nii live
  3676. outDir = '/mainpath/Output/BROADNESS_Output/BROADNESS_nifti/Clusters_k=16_Final_With_Niftis/' % where tables will be saved
  3677. pattern = sprintf('SpatialGradients_OptimalK_%d_Cluster_*.nii*', optimalK);
  3678. % ---- SAFETY CHECKS ----
  3679. if ~(exist('niftiinfo','file')==2 && exist('niftiread','file')==2)
  3680. error('This block requires niftiinfo/niftiread (MATLAB built-in). Update MATLAB or use the NIfTI toolbox + affine parsing.');
  3681. end
  3682. if ~exist(niiDir,'dir')
  3683. error('NIfTI folder not found: %s', niiDir);
  3684. end
  3685. if ~exist(outDir,'dir'); mkdir(outDir); end
  3686. files = dir(fullfile(niiDir, pattern));
  3687. if isempty(files)
  3688. warning('No files found for pattern: %s', fullfile(niiDir, pattern));
  3689. else
  3690. fprintf('Found %d cluster NIfTIs. Exporting tables to: %s\n', numel(files), outDir);
  3691. end
  3692. for f = 1:numel(files)
  3693. fn = fullfile(files(f).folder, files(f).name);
  3694. % Read header + volume
  3695. info = niftiinfo(fn);
  3696. vol = double(niftiread(info));
  3697. % Handle potential 4th dimension of size 1
  3698. if ndims(vol) == 4 && size(vol,4) == 1
  3699. vol = vol(:,:,:,1);
  3700. end
  3701. % Nonzero voxels = "active"
  3702. linIdx = find(vol ~= 0);
  3703. if isempty(linIdx)
  3704. warning('No nonzero voxels in: %s', files(f).name);
  3705. continue;
  3706. end
  3707. % Convert linear indices -> voxel subscripts (i,j,k)
  3708. [i,j,k] = ind2sub(size(vol), linIdx);
  3709. act = vol(linIdx);
  3710. % Convert voxel subscripts -> MNI/world using affine
  3711. % MATLAB uses 1-based subscripts; Transform.T is consistent for niftiinfo.
  3712. T = info.Transform.T; % 4x4
  3713. vox = [i(:) j(:) k(:) ones(numel(i),1)];
  3714. xyz = vox * T; % Nx4
  3715. X = xyz(:,1); Y = xyz(:,2); Z = xyz(:,3);
  3716. % Build table
  3717. Index = (1:numel(act))';
  3718. TBL = table(Index, X, Y, Z, act, ...
  3719. 'VariableNames', {'Index','X','Y','Z','Activation'});
  3720. % Write outputs
  3721. [~,base,~] = fileparts(files(f).name);
  3722. outCsv = fullfile(outDir, [base '_MNIcoords.csv']);
  3723. writetable(TBL, outCsv);
  3724. % Try XLSX too (may fail on some Linux setups)
  3725. outXlsx = fullfile(outDir, [base '_MNIcoords.xlsx']);
  3726. try
  3727. writetable(TBL, outXlsx);
  3728. catch
  3729. % ignore; CSV is the reliable output
  3730. end
  3731. fprintf('Exported %s (%d voxels)\n', outCsv, height(TBL));
  3732. end
  3733. %% 4f) Converts cluster MNI coords to AAL labels: Outputs per labels cluster sheet in one Excel workbook.
  3734. %
  3735. % Takes all cluster coordinate tables exported in Code 1 (*_MNIcoords.csv or .xlsx),
  3736. % assigns AAL (or other integer-coded atlas) labels voxelwise, and writes ONE
  3737. % Excel file with ONE sheet per cluster containing:
  3738. % Area | Dist0 | Dist1 | ... | DistR | Total
  3739. %
  3740. % where DistR = number of voxels whose nearest-label snapping used radius R
  3741. % (Chebyshev ring radius in ATLAS VOXELS; r=0 = exact atlas hit).
  3742. addpath('/home/mathiasha/MATLAB_Add-Ons/Collections/spm_25.01.02/spm/')
  3743. clear; clc;
  3744. % ----------------------- USER SETTINGS -----------------------
  3745. optimalK = 16;
  3746. % Folder containing the exported MNI tables from Code 1
  3747. % (e.g., SpatialGradients_OptimalK_16_Cluster_1_MNIcoords.csv)
  3748. clusterTableDir = '/mainpath/Output/BROADNESS_Output/BROADNESS_nifti/Clusters_k=16_Final_With_Niftis/';
  3749. % Atlas in MNI space + label lookup table
  3750. atlasNiftiPath = '/mainpath/AAL3/AAL3v1.nii';
  3751. atlasLabelsPath = '/mainpath/AAL3/AAL3v1.nii.txt';
  3752. unknownLabelName = "Unknown/Background";
  3753. % Max snapping distance in ATLAS VOXELS (0 = exact only)
  3754. maxSearchRadius = 3;
  3755. % Output Excel (one workbook; one sheet per cluster)
  3756. outXlsx = fullfile(clusterTableDir, sprintf('Clusters_k=%d_AAL_labels_by_cluster.xlsx', optimalK));
  3757. % ---- Dependencies / safety checks ----
  3758. if exist('spm_vol','file')~=2 || exist('spm_read_vols','file')~=2
  3759. error('This script requires SPM on the MATLAB path (spm_vol, spm_read_vols).');
  3760. end
  3761. assert(exist(clusterTableDir,'dir')==7, 'Cluster table folder not found: %s', clusterTableDir);
  3762. assert(exist(atlasNiftiPath,'file')==2, 'Atlas NIfTI not found: %s', atlasNiftiPath);
  3763. assert(exist(atlasLabelsPath,'file')==2, 'Atlas labels file not found: %s', atlasLabelsPath);
  3764. % ---- Load atlas volume + LUT ----
  3765. V = spm_vol(atlasNiftiPath);
  3766. A = spm_read_vols(V);
  3767. lut = loadAtlasLUT(atlasLabelsPath, unknownLabelName);
  3768. % ---- Find cluster coordinate tables ----
  3769. % Prefer CSV if both exist, otherwise use XLSX.
  3770. csvFiles = dir(fullfile(clusterTableDir, sprintf('SpatialGradients_OptimalK_%d_Cluster_*_MNIcoords.csv', optimalK)));
  3771. xlsxFiles = dir(fullfile(clusterTableDir, sprintf('SpatialGradients_OptimalK_%d_Cluster_*_MNIcoords.xlsx', optimalK)));
  3772. if isempty(csvFiles) && isempty(xlsxFiles)
  3773. error('No cluster MNI tables found in: %s', clusterTableDir);
  3774. end
  3775. % Build map: clusterNumber -> filepath, preferring CSV
  3776. clusterMap = containers.Map('KeyType','double','ValueType','char');
  3777. for f = 1:numel(xlsxFiles)
  3778. cnum = parseClusterNumber(xlsxFiles(f).name);
  3779. if ~isnan(cnum)
  3780. clusterMap(cnum) = fullfile(xlsxFiles(f).folder, xlsxFiles(f).name);
  3781. end
  3782. end
  3783. for f = 1:numel(csvFiles)
  3784. cnum = parseClusterNumber(csvFiles(f).name);
  3785. if ~isnan(cnum)
  3786. % CSV overwrites XLSX preference
  3787. clusterMap(cnum) = fullfile(csvFiles(f).folder, csvFiles(f).name);
  3788. end
  3789. end
  3790. % Sort cluster numbers
  3791. clusterNums = sort(cell2mat(keys(clusterMap)));
  3792. fprintf('Found %d cluster tables.\n', numel(clusterNums));
  3793. fprintf('Writing output workbook:\n %s\n', outXlsx);
  3794. % ---- Process each cluster and write one sheet per cluster ----
  3795. for iC = 1:numel(clusterNums)
  3796. cnum = clusterNums(iC);
  3797. inPath = clusterMap(cnum);
  3798. % Read table
  3799. T = readtable(inPath);
  3800. % Find MNI columns (X,Y,Z) robustly
  3801. [xCol, yCol, zCol] = guessMNIColumns(T);
  3802. XYZ = [T.(xCol), T.(yCol), T.(zCol)];
  3803. if ~isnumeric(XYZ) || size(XYZ,2)~=3
  3804. error('Cluster %d: MNI columns are not numeric 3D coords. Detected: %s %s %s', cnum, xCol, yCol, zCol);
  3805. end
  3806. % Map MNI -> atlas label with snapping (FIXED: function returns 3 outputs)
  3807. [labels, usedRadius, labelIDs] = mniToAtlasLabelWithSnap2(XYZ, V, A, lut, unknownLabelName, maxSearchRadius);
  3808. % Count labels by distance radius used (0..R)
  3809. countsByR = countLabelsByRadius(labels, usedRadius, maxSearchRadius);
  3810. % Convert to the exact kind of table Code 2 produced (Area + Dist0..DistR + Total)
  3811. outTable = countsByRadiusToTable(countsByR, maxSearchRadius, "Area");
  3812. % Optional: raw per-voxel mapping too
  3813. voxelTable = table( ...
  3814. (1:size(XYZ,1))', XYZ(:,1), XYZ(:,2), XYZ(:,3), labelIDs(:), string(labels(:)), usedRadius(:), ...
  3815. 'VariableNames', {'Index','X','Y','Z','AtlasID','Area','Distance'} );
  3816. % Excel sheet name
  3817. sheetName = sprintf('Cluster_%d', cnum);
  3818. % Write the summary counts table to the cluster sheet
  3819. writetable(outTable, outXlsx, 'Sheet', sheetName, 'WriteMode', 'overwritesheet');
  3820. % Also write the per-voxel assignments below the summary (same sheet),
  3821. % leaving a blank row between (Excel-friendly).
  3822. try
  3823. % Write voxelTable starting at (row = height(outTable)+3, col = 1)
  3824. startRow = height(outTable) + 3;
  3825. writetable(voxelTable, outXlsx, 'Sheet', sheetName, 'Range', sprintf('A%d', startRow));
  3826. catch ME
  3827. % If MATLAB/Excel writer on Linux is finicky, at least keep the summary.
  3828. warning('Cluster %d: Could not append per-voxel table; kept summary only. (%s) %s', cnum, sheetName, ME.message);
  3829. end
  3830. % Print quick diagnostics
  3831. fracSnapped = mean(usedRadius(:) > 0);
  3832. fprintf('Cluster %d: %d voxels | %.1f%% snapped (radius>0) | top area: %s\n', ...
  3833. cnum, size(XYZ,1), 100*fracSnapped, string(outTable.Area(1)));
  3834. end
  3835. fprintf('\nDone.\nWorkbook saved:\n %s\n', outXlsx);
  3836. % ========================= LOCAL FUNCTIONS =========================
  3837. function cnum = parseClusterNumber(filename)
  3838. % Extract cluster number from names like:
  3839. % SpatialGradients_OptimalK_16_Cluster_3_MNIcoords.csv
  3840. cnum = NaN;
  3841. tok = regexp(filename, 'Cluster_(\d+)', 'tokens', 'once');
  3842. if ~isempty(tok)
  3843. cnum = str2double(tok{1});
  3844. end
  3845. end
  3846. function [labels, usedRadius, labelIDs] = mniToAtlasLabelWithSnap2(XYZmm, V, A, lut, unknownLabelName, maxR)
  3847. % Returns:
  3848. % labels: assigned region name for each coordinate (string)
  3849. % usedRadius:
  3850. % 0..maxR = snapped using that voxel shell radius (0 means exact voxel non-zero label)
  3851. % -1 = could not find any non-zero label within maxR or out-of-bounds
  3852. % labelIDs:
  3853. % integer atlas parcel ID used (0 = unknown/background)
  3854. %
  3855. % NOTE: "radius" is Chebyshev shells in ATLAS VOXELS, not mm.
  3856. n = size(XYZmm,1);
  3857. labels = strings(n,1);
  3858. usedRadius = -1 * ones(n,1);
  3859. labelIDs = zeros(n,1);
  3860. M = V.mat; %#ok<NASGU> % voxel->mm (kept for clarity)
  3861. iM = inv(V.mat); % mm->voxel
  3862. volSize = size(A);
  3863. for iPt = 1:n
  3864. mm = [XYZmm(iPt,:), 1]';
  3865. vox = iM * mm;
  3866. ijk0 = round(vox(1:3))';
  3867. % Out of bounds -> Unknown
  3868. if any(isnan(ijk0)) || any(ijk0 < 1) || ...
  3869. ijk0(1) > volSize(1) || ijk0(2) > volSize(2) || ijk0(3) > volSize(3)
  3870. labels(iPt) = unknownLabelName;
  3871. usedRadius(iPt) = -1;
  3872. labelIDs(iPt) = 0;
  3873. continue;
  3874. end
  3875. val0 = A(ijk0(1), ijk0(2), ijk0(3));
  3876. if isnan(val0); val0 = 0; end
  3877. key0 = double(round(val0));
  3878. % Exact hit must be non-zero AND in LUT
  3879. if key0 ~= 0 && isKey(lut, key0)
  3880. labels(iPt) = string(lut(key0));
  3881. usedRadius(iPt) = 0;
  3882. labelIDs(iPt) = key0;
  3883. continue;
  3884. end
  3885. % Otherwise, search shells rad=1..maxR for nearest non-zero labels
  3886. found = false;
  3887. for rad = 1:maxR
  3888. keysInShell = collectNonzeroKeysInShell(A, ijk0, rad);
  3889. if ~isempty(keysInShell)
  3890. % Choose the most frequent label key in this shell (mode; tiebreak smallest)
  3891. chosenKey = modeTiebreakSmallest(keysInShell);
  3892. if isKey(lut, chosenKey)
  3893. labels(iPt) = string(lut(chosenKey));
  3894. labelIDs(iPt) = chosenKey;
  3895. else
  3896. % LUT missing this key (unexpected) -> mark unknown but keep ID for debugging
  3897. labels(iPt) = unknownLabelName;
  3898. labelIDs(iPt) = chosenKey;
  3899. end
  3900. usedRadius(iPt) = rad;
  3901. found = true;
  3902. break;
  3903. end
  3904. end
  3905. if ~found
  3906. labels(iPt) = unknownLabelName;
  3907. usedRadius(iPt) = -1;
  3908. labelIDs(iPt) = 0;
  3909. end
  3910. end
  3911. end
  3912. function col = pickFirstMatching(vars, candidates)
  3913. col = "";
  3914. lowerVars = lower(vars);
  3915. for c = candidates
  3916. idx = find(lowerVars == lower(string(c)), 1, 'first');
  3917. if ~isempty(idx)
  3918. col = vars(idx);
  3919. return;
  3920. end
  3921. end
  3922. end
  3923. function [id, rUsed] = lookupWithSnap(A, ijk, dims, maxR)
  3924. % Returns atlas id at ijk if valid and nonzero, else nearest nonzero within Chebyshev radius.
  3925. % Chebyshev shell radius r: max(|dx|,|dy|,|dz|)==r
  3926. id = 0;
  3927. rUsed = 0;
  3928. if isInside(ijk, dims)
  3929. v = A(ijk(1), ijk(2), ijk(3));
  3930. if ~isnan(v) && v ~= 0
  3931. id = round(v);
  3932. rUsed = 0;
  3933. return;
  3934. end
  3935. end
  3936. % Snap search
  3937. for r = 1:maxR
  3938. bestId = 0;
  3939. % Iterate only over the shell (ring) at radius r
  3940. for dx = -r:r
  3941. for dy = -r:r
  3942. for dz = -r:r
  3943. if max(abs([dx,dy,dz])) ~= r
  3944. continue; % not on the shell
  3945. end
  3946. p = ijk + [dx dy dz];
  3947. if ~isInside(p, dims)
  3948. continue;
  3949. end
  3950. v = A(p(1), p(2), p(3));
  3951. if ~isnan(v) && v ~= 0
  3952. bestId = round(v);
  3953. break;
  3954. end
  3955. end
  3956. if bestId ~= 0, break; end
  3957. end
  3958. if bestId ~= 0, break; end
  3959. end
  3960. if bestId ~= 0
  3961. id = bestId;
  3962. rUsed = r;
  3963. return;
  3964. end
  3965. end
  3966. % if still 0 -> unknown
  3967. id = 0;
  3968. rUsed = maxR + 1; % indicates "not found within maxR"
  3969. end
  3970. function tf = isInside(ijk, dims)
  3971. tf = all(ijk >= 1) && ijk(1) <= dims(1) && ijk(2) <= dims(2) && ijk(3) <= dims(3);
  3972. end
  3973. %% 5a) BRAIN NETWORK MODULARITY CALCULATION - PCA APPLIED TO INDIVIDUAL DATA
  3974. % RELOAD THINGS TO AVOID CONFUSION
  3975. clear
  3976. close all
  3977. clc
  3978. % Setup directories
  3979. path_home = '/mainpath/BROADNESS_MEG_AuditoryRecognition-main/BROADNESS_Toolbox';
  3980. addpath(path_home)
  3981. BROADNESS_Startup(path_home);
  3982. addpath(fullfile(matlabroot,'toolbox','stats','stats'),'-begin') % makes sure the pca function is the standard function in matlab
  3983. %%% For each participant we calculate how many networks (PCs) it takes to
  3984. %%% to reach a certain threshold of explained varience in the brain and use
  3985. %%% this as a measure of brain network modularity/partitioning.
  3986. load('/mainpath/MMNSubtracted_Average_SignFixed.mat');
  3987. DATA = dum;
  3988. % Remove first participant and define time vector
  3989. % OPTION 2: 52 ms baseline
  3990. time = -0.100:0.004:0.8;
  3991. DATA = DATA(:,101:326,:,2:78);
  3992. DATA_global = DATA(:,:,1,:);
  3993. DATA_local = DATA(:,:,2,:);
  3994. size(DATA)
  3995. % Load groups and adjust subid after removing first participant
  3996. load('/mainpath/groups.mat'); % older, young
  3997. older = older - 1;
  3998. young = young - 1;
  3999. older_subj = older(:); % row-wise versions
  4000. young_subj = young(:);
  4001. %%% ------------------ BROADNESS --------------------- %%%
  4002. % Run BROADNESS network estimation (default parameters)
  4003. numSubjects = size(DATA_global, 4);
  4004. BROADNESS_global = cell(numSubjects,1);
  4005. BROADNESS_local = cell(numSubjects,1);
  4006. for s = 1:numSubjects
  4007. % Extract subject data (keeping dimensionality intact)
  4008. sub_global = DATA_global(:,:,:,s);
  4009. sub_local = DATA_local(:,:,:,s);
  4010. % Run BROADNESS for this subject
  4011. BROADNESS_global{s} = BROADNESS_NetworkEstimation(sub_global, time);
  4012. BROADNESS_local{s} = BROADNESS_NetworkEstimation(sub_local, time);
  4013. fprintf('Subject %d/%d completed\n', s, numSubjects);
  4014. end
  4015. %% 5b) Calculate effective dimensionality (ED) on PCA data applied to individuals and plot per participant
  4016. % This function estimates effective dimensionality (ED) and quadratic
  4017. % Rényi entropy (H2) across frequencies, given the eigenspectrum of a
  4018. % covariance matrix. It uses the entropy-based index derived by Pirk et al.
  4019. % (2012) to estimate the effective number of uncorrelated measurements, as
  4020. % described in Del Giudice (2020).
  4021. %
  4022. % It can be applied to a 1-D vector of eigenvalues, as well as directly to
  4023. % the FREQ.evals output produced by FREQNESS_NetworkEstimation().
  4024. % If FREQ.evals is given as an input, this can consist of either a 2D or
  4025. % 3D matrix, depending on whether FREQNESS was run on individual
  4026. % participants or at the group level.
  4027. %
  4028. % When FREQ.evals is provided as input, the ED output will reflect the
  4029. % effective number of uncorrelated measurements across the frequency
  4030. % spectrum. If FREQ.evals contains participants as a 3rd dimension, then ED
  4031. % will also include an additional dimension for multiple participants.
  4032. % This will allow to visualize the grand-average entropy landscape and
  4033. % eventually carry out statistical testing.
  4034. % Assumes one already has:
  4035. % BROADNESS_global : nSubs x 1 cell
  4036. % BROADNESS_local : nSubs x 1 cell
  4037. %
  4038. % Each cell contains a struct with field:
  4039. % .Variance_BrainNetworks : 225 x 1 double
  4040. % ---- Settings ----
  4041. fieldName = 'Variance_BrainNetworks';
  4042. % ---- Compute ED vectors ----
  4043. ED_global = computeED_fromBROADNESS(BROADNESS_global, fieldName);
  4044. ED_local = computeED_fromBROADNESS(BROADNESS_local, fieldName);
  4045. % ---- Quick sanity checks / summary ----
  4046. fprintf('GLOBAL: computed ED for %d/%d participants.\n', sum(~isnan(ED_global)), numel(ED_global));
  4047. fprintf('LOCAL : computed ED for %d/%d participants.\n', sum(~isnan(ED_local)), numel(ED_local));
  4048. fprintf('GLOBAL ED: mean = %.3f, sd = %.3f\n', mean(ED_global,'omitnan'), std(ED_global,'omitnan'));
  4049. fprintf('LOCAL ED: mean = %.3f, sd = %.3f\n', mean(ED_local,'omitnan'), std(ED_local,'omitnan'));
  4050. % Optional: paired difference
  4051. ED_diff = ED_global - ED_local;
  4052. fprintf('DIFF (global-local): mean = %.3f, sd = %.3f\n', mean(ED_diff,'omitnan'), std(ED_diff,'omitnan'));
  4053. % ===== User-adjustable legend parameters =====
  4054. legendFontSize = 11; % controls legend text size
  4055. legendTokenSize = [24 12]; % controls line/marker size in legend [length height]
  4056. % ============================================
  4057. figure;
  4058. plot(ED_global, 'o-', ...
  4059. 'Color', [0.75 0.6 0.9], ... % light purple
  4060. 'LineWidth', 1.5);
  4061. hold on;
  4062. plot(ED_local, 'o-', ...
  4063. 'Color', [0.4 0.1 0.6], ... % dark purple
  4064. 'LineWidth', 1.5);
  4065. xlabel('Participant');
  4066. ylabel('Effective Dimensionality (ED)');
  4067. title('ED per participant');
  4068. grid on;
  4069. lgd = legend({'Global','Local'});
  4070. lgd.Location = 'south';
  4071. lgd.Box = 'off';
  4072. lgd.FontSize = legendFontSize;
  4073. lgd.ItemTokenSize = legendTokenSize;
  4074. %% -------- Local function(s) --------
  4075. function ED = computeED_fromBROADNESS(BROADNESS_cell, fieldName)
  4076. % Returns ED as [nSubs x 1] double. Participants that fail checks -> NaN.
  4077. nSubs = numel(BROADNESS_cell);
  4078. ED = nan(nSubs, 1);
  4079. for s = 1:nSubs
  4080. x = BROADNESS_cell{s};
  4081. % Basic structure checks
  4082. if isempty(x) || ~isstruct(x) || ~isfield(x, fieldName)
  4083. warning('Sub %d: missing struct or field "%s". Setting ED=NaN.', s, fieldName);
  4084. continue;
  4085. end
  4086. lam = x.(fieldName);
  4087. % Ensure numeric column vector
  4088. if isempty(lam) || ~isnumeric(lam)
  4089. warning('Sub %d: "%s" is empty/non-numeric. Setting ED=NaN.', s, fieldName);
  4090. continue;
  4091. end
  4092. lam = lam(:); % force column
  4093. % Clean invalid values
  4094. lam(~isfinite(lam)) = 0;
  4095. % ED formula expects nonnegative spectrum; clamp tiny negatives if present
  4096. % (If seeing large negatives, something upstream is wrong.)
  4097. if any(lam < -1e-12)
  4098. warning('Sub %d: large negative values in "%s". Check upstream. Clamping negatives to 0.', s, fieldName);
  4099. end
  4100. lam(lam < 0) = 0;
  4101. % Compute ED
  4102. num = (sum(lam))^2;
  4103. denom = sum(lam.^2);
  4104. % Guard against division by zero
  4105. denom = max(denom, realmin);
  4106. ED(s) = num / denom;
  4107. end
  4108. end
  4109. %% 5c) Plotting ED for each YL, OL, YG, OG - VIOLIN PLOT
  4110. % Requires variables in workspace:
  4111. % ED_local [nSubs x 1]
  4112. % ED_global [nSubs x 1]
  4113. % young [1 x 37] participant indices (1..nSubs)
  4114. % older [1 x 40] participant indices (1..nSubs)
  4115. %
  4116. % Also requires the helper function on path:
  4117. % draw_violin_mean_SD(ax, values, xposition, color)
  4118. % ---------------- User-adjustable ----------------
  4119. EXTRA_TOP_PAD = 0.30; % extra headroom above max datapoint (for markers/text)
  4120. plot_outdir = ''; % set to a folder to save; leave '' to not save
  4121. % Colors (same as RQA code)
  4122. % 1: Young-Local, 2: Old-Local, 3: Young-Global, 4: Old-Global
  4123. comb_colors = [ ...
  4124. 0.1 0.2 0.7; ... Young-Local, blue
  4125. 0.4 0.6 0.85; ... Young-Global, light blue
  4126. 0.5 0.1 0.1; ... Old-Local, red
  4127. 0.8 0.2 0.2]; ... Old-Global, light red
  4128. condLabelsOrder = {'Younger - Local','Older - Local','Younger - Global','Older - Global'};
  4129. % ---------------- Collect data ----------------
  4130. young = young(:);
  4131. older = older(:);
  4132. % Sanity checks: if these fail, the "young/older" are not indices into ED vectors
  4133. if any(young < 1) || any(older < 1) || any(young > numel(ED_local)) || any(older > numel(ED_local))
  4134. error('Entries in "young" or "older" are out of bounds for ED vectors. Check participant numbering (IDs vs indices).');
  4135. end
  4136. YL_vals = ED_local(young);
  4137. OL_vals = ED_local(older);
  4138. YG_vals = ED_global(young);
  4139. OG_vals = ED_global(older);
  4140. % Group into a cell array for easier looping (index meaning fixed):
  4141. % 1: Young-Local, 2: Young-Global, 3: Old-Local, 4: Old-Global
  4142. groupVals = {
  4143. YL_vals(~isnan(YL_vals) & isfinite(YL_vals));
  4144. YG_vals(~isnan(YG_vals) & isfinite(YG_vals));
  4145. OL_vals(~isnan(OL_vals) & isfinite(OL_vals));
  4146. OG_vals(~isnan(OG_vals) & isfinite(OG_vals));
  4147. };
  4148. if all(cellfun(@isempty, groupVals))
  4149. error('All ED groups are empty after removing NaN/Inf. Fix upstream ED computation or indexing.');
  4150. end
  4151. % ---------------- Plot (violin-style) ----------------
  4152. fig = figure('Color','w','Units','pixels','Position',[100 100 1400 700]);
  4153. tl = tiledlayout(fig, 1, 2, 'TileSpacing','compact','Padding','compact');
  4154. ax = nexttile(tl, 1);
  4155. hold(ax,'on');
  4156. xpositions = 1:4;
  4157. % We want x-axis order: Young-Local, Old-Local, Young-Global, Old-Global
  4158. % But groupVals order is: 1=YL, 2=YG, 3=OL, 4=OG
  4159. plot_order = [1 3 2 4]; % indices into groupVals / colors
  4160. for ip = 1:numel(plot_order)
  4161. g = plot_order(ip);
  4162. thisVals = groupVals{g};
  4163. if isempty(thisVals)
  4164. continue;
  4165. end
  4166. draw_violin_mean_SD(ax, thisVals, xpositions(ip), comb_colors(g,:));
  4167. end
  4168. % ---------------- Data-driven y-limits (same logic as the RQA code) ----------------
  4169. kids = findall(ax);
  4170. Y = [];
  4171. for h = reshape(kids,1,[])
  4172. if isprop(h,'YData')
  4173. y = get(h,'YData');
  4174. if ~isempty(y) && isnumeric(y)
  4175. Y = [Y; y(:)]; %#ok<AGROW>
  4176. end
  4177. end
  4178. end
  4179. finiteY = Y(isfinite(Y));
  4180. if isempty(finiteY)
  4181. ymin = 0; ymax = 1;
  4182. else
  4183. ymin = min(finiteY);
  4184. ymax = max(finiteY);
  4185. end
  4186. if ymax == ymin
  4187. ymax = ymin + 1;
  4188. end
  4189. yr = ymax - ymin;
  4190. pad_lower = 0.10 * yr;
  4191. pad_upper = EXTRA_TOP_PAD * yr;
  4192. ylim(ax, [ymin - pad_lower, ymax + pad_upper]);
  4193. xlim(ax, [0.5 4.5]);
  4194. % ---------------- Cosmetics ----------------
  4195. set(ax, 'XTick', xpositions, 'XTickLabel', condLabelsOrder, ...
  4196. 'TickLabelInterpreter','none');
  4197. xtickangle(ax, 30);
  4198. ylabel(ax, 'Effective Dimensionality (ED)', 'Interpreter','none');
  4199. title(ax, 'ED by Group and Condition', 'FontWeight','bold', 'Interpreter','none');
  4200. grid(ax,'on');
  4201. box(ax,'on');
  4202. % ---------------- Legend tile (matching the RQA style) ----------------
  4203. axL = nexttile(tl, 2);
  4204. cla(axL);
  4205. hold(axL,'on');
  4206. axis(axL,'off');
  4207. legend_items = gobjects(0);
  4208. legend_labels = strings(0);
  4209. % Legend order should match the x-axis labels (not plot_order indices)
  4210. % x-axis: YL, OL, YG, OG -> color rows: 1,2,3,4 respectively
  4211. for k = 1:4
  4212. legend_items(end+1) = plot(axL, NaN, NaN, 'o', 'MarkerSize', 10, ...
  4213. 'MarkerFaceColor', comb_colors(k,:), 'MarkerEdgeColor', 'k'); %#ok<AGROW>
  4214. legend_labels(end+1) = condLabelsOrder{k}; %#ok<AGROW>
  4215. end
  4216. lg = legend(axL, legend_items, legend_labels, 'Location', 'northwest');
  4217. set(lg, 'Interpreter','none', 'FontSize',13, 'Box','off');
  4218. % Global font
  4219. set(findall(fig, '-property', 'FontName'), 'FontName', 'Helvetica');
  4220. % Optional: overall title
  4221. sgtitle(tl, 'Effective Dimensionality (ED): Violin plots per condition', 'FontWeight','bold');
  4222. % ---------------- Optional save ----------------
  4223. if ~isempty(plot_outdir)
  4224. if ~exist(plot_outdir,'dir'); mkdir(plot_outdir); end
  4225. out_png = fullfile(plot_outdir, 'ED_violin_4groups.png');
  4226. out_fig = fullfile(plot_outdir, 'ED_violin_4groups.fig');
  4227. set(fig, 'PaperPositionMode','auto');
  4228. print(fig, out_png, '-dpng', '-r300');
  4229. saveas(fig, out_fig);
  4230. fprintf('ED violin plot saved to:\n %s\n %s\n', out_png, out_fig);
  4231. end
  4232. %% 5d) ED statistics: Normality checks + 2x2 mixed ANOVA (Group x Condition)
  4233. % Assumes already in workspace:
  4234. % ED_local [numSubjects x 1]
  4235. % ED_global [numSubjects x 1]
  4236. % young [nY x 1 or 1 x nY] indices into ED vectors
  4237. % older [nO x 1 or 1 x nO] indices into ED vectors
  4238. % -----------------------------
  4239. % 1) Split ED into groups
  4240. % -----------------------------
  4241. numSubjects = numel(ED_local);
  4242. young_idx = young(:);
  4243. older_idx = older(:);
  4244. % Sanity check: indices valid
  4245. if any(young_idx < 1) || any(older_idx < 1) || ...
  4246. any(young_idx > numSubjects) || any(older_idx > numSubjects)
  4247. error('young/older contain indices outside 1..numSubjects. If these are IDs, map IDs -> indices first.');
  4248. end
  4249. Y_local_ED = ED_local(young_idx);
  4250. O_local_ED = ED_local(older_idx);
  4251. Y_global_ED = ED_global(young_idx);
  4252. O_global_ED = ED_global(older_idx);
  4253. fprintf('\nED means:\n');
  4254. fprintf(' Local - Young mean = %.3f, Older mean = %.3f\n', ...
  4255. mean(Y_local_ED,'omitnan'), mean(O_local_ED,'omitnan'));
  4256. fprintf(' Global - Young mean = %.3f, Older mean = %.3f\n', ...
  4257. mean(Y_global_ED,'omitnan'), mean(O_global_ED,'omitnan'));
  4258. % Optional: check amount of missing values (important for rmANOVA)
  4259. fprintf('Missing ED values (NaN): Local=%d, Global=%d\n', ...
  4260. sum(isnan(ED_local)), sum(isnan(ED_global)));
  4261. % -----------------------------
  4262. % 2) Normality checks (Anderson-Darling)
  4263. % -----------------------------
  4264. fprintf('\n--- Normality Testing for ED (Anderson-Darling) ---\n');
  4265. [h1,p1] = adtest(Y_local_ED(~isnan(Y_local_ED)));
  4266. fprintf('Local Young: h=%d (1=non-normal), p=%.4f\n', h1, p1);
  4267. [h2,p2] = adtest(O_local_ED(~isnan(O_local_ED)));
  4268. fprintf('Local Older: h=%d (1=non-normal), p=%.4f\n', h2, p2);
  4269. [h3,p3] = adtest(Y_global_ED(~isnan(Y_global_ED)));
  4270. fprintf('Global Young: h=%d (1=non-normal), p=%.4f\n', h3, p3);
  4271. [h4,p4] = adtest(O_global_ED(~isnan(O_global_ED)));
  4272. fprintf('Global Older: h=%d (1=non-normal), p=%.4f\n', h4, p4);
  4273. % -----------------------------
  4274. % 3) 2x2 mixed ANOVA: Group (Young/Older) x Condition (Local/Global)
  4275. % -----------------------------
  4276. % IMPORTANT limitation: fitrm/ranova uses listwise deletion if any repeated
  4277. % measure is missing. So we explicitly keep only subjects with BOTH Local
  4278. % and Global ED present, otherwise effective N can drop silently.
  4279. valid = isfinite(ED_local) & isfinite(ED_global);
  4280. if ~all(valid)
  4281. fprintf('\nNote: %d/%d subjects have both Local and Global ED. Using only these for mixed ANOVA.\n', ...
  4282. sum(valid), numSubjects);
  4283. end
  4284. ED_local_valid = ED_local(valid);
  4285. ED_global_valid = ED_global(valid);
  4286. % Build group factor for valid subjects
  4287. group = strings(sum(valid),1);
  4288. % Map original indices -> valid-subset indices
  4289. valid_idx = find(valid);
  4290. % Mark group labels within the valid subset
  4291. group(ismember(valid_idx, young_idx)) = "Young";
  4292. group(ismember(valid_idx, older_idx)) = "Older";
  4293. % If someone is in neither group, that's a problem
  4294. if any(group == "")
  4295. missingLabelSubs = valid_idx(group=="");
  4296. error('Some valid subjects were not labeled Young/Older. Check young/older indices. Example subject indices: %s', mat2str(missingLabelSubs(1:min(10,end))'));
  4297. end
  4298. group = categorical(group);
  4299. % Table for repeated-measures model: two columns = within-subject factor
  4300. T = table(group, ED_local_valid, ED_global_valid, ...
  4301. 'VariableNames', {'Group','Local','Global'});
  4302. % Within-subject design table
  4303. Within = table({'Local'; 'Global'}, 'VariableNames', {'Condition'});
  4304. % Repeated-measures model: Group = between factor, Condition = within factor
  4305. rm = fitrm(T, 'Local,Global ~ Group', 'WithinDesign', Within);
  4306. % Mixed ANOVA table (includes Group, Condition, Group:Condition)
  4307. ranovatbl = ranova(rm, 'WithinModel', 'Condition');
  4308. % -------- Extract p-values from ranovatbl --------
  4309. % Row names usually include:
  4310. % '(Intercept)', 'Group', 'Error', '(Intercept):Condition', 'Group:Condition', 'Error(Condition)'
  4311. p_group = ranovatbl{'Group', 'pValue'}; % Group main effect
  4312. p_condition = ranovatbl{'(Intercept):Condition', 'pValue'}; % Condition main effect
  4313. p_interaction = ranovatbl{'Group:Condition', 'pValue'}; % Interaction
  4314. % Bonferroni correction for 3 tests (Group, Condition, Interaction)
  4315. pvals = [p_group, p_condition, p_interaction];
  4316. pvals_corr = min(pvals * 3, 1);
  4317. fprintf('\n--- Mixed ANOVA (ED) ---\n');
  4318. fprintf('Group main effect: p = %.4f (Bonferroni-corr = %.4f)\n', ...
  4319. p_group, pvals_corr(1));
  4320. fprintf('Condition main effect: p = %.4f (Bonferroni-corr = %.4f)\n', ...
  4321. p_condition, pvals_corr(2));
  4322. fprintf('Group x Condition int.: p = %.4f (Bonferroni-corr = %.4f)\n', ...
  4323. p_interaction, pvals_corr(3));
  4324. % Optional: display the full ANOVA table for transparency/debugging
  4325. disp(ranovatbl);
  4326. %% 5e) Cumulative varience explained per component for each group in each condition - calculation
  4327. %%% --------------------------------------------------------------- %%%
  4328. %%% Cumulative variance explained (PC1–PC100) for local & global
  4329. %%% --------------------------------------------------------------- %%%
  4330. % How many PCs to include on the x-axis
  4331. maxPC = 30;
  4332. % Sanity: make sure we don't exceed available PCs
  4333. nPC_local = numel(BROADNESS_local{1}.Variance_BrainNetworks);
  4334. nPC_global = numel(BROADNESS_global{1}.Variance_BrainNetworks);
  4335. maxPC = min([maxPC, nPC_local, nPC_global]); % in case vectors are shorter
  4336. pcRange = 1:maxPC;
  4337. % Preallocate: rows = PCs, columns = subjects
  4338. cumVar_local = nan(maxPC, numSubjects);
  4339. cumVar_global = nan(maxPC, numSubjects);
  4340. for s = 1:numSubjects
  4341. % Local variance explained (per PC) for subject s
  4342. v_loc = BROADNESS_local{s}.Variance_BrainNetworks(:);
  4343. v_glob = BROADNESS_global{s}.Variance_BrainNetworks(:);
  4344. % Defensive: limit to maxPC in case length differs slightly
  4345. nLoc = min(maxPC, numel(v_loc));
  4346. nGlob = min(maxPC, numel(v_glob));
  4347. cumVar_local(1:nLoc, s) = cumsum(v_loc(1:nLoc));
  4348. cumVar_global(1:nGlob, s) = cumsum(v_glob(1:nGlob));
  4349. end
  4350. % Make sure group indices are column vectors
  4351. young_idx = young_subj(:);
  4352. older_idx = older_subj(:);
  4353. % (Optional) sanity checks
  4354. % assert(all(young_idx >= 1 & young_idx <= numSubjects), 'young_subj has invalid indices');
  4355. % assert(all(older_idx >= 1 & older_idx <= numSubjects), 'older_subj has invalid indices');
  4356. % Split into groups: matrices [PC x subjects_in_group]
  4357. Y_local = cumVar_local(:, young_idx);
  4358. O_local = cumVar_local(:, older_idx);
  4359. Y_global = cumVar_global(:, young_idx);
  4360. O_global = cumVar_global(:, older_idx);
  4361. % Group sizes
  4362. nY = size(Y_local, 2);
  4363. nO = size(O_local, 2);
  4364. % Mean and SEM across subjects (per PC)
  4365. meanY_loc = mean(Y_local, 2, 'omitnan');
  4366. meanO_loc = mean(O_local, 2, 'omitnan');
  4367. semY_loc = std(Y_local, 0, 2, 'omitnan') ./ sqrt(nY);
  4368. semO_loc = std(O_local, 0, 2, 'omitnan') ./ sqrt(nO);
  4369. meanY_glob = mean(Y_global, 2, 'omitnan');
  4370. meanO_glob = mean(O_global, 2, 'omitnan');
  4371. semY_glob = std(Y_global, 0, 2, 'omitnan') ./ sqrt(nY);
  4372. semO_glob = std(O_global, 0, 2, 'omitnan') ./ sqrt(nO);
  4373. % Colors for plotting (MATLAB default-ish)
  4374. colYoung = [0 0.4470 0.7410]; % blue
  4375. colOlder = [0.8500 0.3250 0.0980]; % orange
  4376. %% 5f) Explained cumulative variance plot: Local vs Global & Young vs Older
  4377. set(0, 'DefaultAxesFontName', 'Helvetica');
  4378. set(0, 'DefaultTextFontName', 'Helvetica');
  4379. % ===================== USER PARAMETERS =====================
  4380. legendFontSize = 12; % change legend text size here
  4381. legendTokenSize = [28 14];% change legend symbol size here: [lineLength height] (points)
  4382. legendLineWidth = 2.5; % change legend line thickness here (applies to plotted lines too)
  4383. %

Main_Analysis.m at commit 0fbee79, no license · at the source

Overview

  1. Center For Music in the Brain, Department of Clinical Medicine, Aarhus University & The Royal Academy of Music, Aarhus/Aalborg, Denmark
  2. Danish Research Centre For Magnetic Resonance, Department of Radiology and Nuclear Medicine, Copenhagen University Hospital – Amager and Hvidovre, Hvidovre, Denmark
  3. Faculty of Health and Medical Sciences, University of Copenhagen, Copenhagen, Denmark
  4. Department of Psychology, University of Copenhagen, Copenhagen, Denmark
  5. IPEM Institute for Systematic Musicology, Ghent University, Ghent, Belgium
  6. Department of Neurology, Copenhagen University Hospital Bispebjerg and Frederiksberg, Copenhagen, Denmark
  7. Department of Psychiatry, University of Oxford, Oxford, UK
  8. Centre For Eudaimonia and Human Flourishing, Linacre College, University of Oxford, Oxford, UK
Institutions: University of Copenhagen (Denmark); Hvidovre Hospital (Denmark); Royal Academy of Music (Denmark); Ghent University (Belgium); Bispebjerg Hospital (Denmark); Copenhagen University Hospital (Denmark); University of Oxford (United Kingdom)
Dates: received 27 April 2026; accepted 11 September 2026; published online 27 September 2026; in print September 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1002/advs.77857 · PMID 42801541 · PMCID PMC13616317 · OpenAlex W7214554861
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: human (organism), cognitive (subfield)
Methods: Connectivity, Statistics, Smoothing, state filtering, decompositions, Graphs, Physiology & signal measures
Keywords: aging, brain networks, phase space, predictive coding, principal component analysis (PCA), recurrence quantification analysis (RQA)
Topic: Neuroscience and Music Perception (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Funding: Independent Research Fund Denmark (10.46540/5253-00003B, 1029-00007B); Carlsberg Foundation (CF20-0239, CF23-1491); Danish National Research Foundation (DNRF117); Lundbeck Foundation (R515-2025-684, R469-2024-1573); Købmand herman Sallings Fond; Pettit and Carlsberg Foundations; Nordic Mensa Fund; Center for Music in the Brain, Linacre College of the University of Oxford
Citations: not cited yet (Europe PMC); 109 references in the paper

Abstract

Cognitive aging is widely associated with a progressive weakening of neural responses associated with predictive brain mechanisms. This view is supported by decades of electrophysiological studies reporting attenuated mismatch responses in older adults. Yet the literature remains inconsistent, suggesting that aging may not uniformly attenuate such responses. One possibility is that aging exerts differential effects depending on task demands. Here we aim to separate whole‐brain networks underlying deviance processing in source‐reconstructed magnetoencephalography (MEG) data from 37 younger and 40 older adults performing the auditory local–global paradigm. Network decomposition revealed three temporally overlapping subsystems. Aging exerted selective effects across these networks. Early sensory deviance responses were enhanced within a network recruiting auditory cortices and medial cingulate regions, whereas later cognitive processes were attenuated in older adults. The level of multivariate recurrence across these networks was preserved with aging, while the processing of sensory violations induced more recurrence and less divergence relative to pattern‐based violations in both groups. This age‐related increase in sensory‐related mismatch responses challenges the prevailing view that sensory deviance processing simply declines with age. These results suggest that in aging, neural responses may be differentially distributed across distinct neural systems, amplifying sensory‐based processes while weakening cognitively demanding mechanisms.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repositories

Its files are read in the Code ↔ Paper reader above, with 30 matches between paragraphs and lines of code.

leonardob92/LBPD-1.0

License: GPL-3.0
State: the link answers, verified on 26 September 2026
Evidence: files inventoried
Commit: 17ce460e8036eb77f2d81dc2fa0f24c0a8ecb6b1, 2 June 2025
Languages: MATLAB (166), Java (1)
Size: 201 files, 167 scripts
Software Heritage: not archived
Found in: the text, “MEG Data Pre‐Processing”
Holds: README, license file
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 26 September 2026: the link answers
  • 26 September 2026: the link answers
168 files

leonardob92/broadness_meg_auditoryrecognition

License: none: the authors keep all their rights
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: 03721089e8c826a929c21f4e8833d51b7348889c, 4 September 2026
Languages: MATLAB (62)
Size: 74 files, 62 scripts
Software Heritage: not archived
Found in: “Code Availability Statement”
Holds: README
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
63 files

MathiasHoueAndersen/Predictive-Processing-In-Aging

License: none: the authors keep all their rights
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: 0fbee79640fee8db582a9759101515734fde2732, 11 August 2026
Languages: MATLAB (5), Python (1)
Size: 8 files, 6 scripts
Software Heritage: not archived
Found in: “Code Availability Statement”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
6 files

leonardob92/BROADNESS_Aging_MMN_AdvancedScience

License: none: the authors keep all their rights
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: 98fb36a11366f0ef71e6154f2236a5d29ee4d486, 22 June 2026
Languages: MATLAB (1)
Size: 2 files, 1 script
Software Heritage: not archived
Found in: “Code Availability Statement”
Holds: README
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
2 files

mathiashoueandersen/hierarchical-predictive-processing

License: none: the authors keep all their rights
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: 0fbee79640fee8db582a9759101515734fde2732, 11 August 2026
Languages: MATLAB (5), Python (1)
Size: 8 files, 6 scripts
Software Heritage: not archived
Found in: “Data Availability Statement”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
6 files

Code Availability Statement

The BROAD‐NESS toolbox is available at the following link and is required to run the data analysis pipeline: https://github.com/leonardob92/BROADNESS_MEG_AuditoryRecognition/tree/main/BROADNESS_Toolbox The main data analysis pipeline used in this study is available at the following link: https://github.com/MathiasHoueAndersen/Predictive‐Processing‐In‐Aging (https://github.com/MathiasHoueAndersen/Predictive-Processing-In-Aging) In‐house‐built code and functions used for the pre‐processing of MEG data in this study are part of the LBPD repository which is available at the following link: https://github.com/leonardob92/BROADNESS_Aging_MMN_AdvancedScience.git.

Reproduced under the paper's license (CC BY), from the paper cited above.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 5 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 242 scripts, each with its path and the digest of its content;
  • 30 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

Datasets cited

Data Availability Statement

The pre‐processed neuroimaging data generated in this study have been deposited in the Zenodo database and are publicly available: https://doi.org/10.5281/zenodo.18231641 [109]. The code used to present the stimuli of the experimental paradigm used in this study has been deposited on GitHub and is publicly available: https://github.com/MathiasHoueAndersen/Hierarchical‐Predictive‐Processing/blob/main/Experimental_Paradigm.py (https://github.com/MathiasHoueAndersen/Hierarchical-Predictive-Processing/blob/main/Experimental_Paradigm.py)

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 28 September 2026: the first record

Recorded: type, language, journal, pages, dates, 10 authors, 6 keywords, 8 funders, 107 references.

Cite

This paper

Andersen, M. H., Fernández‐Rubio, G., Quiroga‐Martinez, D. R., Rosso, M., Klarlund, M., Larsen, K. M., Siebner, H. R., Kringelbach, M. L., Vuust, P., & Bonetti, L. (2026). Brain Network Dynamics of Local and Global Predictive Processing in Aging. Advanced science (Weinheim, Baden-Wurttemberg, Germany), e77857. https://doi.org/10.1002/advs.77857

BibTeX

@article{andersen2026brain,
author = {Andersen, Mathias Houe and Fernández‐Rubio, Gemma and Quiroga‐Martinez, David R and Rosso, Mattia and Klarlund, Mathias and Larsen, Kit Melissa and Siebner, Hartwig Roman and Kringelbach, Morten L and Vuust, Peter and Bonetti, Leonardo},
title = {{Brain Network Dynamics of Local and Global Predictive Processing in Aging}},
journal = {Advanced science (Weinheim, Baden-Wurttemberg, Germany)},
year = {2026},
month = sep,
pages = {e77857},
publisher = {Wiley},
issn = {2198-3844},
doi = {10.1002/advs.77857},
url = {https://doi.org/10.1002/advs.77857},
pmid = {42801541},
pmcid = {PMC13616317}
}

RIS

TY - JOUR
AU - Andersen, Mathias Houe
AU - Fernández‐Rubio, Gemma
AU - Quiroga‐Martinez, David R
AU - Rosso, Mattia
AU - Klarlund, Mathias
AU - Larsen, Kit Melissa
AU - Siebner, Hartwig Roman
AU - Kringelbach, Morten L
AU - Vuust, Peter
AU - Bonetti, Leonardo
TI - Brain Network Dynamics of Local and Global Predictive Processing in Aging
T2 - Advanced science (Weinheim, Baden-Wurttemberg, Germany)
J2 - Adv Sci (Weinh)
PY - 2026
DA - 2026/09/27
SP - e77857
SN - 2198-3844
PB - Wiley
DO - 10.1002/advs.77857
UR - https://doi.org/10.1002/advs.77857
LA - en
ER -

CSL-JSON

{
"id": "10.1002/advs.77857",
"type": "article-journal",
"title": "Brain Network Dynamics of Local and Global Predictive Processing in Aging",
"container-title": "Advanced science (Weinheim, Baden-Wurttemberg, Germany)",
"author": [
{
"family": "Andersen",
"given": "Mathias Houe"
},
{
"family": "Fernández‐Rubio",
"given": "Gemma"
},
{
"family": "Quiroga‐Martinez",
"given": "David R"
},
{
"family": "Rosso",
"given": "Mattia"
},
{
"family": "Klarlund",
"given": "Mathias"
},
{
"family": "Larsen",
"given": "Kit Melissa"
},
{
"family": "Siebner",
"given": "Hartwig Roman"
},
{
"family": "Kringelbach",
"given": "Morten L"
},
{
"family": "Vuust",
"given": "Peter"
},
{
"family": "Bonetti",
"given": "Leonardo"
}
],
"container-title-short": "Adv Sci (Weinh)",
"page": "e77857",
"DOI": "10.1002/advs.77857",
"PMID": "42801541",
"PMCID": "PMC13616317",
"ISSN": "2198-3844",
"publisher": "Wiley",
"URL": "https://doi.org/10.1002/advs.77857",
"language": "en",
"issued": {
"date-parts": [
[
2026,
9,
27
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1111/nyas.70349 [code]
FREQ-NESS Reveals Age-Related Differences in Frequency-Resolved Brain Networks During Auditory Recognition and Resting State.
Journal: Annals of the New York Academy of Sciences
In common: OHBA Software Library (OSL), export_fig, Brain Connectivity Toolbox, 9 other tools, cognitive, 19 references, 2 authors
[2] doi:10.1038/s41467-026-71151-2 [code]
Common and distinct neural correlates of social interaction processing and theory of mind in narratives.
Journal: Nature communications
In common: Violinplot-Matlab, Brain Connectivity Toolbox, Connectome Workbench, 7 other tools, cognitive, 1 reference
[3] doi:10.7554/elife.108408 [code]
Frequency and laminar profile of feature-specific visual activity revealed by interleaved EEG-fMRI.
Journal: eLife
In common: Connectome Workbench, Tools for NIfTI and ANALYZE image (MATLAB), FieldTrip, 6 other tools, 4 references
[4] doi:10.1038/s41467-026-74565-0 [code]
The functional neurobiology of dispositions towards negative emotions.
Journal: Nature communications
In common: Violinplot-Matlab, Brain Connectivity Toolbox, Connectome Workbench, 7 other tools, cognitive
[5] doi:10.1038/s41467-026-73540-z [code]
Predictive acoustical processing in human cortical layers.
Journal: Nature communications
In common: Brain Connectivity Toolbox, FieldTrip, SPM, 3 other tools, cognitive, 5 references
[6] doi:10.1002/hbm.70577 [code]
Disgust Propensity, Not Disgust Sensitivity, Shapes the Reactivity of a Subjective Disgust Circuit in Humans.
Journal: Human brain mapping
In common: Violinplot-Matlab, Brain Connectivity Toolbox, Connectome Workbench, 7 other tools
[7] doi:10.1038/s41467-026-75359-0 [code]
Neural mechanisms of time-forward predictions for naturalistic auditory tone sequences.
Journal: Nature communications
In common: FieldTrip, SPM, Signal Processing Toolbox, 1 other tool, cognitive, 7 references
[8] doi:10.1007/s00429-025-03012-5 [code]
The neurophysiology of healthy and pathological aging: a comprehensive systematic review
Journal: n/a
In common: 3 references, 2 authors
[9] doi:10.1038/s41467-026-73668-y [code]
Convergent and divergent brain-cognition development in early adolescence.
Journal: Nature communications
In common: Connectome Workbench, Tools for NIfTI and ANALYZE image (MATLAB), FieldTrip, 6 other tools, 1 reference
[10] doi:10.1038/s41593-026-02345-6 [code]
Human hippocampal ripples tune cortical responses based on predicted uncertainty.
Journal: Nature neuroscience
In common: FieldTrip, Image Processing Toolbox, Signal Processing Toolbox, 1 other tool, cognitive, 7 references

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.