OSCR

Model-based cardiac field artefact correction for OP-MEG.

Code ↔ Paper

25 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 25 matches
  1. [1] § Methods › Empirical measurement: Methods › Performance relative to established artefact correction methods ↔ Pilot Study/Comparative_analysis_with_ICA.m, lines 63–174 · score 0.89 · 0–100 Hz, ft_connectivityanalysis, ft_freqanalysis, ft_componentanalysis, Fourier, coherence
  2. [2] § Methods › Empirical measurement: Methods › Assessment of CFA-correction performance for the condition with auditory stimulus ↔ Pilot Study/OPM_CFA_correction_Pilot_Study.m, lines 470–526 · score 0.84 · silicone tube, sound travels, system latencies, delays, onset, CFA correction
  3. [3] § Methods › Empirical measurement: Methods › Assessment of CFA-correction performance for the condition with auditory stimulus ↔ Pilot Study/OPM_CFA_correction_Pilot_Study.m, lines 1651–1744 · score 0.72 · 80–120 ms, baseline corrected, Ground truth, epoched, 80 ms, trigger
  4. [4] § Methods › Empirical measurement: Methods › Heartbeat extraction from chest OP-MEG sensors ↔ Pilot Study/OPM_CFA_correction_Pilot_Study.m, lines 353–447 · score 0.68 · peak distance, BPM, findpeaks, heart rate, outliers, heartbeats
  5. [5] § Results › Empirical measurement: Results › CFA-correction performance for trials with auditory stimulus ↔ Pilot Study/Comparative_analysis_with_ICA.m, lines 308–412 · score 0.66 · ICA correction, 80–120 ms, baseline corrected, 80 ms, HFC, N100
  6. [6] § Results › Simulations: Results › Robustness of the CDM estimate to cardiac source misplacement in the forward model ↔ Simulations/CFA_Simulation_Heart_Only_Mislocated_Multidipole_Template.m, lines 679–723 · score 0.65 · dipole distance, RMSE increase, heart location, single dipole, fold, correlation
  7. [7] § Methods › Empirical measurement: Methods › Assessment of CFA-correction performance for the condition with auditory stimulus ↔ Pilot Study/OPM_CFA_correction_Pilot_Study.m, lines 1297–1384 · score 0.65 · pre stimulus, post stimulus, window range, auditory, peak, CFA
  8. [8] § Results › Empirical measurement: Results › CFA-correction performance for trials with auditory stimulus ↔ Pilot Study/OPM_CFA_correction_Pilot_Study.m, lines 1651–1744 · score 0.65 · 80–120 ms, N100 window, baseline corrected, 80 ms, amplitudes, Channel
  9. [9] § Methods › Empirical measurement: Methods › OP-MEG data pre-processing ↔ Pilot Study/OPM_CFA_correction_Pilot_Study.m, lines 223–268 · score 0.63 · power spectral density, bad channels, SPM, sensors
  10. [10] § Methods › Empirical measurement: Methods › OP-MEG data pre-processing ↔ Pilot Study/OPM_CFA_correction_Pilot_Study.m, lines 319–337 · score 0.62 · 48–52 Hz, spm eeg, band, filtered, 48 Hz
  11. [11] § Methods › Simulations: Methods › OP-MEG sensor simulation ↔ Simulations/CFA_Simulation_Heart_Only_Mislocated_Multidipole_Template.m, lines 60–98 · score 0.61 · spm_opm_sim, single axis, template, distance, scalp, spacing
  12. [12] § Results › Empirical measurement: Results › CFA-correction performance for trials with auditory stimulus ↔ Pilot Study/Bootstrap_comparison_of_N100_recovery.m, lines 10–52 · score 0.61 · absolute bias, bootstrap comparisons, ground truth, N100, corrupted, correlations
  13. [13] § Methods › Empirical measurement: Methods › Co-registration ↔ Pilot Study/OPM_CFA_correction_Pilot_Study.m, lines 1107–1146 · score 0.60 · selected sensor locations, brain sensor, centred torso, rigid, chest, positioned
  14. [14] § Methods › Simulations: Methods › OP-MEG sensor simulation ↔ Simulations/CFA_Simulation_Heart_and_Brain.m, lines 63–101 · score 0.60 · spm_opm_sim, single axis, distance, scalp, brain, spacing
  15. [15] § Methods › Simulations: Methods › Simulation of the heart’s electromagnetic activity ↔ Simulations/CFA_Simulation_Impact_of_Torso_Proportions.m, lines 335–405 · score 0.60 · spm_mesh_clusters, largest, heart mesh, compartments, vertex, FieldTrip
  16. [16] § Methods › Simulations: Methods › Simulation of the heart’s electromagnetic activity ↔ Simulations/CFA_Simulation_Heart_Only_Mislocated_Multidipole_Template.m, lines 326–438 · score 0.59 · spm_mesh_clusters, heart dipole, vertex, FieldTrip, inside, space
  17. [17] § Methods › Empirical measurement: Methods › Assessment of CFA-correction performance for the condition with auditory stimulus ↔ Simulations/CFA_Simulation_Impact_of_Omitting_the_Head.m, lines 514–658 · score 0.58 · minimum norm, source space, skull, fit, template, mesh
  18. [18] § Methods › Simulations: Methods › Recovering brain activity that is time locked to the cardiac cycle ↔ Simulations/CFA_Simulation_Heart_and_Brain.m, lines 104–133 · score 0.56 · volume conduction model, cortex, ft prepare, surface, template, scalp
  19. [19] § Methods › Empirical measurement: Methods › Co-registration ↔ Pilot Study/Coregistration.m, lines 686–742 · score 0.55 · source landmarks, centred torso mesh, Alignment, scan, chest, locations
  20. [20] § Methods › Empirical measurement: Methods › Assessment of CFA-correction performance for the condition with auditory stimulus ↔ Pilot Study/Bootstrap_comparison_of_N100_recovery.m, lines 10–52 · score 0.54 · absolute bias, ground truth, metrics, N100, bootstrap, correlation
  21. [21] § Methods › Simulations: Methods › Testing the accuracy of the CDM estimate ↔ Simulations/CFA_Simulation_Heart_Only.m, lines 661–763 · score 0.53 · CFA prediction, rotation angles, reference signal, accuracy, RMSE, matrix
  22. [22] § Methods › Empirical measurement: Methods › Assessment of CFA-correction performance for the condition with auditory stimulus ↔ Simulations/CFA_Simulation_Heart_and_Brain.m, lines 104–133 · score 0.53 · template mesh, canonical, surface, skull, post, SPM
  23. [23] § Methods › Empirical measurement: Methods › Assessment of CFA-correction performance for the condition with auditory stimulus ↔ Pilot Study/OPM_CFA_correction_Pilot_Study.m, lines 353–447 · score 0.52 · heart rate, outlier, 150 ms, heartbeat, window, 500 ms
  24. [24] § Methods › Simulations: Methods › Testing the accuracy of the CDM estimate ↔ Simulations/CFA_Simulation_Impact_of_Omitting_the_Head.m, lines 514–658 · score 0.51 · CFA prediction, rotation angles, reference signal, error, training, matrix
  25. [25] § Methods › Empirical measurement: Methods › Co-registration ↔ Pilot Study/OPM_CFA_correction_Pilot_Study.m, lines 1107–1146 · score 0.50 · sensor locations, sensor position, rigid, transformation, centred, model

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 · 2,639 lines · 85 KB · MIT · 10 matches

  1. %% Overview
  2. % The main goal of this pipeline is to process and analyze the OPM
  3. % CFA-correction pilot study results.
  4. %
  5. % The pipeline is built to work on all
  6. % four recordings of the pilot study. For each recording, the
  7. % heartbeats / auditory stimuli are split into a train and test set. A
  8. % cardiac dipole moment (CDM) estimate is derived using the train set.
  9. % This template is then used to perform CFA-correction on the test set.
  10. %
  11. % Code for calculation of accuracy metrics and plots from the manuscript is
  12. % included. For all plots, the corresponding figure numbers in the
  13. % manuscript are indicated.
  14. %% Parameter settings
  15. clearvars
  16. close all
  17. clc
  18. restoredefaultpath;
  19. rehash toolboxcache;
  20. % Settings for which OP-MEG data to use
  21. record = 4; % set which dataset to process
  22. preprocessed_template = sprintf('CDM_estimate_record_%d.mat', record);
  23. % Settings for processing movement data
  24. process_movement_data = false; % whether to process the movement data
  25. channelLevelRbTimeseriesHead_filename = sprintf('channelLevelRbTimeseriesHeadSorted_record_%d.mat', record);
  26. channelLevelRbTimeseriesChest_filename = sprintf('channelLevelRbTimeseriesChestSorted_record_%d.mat', record);
  27. % Settings for building a new CDM estimate
  28. build_template = false; % whether to create a new CDM estimate or load
  29. new_template_name = sprintf('CDM_estimate_record_%d.mat', record);
  30. start_before = 0.4; % start the R-peak window 400ms prior to R-peak
  31. end_after = 0.6; % end the R-peak window 600ms after R-peak
  32. % Set whether to baseline correct the trial-averaged data
  33. if record == 1 || record == 2
  34. baseline_correct_bool = false;
  35. elseif record == 3 || record == 4
  36. baseline_correct_bool = true;
  37. baseline_win = [-100 0]; % baseline-correction window
  38. end
  39. % Set whether to estimate the headmodel
  40. % (required for the source estimation follow-on script)
  41. estimate_headmodel = false;
  42. %% Set up environment and load required packages
  43. % update these paths to their where they are on your machine.
  44. spm_path = 'C:\Users\swoelk\Documents\MATLAB\spm\';
  45. torso_tools_path = 'C:\Users\swoelk\Documents\MATLAB\torso_tools\';
  46. hbf_lc_path = 'C:\Users\swoelk\Documents\MATLAB\hbf_lc_p\';
  47. optitrack_path = 'C:\Users\swoelk\Documents\MATLAB\optitrack';
  48. scannercast_path = 'C:\Users\swoelk\Documents\MATLAB\scannercast';
  49. % init spm
  50. if isempty(which('spm'))
  51. addpath(spm_path)
  52. spm('defaults','eeg');
  53. spm_jobman('initcfg');
  54. fprintf('SPM environment loaded\n');
  55. end
  56. % create progress bar window
  57. spm('createintwin');
  58. % init hbf bem
  59. if isempty(which('hbf_SetPaths'))
  60. addpath(hbf_lc_path)
  61. hbf_SetPaths;
  62. % for FT compatibility install subfunctions to private folder for now
  63. fnames = {'hbf_LFM_B_LC_xyz', 'hbf_Phiinf_xyz', 'hbf_Binf_xyz'};
  64. exts = {'m','p'};
  65. dir_in = fullfile(hbf_lc_path, 'hbf_calc', 'private');
  66. dir_out = fullfile(torso_tools_path, 'hbf_lc_p', 'hbf_calc', 'private');
  67. if ~exist(dir_out, 'dir')
  68. mkdir(dir_out);
  69. end
  70. for ii = 1:numel(fnames)
  71. for jj = 1:numel(exts)
  72. fin = spm_file(fnames{ii},'ext',exts{jj},'path',dir_in);
  73. fout = spm_file(fin,'path',dir_out);
  74. copyfile(fin,fout);
  75. end
  76. end
  77. end
  78. % init torso tools (and fieldtrip modifications)
  79. if isempty(which('tt_add_bem'))
  80. addpath(torso_tools_path)
  81. tt_add_bem;
  82. end
  83. % init Optitrack
  84. if isempty(which('resampleOptiTrack'))
  85. addpath(optitrack_path)
  86. end
  87. % init scannercast
  88. if isempty(which('extractSensorPositions_V3'))
  89. addpath(genpath(scannercast_path))
  90. end
  91. %% Create the positions.tsv file
  92. % info2pos_neuro1()
  93. %% Read data
  94. mainDir = 'D:\OPM - Pilot 2';
  95. cd(mainDir);
  96. if record == 1
  97. % Fixed head, silence
  98. lvm_folder = 'sub-OP00208\meg\ses-001\resting-run-001_13-03-2025_10-40-56\';
  99. S = [];
  100. S.data = fullfile(lvm_folder, 'resting-run-001_array1.lvm');
  101. S.positions= 'sub-OP00208\meg\ses-001\head_rotation-run-001_13-03-2025_10-52-29\head_rotation-run-001_ar_positions.tsv';
  102. D = spm_opm_create(S);
  103. cfg = [];
  104. cfg.filename = "sub-002-optitrack/Take 2025-03-13 09.39.01 AM_001 (quarternion).csv";
  105. OptiData = readRigidBody(cfg);
  106. elseif record == 2
  107. % Head rotation, silence
  108. lvm_folder = 'sub-OP00208\meg\ses-001\head_rotation-run-001_13-03-2025_10-52-29\';
  109. S = [];
  110. S.data = fullfile(lvm_folder, 'head_rotation-run-001_array1.lvm');
  111. S.positions= 'sub-OP00208\meg\ses-001\head_rotation-run-001_13-03-2025_10-52-29\head_rotation-run-001_ar_positions.tsv';
  112. D = spm_opm_create(S);
  113. cfg = [];
  114. cfg.filename = "sub-002-optitrack/Take 2025-03-13 09.39.01 AM_002 (quarternion).csv";
  115. OptiData = readRigidBody(cfg);
  116. elseif record == 3
  117. % Fixed head, auditory stimulus
  118. lvm_folder = 'sub-OP00208\meg\ses-001\resting_beep-run-001_13-03-2025_11-04-48\';
  119. S = [];
  120. S.data = fullfile(lvm_folder, 'resting_beep-run-001_array1.lvm');
  121. S.positions= 'sub-OP00208\meg\ses-001\head_rotation-run-001_13-03-2025_10-52-29\head_rotation-run-001_ar_positions.tsv';
  122. D = spm_opm_create(S);
  123. cfg = [];
  124. cfg.filename = "sub-002-optitrack/Take 2025-03-13 09.39.01 AM_003 (quarternion).csv";
  125. OptiData = readRigidBody(cfg);
  126. elseif record == 4
  127. % Head rotation, auditory stimulus
  128. lvm_folder = 'sub-OP00208\meg\ses-001\headrotation_beep-run-001_13-03-2025_11-21-03\';
  129. S = [];
  130. S.data = fullfile(lvm_folder, 'headrotation_beep-run-001_array1.lvm');
  131. S.positions= 'sub-OP00208\meg\ses-001\head_rotation-run-001_13-03-2025_10-52-29\head_rotation-run-001_ar_positions.tsv';
  132. D = spm_opm_create(S);
  133. cfg = [];
  134. cfg.filename = "sub-002-optitrack/Take 2025-03-13 09.39.01 AM_004 (quarternion).csv";
  135. OptiData = readRigidBody(cfg);
  136. end
  137. % load preprocessed movement data
  138. if ~(exist('process_movement_data', 'var') && process_movement_data)
  139. load(channelLevelRbTimeseriesHead_filename);
  140. load(channelLevelRbTimeseriesChest_filename);
  141. end
  142. % load translation matrix to recover relative head sensor locations from
  143. % chest sensor locations
  144. load("head_positions_rel_to_chest.mat")
  145. %% Select OP-MEG channels
  146. opms=indchantype(D, 'MEGMAG');
  147. %% Plot trigger channels
  148. % manually set the Optitrack trigger channel, based on visual identification in plot
  149. trig_chan_opti = {'T2'};
  150. trig_ind_opti = find(strcmp(D.chanlabels,trig_chan_opti));
  151. % manually set auditory stimulus trigger channel
  152. trig_chan = {'A8'};
  153. trig_ind = find(strcmp(D.chanlabels,trig_chan));
  154. figure;
  155. hold on;
  156. plot(D.time, D(trig_ind_opti,:,1));
  157. plot(D.time, D(trig_ind,:,1));
  158. legend();
  159. hold off;
  160. %% Plot PSD
  161. S=[];
  162. S.triallength = 3000;
  163. S.plot=1;
  164. S.D=D;
  165. S.channels='MEG';
  166. % S.selectbad=1;
  167. [~, ~, badind_psd] = spm_opm_psd(S);
  168. ylim([1,1e5]);
  169. %% mark bad channels for the head sensor array
  170. % Note: bad channels from the chest sensor array are not excluded at this
  171. % stage, as later the SPM object will be split into head/chest and only a
  172. % single chest channel is selected for ECG extraction.
  173. badchansPSD = D.chanlabels(badind_psd);
  174. badchans = { ...
  175. 'X14', 'Y14', 'Z14', ...
  176. 'X15', ...
  177. 'X17', 'Y17', 'Z17', ...
  178. 'X20', 'Y20', 'Z20', ...
  179. 'X22', 'Y22', 'Z22', ...
  180. 'Y23', 'Z23', ...
  181. 'X26', 'Y26', 'Z26', ...
  182. 'X28', 'Y28', 'Z28', ...
  183. 'X33', 'Y33', 'Z33', ...
  184. 'X36', 'Y36', 'Z36', ...
  185. 'X37', 'Y37', ...
  186. 'X38', 'Y38', ...
  187. 'Y40', 'Z40', ...
  188. 'X43', ...
  189. 'X45', 'Y45', 'Z45', ...
  190. 'X56', 'Y56', 'Z56', ...
  191. 'X57', 'Y57', 'Z57' ...
  192. };
  193. if~isempty(badchans)
  194. badind=[];
  195. for f=1:size(badchans,2)
  196. badind(f)=find(strcmp(D.chanlabels,deblank(badchans(:,f))));
  197. end
  198. D = badchannels(D, badind, 1); %% set channels to bad
  199. end
  200. used = setdiff(opms,badchannels(D));
  201. S=[];
  202. S.triallength = 3000;
  203. S.plot=1;
  204. S.D=D;
  205. S.channels=D.chanlabels(used);
  206. spm_opm_psd(S);
  207. ylim([1,1e5]);
  208. %% Find heart channels
  209. % Find channels 1-8 all axes
  210. heart_ind = find(~cellfun('isempty', regexp(D.chanlabels, '^[XYZ][1-8]$', 'once')));
  211. % heart_ind = find(contains(D.chanlabels, 'B'));
  212. heart_opms = chanlabels(D, heart_ind);
  213. if~isempty(heart_opms)
  214. heartind=[];
  215. for f=1:size(heart_opms,2)
  216. heartind(f)=find(strcmp(D.chanlabels,deblank(heart_opms(:,f))));
  217. end
  218. end
  219. %% Create new SPM object with only heart
  220. clear heartD
  221. % exclude bad channel indeces from OPMs on the chest
  222. heart_opms_ind = setdiff(heart_ind, badchannels(D));
  223. % filter D
  224. S = [];
  225. S.channels = [D.chanlabels([heart_opms_ind, trig_ind_opti, trig_ind])];
  226. S.D = D;
  227. S.prefix = 'pH_';
  228. heartD = spm_eeg_crop(S);
  229. heartD.save()
  230. %% Align chest OPM and Optitrack data and resample Optitrack data
  231. if record == 1 % record 1 has a single Optitrack trigger (only end)
  232. [~, heartD] = syncOptitrackAndOPMdata(OptiData, heartD, ...
  233. 'TriggerChannelName', 'T2', ...
  234. 'RigidBodyNames', {'head', 'heart'}, ...
  235. 'TriggerType', 'end');
  236. else
  237. [~, heartD] = syncOptitrackAndOPMdata(OptiData, heartD, ...
  238. 'TriggerChannelName', 'T2', ...
  239. 'RigidBodyNames', {'head', 'heart'});
  240. end
  241. %% Filter cascade HEART
  242. S=[];
  243. S.D=heartD;
  244. S.freq=2;
  245. S.band = 'high';
  246. fDH = spm_eeg_ffilter(S);
  247. S = [];
  248. S.D = fDH;
  249. S.freq = 70;
  250. S.band = 'low';
  251. fDH = spm_eeg_ffilter(S);
  252. S = [];
  253. S.D = fDH;
  254. S.freq = [48,52];
  255. S.band = 'stop';
  256. fDH = spm_eeg_ffilter(S);
  257. %% Extract the heartbeat from the chest sensors
  258. % Visual inspection of heart opms after filtering
  259. labs=fDH.chanlabels();
  260. datsamp=squeeze(fDH(:,:,1));
  261. figure; hline = plot(datsamp');
  262. legend(labs(:));
  263. title('raw timeseries');
  264. % Select an OPM sensor with a clean ECG trace
  265. one_clean_heart_chan = 'Z7'; % Alternatively use 'Y4' 'X2'
  266. one_clean_heart_ind = find(strcmp(fDH.chanlabels, one_clean_heart_chan));
  267. ecg_trace = squeeze(fDH(one_clean_heart_ind,:,1));
  268. %% Detect heartbeats
  269. figure()
  270. plot(fDH.time,ecg_trace);
  271. beatlen_sec=[];
  272. thresh=std(ecg_trace)*0.5;
  273. if isempty(beatlen_sec)
  274. minpeakdist=fDH.fsample/2; %% 2 beats per second, i.e. 120 BPM
  275. else
  276. minpeakdist=beatlen_sec*fDH.fsample/2; %% half of estimated heartbeat time in samples
  277. end
  278. [rPeakAmp, rPeakIdx]=findpeaks(ecg_trace,'MinPeakDistance',minpeakdist,'MinPeakHeight',thresh);
  279. % Exclude the min/max heartbeats to avoid exceeding recording length
  280. min_rPeakIdx = find(rPeakIdx == min(rPeakIdx));
  281. max_rPeakIdx = find(rPeakIdx == max(rPeakIdx));
  282. rPeakIdx([max_rPeakIdx, min_rPeakIdx]) = [];
  283. rPeakAmp([max_rPeakIdx, min_rPeakIdx]) = [];
  284. hold on
  285. % Calculate percentile thresholds
  286. pct95pks = prctile(rPeakAmp, 95);
  287. pct5pks = prctile(rPeakAmp, 5);
  288. % Find outliers (too high or too low)
  289. outliers_high = find(rPeakAmp > pct95pks);
  290. outliers_low = find(rPeakAmp < pct5pks);
  291. outliers = unique([outliers_high; outliers_low]);
  292. outlierIdx = rPeakIdx(outliers);
  293. % Plot and remove outliers
  294. plot(rPeakIdx/fDH.fsample,rPeakAmp,'o')
  295. plot(outlierIdx/fDH.fsample,rPeakAmp(outliers),'r*')
  296. rPeakAmp(outliers) = [];
  297. rPeakIdx(outliers)= [];
  298. % Heartbeat length is median diff between peaks
  299. if isempty(beatlen_sec)
  300. beatlen_sec=2*floor((median(diff(rPeakIdx)))/2)/fDH.fsample;
  301. end
  302. averageHR = 60/beatlen_sec;
  303. fprintf("Average heart rate estimated to be %.0f BPM.\n", averageHR);
  304. % Secondary peak (T-wave detection): 150–350 ms after each primary peak
  305. tPeakWin = round([0.150, 0.350] * fDH.fsample); % in samples
  306. tPeakAmp = []; % to store secondary peak values
  307. tPeakIdx = []; % to store time indices of secondary peaks
  308. for i = 1:length(rPeakIdx)
  309. start_idx = rPeakIdx(i) + tPeakWin(1);
  310. end_idx = rPeakIdx(i) + tPeakWin(2);
  311. % Ensure the window is within bounds
  312. if end_idx > length(ecg_trace)
  313. % Append NaNs to keep arrays aligned
  314. tPeakAmp(end+1) = NaN;
  315. tPeakIdx(end+1) = NaN;
  316. else
  317. % Extract window segment
  318. segment = ecg_trace(start_idx:end_idx);
  319. % Find secondary peak in the segment
  320. [sec_pks, sec_locs] = findpeaks(segment, 'NPeaks', 1, 'SortStr', 'descend');
  321. if ~isempty(sec_pks)
  322. tPeakAmp(end+1) = sec_pks;
  323. tPeakIdx(end+1) = start_idx + sec_locs - 1;
  324. else
  325. tPeakAmp(end+1) = NaN;
  326. tPeakIdx(end+1) = NaN;
  327. end
  328. end
  329. end
  330. % Plot secondary peaks
  331. plot(tPeakIdx / fDH.fsample, tPeakAmp, 'gx') % green 'x' for secondary peaks
  332. % Calculate time between R-peak and T-peak in milliseconds
  333. latencies_ms = (tPeakIdx - rPeakIdx) / fDH.fsample * 1000;
  334. % Plot histogram of latencies
  335. figure;
  336. histogram(latencies_ms, 100);
  337. xlabel('Latency (ms)');
  338. ylabel('Peak count');
  339. title('Timing of T-peaks after R-peaks');
  340. grid on;
  341. % Calculate mean and standard deviation
  342. mean_latency = mean(latencies_ms);
  343. std_latency = std(latencies_ms);
  344. fprintf('The mean latency was %.2f ms (SD = %.2f).\n', mean_latency, std_latency);
  345. %% Epoch heartbeats and plot average heartbeat over all chest channels
  346. beatsamples=beatlen_sec*fDH.fsample;
  347. heartep=[];
  348. % loop over peak indices to epoch
  349. for f=1:length(rPeakIdx)
  350. if (((rPeakIdx(f)+beatsamples)<length(ecg_trace)) && ((rPeakIdx(f)-beatsamples)>0))
  351. heartep(:,:,f)=fDH(:,rPeakIdx(f)-beatsamples/2:rPeakIdx(f)+beatsamples/2-1,1);
  352. end
  353. end
  354. heartep=mean(heartep,3);
  355. figure;
  356. plot(heartep')
  357. legend('estimate of heartbeat (over all chans)')
  358. %% Extract auditory stimulus indices from the SPM object
  359. if record == 3 || record == 4
  360. % find the trigger channel for the auditory stimulus
  361. trig_chan = {'A8'};
  362. trig_ind_heartD = find(strcmp(heartD.chanlabels, trig_chan));
  363. % get auditory stimulus trigger data
  364. stim_trig_data = heartD(trig_ind_heartD,:,:);
  365. stim_trig_data = stim_trig_data(:); % flatten
  366. % Extract step changes (trigger onset)
  367. max_trig_value = max(stim_trig_data);
  368. threshold = max_trig_value / 3;
  369. aboveThreshold = stim_trig_data > threshold;
  370. stimIndices = find(diff([0; aboveThreshold]) == 1);
  371. % Drop first trigger if it's an artefact
  372. stimIndices = stimIndices(2:end);
  373. % Correct for stimulus delays due to sound travel time in silicone tube and system latencies
  374. delay_samples = round(heartD.fsample * 0.03); % 30 ms
  375. stimIndices = stimIndices + delay_samples;
  376. % Optional: remove test stimuli before experiment start (only record 3)
  377. if record == 3
  378. stimIndices = stimIndices(stimIndices > D.fsample * 5.83 * 60);
  379. end
  380. % remove stimulus indices close to heartbeat outliers
  381. % Define time window around each stimulus
  382. samplesBefore = round(0.200 * heartD.fsample); % 200 ms before (with N100 latency, this covers approx. QRS to end of T-wave)
  383. samplesAfter = round(0.200 * heartD.fsample); % 200 ms after
  384. % Initialize logical index to keep valid stimIndices
  385. toKeepStims = true(size(stimIndices));
  386. for i = 1:length(stimIndices)
  387. stimInd = stimIndices(i);
  388. windowStart = stimInd - samplesBefore;
  389. windowEnd = stimInd + samplesAfter;
  390. % If any heartbeat trigger occurs within this window, exclude the stimulus
  391. if any(outlierIdx >= windowStart & outlierIdx <= windowEnd)
  392. toKeepStims(i) = false;
  393. end
  394. end
  395. % Final result: only stimIndices without nearby rPeakIdx in the defined window
  396. stimIndices = stimIndices(toKeepStims);
  397. % Ensure both heartbeat and stimulus triggers are column vectors
  398. rPeakIdx = rPeakIdx(:);
  399. stimIndices = stimIndices(:);
  400. end
  401. %% Training set: Filter R-peaks to retain only those far from any auditory stimulus
  402. if record == 3 || record == 4
  403. % Define time window after stimulus
  404. sec_before_rpeak_clean = 0.350;
  405. sec_after_rpeak_clean = 0.150;
  406. samplesBefore = round(sec_before_rpeak_clean * heartD.fsample);
  407. samplesAfter = round(sec_after_rpeak_clean * heartD.fsample);
  408. % Initialize logical index to keep valid stimIndices
  409. toKeepPeaks = true(size(rPeakIdx));
  410. for i = 1:length(rPeakIdx)
  411. peakInd = rPeakIdx(i);
  412. windowStart = peakInd - samplesBefore;
  413. windowEnd = peakInd + samplesAfter;
  414. % If any auditory stimulus occurs within this window, remove the R-peak
  415. if any(stimIndices >= windowStart & stimIndices <= windowEnd)
  416. toKeepPeaks(i) = false;
  417. end
  418. end
  419. % Final result: only stimIndices without nearby rPeakIdx in the defined window
  420. clean_rPeakIdx = rPeakIdx(toKeepPeaks);
  421. % === Optional: remove rPeakIdx before stimulus onset (only record 3) ===
  422. if record == 3
  423. clean_rPeakIdx = clean_rPeakIdx(clean_rPeakIdx > D.fsample * 5.83 * 60);
  424. end
  425. fprintf("Training set: Retained %.2f clean heartbeats after exclusion of those with a tone %.0f ms before to %.0f ms after. \n", numel(clean_rPeakIdx), sec_before_rpeak_clean * 1000, sec_after_rpeak_clean * 1000);
  426. end
  427. %% Filter stimulus start times to retain CFA-corrupted ones
  428. if record == 3 || record == 4
  429. % Define time window after stimulus
  430. sec_n100_corrupted_win_low = 0.050; % ms after tone
  431. sec_n100_corrupted_win_high = 0.150; % ms after tone
  432. samplesLower = round(sec_n100_corrupted_win_low * heartD.fsample);
  433. samplesUpper = round(sec_n100_corrupted_win_high * heartD.fsample);
  434. % Initialize logical index to keep valid stimIndices
  435. toKeepStims = false(size(stimIndices));
  436. for i = 1:length(stimIndices)
  437. stimInd = stimIndices(i);
  438. windowStart = stimInd + samplesLower;
  439. windowEnd = stimInd + samplesUpper;
  440. % If any heartbeat trigger occurs within this window, keep the stimulus
  441. if any(rPeakIdx >= windowStart & rPeakIdx <= windowEnd)
  442. toKeepStims(i) = true;
  443. end
  444. end
  445. % Final result: only stimIndices with at least one nearby rPeakIdx in the defined window
  446. filteredStimIndices = stimIndices(toKeepStims);
  447. % === Plotting ===
  448. htimes = zeros(size(heartD.time));
  449. htimes(rPeakIdx) = 1;
  450. stimtimes = zeros(size(heartD.time));
  451. stimtimes(stimIndices) = 0.5;
  452. filtered_stimtimes = zeros(size(heartD.time));
  453. filtered_stimtimes(filteredStimIndices) = 0.6;
  454. stimIndicesWithArtefact = filteredStimIndices;
  455. fprintf("Test set: Retained %.2f trials with a heartbeat a heartbeat %.0f-%.0f ms after the stimulus (i.e. in N100 window). \n", numel(stimIndicesWithArtefact), sec_n100_corrupted_win_low * 1000, sec_n100_corrupted_win_high * 1000);
  456. end
  457. %% Filter stimulus start times to retain only CFA-free ones
  458. if record == 3 || record == 4
  459. % Define time window around each stimulus
  460. sec_before_tone_clean = 0.200;
  461. sec_after_tone_clean = 0.200;
  462. samplesBefore = round(sec_before_tone_clean * heartD.fsample);
  463. samplesAfter = round(sec_after_tone_clean * heartD.fsample);
  464. % Initialize logical index to keep valid stimIndices
  465. toKeepStims = true(size(stimIndices));
  466. for i = 1:length(stimIndices)
  467. stimInd = stimIndices(i);
  468. windowStart = stimInd - samplesBefore;
  469. windowEnd = stimInd + samplesAfter;
  470. % If any heartbeat trigger occurs within this window, exclude the stimulus
  471. if any(rPeakIdx >= windowStart & rPeakIdx <= windowEnd)
  472. toKeepStims(i) = false;
  473. end
  474. end
  475. % Final result: only stimIndices without nearby rPeakIdx in the defined window
  476. filteredStimIndices = stimIndices(toKeepStims);
  477. % === Plotting ===
  478. htimes = zeros(size(heartD.time));
  479. htimes(rPeakIdx) = 1;
  480. stimtimes = zeros(size(heartD.time));
  481. stimtimes(stimIndices) = 0.5;
  482. filtered_stimtimes = zeros(size(heartD.time));
  483. filtered_stimtimes(filteredStimIndices) = 0.6;
  484. stimIndicesArtefactFree = filteredStimIndices;
  485. fprintf("Ground truth: Retained %.2f trials without any heartbeat %.0f ms before to %.0f ms after the auditory stimulus. \n", numel(stimIndicesArtefactFree), sec_before_tone_clean * 1000, sec_after_tone_clean * 1000);
  486. end
  487. %% Create new SPM object with only brain
  488. clear brainD
  489. % find indeces of OPMs on the head
  490. clean_opms_ind = setdiff(opms, badchannels(D));
  491. brain_opms_ind = setdiff(clean_opms_ind, heart_ind);
  492. % % select only the radial channels
  493. % pattern_rad = '^Y'; % Create regex patterns
  494. % radial_chan_ind = find(~cellfun(@isempty, regexp(D.chanlabels, pattern_rad)));
  495. % find indices of trigger channels that will be needed for saving
  496. % heartbeat timings
  497. t5_ind = find(strcmp(D.chanlabels,{'T5'}));
  498. t6_ind = find(strcmp(D.chanlabels,{'T6'}));
  499. t7_ind = find(strcmp(D.chanlabels,{'T7'}));
  500. % filter D
  501. S = [];
  502. S.channels = [D.chanlabels([brain_opms_ind, trig_ind_opti, trig_ind, t5_ind, t6_ind, t7_ind])];
  503. S.D = D;
  504. S.prefix = 'pB_';
  505. brainD = spm_eeg_crop(S);
  506. brainD.save()
  507. %% Prepare headmodel and forward model
  508. if estimate_headmodel
  509. S = [];
  510. S.D = brainD;
  511. S.sMRI = 'D:\OPM - Pilot 2\headcast_GRB\mri\mmsMQ0484_orig.img';
  512. S.meshres = 2;
  513. S.voltype = 'Single Shell';
  514. S.lead = 1;
  515. [brainD, brainL] = spm_opm_headmodel(S);
  516. brainD.save()
  517. end
  518. %% Align brain OPMs and Optitrack data and resample Optitrack data
  519. if record == 1 % record 1 has a single Optitrack trigger (only end)
  520. [rigidBodyT, brainD] = syncOptitrackAndOPMdata(OptiData, brainD, ...
  521. 'TriggerChannelName', 'T2', ...
  522. 'RigidBodyNames', {'head', 'heart'}, ...
  523. 'TriggerType', 'end');
  524. else
  525. [rigidBodyT, brainD] = syncOptitrackAndOPMdata(OptiData, brainD, ...
  526. 'TriggerChannelName', 'T2', ...
  527. 'RigidBodyNames', {'head', 'heart'});
  528. end
  529. %% Filter cascade BRAIN
  530. clear fDB
  531. S=[];
  532. S.D=brainD;
  533. S.freq=[2];
  534. S.band = 'high';
  535. fDB = spm_eeg_ffilter(S);
  536. S = [];
  537. S.D = fDB;
  538. S.freq = [70];
  539. S.band = 'low';
  540. fDB = spm_eeg_ffilter(S);
  541. S = [];
  542. S.D = fDB;
  543. S.freq = [48,52];
  544. S.band = 'stop';
  545. fDB = spm_eeg_ffilter(S);
  546. %% Plot PSD of brain channels after filtering
  547. S=[];
  548. S.triallength = 3000;
  549. S.plot=1;
  550. S.D=fDB;
  551. [~,freq]=spm_opm_psd(S);
  552. ylim([1,1e5])
  553. %% Process movement data
  554. if exist('process_movement_data', 'var') && process_movement_data
  555. % Read in sensor positions for the headcast
  556. cfg = [];
  557. cfg.folder = 'headcast_GRB/individualSensors/';
  558. cfg.plot = 'yes';
  559. cfg.outputfolder = cd;
  560. cfg.output = {'G3','G2_shim'};
  561. cfg.flipX = true;
  562. headSensorPositions = extractSensorPositions_V3(cfg);
  563. % Create a cell array of tables containing rigid body information for each HEAD sensor position provided
  564. cfg = [];
  565. cfg.rigidBodyFile = 'sub-002-optitrack\Head_asset.motive';
  566. cfg.sensorPositions = headSensorPositions;
  567. cfg.shortStalkSlots = [19 35 54]; % specify the slots the stalks were on which length stalk was used.
  568. cfg.longStalkSlots = [4 41];
  569. cfg.shortStalkTranslation = [-1.65, -49.9, -5.35]; % These are default values which should not be changed unless you have changed the stalks.
  570. cfg.longStalkTranslation = [-1.65, -57.2, -5.35];
  571. cfg.rigidBodyT = rigidBodyT.head.RigidBody; % Provide the rigid body timeseries table
  572. cfg.plot = true; % Whether to plot some outputs. Recommended.
  573. cfg.output_scaling = 'm'; % Output in metres
  574. channelLevelRbTimeseriesHead = getChannelLevelRigidBodyTimeseries(cfg);
  575. % Select only the channels contained in the brain SPM object and sort accordingly
  576. cfg = [];
  577. cfg.D = brainD;
  578. cfg.slot2sens = 'sensor_positions_head_final.csv';
  579. cfg.sensorPositions = headSensorPositions;
  580. cfg.channelLevelRbTimeseries = channelLevelRbTimeseriesHead;
  581. channelLevelRbTimeseriesHeadSorted = selectSortedChannelsRigidBodyTimeseries(cfg);
  582. % Read in sensor positions for the chest
  583. cfg = [];
  584. cfg.folder = 'chest_array/Sensor slots all/';
  585. cfg.plot = 'yes';
  586. cfg.outputfolder = cd;
  587. cfg.output = {'G3','G2_shim'};
  588. cfg.flipX = true;
  589. chestSensorPositions = extractSensorPositions_V3(cfg);
  590. % Create a cell array of tables containing rigid body information for each CHEST sensor position provided
  591. cfg = [];
  592. cfg.rigidBodyFile = 'sub-002-optitrack/Heart_asset.motive';
  593. cfg.sensorPositions = chestSensorPositions;
  594. cfg.shortStalkSlots = [10]; % specify the slots the stalks were on which length stalk was used.
  595. cfg.longStalkSlots = [5 13];
  596. cfg.sideStalkSlots = [2];
  597. cfg.shortStalkTranslation = [15, 0, 0]; % These are default values which should not be changed unless you have changed the stalks.
  598. cfg.longStalkTranslation = [30, 0, 0];
  599. cfg.sideStalkTranslation = [0, 30, 0];
  600. cfg.rigidBodyT = rigidBodyT.heart.RigidBody; % Provide the rigid body timeseries table
  601. cfg.plot = true; % Whether to plot some outputs. Recommended.
  602. cfg.output_scaling = 'm'; % Output in metres
  603. channelLevelRbTimeseriesChest = getChannelLevelRigidBodyTimeseries(cfg);
  604. % Select only the channels contained in the chest SPM object and sort accordingly
  605. cfg = [];
  606. cfg.D = heartD;
  607. cfg.slot2sens = 'sensor_positions_chest_used_only_final.csv';
  608. cfg.sensorPositions = chestSensorPositions;
  609. cfg.channelLevelRbTimeseries = channelLevelRbTimeseriesChest;
  610. channelLevelRbTimeseriesChestSorted = selectSortedChannelsRigidBodyTimeseries(cfg);
  611. % Save processed movement data to disk
  612. save(channelLevelRbTimeseriesHead_filename, 'channelLevelRbTimeseriesHeadSorted', '-v7.3');
  613. save(channelLevelRbTimeseriesChest_filename, 'channelLevelRbTimeseriesChestSorted', '-v7.3');
  614. end
  615. %% Split trials into train and test sets
  616. rng(42);
  617. if record == 1 || record == 2
  618. % split the trials 50/50 into train and test heartbeats
  619. idx = randperm(length(rPeakIdx)); %% Generate a random permutation of the indices of rPeakIdx
  620. split_idx = floor(length(idx) / 2); % Split the indices into two halves
  621. train_htrigs = rPeakIdx(idx(1:split_idx));
  622. test_stims = rPeakIdx(idx(split_idx+1:end));
  623. fprintf("Split dataset into %.0f train and %.0f test trials.\n", numel(train_htrigs), numel(test_stims));
  624. elseif record == 3 || record == 4
  625. % select heartbeats to train on
  626. train_htrigs = clean_rPeakIdx;
  627. % select the stimuli to test correcton on
  628. test_stims = stimIndicesWithArtefact;
  629. fprintf("Split dataset into %.0f train and %.0f test trials.\n", numel(train_htrigs), numel(test_stims));
  630. end
  631. % save the train triggers to channels T5
  632. t5_ind = find(strcmp(fDB.chanlabels,{'T5'}));
  633. tempVec = zeros(1, size(fDB(t5_ind,:,1), 2));
  634. tempVec(train_htrigs) = 1;
  635. fDB(t5_ind,:,1) = tempVec;
  636. % save the test triggers to channels T6
  637. t6_ind = find(strcmp(fDB.chanlabels,{'T6'}));
  638. tempVec = zeros(1, size(fDB(t6_ind,:,1), 2));
  639. tempVec(test_stims) = 1;
  640. fDB(t6_ind,:,1) = tempVec;
  641. if record == 3
  642. % save the clean triggers to channels T7
  643. t7_ind = find(strcmp(fDB.chanlabels,{'T7'}));
  644. tempVec = zeros(1, size(fDB(t7_ind,:,1), 2));
  645. tempVec(stimIndicesArtefactFree) = 1;
  646. fDB(t7_ind,:,1) = tempVec;
  647. elseif record == 4
  648. % save all triggers to channels T7 to increase SNR and get a clean AEP
  649. t7_ind = find(strcmp(fDB.chanlabels,{'T7'}));
  650. tempVec = zeros(1, size(fDB(t7_ind,:,1), 2));
  651. tempVec(stimIndices) = 1;
  652. fDB(t7_ind,:,1) = tempVec;
  653. end
  654. %% Save a copy in Fieldtrip format for later source analysis
  655. fDB_ftraw = fDB.ftraw;
  656. fDB_ftraw.grad = extractGradSelectedSensors(fDB);
  657. fDB_ftraw.fsample = fDB.fsample;
  658. fDB_ftraw.trial = cellfun(@(x) x * 1e-15, fDB_ftraw.trial, 'UniformOutput', false);
  659. % Patch the header
  660. hdr = fDB_ftraw.hdr;
  661. nchan = numel(hdr.label);
  662. if numel(hdr.chantype) < nchan
  663. hdr.chantype(nchan) = {''};
  664. end
  665. for k = 1:nchan
  666. if isempty(hdr.chantype{k}) || strcmp(hdr.chantype{k}, '')
  667. u = hdr.chanunit{k};
  668. if strcmp(u, '1|0')
  669. hdr.chantype{k} = 'trigger';
  670. elseif strcmp(u, 'V')
  671. hdr.chantype{k} = 'trigger';
  672. end
  673. end
  674. end
  675. fDB_ftraw.hdr = hdr;
  676. %% Load torso, including heart and lungs
  677. cd('C:\Users\swoelk\Documents\MATLAB\example_heart_pipeline')
  678. load('./thorax2mni.mat');
  679. % load body mesh (already in MNI space - hopefully)
  680. mesh2mni_transform = readmatrix('torso2mni.txt');
  681. thorax_mesh = ft_read_headshape('./thorax.gii');
  682. body_mesh_front = ft_transform_geometry(mesh2mni_transform, thorax_mesh);
  683. % % get full torso body mesh
  684. % body_mesh_front = ft_read_headshape('./head_torso_mni.gii');
  685. % Get the heart and find the source location
  686. mesh = tt_load_meshes(T,{'blood'});
  687. heart = mesh{1};
  688. % extract the central location of one of the chambers (first need to work
  689. % out which vertices correspond to each
  690. cluster = spm_mesh_clusters(heart,ones(1,length(heart.vertices)));
  691. id = find(cluster == 1);
  692. heart_source = mean(heart.vertices(id,:));
  693. % check its inside the chamber!
  694. in1 = tt_is_inside(heart_source, heart.vertices, heart.faces);
  695. assert(in1 ,'source doesnt originate from inside heart!')
  696. % create the sourcemodel structure for FT
  697. src = [];
  698. src.pos = heart_source;
  699. src.inside = 1;
  700. src.unit = tt_determine_mesh_units(tt_load_meshes(T));
  701. %%% Set up meshes for volume conduction model
  702. % Heart first.
  703. tmp = [];
  704. tmp.tri = heart.faces;
  705. tmp.pos = heart.vertices;
  706. tmp.unit = tt_determine_mesh_units(tt_load_meshes(T));
  707. tmp = ft_convert_units(tmp,'m');
  708. clear bnd
  709. bnd(1) = tmp;
  710. % Lungs next.
  711. mesh = tt_load_meshes(T,{'lungs'});
  712. lungs = mesh{1};
  713. tmp = [];
  714. tmp.tri = lungs.faces;
  715. tmp.pos = lungs.vertices;
  716. tmp.unit = tt_determine_mesh_units(tt_load_meshes(T));
  717. tmp = ft_convert_units(tmp,'m');
  718. bnd(2) = tmp;
  719. % get brain boundary - remember this is all in MNI space, so can use SPM!
  720. mesh = spm_eeg_inv_mesh();
  721. brain = export(gifti(mesh.tess_iskull),'patch');
  722. % get body/skull boundary - using rotated version from previous step
  723. tmp = [];
  724. tmp.tri = body_mesh_front.tri;
  725. tmp.pos = body_mesh_front.pos;
  726. tmp.unit = tt_determine_mesh_units(tt_load_meshes(T));
  727. tmp = ft_convert_units(tmp,'m');
  728. bnd(3) = tmp;
  729. ci = [0.62 0.05 0.23]; % conductivity inside boundary
  730. co = [0.23 0.23 0]; % conductivity outside boundary
  731. % assert source space is also in meters
  732. src = ft_convert_units(src,'m');
  733. cd('D:\OPM - Pilot 2')
  734. %% Center the torso, lungs, and heart
  735. % Centre the torso
  736. vertices = bnd(3).pos; % Nx3 matrix of vertex positions
  737. faces = bnd(3).tri; % Mx3 matrix of face vertex indices
  738. num_faces = size(faces, 1);
  739. total_area = 0;
  740. centroid_sum = [0, 0, 0];
  741. for f = 1:num_faces
  742. % Get the vertices of the current face
  743. v1 = vertices(faces(f, 1), :);
  744. v2 = vertices(faces(f, 2), :);
  745. v3 = vertices(faces(f, 3), :);
  746. % Calculate the centroid of the face
  747. face_centroid = (v1 + v2 + v3) / 3;
  748. % Calculate the area of the face
  749. face_area = 0.5 * norm(cross(v2 - v1, v3 - v1));
  750. % Accumulate the weighted centroid
  751. centroid_sum = centroid_sum + face_centroid * face_area;
  752. % Accumulate the total area
  753. total_area = total_area + face_area;
  754. end
  755. % Compute the overall centroid by dividing the weighted sum by the total area
  756. torsoCentroid = centroid_sum / total_area;
  757. % Centre the torso, lungs, heart, and source
  758. centredTorso = bnd(3);
  759. centredTorso.pos = bnd(3).pos - torsoCentroid;
  760. centredLungs = bnd(2);
  761. centredLungs.pos = bnd(2).pos - torsoCentroid;
  762. centredHeart = bnd(1);
  763. centredHeart.pos = bnd(1).pos - torsoCentroid;
  764. %% Prepare the volume-conduction model
  765. bndTranslated(1) = centredHeart;
  766. bndTranslated(2) = centredLungs;
  767. bndTranslated(3) = centredTorso;
  768. cfg = [];
  769. cfg.method = 'bem_hbf';
  770. cfg.conductivity = [ci;co];
  771. vol_bem = ft_prepare_headmodel(cfg,bndTranslated);
  772. %% Define locations for the 2 heart dipoles (random offset from true heart)
  773. rng(42);
  774. % Define the direction vector (normalized) for the 1st dipole
  775. direction_dip_1 = randn(1, 3); % random orientation
  776. direction_dip_1 = direction_dip_1 / norm(direction_dip_1); % normalize
  777. % Define the direction vector (normalized) for the 2nd dipole
  778. direction_dip_2 = randn(1, 3); % random orientation
  779. direction_dip_2 = direction_dip_2 / norm(direction_dip_2); % normalize
  780. % Random scaling of the 2 direction vectors, defining offset from true location
  781. n_distances = 20; % define how many distances to sample
  782. max_distance = 0.05; % maximum distance in metres
  783. distance_magnitudes = linspace(0.01, max_distance, n_distances)';
  784. dipole1_dist = distance_magnitudes(randi(length(distance_magnitudes))) * direction_dip_1;
  785. dipole2_dist = distance_magnitudes(randi(length(distance_magnitudes))) * direction_dip_2;
  786. % Define the location of the first dipole
  787. centredSource = src;
  788. centredSource.pos = src.pos - torsoCentroid + dipole1_dist;
  789. % Define the location of the second dipole
  790. centredSource_2 = src;
  791. centredSource_2.pos = src.pos - torsoCentroid + dipole2_dist;
  792. disp(centredSource.pos)
  793. disp(centredSource_2.pos)
  794. % check the dipoles are is still within the torso!
  795. in1 = tt_is_inside(centredSource.pos, centredTorso.pos, centredTorso.tri);
  796. assert(in1 ,'Dipole 1 doesnt originate from inside the torso!')
  797. in2 = tt_is_inside(centredSource_2.pos, centredTorso.pos, centredTorso.tri);
  798. assert(in2 ,'Dipole 2 doesnt originate from inside the torso!')
  799. %% STEP 1 - CFA Template Building (generate leadfields and trial-averaged data)
  800. if exist('build_template', 'var') && build_template
  801. % Whether to create 3D plots of torso and sensors
  802. plotOutput = false;
  803. % create empty array to hold the leadfields from all head rotations
  804. n_sensors = numel(fDB.indchantype('meg'));
  805. n_orientations = 3;
  806. n_dipoles = 2;
  807. n_trials = (length(train_htrigs) - 1); % Remove last index to prevent extending the recording length
  808. L_all_sensors = zeros(n_trials * n_sensors, n_orientations * n_dipoles);
  809. % create empty array to hold the data from all rotations
  810. startIdx = train_htrigs(1) - fDB.fsample * start_before;
  811. endIdx = train_htrigs(1) + fDB.fsample * end_after;
  812. winData = fDB(:,startIdx:endIdx,:);
  813. n_samples = size(winData,2);
  814. trial_avg_all_rotations = zeros(n_trials * n_sensors, n_samples);
  815. % create empty struct to hold data from all rotations (in seperate fields)
  816. trainData = struct();
  817. % initialize counter
  818. run = 0;
  819. for hb = 1:n_trials
  820. % Update counter
  821. run = run + 1;
  822. fprintf('Current run %d out of %d\n', run, n_trials);
  823. % Get the index of the current heartbeat
  824. selected_htrig = train_htrigs(hb);
  825. % Get the brain grad structure
  826. clear brainGrad
  827. brainGrad = extractGradSelectedSensors(brainD);
  828. brainGrad.unit = 'm';
  829. % Get positions of the brain sensor array based on Optitrack data
  830. brainGrad = updateSensorPositionsFrame(brainGrad, channelLevelRbTimeseriesHeadSorted, selected_htrig);
  831. % Get the heart grad structure
  832. clear heartGrad
  833. heartGrad = extractGradSelectedSensors(heartD);
  834. heartGrad.unit = 'm';
  835. % Get positions of the heart sensor array based on Optitrack data
  836. heartGrad = updateSensorPositionsFrame(heartGrad, channelLevelRbTimeseriesChestSorted, selected_htrig);
  837. % % Align sensor arrays with the centred torso % %
  838. % Select the radial channels for the chest array
  839. pattern_rad = '^Y'; % Pattern to find the radial channels
  840. radial_chan_ind = find(~cellfun(@isempty, regexp(heartD.chanlabels, pattern_rad)));
  841. heartGradY_pos = heartGrad.coilpos(radial_chan_ind,:);
  842. heartGradY_pos = heartGradY_pos([7,2,6],:); % Limit to only 3 locations
  843. % Recover relative head sensor locations (from still head position)
  844. brainGradY_pos = reconstruct_head_from_chest(heartGradY_pos, head_positions_rel_to_chest);
  845. % Define STL model points head
  846. sensor_pts_head = [0.036, 0.257, 0.463; % '32-ABOV-Z'
  847. -0.018, 0.041, 0.532; % '40-AAQK-Z'
  848. 0.100, 0.197, 0.430]; % '63-AAQ6-Z'
  849. % Define STL model points chest (facing front)
  850. sensor_pts_chest = [-0.023, 0.135, 0.092; % second from right top
  851. 0.063, 0.120, 0.024; % second from bottom left
  852. -0.056, 0.130, -0.018]; % bottom right
  853. % Aggregate all STL model points
  854. sensor_pts = [sensor_pts_chest;
  855. sensor_pts_head];
  856. % Perform the rigid transformation
  857. target_pts = sensor_pts;
  858. source_pts = [heartGradY_pos; brainGradY_pos];
  859. [rotationMatrix, translationVector] = rigid_transform_3D(source_pts, target_pts);
  860. % Translate sensor positions
  861. heartGrad.coilpos = ((rotationMatrix * heartGrad.coilpos') + translationVector)';
  862. heartGrad.chanpos = ((rotationMatrix * heartGrad.chanpos') + translationVector)';
  863. brainGrad.coilpos = ((rotationMatrix * brainGrad.coilpos') + translationVector)';
  864. brainGrad.chanpos = ((rotationMatrix * brainGrad.chanpos') + translationVector)';
  865. % Apply rotations also to sensor orientations
  866. heartGrad.coilori = (rotationMatrix * heartGrad.coilori')';
  867. heartGrad.chanori = (rotationMatrix * heartGrad.chanori')';
  868. brainGrad.coilori = (rotationMatrix * brainGrad.coilori')';
  869. brainGrad.chanori = (rotationMatrix * brainGrad.chanori')';
  870. if plotOutput
  871. figure()
  872. hold on
  873. % Plot sensors
  874. ft_plot_sens(brainGrad, 'coil', true, 'orientation', true);
  875. ft_plot_sens(heartGrad, 'coil', false, 'orientation', true);
  876. % Plot centred toros and axis vectors
  877. ft_plot_mesh(centredTorso,'facecolor','none','edgecolor','k','edgealpha',0.2);
  878. ft_plot_mesh(centredLungs,'facecolor','none','edgecolor','b','edgealpha',0.5);
  879. ft_plot_mesh(centredHeart,'facecolor','none','edgecolor','r','edgealpha',0.5);
  880. scatter3(centredSource.pos(1),centredSource.pos(2),centredSource.pos(3),'b','filled');
  881. scatter3(centredSource_2.pos(1),centredSource_2.pos(2),centredSource_2.pos(3),'b','filled');
  882. view(180, 0);
  883. rotate3d('on');
  884. axis on;
  885. axis equal;
  886. axis vis3d;
  887. hold off
  888. end
  889. % % Compute leadfields % %
  890. cfg = [];
  891. cfg.sourcemodel.pos = [centredSource.pos; centredSource_2.pos];
  892. cfg.sourcemodel.inside = [1; 1];
  893. cfg.sourcemodel.unit = 'm';
  894. cfg.headmodel = vol_bem;
  895. cfg.grad = brainGrad;
  896. cfg.reducerank = 'no';
  897. fwd = ft_prepare_leadfield(cfg);
  898. lf2a = fwd.leadfield{1}; % extract the leadfield of the first dipole
  899. lf2b = fwd.leadfield{2}; % extract the leadfiled of the second dipole
  900. lf2 = [lf2a, lf2b];
  901. % Define the start and end index for filling trial_avg_all_rotations
  902. start_idx = (run - 1) * n_sensors + 1; % Start at trial's sensor range
  903. end_idx = run * n_sensors; % End at the end of the current trial's sensors
  904. % Save leadfield to summary array
  905. L_all_sensors(start_idx:end_idx, :) = lf2;
  906. % Select data for the current heartbeat
  907. segmentStart = train_htrigs(hb) - brainD.fsample * start_before;
  908. segmentEnd = train_htrigs(hb) + brainD.fsample * end_after;
  909. winData = fDB(indchantype(fDB, 'MEGMAG'),segmentStart:segmentEnd,:);
  910. % Assign winData to the current trial field
  911. fieldName = sprintf('trial%d', run);
  912. trainData.(fieldName) = winData;
  913. % Save trial data to summary array
  914. trial_avg_all_rotations(start_idx:end_idx, :) = winData;
  915. end
  916. % % % CFA Template Building % % %
  917. % sample value corresponding to the R-peak
  918. rPeakTimeIdx = start_before * D.fsample;
  919. % get the amplitudes of all heartbeats used for template building
  920. template_htrig_amps = ecg_trace(train_htrigs);
  921. % pre-allocate an array for normalized data
  922. normalized_data = zeros(size(trial_avg_all_rotations));
  923. % normalize trial data
  924. for t = 1:n_trials
  925. % get row indices for trial t (in concatenated trial matrix)
  926. idx_start = (t-1)*n_sensors + 1;
  927. idx_end = t*n_sensors;
  928. % divide the data from trial t by the trial's heartbeat amplitude
  929. normalized_data(idx_start:idx_end, :) = trial_avg_all_rotations(idx_start:idx_end, :) / template_htrig_amps(t);
  930. end
  931. % build the template
  932. CDM_estimate_2dip = pinv(L_all_sensors, 1e-6) * trial_avg_all_rotations;
  933. CDM_estimate_2dip_scaled = pinv(L_all_sensors, 1e-6) * normalized_data;
  934. % plot the template
  935. epochTimeAxis = linspace(-start_before, end_after, (end_after + start_before) * D.fsample + 1);
  936. figure();
  937. hold on
  938. plot(epochTimeAxis, CDM_estimate_2dip(1,:))
  939. plot(epochTimeAxis, CDM_estimate_2dip(2,:))
  940. plot(epochTimeAxis, CDM_estimate_2dip(3,:))
  941. hold off
  942. % Save template files to disk
  943. save(new_template_name, ...
  944. 'CDM_estimate_2dip', ...
  945. 'CDM_estimate_2dip_scaled', ...
  946. 'train_htrigs', ...
  947. 'template_htrig_amps' ...
  948. );
  949. % % Check for noisy sensors % %
  950. fieldNames = fieldnames(trainData);
  951. nSensors = size(trainData.(fieldNames{1}), 1); % get # of sensors from first field
  952. nTrials = numel(fieldNames);
  953. sensorValuesAtPeak = zeros(nSensors, nTrials); % preallocate
  954. for i = 1:nTrials
  955. thisTrial = trainData.(fieldNames{i}); % [sensors x timepoints]
  956. sensorValuesAtPeak(:, i) = thisTrial(:, rPeakTimeIdx); % record values at timepoint
  957. end
  958. % Compute average across trials (columns)
  959. sensor_means = mean(sensorValuesAtPeak, 2); % [sensors x 1]
  960. sensor_stds = std(sensorValuesAtPeak, 0, 2); % [sensors x 1]
  961. zero_means = zeros(numel(sensor_means),1);
  962. % Plot with error bars
  963. figure;
  964. errorbar(categorical(fDB.chanlabels(fDB.indchantype('meg'))), zero_means, sensor_stds, 'o');
  965. xlabel('Sensor index');
  966. ylabel('Average signal ± STD');
  967. title('Per-sensor amplitude standard deviation across trials');
  968. grid on;
  969. hold off;
  970. else
  971. %Load pre-processed CFA Template
  972. load(preprocessed_template)
  973. end
  974. %% STEP 2 - TEST LOOP
  975. rng(42);
  976. % Whether to create 3D plots of torso and sensors
  977. plotOutput = false;
  978. % set R-peak window range (needs to align with template building settings)
  979. start_before_template = start_before; % start the window prior to R-peak
  980. end_after_template = end_after; % end the window after R-peak
  981. preRPeaksamples = fDB.fsample * start_before_template;
  982. postRPeaksamples = fDB.fsample * end_after_template;
  983. % find number of trials to correct (either R-peaks or auditory stimuli)
  984. n_trials = length(test_stims);
  985. % define stimulus window range
  986. if record == 1 || record == 2
  987. start_before_trigger = start_before; % start the window prior to R-peak
  988. end_after_trigger = end_after; % end the window after to R-peak
  989. elseif record == 3 || record == 4
  990. start_before_trigger = 0.2; % start the window x ms prior to R-peak
  991. end_after_trigger = 0.6; % end the window x ms after to R-peak
  992. end
  993. % create empty array to hold the leadfields from all orientations
  994. n_sensors = numel(fDB.indchantype('meg'));
  995. % create empty array to hold the data from all trials
  996. startIdx = test_stims(1) - fDB.fsample * start_before_trigger;
  997. endIdx = test_stims(1) + fDB.fsample * end_after_trigger;
  998. n_samples = endIdx - startIdx + 1;
  999. trial_avg_TEST = zeros(n_sensors, n_samples);
  1000. trial_avg_CORRECTED = zeros(n_sensors, n_samples);
  1001. % empty struct to hold results trial-by-trial
  1002. rawTrials = zeros(n_sensors, n_samples, n_trials);
  1003. correctedTrials = zeros(n_sensors, n_samples, n_trials);
  1004. peakIdxTemplate = zeros(n_trials, 1);
  1005. peakAmpsRaw = zeros(n_trials, 1);
  1006. % per-trial CDM estimate (i.e. scaled by the R-peak)
  1007. allTemplates = struct();
  1008. % initialize counter
  1009. run = 0;
  1010. latencies = []; % initialize an empty array
  1011. % for hb = 1:n_trials
  1012. for n = 1:n_trials
  1013. % update counter
  1014. run = run + 1;
  1015. fprintf('Current run %d out of %d\n', run, n_trials);
  1016. % get the index of the current stimulus
  1017. trial_ind = test_stims(n);
  1018. % find the index of the closest heartbeat
  1019. [closest_htrig_dist_samples, index] = min(abs(rPeakIdx - trial_ind));
  1020. % distance *with sign* (can be negative if heartbeat is BEFORE the stimulus)
  1021. closest_htrig = rPeakIdx(index);
  1022. signed_dist_samples = rPeakIdx(index) - trial_ind;
  1023. % store index inside the test window:
  1024. peakIdxTemplate(n) = fDB.fsample * start_before_trigger + signed_dist_samples;
  1025. % convert to ms (can now be ±)
  1026. closest_htrig_latency = signed_dist_samples / D.fsample * 1000;
  1027. fprintf('Closest heartbeat has %.0f ms latency.\n', closest_htrig_latency);
  1028. latencies(end+1) = closest_htrig_latency;
  1029. % get the amplitude of the heartbeat
  1030. % normalize trial data by R-peak amplitude
  1031. rpeak_amplitude = ecg_trace(closest_htrig);
  1032. peakAmpsRaw(n) = rpeak_amplitude;
  1033. % select data for the current stimulus
  1034. preStimSamples = brainD.fsample * start_before_trigger;
  1035. postStimSamples = brainD.fsample * end_after_trigger;
  1036. startIdx = trial_ind - preStimSamples;
  1037. endIdx = trial_ind + postStimSamples;
  1038. winData = fDB(indchantype(fDB, 'MEGMAG'),startIdx:endIdx,:);
  1039. % assign uncorrected trial data to results matrix
  1040. rawTrials(:,:,n) = winData;
  1041. % assign uncorrected trial data to trial-average array
  1042. trial_avg_TEST = trial_avg_TEST + winData;
  1043. %%% Apply CFA Correction %%%
  1044. % Define the source locations
  1045. % Define the location of the first dipole
  1046. centredSource = src;
  1047. centredSource.pos = src.pos - torsoCentroid + dipole1_dist;
  1048. % Define the location of the second dipole
  1049. centredSource_2 = src;
  1050. centredSource_2.pos = src.pos - torsoCentroid + dipole2_dist;
  1051. disp(centredSource.pos)
  1052. disp(centredSource_2.pos)
  1053. % check the dipoles are is still within the torso!
  1054. in1 = tt_is_inside(centredSource.pos, centredTorso.pos, centredTorso.tri);
  1055. assert(in1 ,'Dipole 1 doesnt originate from inside the torso!')
  1056. in2 = tt_is_inside(centredSource_2.pos, centredTorso.pos, centredTorso.tri);
  1057. assert(in2 ,'Dipole 2 doesnt originate from inside the torso!')
  1058. % skip the current loop if the heart is outside the chest
  1059. if in1 == 0 || in2 == 0
  1060. fprintf('Skipping trial %d: dipole(s) outside torso.\n', run);
  1061. continue
  1062. end
  1063. % Get the brain grad structure
  1064. clear brainGrad
  1065. brainGrad = extractGradSelectedSensors(brainD);
  1066. brainGrad.unit = 'm';
  1067. % Get positions of the brain sensor array based on Optitrack data
  1068. brainGrad = updateSensorPositionsFrame(brainGrad, channelLevelRbTimeseriesHeadSorted, closest_htrig);
  1069. % Get the chest grad structure
  1070. clear heartGrad
  1071. heartGrad = extractGradSelectedSensors(heartD);
  1072. heartGrad.unit = 'm';
  1073. % Get positions of the heart sensor array based on Optitrack data
  1074. heartGrad = updateSensorPositionsFrame(heartGrad, channelLevelRbTimeseriesChestSorted, closest_htrig);
  1075. % % Align sensor arrays with the centred torso % %
  1076. % Select the radial channels for the chest array
  1077. pattern_rad = '^Y'; % Pattern to find the radial channels
  1078. radial_chan_ind = find(~cellfun(@isempty, regexp(heartD.chanlabels, pattern_rad)));
  1079. heartGradY_pos = heartGrad.coilpos(radial_chan_ind,:);
  1080. heartGradY_pos = heartGradY_pos([7,2,6],:); % Limit to only 3 locations
  1081. % Recover relative head sensor locations (from head still positions)
  1082. brainGradY_pos = reconstruct_head_from_chest(heartGradY_pos, head_positions_rel_to_chest);
  1083. % Define STL model points head
  1084. sensor_pts_head = [0.036, 0.257, 0.463; % '32-ABOV-Z'
  1085. -0.018, 0.041, 0.532; % '40-AAQK-Z'
  1086. 0.100, 0.197, 0.430]; % '63-AAQ6-Z'
  1087. % Define STL model points chest (facing from the front)
  1088. sensor_pts_chest = [-0.023, 0.135, 0.092; % second from right top
  1089. 0.063, 0.120, 0.024; % second from bottom left
  1090. -0.056, 0.130, -0.018]; % bottom right
  1091. % Aggregate all STL model points
  1092. sensor_pts = [sensor_pts_chest;
  1093. sensor_pts_head];
  1094. % Perform the rigid transformation
  1095. target_pts = sensor_pts;
  1096. source_pts = [heartGradY_pos; brainGradY_pos];
  1097. [rotationMatrix, translationVector] = rigid_transform_3D(source_pts, target_pts);
  1098. % Translate torso position
  1099. heartGrad.coilpos = ((rotationMatrix * heartGrad.coilpos') + translationVector)';
  1100. heartGrad.chanpos = ((rotationMatrix * heartGrad.chanpos') + translationVector)';
  1101. brainGrad.coilpos = ((rotationMatrix * brainGrad.coilpos') + translationVector)';
  1102. brainGrad.chanpos = ((rotationMatrix * brainGrad.chanpos') + translationVector)';
  1103. % Apply rotations also to sensor orientations
  1104. heartGrad.coilori = (rotationMatrix * heartGrad.coilori')';
  1105. heartGrad.chanori = (rotationMatrix * heartGrad.chanori')';
  1106. brainGrad.coilori = (rotationMatrix * brainGrad.coilori')';
  1107. brainGrad.chanori = (rotationMatrix * brainGrad.chanori')';
  1108. if plotOutput
  1109. figure()
  1110. hold on
  1111. % Plot sensors
  1112. ft_plot_sens(brainGrad, 'coil', true, 'orientation', true);
  1113. ft_plot_sens(heartGrad, 'coil', false, 'orientation', true);
  1114. % Plot centred toros and axis vectors
  1115. ft_plot_mesh(centredTorso,'facecolor','none','edgecolor','k','edgealpha',0.2);
  1116. ft_plot_mesh(centredLungs,'facecolor','none','edgecolor','b','edgealpha',0.5);
  1117. ft_plot_mesh(centredHeart,'facecolor','none','edgecolor','r','edgealpha',0.5);
  1118. scatter3(centredSource.pos(1),centredSource.pos(2),centredSource.pos(3),'b','filled');
  1119. scatter3(centredSource_2.pos(1),centredSource_2.pos(2),centredSource_2.pos(3),'b','filled');
  1120. view(180, 0);
  1121. rotate3d('on');
  1122. axis on;
  1123. axis equal;
  1124. axis vis3d;
  1125. hold off
  1126. end
  1127. % Compute leadfield 2 dipoles
  1128. cfg = [];
  1129. cfg.sourcemodel.pos = [centredSource.pos; centredSource_2.pos];
  1130. cfg.sourcemodel.inside = [1; 1];
  1131. cfg.sourcemodel.unit = 'm';
  1132. cfg.headmodel = vol_bem;
  1133. cfg.grad = brainGrad;
  1134. cfg.reducerank = 'no';
  1135. fwd_unseen = ft_prepare_leadfield(cfg);
  1136. lf_unseen_a = fwd_unseen.leadfield{1}; % extract the leadfield of the first dipole
  1137. lf_unseen_b = fwd_unseen.leadfield{2}; % extract the leadfiled of the second dipole
  1138. L_unseen = [lf_unseen_a, lf_unseen_b]; % N sensors * M dipoles by 3 orientations
  1139. % Get dimensions of the original CDM estimate
  1140. [nDipoleAngles, nSamplesTemplate] = size(CDM_estimate_2dip_scaled);
  1141. % Create a copy of the original CDM estimate (to be adjusted)
  1142. CDM_estimate_adjusted = CDM_estimate_2dip_scaled;
  1143. % identify parameters for padding/trimming the CDM estimate
  1144. if closest_htrig_latency >= 0
  1145. preHtrig_samples = preStimSamples + closest_htrig_dist_samples;
  1146. postHtrig_samples = postStimSamples - closest_htrig_dist_samples;
  1147. elseif closest_htrig_latency < 0
  1148. preHtrig_samples = preStimSamples - closest_htrig_dist_samples;
  1149. postHtrig_samples = postStimSamples + closest_htrig_dist_samples;
  1150. end
  1151. adjustBeforeRPeak = preHtrig_samples - preRPeaksamples;
  1152. adjustAfterRPeak = postHtrig_samples - postRPeaksamples;
  1153. % Handle template start adjustment
  1154. if adjustBeforeRPeak > 0
  1155. % Pad with zeros at the beginning
  1156. padSize = adjustBeforeRPeak;
  1157. CDM_estimate_adjusted = [zeros(nDipoleAngles, padSize), CDM_estimate_adjusted];
  1158. elseif adjustBeforeRPeak < 0
  1159. % Trim from the beginning
  1160. trim_length = abs(adjustBeforeRPeak);
  1161. CDM_estimate_adjusted = CDM_estimate_adjusted(:, (trim_length + 1):end);
  1162. end
  1163. % Handle template end adjustment
  1164. if adjustAfterRPeak > 0
  1165. % Pad with zeros at the end
  1166. padSize = adjustAfterRPeak;
  1167. CDM_estimate_adjusted = [CDM_estimate_adjusted, zeros(nDipoleAngles, padSize)];
  1168. elseif adjustAfterRPeak < 0
  1169. % Trim from the end
  1170. trim_length = abs(adjustAfterRPeak);
  1171. trim_ind = size(CDM_estimate_adjusted, 2) - trim_length;
  1172. CDM_estimate_adjusted = CDM_estimate_adjusted(:, 1:trim_ind);
  1173. end
  1174. % Correct the heart signal using the adjusted template
  1175. predict_heart = L_unseen * CDM_estimate_adjusted * rpeak_amplitude;
  1176. % assign current template to summary struct
  1177. fieldName = sprintf('trial%d', run);
  1178. allTemplates.(fieldName) = predict_heart;
  1179. % apply CFA correction to the current trial
  1180. correctedWinData = winData - predict_heart;
  1181. % assign trial data to results matrix
  1182. correctedTrials(:,:,n) = correctedWinData;
  1183. % Save trial data to summary array
  1184. % Accumulate sum for averaging
  1185. trial_avg_CORRECTED = trial_avg_CORRECTED + correctedWinData;
  1186. end
  1187. % Compute the average data across trials
  1188. trial_avg_TEST = trial_avg_TEST / n_trials;
  1189. trial_avg_CORRECTED = trial_avg_CORRECTED / n_trials;
  1190. % % Plot results % %
  1191. % Creater a time axis
  1192. epochTimeAxis = linspace(-start_before_trigger, end_after_trigger, (end_after_trigger + start_before_trigger) * D.fsample + 1);
  1193. % Uncorrected
  1194. figure;
  1195. hold on
  1196. plot(epochTimeAxis, trial_avg_TEST);
  1197. plot(epochTimeAxis, mean(trial_avg_TEST, 1), 'k', 'LineWidth', 2);
  1198. xlabel('Time (s)');
  1199. ylabel('Amplitude');
  1200. % xlim([-0.1 0.4]);
  1201. t = title(sprintf('Trial-averaged data: with cardiac artefact (%d trials)', n_trials));
  1202. t.Units = 'normalized';
  1203. t.Position(2) = 1.05; % increase this value to move title higher
  1204. hold off
  1205. ylims = ylim; % get y-limits to apply to next plot
  1206. % Corrected
  1207. figure()
  1208. hold on
  1209. plot(epochTimeAxis, trial_avg_CORRECTED)
  1210. plot(epochTimeAxis, mean(trial_avg_CORRECTED, 1), 'k', 'LineWidth', 2);
  1211. xlabel('Time (s)');
  1212. ylabel('Amplitude');
  1213. % xlim([-0.1 0.4]);
  1214. ylim(ylims);
  1215. t = title(sprintf('Trial-averaged data: CFA-correction applied (%d trials)', n_trials));
  1216. t.Units = 'normalized';
  1217. t.Position(2) = 1.05; % increase this value to move title higher
  1218. hold off
  1219. %% Calculate R-peak field strength before/after correction
  1220. if record == 1 || record == 2
  1221. rPeakTime = start_before_trigger * D.fsample;
  1222. sensor_values_before = abs((trial_avg_TEST(:,rPeakTime)));
  1223. sensor_values_after_CFA = abs((trial_avg_CORRECTED(:,rPeakTime)));
  1224. sensor_mean_before = mean(sensor_values_before);
  1225. sensor_std_before = std(sensor_values_before);
  1226. sensor_mean_after = mean(sensor_values_after_CFA);
  1227. sensor_std_after = std(sensor_values_after_CFA);
  1228. fprintf('Before: M = %.2f, SD = %.2f; After: M = %.2f, SD = %.2f\n', ...
  1229. sensor_mean_before, sensor_std_before, sensor_mean_after, sensor_std_after);
  1230. end
  1231. %% Prepare topoplot layout
  1232. % Get the brain grad structure
  1233. clear brainGrad
  1234. brainGrad = extractGradSelectedSensors(brainD);
  1235. brainGrad.unit = 'm';
  1236. % Prepare topoplot layout
  1237. gradLay = brainGrad;
  1238. gradLay.coilpos = gradLay.coilpos - mean(gradLay.coilpos, 1);
  1239. gradLay.chanpos = gradLay.chanpos - mean(gradLay.chanpos, 1);
  1240. cfg = [];
  1241. cfg.output = [];
  1242. cfg.grad = gradLay;
  1243. cfg.rotate = [];
  1244. cfg.center = 'yes';
  1245. cfg.projection = 'polar';
  1246. cfg.channel = brainGrad.label(startsWith(brainGrad.label, 'Y'));
  1247. lay = ft_prepare_layout(cfg);
  1248. %% Set formatting for output plotsg
  1249. set(groot, 'defaultColorbarFontSize', 20);
  1250. set(groot, 'defaultAxesFontSize', 20);
  1251. set(groot, 'DefaultTextFontSize', 20);
  1252. set(groot, 'DefaultLegendFontSize', 20);
  1253. %% GROUND TRUTH SIGNAL: Epoching, baseline correction, trial-averaging
  1254. % Define N100 window (ms)
  1255. N100_start = 0.08;
  1256. N100_end = 0.120;
  1257. % Create a copy to use for the trial-averaged ground truth estimates
  1258. fDB = spm_eeg_load(fDB);
  1259. fDB_GroundTruth = fDB.copy('fDB_GroundTruth');
  1260. fDB_GroundTruth.save()
  1261. if record == 3 || record == 4
  1262. % Epoch trials to trigger
  1263. S =[];
  1264. S.D=fDB_GroundTruth;
  1265. S.timewin=[-start_before_trigger * 1000 end_after_trigger * 1000];
  1266. S.triggerChannels ={'T7'};
  1267. eD_GroundTruth = spm_opm_epoch_trigger(S);
  1268. % Baseline-correct, if desired
  1269. if baseline_correct_bool
  1270. S=[];
  1271. S.D = eD_GroundTruth;
  1272. S.timewin = baseline_win;
  1273. eD_GroundTruth = spm_eeg_bc(S);
  1274. end
  1275. % Trial-averaging
  1276. S=[];
  1277. S.D=eD_GroundTruth;
  1278. muD_GroundTruth = spm_eeg_average(S);
  1279. % Plot sensor measurements
  1280. % % Figure 9 (left panel)
  1281. MEGind = indchantype(eD_GroundTruth,'MEGMAG');
  1282. used = setdiff(MEGind,badchannels(muD_GroundTruth));
  1283. pl =muD_GroundTruth(used,:,:)';
  1284. figure();
  1285. plot(muD_GroundTruth.time(),pl)
  1286. xlabel('Time (s)', 'FontSize', 20)
  1287. ylabel('B (fT)', 'FontSize', 20)
  1288. ax = gca; % current axes
  1289. ax.FontSize = 20;
  1290. ax.TickLength = [0.02 0.02];
  1291. fig= gcf;
  1292. fig.Color=[1,1,1];
  1293. xlim([-0.1,.400])
  1294. if record == 1 || record == 2
  1295. ylim([-4000 4000])
  1296. end
  1297. box off
  1298. % Extract ground truth sensor amplitudes during the N100 time window (80–120 ms)
  1299. [~, n100StartIdx] = min(abs(time(eD_GroundTruth) - N100_start));
  1300. [~, n100EndIdx] = min(abs(time(eD_GroundTruth) - N100_end));
  1301. % Get N100 window data: [sensors x windowSamples x trials]
  1302. n100GroundTruthData = eD_GroundTruth(used, n100StartIdx:n100EndIdx, :);
  1303. % Compute mean across time window for each sensor and trial: [sensors x trials]
  1304. n100GroundTruthAvg = squeeze(mean(n100GroundTruthData, 2));
  1305. % Compute T-statistic
  1306. channid=eD_GroundTruth.indchantype('MEG');
  1307. t_uncorrected = computeTStatsAcrossTrials(eD_GroundTruth(channid,:,:));
  1308. % Convert average object
  1309. ft_data_ground_truth = [];
  1310. ft_data_ground_truth.time = epochTimeAxis;
  1311. ft_data_ground_truth.grad = brainGrad;
  1312. ft_data_ground_truth.label = brainGrad.label;
  1313. ft_data_ground_truth.avg = t_uncorrected;
  1314. % Topoplot
  1315. % % Figure 9 (right panel)
  1316. cfg = [];
  1317. cfg.layout = lay;
  1318. if record == 3 || record == 4
  1319. cfg.xlim = [N100_start N100_end]; % N100 time
  1320. else
  1321. cfg.xlim = [0.0 0.0]; % R-peak time time
  1322. end
  1323. cfg.comment = 'no';
  1324. cfg.marker = 'labels';
  1325. cfg.colorbar ='yes';
  1326. ft_topoplotER(cfg, ft_data_ground_truth);
  1327. title("Ground truth (N100)");
  1328. c = colorbar;
  1329. c.Label.String = 'T-statistic';
  1330. end
  1331. %% UNCORRECTED TEST SIGNAL: Epoching, baseline correction, trial-averaging
  1332. % Create a copy to use for the trial-averaged test estimates
  1333. fDB = spm_eeg_load(fDB);
  1334. fDB_test = fDB.copy('fDB_test');
  1335. fDB_test.save()
  1336. % Epoch trials to trigger
  1337. S = [];
  1338. S.D = fDB_test;
  1339. S.timewin = [-start_before_trigger * 1000 end_after_trigger * 1000];
  1340. S.triggerChannels = {'T6'};
  1341. eD_test = spm_opm_epoch_trigger(S);
  1342. % Baseline-correct, if desired
  1343. if baseline_correct_bool
  1344. S=[];
  1345. S.D = eD_test;
  1346. S.timewin = baseline_win;
  1347. eD_test = spm_eeg_bc(S);
  1348. end
  1349. % Trial-averaging
  1350. S=[];
  1351. S.D=eD_test;
  1352. muD_test = spm_eeg_average(S);
  1353. % Plot sensor measurements
  1354. MEGind = indchantype(eD_test,'MEGMAG');
  1355. used = setdiff(MEGind,badchannels(muD_test));
  1356. pl =muD_test(used,:,:)';
  1357. figure();
  1358. plot(muD_test.time(),pl)
  1359. xlabel('Time (s)', 'FontSize', 20)
  1360. ylabel('B (fT)', 'FontSize', 20)
  1361. ax = gca; % current axes
  1362. ax.FontSize = 20;
  1363. ax.TickLength = [0.02 0.02];
  1364. fig= gcf;
  1365. fig.Color=[1,1,1];
  1366. xlim([-0.1,.400])
  1367. if record == 1 || record == 2
  1368. ylim([-4000 4000])
  1369. end
  1370. box off
  1371. if record == 3 || record == 4
  1372. % Extract sensor amplitudes during the N100 time window (80–120 ms)
  1373. [~, n100StartIdx] = min(abs(time(eD_test) - N100_start));
  1374. [~, n100EndIdx] = min(abs(time(eD_test) - N100_end));
  1375. % Get N100 window data: [sensors x windowSamples x trials]
  1376. n100TestData = eD_test(used, n100StartIdx:n100EndIdx, :);
  1377. % Compute mean across time window for each sensor and trial: [sensors x trials]
  1378. n100TestAvg = squeeze(mean(n100TestData, 2));
  1379. end
  1380. % Compute T-statistic
  1381. t_uncorrected = computeTStatsAcrossTrials(eD_test(used,:,:));
  1382. % Convert average object
  1383. ft_data_test = [];
  1384. ft_data_test.time = epochTimeAxis;
  1385. ft_data_test.grad = brainGrad;
  1386. ft_data_test.label = brainGrad.label;
  1387. if record == 1 || record == 2
  1388. ft_data_test.avg = muD_test(used,:,1);
  1389. else
  1390. ft_data_test.avg = t_uncorrected;
  1391. end
  1392. % Topoplot
  1393. % % Figure 8A & Figure 10A
  1394. cfg = [];
  1395. cfg.layout = lay;
  1396. if record == 3 || record == 4
  1397. cfg.xlim = [N100_start N100_end]; % N100 time
  1398. else
  1399. cfg.xlim = [0 0]; % R-peak time time
  1400. end
  1401. cfg.comment = 'no';
  1402. cfg.marker = 'labels';
  1403. cfg.colorbar ='yes';
  1404. ft_topoplotER(cfg, ft_data_test);
  1405. zlims_uncorrected = clim; % captures color axis limits of the current plot
  1406. title("Uncorrected");
  1407. c = colorbar;
  1408. if record ==1 || record == 2
  1409. c.Label.String = 'B (fT)';
  1410. else
  1411. c.Label.String = 'T-statistic';
  1412. end
  1413. %% Apply HFC to the uncorrected data
  1414. % Create a copy to use for the trial-averaged HFC estimates
  1415. fDB = spm_eeg_load(fDB);
  1416. fDB_hfc = fDB.copy('fDB_hfc');
  1417. fDB_hfc.save()
  1418. % Epoch trials to trigger
  1419. S = [];
  1420. S.D = fDB_hfc;
  1421. S.timewin = [-start_before_trigger * 1000 end_after_trigger * 1000];
  1422. S.triggerChannels = {'T6'};
  1423. eD_hfc = spm_opm_epoch_trigger(S);
  1424. % HFC
  1425. S = [];
  1426. S.D = eD_hfc;
  1427. S.L = 1;
  1428. eD_hfc = spm_opm_hfc(S);
  1429. % Baseline-correct, if desired
  1430. if baseline_correct_bool
  1431. S=[];
  1432. S.D = eD_hfc;
  1433. S.timewin = baseline_win;
  1434. eD_hfc = spm_eeg_bc(S);
  1435. end
  1436. % Trial-averaging
  1437. S=[];
  1438. S.D=eD_hfc;
  1439. muD_hfc = spm_eeg_average(S);
  1440. % Plot sensor measurements
  1441. MEGind = indchantype(eD_hfc,'MEGMAG');
  1442. used = setdiff(MEGind,badchannels(muD_hfc));
  1443. pl =muD_hfc(used,:,:)';
  1444. figure();
  1445. plot(muD_hfc.time(),pl)
  1446. xlabel('Time (s)', 'FontSize', 20)
  1447. ylabel('B (fT)', 'FontSize', 20)
  1448. ax = gca; % current axes
  1449. ax.FontSize = 20;
  1450. ax.TickLength = [0.02 0.02];
  1451. fig= gcf;
  1452. fig.Color=[1,1,1];
  1453. xlim([-0.1,.400])
  1454. if record == 1 || record == 2
  1455. ylim([-4000 4000])
  1456. end
  1457. box off
  1458. if record == 3 || record == 4
  1459. % Extract sensor amplitudes during the N100 time window (80–120 ms)
  1460. [~, n100StartIdx] = min(abs(time(eD_hfc) - N100_start));
  1461. [~, n100EndIdx] = min(abs(time(eD_hfc) - N100_end));
  1462. % Get N100 window data: [sensors x windowSamples x trials]
  1463. n100Hfcdata = eD_hfc(used, n100StartIdx:n100EndIdx, :);
  1464. % Compute mean across time window for each sensor and trial: [sensors x trials]
  1465. n100HfcAvg = squeeze(mean(n100Hfcdata, 2));
  1466. end
  1467. % Compute T-statistic
  1468. t_hfc = computeTStatsAcrossTrials(eD_hfc(used,:,:));
  1469. % Convert average object
  1470. ft_data_hfc = [];
  1471. ft_data_hfc.time = epochTimeAxis;
  1472. ft_data_hfc.grad = brainGrad;
  1473. ft_data_hfc.label = brainGrad.label;
  1474. if record == 1 || record == 2
  1475. ft_data_hfc.avg = muD_hfc(used,:,1);
  1476. else
  1477. ft_data_hfc.avg = t_hfc;
  1478. end
  1479. % Topoplot
  1480. % % Figure 8B & Figure 10B
  1481. cfg = [];
  1482. cfg.layout = lay;
  1483. if record == 3 || record == 4
  1484. cfg.xlim = [N100_start N100_end]; % N100 time
  1485. else
  1486. cfg.xlim = [0.0 0.0]; % R-peak time time
  1487. end
  1488. cfg.comment = 'no';
  1489. cfg.marker = 'labels';
  1490. cfg.colorbar ='yes';
  1491. % if record == 1 || record == 2
  1492. % cfg.zlim = zlims; % keep uncorrected data z-limits for comparison
  1493. % end
  1494. ft_topoplotER(cfg, ft_data_hfc);
  1495. zlims_hfc_corrected = clim; % captures color axis limits of the current plot
  1496. title("HFC");
  1497. c = colorbar;
  1498. if record ==1 || record == 2
  1499. c.Label.String = 'B (fT)';
  1500. else
  1501. c.Label.String = 'T-statistic';
  1502. end
  1503. %% Assign CDM-corrected data to an SPM object, baseline-correct if desired, trial-average, and plot
  1504. % Assign CDM-corrected data to a copy of the epoched trial data SPM object
  1505. eD_test = spm_eeg_load(eD_test);
  1506. eD_cfa = eD_test.copy('eD_cfa');
  1507. MEGind = indchantype(eD_cfa,'MEGMAG');
  1508. used = setdiff(MEGind,badchannels(eD_cfa));
  1509. eD_cfa(used,:,:) = correctedTrials;
  1510. eD_cfa.save()
  1511. % Baseline-correct, if desired
  1512. if baseline_correct_bool
  1513. S=[];
  1514. S.D = eD_cfa;
  1515. S.timewin = baseline_win;
  1516. eD_cfa = spm_eeg_bc(S);
  1517. end
  1518. % Trial-averaging
  1519. S=[];
  1520. S.D=eD_cfa;
  1521. muD_cfa = spm_eeg_average(S);
  1522. % Plot sensor measurements
  1523. MEGind = indchantype(eD_cfa,'MEGMAG');
  1524. used = setdiff(MEGind,badchannels(muD_cfa));
  1525. pl =muD_cfa(used,:,:)';
  1526. figure();
  1527. plot(muD_cfa.time(),pl)
  1528. xlabel('Time (s)', 'FontSize', 20)
  1529. ylabel('B (fT)', 'FontSize', 20)
  1530. ax = gca; % current axes
  1531. ax.FontSize = 20;
  1532. ax.TickLength = [0.02 0.02];
  1533. fig= gcf;
  1534. fig.Color=[1,1,1];
  1535. xlim([-0.1,.400])
  1536. if record == 1 || record == 2
  1537. ylim([-4000 4000])
  1538. end
  1539. box off
  1540. if record == 3 || record == 4
  1541. % Extract sensor amplitudes during the N100 time window (80–120 ms)
  1542. [~, n100StartIdx] = min(abs(time(eD_cfa) - N100_start));
  1543. [~, n100EndIdx] = min(abs(time(eD_cfa) - N100_end));
  1544. % Get N100 window data: [sensors x windowSamples x trials]
  1545. n100CfaData = eD_cfa(used, n100StartIdx:n100EndIdx, :);
  1546. % Compute mean across time window for each sensor and trial: [sensors x trials]
  1547. n100CfaAvg = squeeze(mean(n100CfaData, 2));
  1548. end
  1549. % Compute T-statistic
  1550. t_corrected_cfa = computeTStatsAcrossTrials(eD_cfa(used,:,:));
  1551. % Convert average object
  1552. ft_data_cfa_corrected = [];
  1553. ft_data_cfa_corrected.time = epochTimeAxis;
  1554. ft_data_cfa_corrected.grad = brainGrad;
  1555. ft_data_cfa_corrected.label = brainGrad.label;
  1556. if record == 1 || record == 2
  1557. ft_data_cfa_corrected.avg = muD_cfa(used,:,1);
  1558. else
  1559. ft_data_cfa_corrected.avg = t_corrected_cfa;
  1560. end
  1561. % Topoplot
  1562. % % Figure 8D & Figure 10D
  1563. cfg = [];
  1564. cfg.layout = lay;
  1565. if record == 3 || record == 4
  1566. cfg.xlim = [N100_start N100_end]; % N100 time
  1567. else
  1568. cfg.xlim = [0.0 0.0]; % R-peak time time
  1569. end
  1570. cfg.comment = 'no';
  1571. cfg.marker = 'labels';
  1572. cfg.colorbar ='yes';
  1573. ft_topoplotER(cfg, ft_data_cfa_corrected);
  1574. zlims_cfa_corrected = clim; % captures color axis limits of the current plot
  1575. title("CFA-correction");
  1576. c = colorbar;
  1577. if record == 1 || record == 2
  1578. c.Label.String = 'B (fT)';
  1579. else
  1580. c.Label.String = 'T-statistic';
  1581. end
  1582. %% Apply HFC to the CDM-corrected data
  1583. % Assign CDM-corrected data to a copy of the epoched trial data SPM object
  1584. eD_test = spm_eeg_load(eD_test);
  1585. eD_cfa_hfc = eD_test.copy('eD_cfa_hfc');
  1586. channid=eD_cfa_hfc.indchantype('MEG');
  1587. eD_cfa_hfc(channid,:,:) = correctedTrials;
  1588. eD_cfa_hfc.save()
  1589. % HFC
  1590. S = [];
  1591. S.D =eD_cfa_hfc;
  1592. S.L = 1;
  1593. eD_cfa_hfc = spm_opm_hfc(S);
  1594. if baseline_correct_bool
  1595. S=[];
  1596. S.D = eD_cfa_hfc;
  1597. S.timewin = baseline_win;
  1598. eD_cfa_hfc = spm_eeg_bc(S);
  1599. end
  1600. % Trial-averaging
  1601. S=[];
  1602. S.D=eD_cfa_hfc;
  1603. muD_cfa_hfc = spm_eeg_average(S);
  1604. % Plot sensor measurements
  1605. MEGind = indchantype(eD_cfa_hfc,'MEGMAG');
  1606. used = setdiff(MEGind,badchannels(muD_cfa_hfc));
  1607. pl =muD_cfa_hfc(used,:,:)';
  1608. figure();
  1609. plot(muD_cfa_hfc.time(),pl)
  1610. xlabel('Time (s)', 'fontsize', 20)
  1611. ylabel('B (fT)', 'fontsize', 20)
  1612. ax = gca; % current axes
  1613. ax.FontSize = 20;
  1614. ax.TickLength = [0.02 0.02];
  1615. fig= gcf;
  1616. fig.Color=[1,1,1];
  1617. xlim([-0.1,.400])
  1618. if record == 1 || record == 2
  1619. ylim([-4000 4000])
  1620. end
  1621. box off
  1622. if record == 3 || record == 4
  1623. % Extract sensor amplitudes during the N100 time window (80–120 ms)
  1624. [~, n100StartIdx] = min(abs(time(eD_cfa_hfc) - N100_start));
  1625. [~, n100EndIdx] = min(abs(time(eD_cfa_hfc) - N100_end));
  1626. % Get N100 window data: [sensors x windowSamples x trials]
  1627. n100CfaHfcData = eD_cfa_hfc(used, n100StartIdx:n100EndIdx, :);
  1628. % Compute mean across time window for each sensor and trial: [sensors x trials]
  1629. n100CfaHfcAvg = squeeze(mean(n100CfaHfcData, 2));
  1630. end
  1631. % Compute T-statistic
  1632. t_corrected_cfa_hfc = computeTStatsAcrossTrials(eD_cfa_hfc(used,:,:));
  1633. % Convert average object
  1634. ft_data_cfa_hfc_both_corrections = [];
  1635. ft_data_cfa_hfc_both_corrections.time = epochTimeAxis;
  1636. ft_data_cfa_hfc_both_corrections.grad = brainGrad;
  1637. ft_data_cfa_hfc_both_corrections.label = brainGrad.label;
  1638. if record ==1 || record == 2
  1639. ft_data_cfa_hfc_both_corrections.avg = muD_cfa_hfc(used,:,1);
  1640. else
  1641. ft_data_cfa_hfc_both_corrections.avg = t_corrected_cfa_hfc;
  1642. end
  1643. % Topoplot
  1644. % % Figure 8F & Figure 10F
  1645. cfg = [];
  1646. cfg.layout = lay;
  1647. if record == 3 || record == 4
  1648. cfg.xlim = [N100_start N100_end]; % N100 time
  1649. else
  1650. cfg.xlim = [0.0 0.0]; % R-peak time time
  1651. end
  1652. cfg.comment = 'no';
  1653. cfg.marker = 'labels';
  1654. cfg.colorbar ='yes';
  1655. ft_topoplotER(cfg, ft_data_cfa_hfc_both_corrections);
  1656. zlims_cfa_hfc_both_corrections = clim; % captures color axis limits of the current plot
  1657. title("CFA-correction + HFC");
  1658. c = colorbar;
  1659. if record == 1 || record == 2
  1660. c.Label.String = 'B (fT)';
  1661. else
  1662. c.Label.String = 'T-statistic';
  1663. end
  1664. %% For resting-state recordings only: re-create topoplots with shared z-limits
  1665. if record == 1 || record == 2
  1666. spmObjects = {muD_test, muD_hfc, muD_cfa, muD_cfa_hfc};
  1667. zlimsAll = {zlims_uncorrected, zlims_hfc_corrected, zlims_cfa_corrected, zlims_cfa_hfc_both_corrections};
  1668. zMax = max(cellfun(@(x) max(x(:)), zlimsAll));
  1669. zMin = min(cellfun(@(x) min(x(:)), zlimsAll));
  1670. for i = 1:numel(spmObjects)
  1671. topoplotData = spmObjects{i};
  1672. zlims = zlimsAll{i};
  1673. % Convert to average object
  1674. ft_data = [];
  1675. ft_data.time = epochTimeAxis;
  1676. ft_data.grad = brainGrad;
  1677. ft_data.label = brainGrad.label;
  1678. ft_data.avg = topoplotData(used,:,1);
  1679. % Topoplot
  1680. cfg = [];
  1681. cfg.layout = lay;
  1682. cfg.xlim = [0.0 0.0]; % R-peak time time
  1683. cfg.comment = 'no';
  1684. cfg.marker = 'labels';
  1685. cfg.colorbar ='yes';
  1686. cfg.zlim = [zMin, zMax]; % keep uncorrected data z-limits for comparison
  1687. fig = figure('Units','pixels', 'Position', [100 100 800 400]);
  1688. set(fig, 'DefaultAxesFontSize', 22);
  1689. set(fig, 'DefaultTextFontSize', 22);
  1690. set(fig, 'DefaultLineLineWidth', 22);
  1691. ft_topoplotER(cfg, ft_data);
  1692. %
  1693. c = colorbar;
  1694. c.Label.String = 'B (fT)';
  1695. c.Position = [0.75 0.1 0.04 0.8];
  1696. % Values you want to annotate
  1697. markVals = zlims; % example values in data units
  1698. % Create overlay axes on top of the colorbar
  1699. cbpos = c.Position;
  1700. ax_cb = axes('Position', cbpos, ...
  1701. 'Color','none', ...
  1702. 'XLim',[0 1], ...
  1703. 'YLim', c.Limits, ...
  1704. 'XTick',[], 'YTick',[], ...
  1705. 'Box','off', ... % <-- remove frame
  1706. 'XColor','none', ... % <-- remove vertical axis line
  1707. 'YColor','none', ... % <-- remove horizontal axis line
  1708. 'HitTest','off', ...
  1709. 'HandleVisibility','off');
  1710. % Draw horizontal lines at the desired values
  1711. hold(ax_cb, 'on')
  1712. for v = markVals
  1713. if v >= c.Limits(1) && v <= c.Limits(2)
  1714. plot(ax_cb, [0 1], [v v], 'k--', 'LineWidth', 1);
  1715. end
  1716. end
  1717. hold(ax_cb, 'off')
  1718. % Put the overlay axes on top
  1719. uistack(ax_cb, 'top');
  1720. end
  1721. end
  1722. %% Calculate R-peak field strength before/after correction
  1723. if record == 1 || record == 2
  1724. rPeakTime = start_before_trigger * D.fsample;
  1725. meg_chans = muD_test.indchantype('meg');
  1726. sensor_values_before = abs((muD_test(meg_chans,rPeakTime,1)));
  1727. sensor_values_after_HFC = abs((muD_hfc(meg_chans,rPeakTime,1)));
  1728. sensor_values_after_CFA = abs((muD_cfa(meg_chans,rPeakTime,1)));
  1729. sensor_values_after_both = abs((muD_cfa_hfc(meg_chans,rPeakTime,1)));
  1730. sensor_mean_before = mean(sensor_values_before);
  1731. sensor_std_before = std(sensor_values_before);
  1732. sensor_mean_after_HFC = mean(sensor_values_after_HFC);
  1733. sensor_std_after_HFC = std(sensor_values_after_HFC);
  1734. sensor_mean_after_CFA = mean(sensor_values_after_CFA);
  1735. sensor_std_after_CFA = std(sensor_values_after_CFA);
  1736. sensor_mean_after_both = mean(sensor_values_after_both);
  1737. sensor_std_after_both = std(sensor_values_after_both);
  1738. fprintf('Before: M = %.2f, SD = %.2f; After HFC: M = %.2f, SD = %.2f\n', ...
  1739. sensor_mean_before, sensor_std_before, sensor_mean_after_HFC, sensor_std_after_HFC);
  1740. fprintf('Before: M = %.2f, SD = %.2f; After CFA: M = %.2f, SD = %.2f\n', ...
  1741. sensor_mean_before, sensor_std_before, sensor_mean_after_CFA, sensor_std_after_CFA);
  1742. fprintf('Before: M = %.2f, SD = %.2f; After Both: M = %.2f, SD = %.2f\n', ...
  1743. sensor_mean_before, sensor_std_before, sensor_mean_after_both, sensor_std_after_both);
  1744. fprintf('Relative improvement with CFA-correction over HFC = %.2f\n', ...
  1745. 1/(sensor_mean_after_CFA/sensor_mean_after_HFC));
  1746. fprintf('Relative improvement with CFA-correction plus HFC, over CFA alone = %.2f\n', ...
  1747. 1/(sensor_mean_after_both/sensor_mean_after_CFA));
  1748. improvement_CFA_over_HFC = ...
  1749. (sensor_mean_after_HFC - sensor_mean_after_CFA) / sensor_mean_after_HFC * 100;
  1750. fprintf('CFA improves artefact reduction over HFC by %.1f %%\n', ...
  1751. improvement_CFA_over_HFC);
  1752. improvement_both_over_CFA = ...
  1753. (sensor_mean_after_CFA - sensor_mean_after_both) / sensor_mean_after_CFA * 100;
  1754. fprintf('Adding HFC improves CFA by %.1f %%\n', ...
  1755. improvement_both_over_CFA);
  1756. end
  1757. %% Calculate per-trial variance explained by the CFA
  1758. % Initialize
  1759. R_all = zeros(n_trials, 1); % preallocate
  1760. R_squared_all = zeros(n_trials, 1); % preallocate
  1761. % Loop over all trials to compute R²
  1762. for trial_idx = 1:n_trials
  1763. uncorrectedTrial = rawTrials(:, :, trial_idx);
  1764. % Extract template for this trial
  1765. trialName = sprintf('trial%d', trial_idx); % assumes names start at 'trial1'
  1766. template = allTemplates.(trialName);
  1767. % get the index of the R-peak in the current trial
  1768. test_peak_idx = peakIdxTemplate(trial_idx);
  1769. % Extract data at 100 ms
  1770. X = template(:, test_peak_idx);
  1771. Y = uncorrectedTrial(:, test_peak_idx);
  1772. % Compute correlation and R²
  1773. R = corr(X, Y);
  1774. R_all(trial_idx) = R;
  1775. R_squared_all(trial_idx) = R^2;
  1776. end
  1777. R_all = R_all(R_all > 0);
  1778. R_squared_all = R_squared_all(R_squared_all > 0);
  1779. % Plot bar graph
  1780. % % Figure 9
  1781. fig = figure();
  1782. fig.Position = [100, 100, 800, 400]; % [left, bottom, width, height]
  1783. bar(R_squared_all, 'FaceColor', [0.2 0.4 0.6]);
  1784. xlabel('Trial number');
  1785. ylabel('Variance explained (R^2)');
  1786. title('CFA accuracy per trial');
  1787. grid on;
  1788. set(gca, 'FontSize', 12);
  1789. fprintf('The CDM estimates explain on average R² = %.2f (r = %.2f, SD = %.2f) of the variance \n across the uncorrected trials at R-peak.\n', mean(R_squared_all), mean(R_all), std(R_squared_all));
  1790. low_R_squared_ind = find(R_squared_all < 0.5);
  1791. high_R_squared_ind = find(R_squared_all > 0.5);
  1792. %% Calculate the variance explained between the CDM estimate and the trial-averaged data
  1793. if record == 1 || record == 2
  1794. % Average all trials
  1795. avg_trial = mean(rawTrials, 3);
  1796. % Average all templates
  1797. trial_names = fieldnames(allTemplates);
  1798. avg_template = zeros(size(allTemplates.(trial_names{1})));
  1799. for i = 1:length(fieldnames(allTemplates))
  1800. avg_template = avg_template + allTemplates.(trial_names{i});
  1801. end
  1802. avg_template = avg_template / length(fieldnames(allTemplates));
  1803. % Get the index of the R-peak
  1804. r_peak_index = start_before * D.fsample;
  1805. % Extract data at R-peak time
  1806. X = avg_template(:, r_peak_index);
  1807. Y = avg_trial(:, r_peak_index);
  1808. % Compute correlation and R²
  1809. [R, P] = corr(X, Y);
  1810. R_squared = R^2;
  1811. fprintf('The average CDM estimate explained R² = %.2f of the variance across the uncorrected trials at \n the R-peak (r = %.2f, p = %.3f).\n', R_squared, R, P);
  1812. end
  1813. %% Set formatting for output plots
  1814. set(groot, 'defaultColorbarFontSize', 10);
  1815. set(groot, 'defaultAxesFontSize', 10);
  1816. set(groot, 'DefaultTextFontSize', 10);
  1817. set(groot, 'DefaultLegendFontSize', 10);
  1818. %% Quantify the amount of head movment for each heartbeat window
  1819. if record == 1 || record == 2
  1820. % Initialize an empty table with the required column names
  1821. perBeatMovement = table('Size', [0 4], 'VariableTypes', {'int32', 'double', 'double', 'double'}, ...
  1822. 'VariableNames', {'rPeakIdx', 'total_distance', 'total_rotation', 'movement_score'});
  1823. % Find the window range in sample points around each R-peak
  1824. start_before_rPeak = 0.200; % 200ms prior to R-peak
  1825. end_after_rPeak = 0.200; % 400ms after to R-peak
  1826. % Loop over heartbeats
  1827. for i=1:length(rPeakIdx)
  1828. % Get the index of the current R-Peak
  1829. winStartIdx = rPeakIdx(i) - fDB.fsample * start_before_rPeak; % 100ms prior to stimulus
  1830. winEndIdx = rPeakIdx(i) + fDB.fsample * end_after_rPeak; % 400ms after to stimulus
  1831. % Get head position for the frame
  1832. xPos = rigidBodyT.head.RigidBody{winStartIdx:winEndIdx,'X_Position'};
  1833. yPos = rigidBodyT.head.RigidBody{winStartIdx:winEndIdx,'Y_Position'};
  1834. zPos = rigidBodyT.head.RigidBody{winStartIdx:winEndIdx,'Z_Position'};
  1835. headPos = [xPos yPos zPos]';
  1836. % Range of motion (max - min)
  1837. xRange = max(xPos) - min(xPos);
  1838. yRange = max(yPos) - min(yPos);
  1839. zRange = max(zPos) - min(zPos);
  1840. % Use Euclidean distance over the range vector
  1841. range_distance = sqrt(xRange^2 + yRange^2 + zRange^2);
  1842. % Get head rotation quaternion for the frame
  1843. wAngle = rigidBodyT.head.RigidBody{winStartIdx:winEndIdx,'W_Rotation'};
  1844. xAngle = rigidBodyT.head.RigidBody{winStartIdx:winEndIdx,'X_Rotation'};
  1845. yAngle = rigidBodyT.head.RigidBody{winStartIdx:winEndIdx,'Y_Rotation'};
  1846. zAngle = rigidBodyT.head.RigidBody{winStartIdx:winEndIdx,'Z_Rotation'};
  1847. headQuat = [wAngle, xAngle, yAngle, zAngle];
  1848. % Compute angular changes between consecutive quaternions
  1849. angles = zeros(size(headQuat,1)-1, 1);
  1850. for j = 1:length(angles)
  1851. q1 = headQuat(j, :);
  1852. q2 = headQuat(j+1, :);
  1853. dq = quatmultiply(quatconj(q1), q2);
  1854. angle = 2 * acos(min(1, abs(dq(1)))); % avoid NaNs
  1855. angles(j) = angle;
  1856. end
  1857. rot_range = max(angles) - min(angles); % rotational range in radians
  1858. % Combined movement score (optional)
  1859. movement_score = range_distance + rot_range;
  1860. % Append a new row to the table
  1861. perBeatMovement = [perBeatMovement; {rPeakIdx(i), range_distance, rot_range, movement_score}];
  1862. end
  1863. %% Assess whether there is relationship between amount of movement/signal power and variance explained
  1864. aroundPeakVarianceAll = zeros(size(correctedTrials, 3), 1);
  1865. trialMovementAll = zeros(size(correctedTrials, 3), 1);
  1866. rSquaredAll = zeros(size(correctedTrials, 3), 1);
  1867. for i = 1:numel(rSquaredAll)
  1868. trial_idx = i;
  1869. % Extract trial data
  1870. uncorrectedTrial = rawTrials(:,:,trial_idx);
  1871. correctedTrial = correctedTrials(:,:,trial_idx);
  1872. % get the index of the R-peak for this trial
  1873. test_peak_idx = peakIdxTemplate(trial_idx);
  1874. % get signal pre-peak
  1875. prePeakSig = uncorrectedTrial(:,(test_peak_idx - D.fsample * 0.2):(test_peak_idx - D.fsample * 0.0));
  1876. % get signal post-peak
  1877. postPeakSig = uncorrectedTrial(:,(test_peak_idx + D.fsample * 0.0):(test_peak_idx + D.fsample * 0.2));
  1878. % cocatenate and calculate variance
  1879. aroundPeakSig = [prePeakSig, postPeakSig];
  1880. % total signal power
  1881. total_power = sum(aroundPeakSig(:).^2);
  1882. aroundPeakVarianceAll(i) = total_power;
  1883. % get the amplitude of the R-peak from the chest OPM
  1884. test_rPeakAmp = peakAmpsRaw(i);
  1885. % Extract template for this trial
  1886. selectedTrialName = sprintf('trial%d', trial_idx); % assumes names start at 'trial1'
  1887. trialTemplate = allTemplates.(selectedTrialName);
  1888. % Compute R^2 at rPeakTimeIdx
  1889. X = trialTemplate(:, test_peak_idx);
  1890. Y = uncorrectedTrial(:, test_peak_idx);
  1891. R = corr(X, Y);
  1892. R_squared = R^2;
  1893. rSquaredAll(i) = R_squared;
  1894. % Get the amount of head movement in the trial
  1895. beatIdx = test_stims(trial_idx) - fDB.fsample * start_before_trigger + peakIdxTemplate(trial_idx);
  1896. trialMovement = perBeatMovement(perBeatMovement.rPeakIdx == beatIdx,:).movement_score;
  1897. trialMovementAll(i) = trialMovement;
  1898. end
  1899. % Calculate the correlation and p-value
  1900. [R, P] = corr(trialMovementAll, rSquaredAll);
  1901. % Correlation: head movement vs correction performance
  1902. [R_move, P_move] = corr(trialMovementAll, rSquaredAll);
  1903. % Correlation: signal power vs correction performance
  1904. [R_power, P_power] = corr(aroundPeakVarianceAll, rSquaredAll);
  1905. % Display the results
  1906. fprintf('Head movement vs correction performance: r = %.4f, p = %.4g\n', R_move, P_move);
  1907. fprintf('Signal power vs correction performance: r = %.4f, p = %.4g\n', R_power, P_power);
  1908. % Create a scatter plot with color based on aroundPeakVarianceAll
  1909. % % Supplementary figure 1
  1910. figure;
  1911. cvals = aroundPeakVarianceAll;
  1912. cvals(cvals <= 0) = eps; % Prevent log(0)
  1913. logCvals = log10(cvals); % Log-transform the values for coloring
  1914. scatter(trialMovementAll, rSquaredAll, 50, logCvals, 'filled');
  1915. xlabel('Total movement');
  1916. ylabel('R-Squared: Template vs. Raw signal');
  1917. title('Variance explained by the CFA-template');
  1918. cb = colorbar;
  1919. ylabel(cb, 'Signal power around R-peak', 'FontSize', 12); % Adjust 12 to desired size
  1920. colormap('parula');
  1921. % Define the original scale ticks and labels
  1922. originalTicks = [min(cvals), median(cvals), max(cvals)]; % Original scale values
  1923. logTicks = log10(originalTicks); % Corresponding log scale values
  1924. % Set the ticks for the colorbar
  1925. cb.Ticks = logTicks; % Set the ticks to the log scale values
  1926. % Automatically detect the best label sizing notation
  1927. exponents = round(log10(originalTicks)); % Get the exponents
  1928. baseValues = originalTicks ./ 10.^exponents; % Normalize to the base value
  1929. % Create scientific notation labels for the colorbar
  1930. cb.TickLabels = arrayfun(@(b, e) sprintf('%.1f \\times 10^{%d}', b, e), baseValues, exponents, 'UniformOutput', false);
  1931. grid on;
  1932. hold on;
  1933. lsline;
  1934. hold off;
  1935. %% Extract position traces for all sensors
  1936. % Extract X, Y, Z position data from each sensor (937639 x 125 matrices)
  1937. x_all = cellfun(@(tbl) tbl.X_Position(:), channelLevelRbTimeseriesHeadSorted, 'UniformOutput', false);
  1938. y_all = cellfun(@(tbl) tbl.Y_Position(:), channelLevelRbTimeseriesHeadSorted, 'UniformOutput', false);
  1939. z_all = cellfun(@(tbl) tbl.Z_Position(:), channelLevelRbTimeseriesHeadSorted, 'UniformOutput', false);
  1940. % Convert to matrices
  1941. x_all = [x_all{:}];
  1942. y_all = [y_all{:}];
  1943. z_all = [z_all{:}];
  1944. % Reference traces from rigid body
  1945. xPos = rigidBodyT.head.RigidBody.X_Position;
  1946. yPos = rigidBodyT.head.RigidBody.Y_Position;
  1947. zPos = rigidBodyT.head.RigidBody.Z_Position;
  1948. %% Create position trace figures together with CFA-correction errors
  1949. % % Appendix 4
  1950. figure;
  1951. % Create grayscale colormap
  1952. colormap = flipud(gray(size(x_all, 2)));
  1953. % --- X axis subplot
  1954. ax1 = subplot(4,1,1);
  1955. hold on
  1956. % for i = 1:size(x_all, 2)
  1957. % trace = x_all(:, i);
  1958. % plot(trace - mean(trace), 'Color', colormap(i, :));
  1959. % end
  1960. plot(xPos - mean(xPos), 'k', 'DisplayName', 'Position');
  1961. ylabel('X Value')
  1962. title('Zero-centered head position traces')
  1963. hold off
  1964. % --- Y axis subplot
  1965. ax2 = subplot(4,1,2);
  1966. hold on
  1967. % for i = 1:size(y_all, 2)
  1968. % trace = y_all(:, i);
  1969. % plot(trace - mean(trace), 'Color', colormap(i, :));
  1970. % end
  1971. plot(yPos - mean(yPos), 'k', 'DisplayName', 'Position');
  1972. ylabel('Y Value')
  1973. hold off
  1974. % --- Z axis subplot
  1975. ax3 = subplot(4,1,3);
  1976. hold on
  1977. % for i = 1:size(z_all, 2)
  1978. % trace = z_all(:, i);
  1979. % plot(trace - mean(trace), 'Color', colormap(i, :));
  1980. % end
  1981. plot(zPos - mean(zPos), 'k', 'DisplayName', 'Position');
  1982. ylabel('Z Value')
  1983. xlabel('Time (samples)')
  1984. hold off
  1985. % --- R-squared vs test stimulus subplot
  1986. ax4 = subplot(4,1,4);
  1987. % LEFT Y-axis: R² bar plot
  1988. yyaxis left
  1989. hBar = bar(test_stims, R_squared_all, 'k', 'BarWidth', 1);
  1990. ylabel('R^2')
  1991. ylim([0 1])
  1992. ax = gca;
  1993. ax.YColor = 'k';
  1994. % RIGHT Y-axis: Variance around peak as scatter
  1995. yyaxis right
  1996. hScatter = scatter(test_stims, aroundPeakVarianceAll, 30, 'x', ...
  1997. 'MarkerEdgeColor', [0.85 0.33 0.10], 'LineWidth', 1.2);
  1998. ylabel('Signal power')
  1999. set(gca, 'YScale', 'log')
  2000. ax = gca;
  2001. ax.YColor = [0.85 0.33 0.10];
  2002. xlabel('Time (samples)')
  2003. title('Variance explained by the CFA-template (individual R-Peaks)')
  2004. grid off
  2005. % Create combined legend
  2006. legend([hBar, hScatter], {'R^2', 'Signal power'}, ...
  2007. 'Location', 'northeast');
  2008. % Make sure the x-axes line up across all 4 plots
  2009. linkaxes([ax1, ax2, ax3, ax4], 'x');
  2010. % === LOW CORRELATION LINES: R² < 0.2 ===
  2011. stim_mask_red = R_squared_all < 0.5;
  2012. test_stims_red = test_stims(stim_mask_red);
  2013. % === HIGH CORRELATION LINES: R² > 0.8 ===
  2014. stim_mask_green = R_squared_all >= 0.5;
  2015. test_stims_green = test_stims(stim_mask_green);
  2016. % Draw vertical lines on top 3 subplots
  2017. for axj = [ax1, ax2, ax3]
  2018. axes(axj); % Set current axes
  2019. % Plot low correlation trials
  2020. lowCorrColor = [0.84 0.37 0.00];
  2021. if ~isempty(test_stims_red)
  2022. xline(test_stims_red(1), '--', 'Color', lowCorrColor, 'LineWidth', 0.8, ...
  2023. 'DisplayName', 'R^2 < 0.5');
  2024. if length(test_stims_red) > 1
  2025. xline(test_stims_red(2:end), '--', 'Color', lowCorrColor, 'LineWidth', 0.8, ...
  2026. 'HandleVisibility', 'off');
  2027. end
  2028. end
  2029. % Plot high correlation trials
  2030. highCorrColor = [0.00 0.45 0.70];
  2031. if ~isempty(test_stims_green)
  2032. xline(test_stims_green(1), '-', 'Color', highCorrColor, 'LineWidth', 0.8, ...
  2033. 'DisplayName', 'R^2 >= 0.5');
  2034. if length(test_stims_green) > 1
  2035. xline(test_stims_green(2:end), '-', 'Color', highCorrColor, 'LineWidth', 0.8, ...
  2036. 'HandleVisibility', 'off');
  2037. end
  2038. end
  2039. % Show legend only once (top subplot)
  2040. if axj == ax1
  2041. legend('Location', 'northeast');
  2042. end
  2043. end
  2044. % Set font size for all axes in the figure
  2045. set(findall(gcf, '-property', 'FontSize'), 'FontSize', 14);
  2046. ax1.Position(2) = ax1.Position(2) + 0.04;
  2047. ax2.Position(2) = ax2.Position(2) + 0.04;
  2048. ax3.Position(2) = ax3.Position(2) + 0.04;
  2049. end

OPM_CFA_correction_Pilot_Study.m at commit 43b1006, under MIT · at the source

Overview

  1. Institute of Cognitive Neuroscience, University College London, London, United Kingdom
  2. Department of Imaging Neuroscience, Institute of Neurology, University College London, London, United Kingdom
  3. Department of Neuroscience, Physiology and Pharmacology, University College London, London, United Kingdom
Institutions: University College London (United Kingdom); UCL Queen Square Institute of Neurology (United Kingdom)
Journal: Imaging neuroscience (Cambridge, Mass.), volume 4, article IMAG.a.1353
Dates: received 7 January 2026; accepted 7 August 2026; published online 3 September 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1162/imag.a.1353 · PMID 42699506 · PMCID PMC13543444 · OpenAlex W7202389573
Open access: diamond, a free copy (OpenAlex)
Status: code verified
Categories: EEG (modality), MEG (modality), human (organism)
Methods: Spectral & time-frequency, Connectivity, Smoothing, state filtering, decompositions, Preprocessing, Evoked potentials, Complexity, Source localization, Statistics
Keywords: OP-MEG, magnetoencephalography, artefact removal, cardiac field artefact, cardiac cycle effects, interoception
MeSH: Artifacts*, Brain*, Heart*, Magnetoencephalography*, Electroencephalography, Humans (* major topic)
Topic: Atomic and Subatomic Physics Research (Atomic and Molecular Physics, and Optics, Physics and Astronomy), according to OpenAlex
Funding: Wellcome Trust (226778/Z/22/Z, 226793/Z/22/Z)
Citations: not cited yet (Europe PMC); 68 references in the paper

Abstract

Each heartbeat creates an electro-magnetic field that propagates throughout the body and is superimposed on measured brain activity in electroencephalography (EEG) and magnetoencephalography (MEG) recordings. Being several orders of magnitude larger than a typical evoked response in the brain, the Cardiac Field Artefact (CFA) is especially problematic in studies where cortical activity time locked to the cardiac cycle is of interest, such as in interoception research. In this work, we develop a model-based CFA correction method, which exploits the fact that the magnetic field propagates relatively evenly through the mostly diamagnetic tissue of the human body, and that in studies using wearable optically pumped magnetometers (OP-MEG), participants are free to rotate their head. Free head rotation decouples the heart’s magnetic field from that of the brain, allowing for the creation of a cardiac dipole moment (CDM) estimate, that is, an estimate of the electrical current flow in the heart, which is minimally confounded by concurrent neuronal activity in the brain. We show in simulations that, given information about the relative position and orientation of head and chest, this CDM estimate can be used to predict and correct the sensor-level CFA by dynamically accounting for the spatial relationship between heart, brain, and sensors. We further provide an empirical proof-of-principle demonstration of the pipeline in a single participant using recordings with intermittent head movement, tracked by optical motion capture, as well as an established auditory response paradigm with well-characterized response pattern.

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 25 matches between paragraphs and lines of code.

Zenodo 20209782

License: MIT
State: the link answers, verified on 26 September 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: the text, “Software”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: SPM (9 files), FieldTrip (8 files), Statistics and Machine Learning Toolbox (5 files), GIfTI library for MATLAB (3 files), EEGLAB (1 file), Signal Processing Toolbox (1 file)
Availability: 1 check, the latest on 26 September 2026: the link answers (HTTP 200)
  • 26 September 2026: the link answers (HTTP 200)
13 files

sascha-woelk/opm-cfa-correction

License: MIT
State: the link answers, verified on 26 September 2026
Evidence: files inventoried
Commit: 43b1006ebad76b8fd6807f37d65befac0c8437e2, 21 May 2026
Languages: MATLAB (13)
Size: 33 files, 13 scripts
Software Heritage: not archived
Found in: the Zenodo archive record
Holds: README, license file
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: SPM (9 files), FieldTrip (8 files), Statistics and Machine Learning Toolbox (5 files), GIfTI library for MATLAB (3 files), EEGLAB (1 file), Signal Processing Toolbox (1 file)
Availability: 1 check, the latest on 26 September 2026: the link answers
  • 26 September 2026: the link answers
15 files

The paper's code and data availability statement is in the Data section.

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:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 24 scripts, each with its path and the digest of its content;
  • 25 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 and Code Availability

The analysis code is publicly available on Zenodo (https://zenodo.org/records/20209782). The corresponding dataset is publicly available on Zenodo (https://zenodo.org/records/20202674).

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, 27 September 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 5 authors, 6 keywords, 6 MeSH terms, 1 funder, 66 references.

Cite

This paper

Woelk, S. P., Alexander, N. A., O’Neill, G. C., Garfinkel, S. N., & Barnes, G. R. (2026). Model-based cardiac field artefact correction for OP-MEG. Imaging neuroscience (Cambridge, Mass.), 4, IMAG.a.1353. https://doi.org/10.1162/imag.a.1353

BibTeX

@article{woelk2026model,
author = {Woelk, Sascha P and Alexander, Nicholas A and O’Neill, George C and Garfinkel, Sarah N and Barnes, Gareth R},
title = {{Model-based cardiac field artefact correction for OP-MEG}},
journal = {Imaging neuroscience (Cambridge, Mass.)},
year = {2026},
month = sep,
volume = {4},
pages = {IMAG.a.1353},
publisher = {MIT Press},
issn = {2837-6056},
doi = {10.1162/imag.a.1353},
url = {https://doi.org/10.1162/imag.a.1353},
pmid = {42699506},
pmcid = {PMC13543444}
}

RIS

TY - JOUR
AU - Woelk, Sascha P
AU - Alexander, Nicholas A
AU - O’Neill, George C
AU - Garfinkel, Sarah N
AU - Barnes, Gareth R
TI - Model-based cardiac field artefact correction for OP-MEG
T2 - Imaging neuroscience (Cambridge, Mass.)
J2 - Imaging Neurosci (Camb)
PY - 2026
DA - 2026/09/03
VL - 4
SP - IMAG.a.1353
SN - 2837-6056
PB - MIT Press
DO - 10.1162/imag.a.1353
UR - https://doi.org/10.1162/imag.a.1353
LA - en
ER -

CSL-JSON

{
"id": "10.1162/imag.a.1353",
"type": "article-journal",
"title": "Model-based cardiac field artefact correction for OP-MEG",
"container-title": "Imaging neuroscience (Cambridge, Mass.)",
"author": [
{
"family": "Woelk",
"given": "Sascha P"
},
{
"family": "Alexander",
"given": "Nicholas A"
},
{
"family": "O’Neill",
"given": "George C"
},
{
"family": "Garfinkel",
"given": "Sarah N"
},
{
"family": "Barnes",
"given": "Gareth R"
}
],
"container-title-short": "Imaging Neurosci (Camb)",
"volume": "4",
"page": "IMAG.a.1353",
"DOI": "10.1162/imag.a.1353",
"PMID": "42699506",
"PMCID": "PMC13543444",
"ISSN": "2837-6056",
"publisher": "MIT Press",
"URL": "https://doi.org/10.1162/imag.a.1353",
"language": "en",
"issued": {
"date-parts": [
[
2026,
9,
3
]
]
}
}

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/psyp.70297 [code]
Heartbeat-Evoked Responses in M/EEG: A Systematic Review of Methods With Suggestions for Analysis and Reporting.
Journal: Psychophysiology
In common: MEG, EEG, 10 references
[2] doi:10.1162/imag.a.1040 [code]
Novel 4 He-OPMs support waveform-specific beta burst analysis comparable to SQUID-MEG
Journal: n/a
In common: FieldTrip, Signal Processing Toolbox, Statistics and Machine Learning Toolbox, MEG, 5 references
[3] doi:10.1111/psyp.70301
Oscillatory Markers of Interoceptive Attention: Beta Suppression as a Neural Signature of Heartbeat Processing.
Journal: Psychophysiology
In common: MEG, 7 references
[4] doi:10.1162/imag.a.1319 [code]
When the inner clock fades: Interoceptive decline and consolidation of phase resetting in cortical rhythms by cardiac events underlie healthy lifespan aging.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: FieldTrip, Statistics and Machine Learning Toolbox, 6 references
[5] doi:10.1111/psyp.70385 [code]
Exploring EEG and ECG in Music Listening: A Scoping Review.
Journal: Psychophysiology
In common: EEG, 6 references
[6] doi:10.1002/hbm.70599 [code]
Group Joint ICA (gjICA): A Method for Multimodal Fusion of Concurrent EEG and fMRI Data.
Journal: Human brain mapping
In common: GIfTI library for MATLAB, EEGLAB, FieldTrip, 3 other tools, EEG
[7] doi:10.1038/s41467-026-76011-7 [code]
Human cortex organizes dynamic co-fluctuations along the sensorimotor-association axis.
Journal: Nature communications
In common: GIfTI library for MATLAB, EEGLAB, FieldTrip, 3 other tools
[8] doi:10.1016/j.crmeth.2026.101473 [code]
AmygdalaGo-BOLT for boundary-aware segmentation of the human amygdala.
Journal: Cell reports methods
In common: GIfTI library for MATLAB, EEGLAB, FieldTrip, 3 other tools
[9] doi:10.1038/s41467-026-73540-z [code]
Predictive acoustical processing in human cortical layers.
Journal: Nature communications
In common: GIfTI library for MATLAB, EEGLAB, FieldTrip, 3 other tools
[10] doi:10.1038/s41598-026-49900-6 [code]
Global neural oscillations underlie performance variability and attentional state fluctuations in humans.
Journal: Scientific reports
In common: GIfTI library for MATLAB, EEGLAB, FieldTrip, 3 other tools

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.