OSCR

Signal combination in flutter vibration perception.

Code ↔ Paper

6 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 6 matches
  1. [1] § Results › Summation and suppression effects on flutter thresholds ↔ TestVersion.qmd, lines 1527–1646 · score 1.00 · approximately parallel handles, Greenhouse Geisser corrected, probability summation instead, 0.5–2 %, baseline stimuli vibrated, cumulative Gaussians
  2. [2] § Results › Summation and suppression of neural responses ↔ TestVersion.qmd, lines 1527–1646 · score 0.99 · perfect linear summation, Greenhouse Geisser corrected, sub linear summation, EEG amplitudes increased, high baseline response, Responses increased monotonically
  3. [3] § Results › Summation and suppression of neural responses ↔ vibrosummanuscript.qmd, lines 1470–1552 · score 0.98 · Greenhouse Geisser corrected, sub linear summation, EEG amplitudes increased, high baseline response, 32–64 %, increased monotonically
  4. [4] § Results › Computational modelling results ↔ vibrosummanuscript.qmd, lines 1470–1552 · score 0.98 · directly tap mechanisms, sub linear summation, excellent account, suppression evident, neural population, sensitive subset
  5. [5] § Materials and methods › Psychophysical procedures ↔ Experiment code/tactiledippers.m, lines 93–174 · score 0.58 · foot pedal, beep, pressing, headphones, staircases, interval
  6. [6] § Materials and methods › EEG procedures ↔ Experiment code/tactileEEG.m, lines 1–60 · score 0.51 · repeated twice, blocks, SSSEP, EEG

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

Quarto · 1,734 lines · 109 KB · no license · 2 matches

  1. ---
  2. title: "Signal combination in vibration perception"
  3. author:
  4. - name: Shasha Wei$^{1,2}$
  5. - name: Alex R. Wade$^{1,3}$
  6. - name: Catherine E.J. Preston$^{1}$
  7. - name: \& Daniel H. Baker$^{1}$
  8. format: pdf
  9. # prefer-html: true # required if outputting to docx format
  10. bibliography: references.bib
  11. csl: pnas.csl
  12. execute:
  13. echo: false
  14. output: false
  15. ---
  16. $^1$Department of Psychology, University of York, UK, YO10 5DD\
  17. $^2$Corresponding author, email: mfv507\@york.ac.uk\
  18. $^3$York Biomedical Research Institute, University of York, UK, YO10 5DD\
  19. **ORCID**:\
  20. Shasha Wei: [https://orcid.org/0009-0002-8975-4214](https://orcid.org/0009-0002-8975-4214)\
  21. Alex Wade: [https://orcid.org/0000-0003-4871-2747](https://orcid.org/0000-0003-4871-2747)\
  22. Catherine Preston: [https://orcid.org/0000-0001-7158-5382](https://orcid.org/0000-0001-7158-5382)\
  23. Daniel Baker: [https://orcid.org/0000-0002-0161-443X](https://orcid.org/0000-0002-0161-443X)\
  24. **Corresponding author**\
  25. Shasha Wei, Email: mfv507\@york.ac.uk
  26. **Present address**:\
  27. $^1$Department of Psychology, University of York, York, UK, YO10 5DD
  28. **Author Contributions**\
  29. D.H.B. designed research; S.W. performed research; S.W., A.R.W. and D.H.B. analysed data; and S.W., A.R.W., C.E.J.P. and D.H.B. wrote and revised the paper.
  30. **Competing Interests**\
  31. The authors declare no competing interest.
  32. **Classification**: Social Science-Psychological and Cognitive Sciences\
  33. **Keywords**: vibrotactile summation, suppression, somatosensory, computational modelling\
  34. This manuscript was deposited as a preprint on PsyArXiv (DOI: 10.31234/osf.io/yjv6s)
  35. under the CC-BY Attribution 4.0 International license.
  36. ```{r initialiseenvironment}
  37. #| include: false
  38. doanalysis <- 0
  39. fitmodels <- 0
  40. plotfigures <- 1
  41. # install R packages
  42. packagelist <- c('utils','knitr','reticulate','osfr','tinytex','rstatix','kableExtra')
  43. missingpackages <- packagelist[!packagelist %in% installed.packages()[,1]]
  44. if (length(missingpackages)>0){install.packages(missingpackages)}
  45. toinstall <- packagelist[which(!packagelist %in% (.packages()))]
  46. invisible(lapply(toinstall,library,character.only=TRUE))
  47. use_python("/usr/bin/python3.10")
  48. ```
  49. ```{python setup}
  50. #| include: false
  51. # import packages etc.
  52. import os
  53. import mne
  54. import numpy as np
  55. import pandas as pd
  56. import psignifit as ps
  57. from matplotlib.lines import Line2D
  58. import matplotlib.pyplot as plt
  59. from mne import EpochsArray
  60. from mne import viz
  61. from mne.channels import make_standard_montage
  62. import scipy.stats as stats
  63. from scipy.fft import fft, fftfreq
  64. from scipy.stats import norm
  65. from scipy.optimize import minimize
  66. from matplotlib.lines import Line2D
  67. from matplotlib import cm, colors, colorbar
  68. from statsmodels.stats.anova import AnovaRM
  69. import pingouin as pg
  70. from pingouin import pairwise_ttests
  71. from mpl_toolkits.axes_grid1 import make_axes_locatable
  72. ```
  73. ```{r checkfordata}
  74. #| include: false
  75. # check if raw data are available and download if required
  76. if (!dir.exists('local/')){dir.create('local/')}
  77. osfproject <- osf_retrieve_node('m79d2')
  78. osffiles <- osf_ls_files(osfproject,n_max=300)
  79. if (!file.exists('local/8subjects.csv')){osf_download(osffiles[which(osffiles$name=='8subjects.csv'),],'local/',progress=TRUE)}
  80. if (!file.exists('local/allthresh.npz')){osf_download(osffiles[which(osffiles$name=='allthresh.npz'),],'local/',progress=TRUE)}
  81. if (!file.exists('local/allslope.npz')){osf_download(osffiles[which(osffiles$name=='allslope.npz'),],'local/',progress=TRUE)}
  82. if (!file.exists('local/allF1.npy')){osf_download(osffiles[which(osffiles$name=='allF1.npy'),],'local/',progress=TRUE)}
  83. if (!file.exists('local/allF2.npy')){osf_download(osffiles[which(osffiles$name=='allF2.npy'),],'local/',progress=TRUE)}
  84. if (!file.exists('local/dippermodels.npz')){osf_download(osffiles[which(osffiles$name=='dippermodels.npz'),],'local/',progress=TRUE)}
  85. if (!file.exists('local/EEGmodels.npz')){osf_download(osffiles[which(osffiles$name=='EEGmodels.npz'),],'local/',progress=TRUE)}
  86. if (!file.exists('local/EEG_allresp.npy')){osf_download(osffiles[which(osffiles$name=='EEG_allresp.npy'),],'local/',progress=TRUE)}
  87. if ((plotfigures+fitmodels)>0){
  88. if (!file.exists('local/processedEEGdata.npz')){osf_download(osffiles[which(osffiles$name=='processedEEGdata.npz'),],'local/',progress=TRUE)}
  89. if (!file.exists('local/montage-info.fif')){osf_download(osffiles[which(osffiles$name=='montage-info.fif'),],'local/',progress=TRUE)}
  90. }
  91. if (doanalysis==1){
  92. osfproject <- osf_retrieve_node('z2fcm')
  93. osffiles <- osf_ls_files(osfproject,n_max=300)
  94. if (!dir.exists('local/rawEEGdata/')){dir.create('local/rawEEGdata/')
  95. osf_download(osffiles[which(osffiles$name=='setfiles.zip'),],'local/',progress=TRUE,conflicts='skip')
  96. unzip('local/setfiles.zip',exdir='local/rawEEGdata/')
  97. file.remove('local/setfiles.zip')
  98. d <- dir('local/rawEEGdata/setfiles/',full.names=TRUE)
  99. file.copy(d,'local/rawEEGdata/')
  100. file.remove(d)
  101. for (i in 1:4){
  102. osf_download(osffiles[which(osffiles$name==paste0('fdt',i,'.zip')),],'local/',progress=TRUE,conflicts='skip')
  103. unzip(paste0('local/fdt',i,'.zip'),exdir='local/rawEEGdata/')
  104. file.remove(paste0('local/fdt',i,'.zip'))
  105. d <- dir(paste0('local/rawEEGdata/fdt',i),full.names=TRUE)
  106. file.copy(d,'local/rawEEGdata/')
  107. file.remove(d)
  108. }
  109. }
  110. # for (s in 1:31){
  111. # if (!file.exists(paste0('local/rawEEGdata/P',s,'_EEG.fdt'))){osf_download(osffiles[which(osffiles$name==paste0('P',s,'_EEG.fdt')),],'local/rawEEGdata/',progress=TRUE,conflicts='skip')}
  112. # if (!file.exists(paste0('local/rawEEGdata/P',s,'_EEG.set'))){osf_download(osffiles[which(osffiles$name==paste0('P',s,'_EEG.set')),],'local/rawEEGdata/',progress=TRUE,conflicts='skip')}
  113. # }
  114. }
  115. ```
  116. ```{python analysedippers}
  117. #| include: false
  118. if r.doanalysis:
  119. row = pd.read_csv('local/8subjects.csv')
  120. ex1data = row[['Subject','Condition', 'PedestalContrast','TargetContrast','IsCorrect']].values
  121. df = pd.DataFrame(ex1data, columns=['Subject','Condition', 'PedestalContrast','TargetContrast', 'IsCorrect'])
  122. df['Condition'] = (df['Condition'] + 1) // 2
  123. sublist = df['Subject'].unique()
  124. #%% generate tofit data and fit psychometric functions
  125. allthresh = np.zeros((len(sublist), 4, 8))
  126. allslope = np.zeros((len(sublist), 4, 8))
  127. # Loop over each subject
  128. for i, subject in enumerate(sublist):
  129. subdata = df[df['Subject'] == subject]
  130. # Loop over conditions (1 to 4)
  131. for cond in range(1, 5):
  132. conddata = subdata[subdata['Condition'] == cond]
  133. pedlevs = np.sort(conddata['PedestalContrast'].unique())
  134. # Loop over pedestal levels (1 to 8)
  135. for pedlevel in range(8):
  136. if pedlevel < len(pedlevs):
  137. blockdata = conddata[conddata['PedestalContrast'] == pedlevs[pedlevel]]
  138. # Proceed only if there's data in blockdata
  139. if not blockdata.empty:
  140. targetcontrasts = np.sort(blockdata['TargetContrast'].unique())
  141. targetcontrasts = pd.to_numeric(targetcontrasts, errors='coerce')
  142. #targetcontrasts = targetcontrasts[targetcontrasts > 0]
  143. ntrials = []
  144. ncorrect = []
  145. # Loop over each TargetContrast
  146. for target in targetcontrasts:
  147. temp = blockdata[blockdata['TargetContrast'] == target]
  148. # Count ncorr and ntotal
  149. ntrials.append(len(temp))
  150. ncorrect.append(temp['IsCorrect'].sum())
  151. level = np.round(20 * np.log10(targetcontrasts))
  152. tofit = np.vstack((level, ncorrect, ntrials)).T
  153. result_fit = ps.psignifit(tofit, experiment_type='2AFC')
  154. # result_params = result_fit.get_parameters_estimate()
  155. allthresh[i, cond - 1, pedlevel] = result_fit.parameter_estimate['threshold']
  156. allslope[i, cond - 1, pedlevel] = 10.3 / (result_fit.parameter_estimate['width'] / (norm.ppf(1 - 0.05) - norm.ppf(0.05)))
  157. #print("Thresholds:", allthresh)
  158. #print("Slopes:", allslope)
  159. allthresh[:, 2, 1:8] = allthresh[:, 2, 0:7]
  160. allthresh[:, 3, 1:8] = allthresh[:, 3, 0:7]
  161. allslope[:, 2, 1:8] = allslope[:, 2, 0:7]
  162. allslope[:, 3, 1:8] = allslope[:, 3, 0:7]
  163. allthresh[:, 2, 0] = allthresh[:, 0,0]
  164. allthresh[:, 3, 0] = allthresh[:, 0, 0]
  165. allslope[:, 2, 0] = allslope[:, 0,0]
  166. allslope[:, 3, 0] = allslope[:, 0, 0]
  167. np.savez('local/allthresh.npz', allthresh=allthresh)
  168. np.savez('local/allslope.npz', allslope=allslope)
  169. ```
  170. ```{python plotdippers}
  171. #| include: false
  172. if r.plotfigures:
  173. data = np.load('local/allthresh.npz')
  174. allthresh = data['allthresh']
  175. meanthresh=np.mean(allthresh, axis=0) #4*8
  176. SEthresh = np.std(allthresh, axis=0, ddof=1) / np.sqrt(8)
  177. x = np.arange(1, 9)
  178. markers=['o','s','D','^']
  179. lineColor = ['b', 'r', 'orange','g'] # Line colors for the four conditions
  180. labels= ['Pentadactyl', 'Dekadactyl', 'Half-Dekadactyl','Dichodactyl']
  181. markeredgecolors=['black','black','black','black']
  182. legend_elements = []
  183. #%% Plotting
  184. plt.figure(figsize=(20, 10))
  185. plt.subplot(1, 2, 1)
  186. for m in range(meanthresh.shape[0]):#iterate over the columns of average_array
  187. y=meanthresh[m, :]
  188. y_error=SEthresh[m, :] #because SE array have the same number of columns as average_array
  189. y_upper = y + y_error
  190. y_lower = y - y_error
  191. plt.plot(x, y, label=labels[m], color=lineColor[m], marker=markers[m], markersize=10, markeredgecolor=markeredgecolors[m])
  192. plt.fill_between(x, y_lower, y_upper,alpha=0.15, color=lineColor[m])
  193. legend_elements.append(Line2D([0], [0], color=lineColor[m], linestyle='-', label=labels[m], markersize=10, marker=markers[m],markeredgecolor=markeredgecolors[m]))
  194. plt.ylim(-15,30)
  195. plt.yticks([-12,-6,0,6,12,18,24,30],[0.25, 0.5, 1, 2, 4, 8, 16, 32],fontsize = 25)
  196. plt.xticks(x,[0, 0.5, 1, 2, 4, 8, 16, 32], fontsize = 25 )
  197. plt.xlabel('Baseline intensity level (%)', fontsize=30)
  198. plt.ylabel('Threshold (%)', fontsize=30)
  199. plt.legend(handles=legend_elements)
  200. leg = plt.legend(frameon=False, loc='lower right', fontsize=24)
  201. for text in leg.get_texts():
  202. text.set_fontsize(18)
  203. fig = plt.gcf()
  204. plt.text(0.085, 0.92, '(a)', fontsize=40, va='top', ha='left', transform=fig.transFigure)
  205. ax = plt.gca()
  206. for spine in ax.spines.values():
  207. spine.set_linewidth(2.5)
  208. ax.tick_params(axis='both', which='major', width=2)
  209. ################ plot slope
  210. data1= np.load('local/allslope.npz')
  211. allslope = data1['allslope']
  212. allslope = 20 * np.log10(allslope)
  213. meanslope = np.mean(allslope, axis=0) #4*8
  214. SEslope = np.std(allslope, axis=0, ddof=1) / np.sqrt(8)
  215. plt.subplot(1, 2, 2)
  216. for m in range(meanslope.shape[0]):#iterate over the columns of average_array
  217. y=meanslope[m, :]
  218. y_error=SEslope[m,:] #because SE array have the same number of columns as average_array
  219. y_upper = y + y_error
  220. y_lower = y - y_error
  221. plt.plot(x, y, label=labels[m], color=lineColor[m], marker=markers[m], markersize=10,markeredgecolor=markeredgecolors[m])
  222. plt.fill_between(x, y_lower, y_upper,alpha=0.15, color=lineColor[m])
  223. plt.ylim(-7,19)
  224. plt.yticks([-6, 0, 6, 12, 18], ['0.5','1', '2', '4', '8'],fontsize=25)
  225. plt.minorticks_off()
  226. plt.xticks(x, [0, 0.5, 1, 2, 4, 8, 16, 32], fontsize=25)
  227. plt.xlabel('Baseline intensity level (%)', fontsize=30)
  228. plt.ylabel('Weibull ' + r"$\mathrm{\beta}$", fontsize=30)
  229. plt.subplots_adjust(wspace=0.6)
  230. fig = plt.gcf()
  231. plt.text(0.58, 0.92, '(b)', fontsize=40, va='top', ha='left', transform=fig.transFigure)
  232. ax = plt.gca()
  233. for spine in ax.spines.values():
  234. spine.set_linewidth(2.5) # Adjust the thickness of the frame
  235. ax.tick_params(axis='both', which='major', width=2)
  236. plt.tight_layout()
  237. plt.savefig('Figures/Figure1.pdf')
  238. ```
  239. ```{python analyseEEG}
  240. #| include: false
  241. if r.doanalysis:
  242. raw_path = "local/rawEEGdata/" # path containing raw data
  243. nsubjs = 31
  244. electrodenames = np.array(['F1', 'F2', 'Fz', 'FC1', 'FC2', 'FCz']) # electrodes of interest
  245. allsubjsF1 = np.zeros((nsubjs,61,6,5), dtype='complex')
  246. allsubjsF2 = np.zeros((nsubjs,61,6,5), dtype='complex')
  247. selectSpec = np.zeros((nsubjs,len(electrodenames),6,5,10001), dtype='complex')
  248. F1 = 26
  249. F2 = 23
  250. F1index = F1*10
  251. F2index = F2*10
  252. tmin = 1.0
  253. tmax = 11.0
  254. fmin = 1.0
  255. fmax = 50.0
  256. reject_criteria = None #dict(eeg=200e-6)
  257. legaltriggers = np.concatenate([range(11,16),range(21,26),range(31,36),range(41,46),range(51,56),range(61,66),range(71,76),range(81,86),range(91,96),range(101,106),range(111,116),range(121,126)])
  258. eventList = ['Mon/L/C1', 'Mon/L/C2', 'Mon/L/C3', 'Mon/L/C4', 'Mon/L/C5',
  259. 'Mon/R/C1', 'Mon/R/C2', 'Mon/R/C3', 'Mon/R/C4', 'Mon/R/C5',
  260. 'Bin/LR/C1', 'Bin/LR/C2', 'Bin/LR/C3', 'Bin/LR/C4', 'Bin/LR/C5',
  261. 'Bin/RL/C1', 'Bin/RL/C2', 'Bin/RL/C3', 'Bin/RL/C4', 'Bin/RL/C5',
  262. 'Dich/L/C1', 'Dich/L/C2', 'Dich/L/C3', 'Dich/L/C4', 'Dich/L/C5',
  263. 'Dich/R/C1', 'Dich/R/C2', 'Dich/R/C3', 'Dich/R/C4', 'Dich/R/C5',
  264. 'XMon/L/C1', 'XMon/L/C2', 'XMon/L/C3', 'XMon/L/C4', 'XMon/L/C5',
  265. 'XMon/R/C1', 'XMon/R/C2', 'XMon/R/C3', 'XMon/R/C4', 'XMon/R/C5',
  266. 'XBin/LR/C1', 'XBin/LR/C2', 'XBin/LR/C3', 'XBin/LR/C4', 'XBin/LR/C5',
  267. 'XBin/RL/C1', 'XBin/RL/C2', 'XBin/RL/C3', 'XBin/RL/C4', 'XBin/RL/C5',
  268. 'XDich/L/C1', 'XDich/L/C2', 'XDich/L/C3', 'XDich/L/C4', 'XDich/L/C5',
  269. 'XDich/R/C1', 'XDich/R/C2', 'XDich/R/C3', 'XDich/R/C4', 'XDich/R/C5']
  270. levellist = ["C1","C2","C3","C4","C5"]
  271. condlist = ['Mon','Bin','Dich','XMon','XBin','XDich']
  272. # loop through each individual participant, calculate the spectra and amplitude for 6 conditions and 5 intensity levels
  273. for p in range(0,nsubjs):
  274. filename = raw_path + "P" + str(p+1) + '_EEG.set'
  275. raw = mne.io.read_raw_eeglab(filename,preload=True)
  276. raw.drop_channels(["HEOG", "VEOG", "M1", "M2","Fpz"])
  277. ANT_montage = mne.channels.make_standard_montage("standard_1020")
  278. raw.set_montage(ANT_montage)
  279. info = raw.info
  280. events, event_id = mne.events_from_annotations(raw)
  281. ch_names = np.array(raw.ch_names)
  282. selectE = np.where(np.isin(ch_names,electrodenames))[0] # find indices of electrodes of interest
  283. extracted_values = []
  284. for trigger in legaltriggers:
  285. trigger_str = str(trigger) # convert trigger to string for comparison
  286. if trigger_str in event_id:
  287. extracted_values.append(event_id[trigger_str])
  288. event_dict = dict(zip(eventList, extracted_values))
  289. allepochs = mne.Epochs(raw, events, event_id=event_dict, tmin=tmin, tmax=tmax, baseline=(tmin,tmax), reject=reject_criteria, preload=True)
  290. allblocksF1 = np.zeros((61,len(condlist),len(levellist)), dtype='complex')
  291. allblocksF2 = np.zeros((61,len(condlist),len(levellist)), dtype='complex')
  292. spectra = np.zeros((len(selectE), len(condlist), len(levellist), 10001))
  293. for c in range(len(condlist)):
  294. for l in range(len(levellist)):
  295. condstr = str(condlist[c]) + '/' + str(levellist[l])
  296. temp = allepochs[condstr]
  297. conditionmean = temp.average()
  298. d = 1000000*conditionmean.data # rescale to microvolts (from volts)
  299. s = d.shape
  300. for electrode in range(s[0]):
  301. spec = fft(d[electrode,:])/10001
  302. # Check if the electrode is in the selected electrodes list
  303. if electrode in selectE:
  304. idx = np.where(np.isin(selectE,electrode))[0]
  305. spectra[idx, c, l, :] = spec # store all frequencies from 6 electrodes
  306. allblocksF1[electrode,c,l] = spec[F1index]
  307. allblocksF2[electrode,c,l] = spec[F2index]
  308. selectSpec[p,:,:,:,:] = spectra # store spectra: participant X electrode X condition X level X frequency
  309. allsubjsF1[p,:,:,:] = allblocksF1 ##amplitudes at 26Hz: participant X electrode X condition X level
  310. allsubjsF2[p,:,:,:] = allblocksF2 ##amplitude form 23Hz: participant X electrode X condition X level
  311. np.savez('local/processedEEGdata.npz', allsubjsF1=allsubjsF1, allsubjsF2=allsubjsF2, selectSpec=selectSpec, selectE=selectE, info=info)
  312. info.save('local/montage-info.fif') # save montage info in correct format
  313. ```
  314. ```{python plotEEGdata}
  315. #| include: false
  316. if r.plotfigures:
  317. excludelist = 13
  318. mask = np.ones(31, dtype=bool)
  319. mask[excludelist] = False
  320. freqs = np.linspace(0,49,491)
  321. x = [12,18,24,30,36]
  322. eegdata = np.load('local/processedEEGdata.npz',allow_pickle=True)
  323. allsubjsF1 = eegdata['allsubjsF1']
  324. allsubjsF2 = eegdata['allsubjsF2']
  325. selectSpec = eegdata['selectSpec']
  326. selectE = eegdata['selectE']
  327. info = mne.io.read_info('local/montage-info.fif')
  328. levellist = ["C1","C2","C3","C4","C5"]
  329. condlist = ['Mon','Bin','Dich','XMon','XBin','XDich']
  330. # Row data for plotting spectra
  331. Deka_spec = np.mean(np.abs(selectSpec[mask, :, 1, 4,:]), axis=1) #### average across 6 electrodes
  332. Penta_spec = np.mean(np.abs(selectSpec[mask, :, 0, 4,:]), axis=1)
  333. CrossP_spec = np.mean(np.abs(selectSpec[mask, :, 3, 4,:]), axis=1)
  334. CrossDeka_spec = np.mean(np.abs(selectSpec[mask, :, 4, 4,:]), axis=1)
  335. #### Do outlier rejection, calculate the data for plotting topmap
  336. topomapF1 = np.zeros((61,len(condlist),len(levellist)))
  337. topomapF2 = np.zeros((61,len(condlist),len(levellist)))
  338. for el in range(61):
  339. for cond in range(6):
  340. for lev in range(5):
  341. temp = np.abs(allsubjsF1[mask,el,cond,lev])
  342. topomapF1[el,cond,lev] = np.mean(temp)
  343. temp = np.abs(allsubjsF2[mask,el,cond,lev])
  344. topomapF2[el,cond,lev] = np.mean(temp)
  345. ##### Remove one outlier, the rest of 30 participants are used to calculate mean amplitude across conditions, levels
  346. subjsF1 = np.zeros((sum(mask), 61, len(condlist), len(levellist)))
  347. subjsF2 = np.zeros((sum(mask), 61, len(condlist), len(levellist)))
  348. for el in range(61):
  349. for cond in range(6):
  350. for lev in range(5):
  351. # F1 data
  352. subjsF1[:, el, cond, lev] = np.abs(allsubjsF1[mask, el, cond, lev])
  353. # F2 data
  354. subjsF2[:, el, cond, lev] = np.abs(allsubjsF2[mask, el, cond, lev])
  355. ######################## filtered F1, F2 contrast response data
  356. EsubjsF1 = subjsF1[:,selectE, :, :]
  357. dataF1 = np.nanmean(EsubjsF1, axis=1) #average across 6 electrodes
  358. meanF1 = np.mean(dataF1,axis=0)
  359. SEF1 = np.std(dataF1,axis=0)/np.sqrt(sum(mask))
  360. EsubjsF2 = subjsF2[:,selectE, :, :]
  361. dataF2 = np.nanmean(EsubjsF2, axis=1) #average across 6 electrodes
  362. meanF2 = np.mean(dataF2,axis=0)
  363. SEF2 = np.std(dataF2,axis=0)/np.sqrt(sum(mask))
  364. np.save('local/allF1.npy', dataF1)
  365. np.save('local/allF2.npy', dataF2)
  366. ####################################### plot spectrum + topomaps
  367. fig=plt.figure(figsize=(30,45))
  368. ax1=fig.add_subplot(3,2,1)
  369. ax2=fig.add_subplot(3,2,2)
  370. ax3=fig.add_subplot(3,2,3)
  371. ax4=fig.add_subplot(3,2,4)
  372. ax5=fig.add_subplot(3,2,5)
  373. ax6=fig.add_subplot(3,2,6)
  374. pos = ax1.get_position() # set the position of each subplot manually
  375. new_pos = [pos.x0, pos.y0+0.05, pos.width, pos.height] ###adjust position
  376. ax1.set_position(new_pos)
  377. pos = ax2.get_position() # set the position of each subplot manually
  378. new_pos = [pos.x0 + 0.05, pos.y0+0.05, pos.width, pos.height] ###adjust position
  379. ax2.set_position(new_pos)
  380. pos = ax3.get_position()
  381. new_pos = [pos.x0, pos.y0, pos.width, pos.height]
  382. ax3.set_position(new_pos)
  383. pos = ax4.get_position()
  384. new_pos = [pos.x0 + 0.05, pos.y0, pos.width, pos.height]
  385. ax4.set_position(new_pos)
  386. pos = ax5.get_position()
  387. new_pos = [pos.x0, pos.y0-0.05, pos.width, pos.height]
  388. ax5.set_position(new_pos)
  389. pos = ax6.get_position()
  390. new_pos = [pos.x0 + 0.05, pos.y0-0.05, pos.width, pos.height]
  391. ax6.set_position(new_pos)
  392. axes = [ax1, ax2, ax3, ax4, ax5, ax6]
  393. line_weight = 2
  394. for ax in axes:
  395. for spine in ax.spines.values():
  396. spine.set_linewidth(line_weight)
  397. freq_range = range(11, 471)
  398. def plot_PSD_with_topomap(spectrum, label, topomap_data, info, ax):
  399. spectrum = spectrum[:,list(freq_range)]
  400. nsubjs = spectrum.shape[0]
  401. psds_mean = spectrum.mean(axis=0)
  402. psds_std = spectrum.std(axis=0)
  403. psds_sr = psds_std / np.sqrt(nsubjs)
  404. ax.plot(freqs[list(freq_range)], psds_mean, label=label)
  405. ax.fill_between(freqs[list(freq_range)], psds_mean - psds_sr, psds_mean + psds_sr, color="b", linewidth=0, alpha=0.2)
  406. ax.set_title(f'{label}', fontsize=35)
  407. ax.set_ylabel('Amplitude (μV)', fontsize=35)
  408. ax.set_xlabel('Frequency (Hz)', fontsize=35)
  409. ax.set_ylim(0, 0.5)
  410. ax.tick_params(axis='both', which='major', labelsize=25)
  411. # Plot topomap
  412. topomap_ax = ax.inset_axes([0.55, 0.55, 0.38, 0.38])
  413. img, _ = mne.viz.plot_topomap(topomap_data, info, axes=topomap_ax, show=False, cmap='viridis', vlim=(0, 0.5))
  414. divider = make_axes_locatable(topomap_ax)
  415. cax = divider.append_axes("right", size="5%", pad=0.05)
  416. cbar = plt.colorbar(img, cax=cax, ticks=[0, 0.25, 0.5])
  417. cbar.ax.tick_params(labelsize=20)
  418. cbar.ax.yaxis.set_label_position('left')
  419. cbar.ax.yaxis.set_ticks_position('right')
  420. # add the unit at the top center
  421. cbar.ax.set_title('µV', fontsize=25, pad=2, loc='center')
  422. # Create colorbar for topomap
  423. ####Add text (a), (b)......to the plot
  424. ax1.text(0.03, 0.87+0.05, '(a)', fontsize=80, transform=fig.transFigure)
  425. ax2.text(0.51, 0.87+0.05, '(b)', fontsize=80, transform=fig.transFigure)
  426. ax3.text(0.03, 0.55+0.05, '(c)', fontsize=80, transform=fig.transFigure)
  427. ax4.text(0.51, 0.55+0.05, '(d)', fontsize=80, transform=fig.transFigure)
  428. ax5.text(0.03, 0.23+0.05, '(e)', fontsize=80, transform=fig.transFigure)
  429. ax6.text(0.51, 0.23+0.05, '(f)', fontsize=80, transform=fig.transFigure)
  430. plot_PSD_with_topomap(Penta_spec, 'Pentadactyl', topomapF1[:61, 0, 4], info, ax1)
  431. plot_PSD_with_topomap(Deka_spec, 'Dekadactyl', topomapF1[:61, 1, 4], info, ax2)
  432. plot_PSD_with_topomap(CrossP_spec, 'Cross-Pentadactyl', topomapF2[:61, 3, 4], info, ax3)
  433. plot_PSD_with_topomap(CrossDeka_spec, 'Cross-Dekadactyl', topomapF1[:61, 4, 4], info, ax4)
  434. ################################################## Plot contrast response function at 26Hz
  435. fillx = np.concatenate([x, x[::-1]])
  436. x_ticks = [4, 8, 16, 32, 64]
  437. labels= ['Pentadactyl', 'Dekadactyl', 'Dichodactyl','Cross-Pentadactyl','Cross-Dekadactyl','Cross-Dichodactyl']
  438. condlist = ['Mon','Bin','Dich','XMon','XBin','XDich']
  439. lineColor = ['b', 'r', 'g', 'y', 'm', 'k']
  440. markers = ['o', 's', '^', '<', 'p', 'd']
  441. ###########################
  442. legend_elements = []
  443. for c, marker in zip(range(len(condlist)), markers):
  444. a = meanF1[c, 0:5] + SEF1[c, 0:5]
  445. b = meanF1[c, (4, 3, 2, 1, 0)] - SEF1[c, (4, 3, 2, 1, 0)]
  446. filly = np.concatenate([a, b])
  447. ax5.fill(fillx, filly, facecolor=lineColor[c], alpha=0.2)
  448. ax5.plot(x, meanF1[c, 0:5], color=lineColor[c], marker=marker,markersize=10, label=condlist[c])
  449. legend_elements.append(Line2D([0], [0], color=lineColor[c], linestyle='-', marker=marker, markersize=15, label=labels[c]))
  450. ax5.set_ylim(0, 0.5)
  451. y_ticks = np.arange(0, 0.6, 0.1)
  452. formatted_y_ticks = [f'{y:.1f}' for y in y_ticks]
  453. ax5.set_yticks(y_ticks)
  454. ax5.set_yticklabels(formatted_y_ticks, fontsize=25)
  455. ax5.set_xticks(x)
  456. ax5.set_xticklabels(x_ticks, fontsize=25)
  457. ax5.set_ylabel('Response at 26 Hz (µV)', fontsize=35)
  458. ax5.set_xlabel('Intensity level (%)', fontsize=35)
  459. ################################################## Plot contrast response function at 23 Hz
  460. for c, marker in zip(range(len(condlist)) , markers):
  461. a2 = meanF2[c,0:5]+SEF2[c,0:5]
  462. b2 = meanF2[c,(4,3,2,1,0)]-SEF2[c,(4,3,2,1,0)]
  463. filly2 = np.concatenate([a2,b2])
  464. ax6.fill(fillx,filly2,facecolor=lineColor[c],alpha=0.2)
  465. ax6.plot(x, meanF2[c,0:5], color=lineColor[c], marker=marker,markersize=10, label=condlist[c])
  466. #legend_elements.append(Line2D([0], [0], color=lineColor[c], linestyle='-', marker=marker, label=labels[c]))
  467. leg1 = ax6.legend(
  468. handles=legend_elements,
  469. loc='upper left',
  470. bbox_to_anchor=(0.01, 1),
  471. ncol=2, # 3 columns per row
  472. fontsize=22,
  473. frameon=False
  474. )
  475. y_ticks = np.arange(0, 0.6, 0.1)
  476. formatted_y_ticks = [f'{y:.1f}' for y in y_ticks]
  477. ax6.set_yticks(y_ticks)
  478. ax6.set_yticklabels(formatted_y_ticks, fontsize=25)
  479. ax6.set_ylim(0, 0.5)
  480. ax6.set_xticks(x)
  481. ax6.set_xticklabels(x_ticks, fontsize=25)
  482. ax6.set_xlabel('Intensity level (%)', fontsize=35)
  483. ax6.set_ylabel('Response at 23 Hz (µV)', fontsize=35)
  484. axes = [ax1, ax2, ax3, ax4, ax5, ax6]
  485. # set the spine linewidth
  486. for ax in axes:
  487. for spine in ax.spines.values():
  488. spine.set_linewidth(2.5)
  489. ax.tick_params(axis='both', which='major', width=2)
  490. plt.savefig('Figures/Figure2.pdf')
  491. ```
  492. ```{python fitdippers}
  493. #| include: false
  494. if r.fitmodels:
  495. fitpath = "local/fits1/"
  496. if not os.path.exists(fitpath):
  497. os.makedirs(fitpath)
  498. data = np.load('local/allthresh.npz')
  499. allthresh = data['allthresh']
  500. meanthresh = np.mean(allthresh, axis=0)
  501. SEthresh = np.std(allthresh, axis=0, ddof=1) / np.sqrt(8)
  502. datatofit = meanthresh
  503. ntotalsimplexfits = 100
  504. pedcontrasts = np.arange(-12,31,6)
  505. pedlist = pedcontrasts
  506. def getmodelresp(p, L, R):
  507. Lresp = (L**p[2]) / (p[3] + L + p[5] * R)
  508. Rresp = (R**p[2]) / (p[3] + R + p[5] * L)
  509. bs = (Lresp**p[7] + Rresp**p[7])**(1/p[7])
  510. resp = (bs**p[0]) / (p[4] + bs**p[1])
  511. return resp
  512. def discriminate(p, pedC, cond):
  513. if cond == 1:
  514. baseline = getmodelresp(p, pedC, 0)
  515. elif cond == 2:
  516. baseline = getmodelresp(p, pedC, pedC)
  517. elif cond == 3:
  518. baseline = getmodelresp(p, pedC, pedC)
  519. elif cond == 4:
  520. baseline = getmodelresp(p, pedC, 0)
  521. modelresp = -10
  522. contrastinc = 0
  523. if baseline > -999:
  524. while (modelresp - baseline) < p[6]:
  525. contrastinc += 0.1
  526. if cond == 1:
  527. modelresp = getmodelresp(p, pedC + contrastinc, 0)
  528. elif cond == 2:
  529. modelresp = getmodelresp(p, pedC + contrastinc, pedC + contrastinc)
  530. elif cond == 3:
  531. modelresp = getmodelresp(p, pedC + contrastinc, pedC)
  532. elif cond == 4:
  533. modelresp = getmodelresp(p, pedC, contrastinc)
  534. if contrastinc > 100:
  535. modelresp = 999
  536. if modelresp < 999:
  537. while (modelresp - baseline) > p[6]:
  538. contrastinc -= 0.001
  539. if cond == 1:
  540. modelresp = getmodelresp(p, pedC + contrastinc, 0)
  541. elif cond == 2:
  542. modelresp = getmodelresp(p, pedC + contrastinc, pedC + contrastinc)
  543. elif cond == 3:
  544. modelresp = getmodelresp(p, pedC + contrastinc, pedC)
  545. elif cond == 4:
  546. modelresp = getmodelresp(p, pedC, contrastinc)
  547. else:
  548. contrastinc = 99
  549. return contrastinc
  550. def getslope(p, pedC, cond):
  551. targetlevelsdB = np.array(range(-18,36,3))
  552. targetlevelsC = 10 ** (targetlevelsdB/20)
  553. if cond == 1:
  554. baseline = getmodelresp(p, pedC, 0)
  555. elif cond == 2:
  556. baseline = getmodelresp(p, pedC, pedC)
  557. elif cond == 3:
  558. baseline = getmodelresp(p, pedC, pedC)
  559. elif cond == 4:
  560. baseline = getmodelresp(p, pedC, 0)
  561. propcorr = targetlevelsC*0
  562. for t in range(len(targetlevelsC)):
  563. if cond == 1:
  564. modelresp = getmodelresp(p, pedC + targetlevelsC[t], 0)
  565. elif cond == 2:
  566. modelresp = getmodelresp(p, pedC + targetlevelsC[t], pedC + targetlevelsC[t])
  567. elif cond == 3:
  568. modelresp = getmodelresp(p, pedC + targetlevelsC[t], pedC)
  569. elif cond == 4:
  570. modelresp = getmodelresp(p, pedC, targetlevelsC[t])
  571. dprime = (modelresp - baseline)/(p[6]/0.9538726)
  572. propcorr[t] = stats.norm.cdf(dprime/np.sqrt(2), loc=0, scale=1)
  573. level = targetlevelsdB
  574. ncorrect = np.round(propcorr * 100)
  575. ntrials = (targetlevelsdB*0) + 100
  576. tofit = np.vstack((level, ncorrect, ntrials)).T
  577. result_fit = ps.psignifit(tofit, experiment_type='2AFC')
  578. threshval = result_fit.parameter_estimate['threshold']
  579. slopeval = 10.3 / (result_fit.parameter_estimate['width'] / (norm.ppf(1 - 0.05) - norm.ppf(0.05)))
  580. return slopeval
  581. def errorfit(p):
  582. p = 10**np.array(p)
  583. p[1] = p[1] + 1
  584. p[1] = min(p[1], 16)
  585. p[0] = 1 + p[0] + p[1]
  586. p[0] = min(p[0], 20)
  587. p[2] = 1 + p[2]
  588. if len(p) == 7:
  589. p = np.append(p, 1) # append p[7]=1 Minkowski exponent of 1 in the summation model
  590. pedlevelsC = 10**(pedlist / 20)
  591. pedlevelsC[0] = 0
  592. allpred = np.zeros((4, len(pedlist)))
  593. for cond in range(1, 5):
  594. for pedlev in range(len(pedlist)):
  595. allpred[cond - 1, pedlev] = discriminate(p, pedlevelsC[pedlev], cond)
  596. allpreddB = 20 * np.log10(allpred)
  597. rms = np.sqrt(np.mean((allpreddB - datatofit) ** 2)) # return RMS error
  598. if np.isnan(rms):
  599. rms = 999
  600. return rms
  601. #%% fit linear summation model
  602. allp = None
  603. allrms = None
  604. nfits = ntotalsimplexfits
  605. ###################################
  606. def runfitting():
  607. initial_params = np.random.normal(0, 0.1, 7 ) + np.log10([0.5, 5.5, 0.3, 1, 0.01, 1, 0.2])
  608. sout = minimize(errorfit, initial_params, method='Nelder-Mead', tol= 1e-10)
  609. if sout.success:
  610. print("Optimization successful", sout.x)
  611. allout = np.concatenate(([errorfit(sout.x)], sout.x, [0]))
  612. filecount = 1
  613. while True:
  614. filepath = f"{fitpath}M1f{filecount}.npz"
  615. if not os.path.exists(filepath):
  616. np.savez(filepath, allout=allout)
  617. break
  618. filecount += 1
  619. return allout
  620. else:
  621. raise ValueError("Optimization failed")
  622. allparams = np.zeros((ntotalsimplexfits, 9))
  623. nfits = ntotalsimplexfits
  624. for n in range(1, ntotalsimplexfits + 1):
  625. if os.path.exists(os.path.join(fitpath, f'M1f{n}.npz')):
  626. nfits -= 1
  627. if nfits > 0:
  628. for i in range(nfits):
  629. result = runfitting()
  630. allparams[i] = result # Store each result in the allparams array
  631. ####################################
  632. finalout = np.zeros((ntotalsimplexfits, 9))
  633. for n in range(1, ntotalsimplexfits + 1):
  634. filepath = f"{fitpath}M1f{n}.npz"
  635. if os.path.exists(filepath):
  636. data = np.load(filepath)
  637. allout = data['allout']
  638. finalout[n - 1, :] = allout
  639. i = np.argmin(finalout[:, 0]) #Finds the row index i of the minimum value in the first column of finalout
  640. p = 10 ** finalout[i, 1:9] #take the corresponding parameters
  641. allrms = finalout[i, 0]
  642. p[1] += 1
  643. p[1] = min(p[1], 16)
  644. p[0] = 1 + p[0] + p[1]
  645. p[0] = min(p[0], 20)
  646. p[2] += 1
  647. allp = p
  648. allparams = finalout
  649. model1params = allp
  650. model1rms = allrms
  651. ####Generate prediction data
  652. pedlist = np.arange(-12, 40, 0.1)
  653. pedlevelsC = 10 ** (pedlist / 20)
  654. pedlevelsC[0] = 0
  655. pedcontrasts = np.arange(-12,31,6)
  656. pedlevelsC2 = 10 ** (pedcontrasts / 20)
  657. pedlevelsC2[0] = 0
  658. allpred = np.zeros((4, len(pedlist)))
  659. allslopepred = np.zeros((4, len(pedlevelsC2)))
  660. for cond in range(1,5):
  661. for pedlev in range(len(pedlist)):
  662. allpred[cond-1, pedlev] = discriminate(allp, pedlevelsC[pedlev], cond)
  663. for cond in range(1,5):
  664. for pedlev in range(len(pedlevelsC2)):
  665. allslopepred[cond-1, pedlev] = getslope(allp, pedlevelsC2[pedlev], cond)
  666. allpreddB = 20 * np.log10(allpred)
  667. model1rms = round(model1rms,2)
  668. print(model1rms)
  669. print(model1params)
  670. #%%################################ fit Minkowski summation model
  671. allp2 = None
  672. allrms2 = None
  673. pedlist = np.arange(-12,31,6)
  674. datatofit = meanthresh
  675. nfits = ntotalsimplexfits
  676. ###################################
  677. def runfitting2():
  678. initial_params = np.random.normal(0, 0.1, 8 ) + np.log10([0.5, 5.5, 0.3, 1, 0.01, 1, 0.2, 4])
  679. sout = minimize(errorfit, initial_params, method='Nelder-Mead', tol= 1e-10)
  680. if sout.success:
  681. print("Optimization successful", sout.x)
  682. allout = np.concatenate(([errorfit(sout.x)], sout.x))
  683. # Save to a unique file
  684. filecount = 1
  685. while True:
  686. filepath = f"{fitpath}M2f{filecount}.npz"
  687. if not os.path.exists(filepath):
  688. np.savez(filepath, allout=allout)
  689. break
  690. filecount += 1
  691. return allout
  692. else:
  693. raise ValueError("Optimization failed")
  694. allparams2 = np.zeros((ntotalsimplexfits, 9))
  695. nfits = ntotalsimplexfits
  696. for n in range(1, ntotalsimplexfits + 1):
  697. if os.path.exists(os.path.join(fitpath, f'M2f{n}.npz')):
  698. nfits -= 1
  699. if nfits > 0:
  700. for i in range(nfits):
  701. result2 = runfitting2()
  702. allparams2[i] = result2 # Store each result in the allparams array
  703. finalout2 = np.zeros((ntotalsimplexfits, 9))
  704. for n in range(1, ntotalsimplexfits + 1):
  705. filepath = f"{fitpath}M2f{n}.npz"
  706. if os.path.exists(filepath):
  707. data = np.load(filepath)
  708. allout = data['allout']
  709. finalout2[n - 1, :] = allout
  710. i = np.argmin(finalout2[:, 0]) #Finds the row index i of the minimum value in the first column of finalout
  711. p = 10 ** finalout2[i, 1:9] #take the corresponding parameters
  712. allrms2 = finalout2[i, 0]
  713. p[1] += 1
  714. p[1] = min(p[1], 16)
  715. p[0] = 1 + p[0] + p[1]
  716. p[0] = min(p[0], 20)
  717. p[2] += 1
  718. allp2 = p
  719. allparams2 = finalout2
  720. #np.save(os.path.join(fitpath, 'simplexfitsM2.npy'), {'allp': allp, 'allrms': allrms, 'allparams': allparams, 'ntotalsimplexfits': ntotalsimplexfits})
  721. model2params = allp2
  722. model2rms = allrms2
  723. pedlist = np.arange(-12, 40, 0.1)
  724. pedlevelsC = 10 ** (pedlist / 20)
  725. pedlevelsC[0] = 0
  726. pedcontrasts = np.arange(-12,31,6)
  727. pedlevelsC2 = 10 ** (pedcontrasts / 20)
  728. pedlevelsC2[0] = 0
  729. #### generate prediction data
  730. allpred2 = np.zeros((4, len(pedlist)))
  731. allslopepred2 = np.zeros((4, len(pedlevelsC2)))
  732. for cond in range(1,5):
  733. for pedlev in range(len(pedlist)):
  734. allpred2[cond-1, pedlev] = discriminate(allp2, pedlevelsC[pedlev], cond)
  735. for cond in range(1,5):
  736. for pedlev in range(len(pedlevelsC2)):
  737. allslopepred2[cond-1, pedlev] = getslope(allp2, pedlevelsC2[pedlev], cond)
  738. allpreddB2 = 20 * np.log10(allpred2)
  739. model2rms=round(model2rms,2)
  740. print(model2rms)
  741. print(model2params)
  742. np.savez('local/dippermodels.npz', model1params=model1params, allpreddB=allpreddB, model1rms=model1rms, model2params=model2params, allpreddB2=allpreddB2, model2rms=model2rms, allslopepred=allslopepred, allslopepred2=allslopepred2)
  743. ```
  744. ```{python fitEEG}
  745. #| include: false
  746. if r.fitmodels:
  747. fitpath = "local/fits2/"
  748. if not os.path.exists(fitpath):
  749. os.makedirs(fitpath)
  750. EEGF1 = np.load("local/allF1.npy") # 30*6*5
  751. meanF1 = np.mean(EEGF1, axis=0) #6*5
  752. EEGF2 = np.load("local/allF2.npy") # 30*6*5
  753. meanF2 = np.mean(EEGF2, axis=0) #6*5
  754. mean = np.concatenate((meanF1, meanF2) , axis=0) #12*5 , mean data of 26 and 23Hz
  755. def get_twoStage_model1(A, B, C, D, p): # [A, B]= 26Hz, [C, D]= 23Hz
  756. p = np.power(10, p)
  757. respAL = (A ** p[0]) / (p[2] + A + p[3] * B + p[3] * D)
  758. respAR = (B ** p[0]) / (p[2] + B + p[3] * A + p[3] * C)
  759. bsA = p[4]*(respAL + respAR)
  760. respA = bsA + p[1]
  761. return respA
  762. def get_twoStage_model2(A, B, C, D, p): # [A, B]= 26Hz, [C, D]= 23Hz
  763. p = np.power(10, p)
  764. respBL = (C ** p[0]) / (p[2] + C + p[3] * D + p[3] * B)
  765. respBR = (D ** p[0]) / (p[2] + D + p[3] * C + p[3] * A)
  766. bsB = p[4]*(respBL + respBR)
  767. respB = bsB + p[1]
  768. return respB
  769. def get_twoStage_modelresp(p, contrastsdB):
  770. contrastsC = 10**(contrastsdB / 20)
  771. responses = np.array([
  772. # 26 Hz
  773. get_twoStage_model1(contrastsC, 0, 0, 0, p), # Pentedactyl_resp
  774. get_twoStage_model1(contrastsC, contrastsC, 0, 0, p), # Dekadactyl_resp
  775. get_twoStage_model1(contrastsC, 32, 0, 0, p), # Dichodactyl_resp
  776. get_twoStage_model1(0, 0, contrastsC, 0, p), # crossPentedactyl_resp
  777. get_twoStage_model1(contrastsC, 0, 0, contrastsC, p), # crossDekadactyl_resp
  778. get_twoStage_model1(contrastsC, 0, 0, 32, p), # crossDichodactyl_resp
  779. ## 23 Hz
  780. get_twoStage_model2(contrastsC, 0, 0, 0, p), # Pentedactyl_resp
  781. get_twoStage_model2(contrastsC, contrastsC, 0, 0, p), # Dekadactyl_resp
  782. get_twoStage_model2(contrastsC, 32, 0, 0, p), # Dichodactyl_resp
  783. get_twoStage_model2(0, 0, contrastsC, 0, p), # crossPentedactyl_resp
  784. get_twoStage_model2(contrastsC, 0, 0, contrastsC, p), # crossDekadactyl_resp
  785. get_twoStage_model2(contrastsC, 0, 0, 32, p), # crossDichodactyl_resp
  786. ])
  787. return responses #12*5
  788. def geterror(p):
  789. contrastsdB = np.arange(12,37,6)
  790. modelpredictions = get_twoStage_modelresp(p, contrastsdB) # 6*5
  791. rms = np.sqrt(np.mean((modelpredictions - mean)**2))
  792. return rms
  793. allp = None
  794. allrms = None
  795. ntotalsimplexfits = 100
  796. nfits = ntotalsimplexfits
  797. def runfitting():
  798. initial_params = [1.5, 0.05, 20, 0.5, 0.2]+ 0.01 * np.random.randn(5)
  799. sout = minimize(geterror, np.log10(initial_params), method='Nelder-Mead', tol= 1e-10)
  800. if sout.success:
  801. print("Optimization successful", sout.x)
  802. allout = np.concatenate(([geterror(sout.x)], sout.x))
  803. filecount = 1
  804. while True:
  805. filepath = f"{fitpath}M1f{filecount}.npz"
  806. if not os.path.exists(filepath):
  807. np.savez(filepath, allout=allout)
  808. break
  809. filecount += 1
  810. return allout
  811. else:
  812. print("Optimization did not converge.")
  813. #raise ValueError("Optimization failed")
  814. allparams = np.zeros((ntotalsimplexfits, 6))
  815. nfits = ntotalsimplexfits
  816. for n in range(1, ntotalsimplexfits + 1):
  817. if os.path.exists(os.path.join(fitpath, f'M1f{n}.npz')):
  818. nfits -= 1
  819. if nfits > 0:
  820. for i in range(nfits):
  821. result = runfitting()
  822. allparams[i] = result
  823. finalout = np.zeros((ntotalsimplexfits, 6))
  824. for n in range(1, ntotalsimplexfits + 1):
  825. filepath = f"{fitpath}M1f{n}.npz"
  826. if os.path.exists(filepath):
  827. data = np.load(filepath)
  828. allout = data['allout']
  829. finalout[n - 1, :] = allout
  830. i = np.argmin(finalout[:, 0])
  831. p = finalout[i, 1:6]
  832. contrastsfine = np.arange(12,37,0.1)
  833. allresp = get_twoStage_modelresp(p, contrastsfine)
  834. allrms = finalout[i, 0]
  835. allp = 10**p
  836. allparams = finalout
  837. modelparams = allp
  838. print("model 1 optimized p:", modelparams)
  839. modelRMS = allrms
  840. modelRMS = round(modelRMS,2)
  841. print("model 1 rms:", modelRMS)
  842. np.savez('local/EEGmodels.npz', allresp=allresp, modelparams=modelparams, modelRMS=modelRMS)
  843. ```
  844. ```{python plotmodelling}
  845. #| include: false
  846. if r.plotfigures:
  847. pedcontrasts = np.arange(-12,31,6)
  848. pedlist = np.arange(-12, 40, 0.1)
  849. data = np.load('local/allthresh.npz')
  850. allthresh = data['allthresh']
  851. meanthresh = np.mean(allthresh, axis=0) #4*8
  852. SEthresh = np.std(allthresh, axis=0, ddof=1) / np.sqrt(8)
  853. data = np.load('local/dippermodels.npz')
  854. model1params = data['model1params']
  855. allpreddB = data['allpreddB']
  856. model1rms = data['model1rms']
  857. model2params = data['model2params']
  858. allpreddB2 = data['allpreddB2']
  859. model2rms = data['model2rms']
  860. allslopepred = data['allslopepred']
  861. allslopepred2 = data['allslopepred2']
  862. fig=plt.figure(figsize=(20,28))
  863. ax1=fig.add_subplot(3,2,1)
  864. ax2=fig.add_subplot(3,2,2)
  865. ax3=fig.add_subplot(3,2,3)
  866. ax4=fig.add_subplot(3,2,4)
  867. ax5=fig.add_subplot(3,2,5)
  868. ax6=fig.add_subplot(3,2,6)
  869. pos = ax1.get_position()
  870. new_pos = [pos.x0, pos.y0+0.05, pos.width, pos.height] ###adjust position
  871. ax1.set_position(new_pos)
  872. pos = ax2.get_position()
  873. new_pos = [pos.x0 + 0.08, pos.y0+0.05, pos.width, pos.height] ###adjust position
  874. ax2.set_position(new_pos)
  875. pos = ax3.get_position()
  876. new_pos = [pos.x0, pos.y0, pos.width, pos.height]
  877. ax3.set_position(new_pos)
  878. pos = ax4.get_position()
  879. new_pos = [pos.x0 + 0.08, pos.y0, pos.width, pos.height]
  880. ax4.set_position(new_pos)
  881. pos = ax5.get_position()
  882. new_pos = [pos.x0, pos.y0-0.05, pos.width, pos.height]
  883. ax5.set_position(new_pos)
  884. pos = ax6.get_position()
  885. new_pos = [pos.x0 + 0.08, pos.y0-0.05, pos.width, pos.height]
  886. ax6.set_position(new_pos)
  887. axes = [ax1, ax2, ax3, ax4, ax5, ax6]
  888. line_weight = 2
  889. for ax in axes:
  890. for spine in ax.spines.values(): spine.set_linewidth(line_weight)
  891. ax1.text(0.05, 0.950, '(a)', fontsize=40, transform=fig.transFigure)
  892. ax2.text(0.55, 0.950, '(b)', fontsize=40, transform=fig.transFigure)
  893. ax3.text(0.05, 0.625, '(c)', fontsize=40, transform=fig.transFigure)
  894. ax4.text(0.55, 0.625, '(d)', fontsize=40, transform=fig.transFigure)
  895. ax5.text(0.05, 0.300, '(e)', fontsize=40, transform=fig.transFigure)
  896. ax6.text(0.55, 0.300, '(f)', fontsize=40, transform=fig.transFigure)
  897. #%% Plot fitting result of psychophysical data
  898. condition = [ 'Pentadactyl', 'Dekadactyl', 'Half-Dekadactyl', 'Dichodactyl']
  899. collist = [ 'blue', 'red', 'orange', 'darkgreen']
  900. markers=['o','s','D','^']
  901. for cond, marker in zip(range(4), markers):
  902. ax1.scatter(pedcontrasts, meanthresh[cond, :], color=collist[cond], marker=marker, label=condition[cond], s=70)
  903. ax1.plot(pedlist,allpreddB[cond,:], color=collist[cond], lw=2)
  904. ax1.set_xlabel('Baseline intensity level (%)',fontsize=30 )
  905. ax1.set_ylabel('Threshold (%)', fontsize=30)
  906. ax1.set_title('Linear summation model', fontsize=30)
  907. ax1.set_xlim(-13,31)
  908. ax1.set_ylim(-13,31)
  909. ax1.set_yticks([-12,-6,0,6,12,18,24,30],[0.25, 0.5, 1, 2, 4, 8, 16, 32],fontsize = 25)
  910. ax1.set_xticks([-12,-6,0,6,12,18,24,30],[0, 0.5, 1, 2, 4, 8, 16, 32] ,fontsize = 25)
  911. ax1.text(10,-11,"RMSE = " + str(np.round(model1rms,2)) + "dB", fontsize=30)
  912. ax1.plot([-12, 30], [-12, 30], color='black', linestyle="--")
  913. legend_elements2 = []
  914. for cond, marker in zip(range(4) , markers):
  915. ax2.scatter(pedcontrasts,meanthresh[cond, :], color=collist[cond], marker=marker, label=condition[cond], s=70)
  916. ax2.plot(pedlist,allpreddB2[cond], color=collist[cond], lw=2 )
  917. legend_elements2.append(Line2D([0], [0], color=collist[cond], linestyle='-', label=condition[cond], markersize=12, marker=marker))
  918. ax2.set_xlabel('Baseline intensity level (%)',fontsize=30 )
  919. ax2.set_ylabel('Threshold (%)', fontsize=30)
  920. ax2.set_title('Minkowski summation model', fontsize=30)
  921. ax2.set_xlim(-13,31)
  922. ax2.set_ylim(-13,31)
  923. ax2.set_yticks([-12,-6,0,6,12,18,24,30],[0.25, 0.5, 1, 2, 4, 8, 16, 32],fontsize = 25)
  924. ax2.set_xticks([-12,-6,0,6,12,18,24,30],[0, 0.5, 1, 2, 4, 8, 16, 32] ,fontsize = 25 )
  925. leg2 = ax2.legend(
  926. handles=legend_elements2,
  927. loc='upper left',
  928. bbox_to_anchor=(0.01, 1),
  929. fontsize=20,
  930. frameon=False
  931. )
  932. ax2.text(10,-11,"RMSE = " + str(np.round(model2rms,2)) + "dB", fontsize=30)
  933. ax2.plot([-12, 30], [-12, 30], color='black', linestyle="--")
  934. ################ plot slope
  935. data1 = np.load('local/allslope.npz')
  936. allslope = data1['allslope']
  937. allslope = 20 * np.log10(allslope)
  938. allslopepred = 20 * np.log10(allslopepred)
  939. allslopepred2 = 20 * np.log10(allslopepred2)
  940. meanslope = np.mean(allslope, axis=0) #4*8
  941. SEslope = np.std(allslope, axis=0, ddof=1) / np.sqrt(8)
  942. for cond, marker in zip(range(4), markers):
  943. ax3.scatter(pedcontrasts, meanslope[cond, :], color=collist[cond], marker=marker, label=condition[cond], s=70)
  944. ax3.plot(pedcontrasts,allslopepred[cond,:], color=collist[cond], lw=2)
  945. ax3.set_xlabel('Baseline intensity level (%)',fontsize=30 )
  946. ax3.set_ylabel('Weibull ' + r"$\mathrm{\beta}$", fontsize=30)
  947. ax3.set_xlim(-13,31)
  948. ax3.set_ylim(-7,19)
  949. ax3.set_xticks([-12,-6,0,6,12,18,24,30],[0, 0.5, 1, 2, 4, 8, 16, 32],fontsize = 25)
  950. ax3.set_yticks([-6,0,6,12,18],[0.5, 1, 2, 4, 8] ,fontsize = 25)
  951. #ax3.text(10,-11,"RMSE = " + str(np.round(model1rms,2)) + "dB", fontsize=30)
  952. ax3.plot([-12, 30], [20*np.log10(1.3), 20*np.log10(1.3)], color='black', linestyle="--")
  953. for cond, marker in zip(range(4), markers):
  954. ax4.scatter(pedcontrasts, meanslope[cond, :], color=collist[cond], marker=marker, label=condition[cond], s=70)
  955. ax4.plot(pedcontrasts,allslopepred2[cond,:], color=collist[cond], lw=2)
  956. ax4.set_xlabel('Baseline intensity level (%)',fontsize=30 )
  957. ax4.set_ylabel('Weibull ' + r"$\mathrm{\beta}$", fontsize=30)
  958. ax4.set_xlim(-13,31)
  959. ax4.set_ylim(-7,19)
  960. ax4.set_xticks([-12,-6,0,6,12,18,24,30],[0, 0.5, 1, 2, 4, 8, 16, 32],fontsize = 25)
  961. ax4.set_yticks([-6,0,6,12,18],[0.5, 1, 2, 4, 8] ,fontsize = 25)
  962. #ax4.text(10,-11,"RMSE = " + str(np.round(model1rms,2)) + "dB", fontsize=30)
  963. ax4.plot([-12, 30], [20*np.log10(1.3), 20*np.log10(1.3)], color='black', linestyle="--")
  964. #%% Plot fitting result of EEG data
  965. EEGF1 = np.load("local/allF1.npy") # 30*6*5
  966. meanF1 = np.mean(EEGF1, axis=0) #6*5
  967. EEGF2 = np.load("local/allF2.npy") # 30*6*5
  968. meanF2 = np.mean(EEGF2, axis=0) #6*5
  969. data = np.load('local/EEGmodels.npz')
  970. allresp = data['allresp']
  971. modelparams = data['modelparams']
  972. modelRMS = data['modelRMS']
  973. contrastsdB = np.arange(12, 37, 6)
  974. contrastsC = 10**(contrastsdB / 20)
  975. contrastsfine = np.arange(12, 37, 0.1)
  976. conditions = ['Pentadactyl', 'Dekadactyl', 'Dichodactyl','Cross-Pentadactyl', 'Cross-Dekadactyl', 'Cross-Dichodactyl', 'Pentadactyl', 'Dekadactyl', 'Dichodactyl','Cross-Pentadactyl', 'Cross-Dekadactyl', 'Cross-Dichodactyl']
  977. collist = ['b', 'r', 'g','y','m', 'k', 'b', 'r', 'g','y','m', 'k']
  978. markers = ['o', 's', '^', '<', 'p', 'd' , 'o', 's', '^', '<', 'p', 'd']
  979. allresp = np.load('local/EEG_allresp.npy')
  980. # Plot 26 Hz
  981. legend_elements = []
  982. for i in range(6):
  983. idx = i % len(markers)
  984. ax5.plot(contrastsfine, allresp[i, :], color=collist[idx], lw=2)
  985. ax5.scatter(contrastsdB, meanF1[i, :], marker=markers[idx], color=collist[idx], s=70)
  986. legend_elements.append(Line2D([0], [0], marker=markers[idx], color=collist[idx], linestyle='-', label=conditions[i], markersize=12))
  987. ax5.set_title("26 Hz", fontsize=30)
  988. ax5.set_xlabel('Intensity level (%)', fontsize=30)
  989. ax5.set_ylabel('Amplitude (μV)', fontsize=30)
  990. ax5.set_xticks(contrastsdB)
  991. ax5.set_xticklabels(['4', '8', '16', '32', '64'], fontsize=25)
  992. ax5.set_yticks([0,0.1,0.2,0.3,0.4,0.5], [0,0.1,0.2,0.3,0.4,0.5], fontsize = 25)
  993. ax5.text(12,0.48,"RMSE = " + str(np.round(modelRMS,3)) + "μV", fontsize=30)
  994. # Plot 23 Hz
  995. legend_elements = []
  996. for i in range(6):
  997. idx = i % len(markers)
  998. ax6.plot(contrastsfine, allresp[i+6, :], color=collist[idx], lw=2)
  999. ax6.scatter(contrastsdB, meanF2[i, :], marker=markers[idx],color=collist[idx], s=70)
  1000. legend_elements.append(Line2D([0], [0], marker=markers[idx], color=collist[idx], linestyle='-', label=conditions[i], markersize=12))
  1001. ax6.set_title("23 Hz", fontsize=30)
  1002. ax6.set_xlabel('Intensity level (%)', fontsize=30)
  1003. ax6.set_ylabel('Amplitude (μV)', fontsize=30)
  1004. ax6.set_xticks(contrastsdB)
  1005. ax6.set_xticklabels(['4', '8', '16', '32', '64'], fontsize=25)
  1006. ax6.set_yticks([0,0.1,0.2,0.3,0.4,0.5], [0,0.1,0.2,0.3,0.4,0.5], fontsize = 25)
  1007. # Add legend to ax4
  1008. leg6 = ax6.legend(
  1009. handles=legend_elements,
  1010. loc='upper left',
  1011. bbox_to_anchor=(0.005, 1),
  1012. ncol=2,
  1013. fontsize=20,
  1014. frameon=False
  1015. )
  1016. plt.savefig('Figures/Figure3.pdf')
  1017. ```
  1018. ```{python dostats}
  1019. #| include: false
  1020. row = pd.read_csv('local/8subjects.csv')
  1021. ex1data = row[['Subject','Condition', 'PedestalContrast','TargetContrast','IsCorrect']].values
  1022. df = pd.DataFrame(ex1data, columns=['Subject','Condition', 'PedestalContrast','TargetContrast', 'IsCorrect'])
  1023. df['Condition'] = (df['Condition'] + 1) // 2
  1024. r.ntotaltrials = ex1data.shape[0]
  1025. r.trialspersubj = int(np.round(ex1data.shape[0]/8))
  1026. data = np.load('local/allthresh.npz')
  1027. allthresh = data['allthresh'] # 8*4*8
  1028. meanthresh = np.mean(allthresh, axis=0) #4*8
  1029. r.monbinsupradB = np.round(np.mean(meanthresh[0,1:7]-meanthresh[1,1:7]),2)
  1030. r.monbinsupra = np.round(10**(r.monbinsupradB/20),2)
  1031. r.dichthdelevdB = np.round(meanthresh[3,7] - meanthresh[3,0], 2)
  1032. r.dichthdelev = np.round(10**(r.dichthdelevdB/20), 2)
  1033. merged_array = allthresh.reshape(256, 1)
  1034. # Create index arrays
  1035. subs = np.repeat(np.arange(1, 9), 32).reshape(-1, 1) # 8 participants
  1036. conditions = np.tile(np.repeat(np.arange(1, 5), 8), 8).reshape(-1, 1) # 4 conditions
  1037. levels = np.tile(np.arange(1, 9), 32).reshape(-1, 1) # 8 levels, repeated 32 times
  1038. # Combine all data into one array
  1039. alldata_array = np.hstack((subs, conditions, levels, merged_array))
  1040. df = pd.DataFrame(alldata_array, columns=['subs', 'condition', 'level', 'threshold'])
  1041. df['subs'] = df['subs'].astype(int)
  1042. df['condition'] = df['condition'].astype(int)
  1043. df['level'] = df['level'].astype(int)
  1044. anova_results = pg.rm_anova(dv='threshold', within=['condition', 'level'], subject='subs', data=df, detailed=True)
  1045. pd.set_option('display.max_columns', None) # Show all columns
  1046. pd.set_option('display.width', None) # Adjust the display width to show the full DataFrame
  1047. print(anova_results)
  1048. dipperstats = np.array(anova_results)
  1049. dipperstats2dp = np.array(anova_results.round(2))
  1050. dipperstats3dp = np.array(anova_results.round(3))
  1051. posthoc_condition = pg.pairwise_tests(dv='threshold', within='condition', subject='subs', data=df, padjust='bonf')
  1052. print("Pairwise comparisons for condition:")
  1053. print(posthoc_condition)
  1054. t_value = posthoc_condition.iloc[0, 5]
  1055. r.t_value = t_value.round(2)
  1056. r.dipperF1 = dipperstats2dp[0,5]
  1057. r.dipperF2 = dipperstats2dp[1,5]
  1058. r.dipperF3 = dipperstats2dp[2,5]
  1059. r.dipperng1 = dipperstats3dp[0,8]
  1060. r.dipperng2 = dipperstats3dp[1,8]
  1061. r.dipperng3 = dipperstats3dp[2,8]
  1062. posthoc = pairwise_ttests(dv='threshold', within=['level','condition'], padjust='bonf', data=df, subject='subs')
  1063. print(posthoc)
  1064. r.dipperposthocs = posthoc
  1065. EEGF1 = np.load("local/allF1.npy") # 30*6*5
  1066. EEGF2 = np.load("local/allF2.npy") # 30*6*5
  1067. meanF1 = np.mean(EEGF1, axis=0) #6*5
  1068. monbinratios = meanF1[1,:]/meanF1[0,:]
  1069. monbinratiosdB = 20*np.log10(monbinratios)
  1070. meanratiodB = np.round(np.mean(monbinratiosdB), 2)
  1071. r.meanratio = np.round(10**(meanratiodB/20), 2)
  1072. merged_array1 = EEGF1.reshape(np.prod(EEGF1.shape), 1)
  1073. subs = np.repeat(np.arange(1, 31), 30).reshape(-1, 1) # 30 participants
  1074. conditions = np.tile(np.repeat(np.arange(1, 7), 5), 30).reshape(-1, 1)
  1075. levels = np.tile(np.arange(1, 6), 180).reshape(-1, 1)
  1076. # Combine all data into one array
  1077. alldata_array1 = np.hstack((subs, conditions, levels, merged_array1))
  1078. df = pd.DataFrame(alldata_array1, columns=['subs', 'condition', 'level', 'amplitudes'])
  1079. df['subs'] = df['subs'].astype(int)
  1080. df['condition'] = df['condition'].astype(int)
  1081. df['level'] = df['level'].astype(int)
  1082. ###ANOVA F1
  1083. aov_results1 = pg.rm_anova(dv='amplitudes', within=['condition', 'level'], subject='subs', data=df, detailed=True)
  1084. pd.set_option('display.max_columns', None) # Show all columns
  1085. pd.set_option('display.width', None) # Adjust the display width to show the full DataFrame
  1086. print(aov_results1)
  1087. #########post-hoc test
  1088. posthocF1= pairwise_ttests(dv='amplitudes', within=['level','condition'], padjust='bonf', data=df, subject='subs')
  1089. #posthocF1.to_csv('C:/documents/meanEEGresp/posthocF1.csv', index=False)
  1090. print(posthocF1)
  1091. EEGF1stats = np.array(aov_results1)
  1092. EEGF1stats2dp = np.array(aov_results1.round(2))
  1093. EEGF1stats3dp = np.array(aov_results1.round(3))
  1094. r.EEGF1_F1 = EEGF1stats2dp[0,5]
  1095. r.EEGF1_F2 = EEGF1stats2dp[1,5]
  1096. r.EEGF1_F3 = EEGF1stats2dp[2,5]
  1097. r.EEGF1_ng1 = EEGF1stats3dp[0,8]
  1098. r.EEGF1_ng2 = EEGF1stats3dp[1,8]
  1099. r.EEGF1_ng3 = EEGF1stats3dp[2,8]
  1100. merged_array2 = EEGF2.reshape(np.prod(EEGF2.shape), 1)
  1101. subs = np.repeat(np.arange(1, 31), 30).reshape(-1, 1) # 30 participants
  1102. conditions = np.tile(np.repeat(np.arange(1, 7), 5), 30).reshape(-1, 1)
  1103. levels = np.tile(np.arange(1, 6), 180).reshape(-1, 1)
  1104. # Combine all data into one array
  1105. alldata_array2 = np.hstack((subs, conditions, levels, merged_array2))
  1106. df = pd.DataFrame(alldata_array2, columns=['subs', 'condition', 'level', 'amplitudes'])
  1107. df['subs'] = df['subs'].astype(int)
  1108. df['condition'] = df['condition'].astype(int)
  1109. df['level'] = df['level'].astype(int)
  1110. ###ANOVA F2
  1111. aov_results2 = pg.rm_anova(dv='amplitudes', within=['condition', 'level'], subject='subs', data=df, detailed=True)
  1112. pd.set_option('display.max_columns', None) # Show all columns
  1113. pd.set_option('display.width', None) # Adjust the display width to show the full DataFrame
  1114. print(aov_results2)
  1115. #########post-hoc test
  1116. posthocF2= pairwise_ttests(dv='amplitudes', within=['level','condition'], padjust='bonf', data=df, subject='subs')
  1117. #posthocF2.to_csv('C:/documents/meanEEGresp/posthocF2.csv', index=False)
  1118. print(posthocF2)
  1119. EEGF2stats = np.array(aov_results2)
  1120. EEGF2stats2dp = np.array(aov_results2.round(2))
  1121. EEGF2stats3dp = np.array(aov_results2.round(3))
  1122. r.EEGF2_F1 = EEGF2stats2dp[0,5]
  1123. r.EEGF2_F2 = EEGF2stats2dp[1,5]
  1124. r.EEGF2_F3 = EEGF2stats2dp[2,5]
  1125. r.EEGF2_ng1 = EEGF2stats3dp[0,8]
  1126. r.EEGF2_ng2 = EEGF2stats3dp[1,8]
  1127. r.EEGF2_ng3 = EEGF2stats3dp[2,8]
  1128. ### one-way ANOVA on Crossdichodactyl condition
  1129. Crossdichodactyl_dataF2 = EEGF2[:,5,:]
  1130. merged_array3 = Crossdichodactyl_dataF2.reshape(150, 1)
  1131. subs = np.repeat(np.arange(1, 31), 5).reshape(-1, 1)
  1132. levels = np.tile(np.arange(1, 6), 30).reshape(-1, 1)
  1133. alldata_array3 = np.hstack((subs, levels, merged_array3))
  1134. df = pd.DataFrame(alldata_array3, columns=['subs', 'level', 'amplitudes'])
  1135. df['subs'] = df['subs'].astype(int)
  1136. df['level'] = df['level'].astype(int)
  1137. aov_results3 = pg.rm_anova(dv='amplitudes', within=['level'], subject='subs', data=df, detailed=True, effsize='np2')
  1138. pd.set_option('display.max_columns', None) # Show all columns
  1139. pd.set_option('display.width', None) # Adjust the display width to show the full DataFrame
  1140. print('Crossdichodactyl:', aov_results3)
  1141. EEGF2Bstats = np.array(aov_results3)
  1142. EEGF2Bstats2dp = np.array(aov_results3.round(2))
  1143. EEGF2Bstats3dp = np.array(aov_results3.round(3))
  1144. r.EEGF2B_F1 = EEGF2Bstats2dp[0,4]
  1145. r.EEGF2B_ng1 = EEGF2Bstats3dp[0,7]
  1146. data = np.load('local/EEGmodels.npz')
  1147. temp = data['modelparams']
  1148. r.EEGparams_m = np.round(temp[0],3)
  1149. r.EEGparams_S = np.round(temp[2],3)
  1150. r.EEGparams_w = np.round(temp[3],3)
  1151. r.EEGparams_k = np.round(temp[4],3)
  1152. r.EEGparams_R = np.round(temp[1],3)
  1153. temp = data['modelRMS']
  1154. r.EEGRMS = np.round(temp,3)
  1155. data = np.load('local/dippermodels.npz')
  1156. temp = data['model1params']
  1157. r.model1params_p = np.round(temp[0],3)
  1158. r.model1params_q = np.round(temp[1],3)
  1159. r.model1params_m = np.round(temp[2],3)
  1160. r.model1params_S = np.round(temp[3],3)
  1161. r.model1params_Z = np.round(temp[4],3)
  1162. r.model1params_w = np.round(temp[5],3)
  1163. r.model1params_k = np.round(temp[6],3)
  1164. r.model1params_y = np.round(temp[7],3)
  1165. temp = data['model1rms']
  1166. r.model1rms = np.round(temp,2)
  1167. temp = data['model2params']
  1168. r.model2params_p = np.round(temp[0],3)
  1169. r.model2params_q = np.round(temp[1],3)
  1170. r.model2params_m = np.round(temp[2],3)
  1171. r.model2params_S = np.round(temp[3],3)
  1172. r.model2params_Z = np.round(temp[4],3)
  1173. r.model2params_w = np.round(temp[5],3)
  1174. r.model2params_k = np.round(temp[6],3)
  1175. r.model2params_y = np.round(temp[7],3)
  1176. temp = data['model2rms']
  1177. r.model2rms = np.round(temp,2)
  1178. ```
  1179. # Abstract
  1180. While the brain’s integration of auditory and visual inputs has been extensively investigated, the mechanisms underlying the combination of somatosensory signals remain less explored. Here, we investigated vibrotactile summation across fingers using psychophysical and electrophysiological methods. In Experiment 1, discrimination thresholds for 26 Hz vibrations were measured using a two-interval forced-choice (2IFC) task. Thresholds exhibited a 'dipper' pattern when plotted against baseline intensity level. Detection thresholds decreased by approximately 1dB when all ten fingers were stimulated ('dekadactyl' condition) simultaneously compared to when each alternate finger was stimulated ('pentadactyl' condition), suggesting a process of probability summation. When a target stimulus was presented to five digits and a baseline stimulus to the remaining digits (‘dichodactyl’ condition) simultaneously, thresholds increased, consistent with suppression between digit representations. In Experiment 2, steady-state somatosensory evoked potential (SSSEP) signals showed approximately a 1.4-fold amplitude increase in dekadactyl compared to pentadactyl conditions, indicating a summation effect. Conversely, SSSEP amplitudes decreased when targets and masks were presented simultaneously at different frequencies, providing additional evidence of suppression. These results are consistent with a model featuring inhibition between digits and reveal that the weight of suppression is intermediate between that observed in binocular vision and binaural hearing.
  1181. ## Keywords:
  1182. *vibrotactile summation*, *suppression*, *somatosensory*, *computational modelling*
  1183. # Significance
  1184. Suppression is known to occur both between the eyes and the ears, but how does the brain combine tactile inputs from multiple fingers? Using psychophysical threshold measurements and EEG recordings, we found that doubling the inputs led to a weak improvement in threshold and an increase in EEG amplitude, though less than double. This suggests that the combination of vibration signals involves both probability summation and suppressive processes. Computational modelling further suggests a unique mechanism of tactile integration, distinct from those observed in vision and audition.
  1185. # Introduction
  1186. Signal combination is crucial for human perception and our interaction with the environment. Our sensory systems comprise a variety of receptors and neural pathways that detect and process a wide range of stimuli, including sight, hearing, touch, and smell. To construct a coherent and comprehensive representation of our surroundings and our own body, the brain must filter out overlapping or redundant information and avoid excessively strong signals to prevent sensory overload. Therefore, in addition to additive processes, suppressive processes are also involved in signal combination. This suppression during signal combination has been investigated both within [e.g., vision, hearing, touch, @Baker2017; @Baker2020; @Biermann1998; @Gandevia1983] and between [e.g., visuo-tactile, audio-visual, @Ide2013; @Hidaka2015] modalities.
  1187. In visual and auditory perception, psychophysical studies have shown that detection performance for binocular or binaural presentation is typically between a factor of $\sqrt{2}$ and 2 better than for monocular or monaural presentation [@Baker2018; @Baker2020; @Campbell1965]. This summation at threshold implies the existence of physiological mechanisms that combine signals across eyes or ears. Above threshold, detection performance improves when a weak fixed intensity (‘baseline’) stimulus is added, a phenomenon known as facilitation. At higher baseline intensities performance worsens, producing a masking effect. When plotted against baseline intensity, the discrimination thresholds exhibit a "dipper" shape (see [@fig-methods]b). The facilitation and masking effects arise from the brain transducing physical signals into neural responses in a non-linear manner, and are observed for many sensory stimuli [reviewed by @Solomon2009].
  1188. To complement psychophysical work, we can directly measure the response to stimuli of different intensities by recording brain activity. One convenient method is the steady state technique, in which periodic stimulus oscillations are reflected in electromagnetic neural responses at the same frequency, which can be recorded using EEG or MEG. For instance, some EEG studies have investigated the signal combination process in visual and auditory modalities by recording steady-state signals [@Baker2017; @Baker2020]. These studies found that responses increased when input channels (eyes or ears) were doubled, but by less than a factor of two. Additionally, when a mask was added to one channel instead of a signal input (i.e. oscillating at a different frequency), the signal response decreased due to suppression [@Busse2009].
  1189. The process by which the brain combines multiple signals has attracted great interest in recent years, leading to the development of several computational models. The two stage gain control model of signal combination [@Meese2006] successfully accounts for both binocular and binaural perception, positing that the signal from one channel (left or right) is inhibited by the other channel before being summed. The equations describing this model are defined as:
  1190. $$Stage1_L = \frac{I^m_L}{S + I_L + \omega I_R}$$ {#eq-stage1L}
  1191. $$Stage1_R = \frac{I^m_R}{S + I_R + \omega I_L}$$ {#eq-stage1R}
  1192. $$sum = Stage1_L + Stage1_R$$ {#eq-binsum}
  1193. $$resp = \frac{sum^p}{Z + sum^q}$$ {#eq-stage2}
  1194. In this model, sensory inputs from adjacent channels are processed in two stages. At the first stage, each input undergoes gain control, where the response from one channel is suppressed by the input from the other channel. This is described by Equations 1 and 2. For example, the left channel response ($Stage1_L$) depends not only on its own stimulus intensity ($I_L$) but also on the intensity of the right channel ($I_R$), modulated by the suppression weight $\omega$. The exponent $m$ and saturation constant $S$ are free parameters and determine how the input is transformed nonlinearly. The outputs of the left and right channels are then summed to form $sum$ (Equation 3), which represents the combined signal strength after inter-channel suppression. This combined signal is then passed through a nonlinear transducer function (Equation 4), which produces the final response ($resp$) based on the free parameters $p$, $q$ and $Z$. These parameters govern the gain and saturation characteristics of the system. In vision, the weight of suppression is approximately $\omega = 1$, whereas in auditory perception, suppression between the ears is dramatically weaker, with $\omega$ close to 0. Therefore, while suppression can be key to signal combination, its strength varies across different sensory modalities.
  1195. In tactile perception, some neuroimaging findings suggest there exist summation and suppression of vibration signals also. For instance, studies have recorded brain responses to vibration stimuli delivered to two fingers separately and simultaneously. Results indicated that at low levels of stimulation near the perceptual threshold, there was a facilitation or summation effect between two fingers. However, at higher levels of stimulation, a suppression effect was observed [@Gandevia1983]. Furthermore, the brain's response to simultaneous vibration of two fingers is less than (approximately 50\% of) the sum of the responses to individual finger stimuli [@Biermann1998; @Gandevia1983]. In addition, the extent of suppression is dependent on the spatial distance between the fingers [@Gandevia1983; @Hoechstetter2001]. In some psychophysical studies, researchers measured vibration detection thresholds using various contactor sizes [@Gescheider2005; @Gu2013]. Results showed that the threshold decreased as the contactor size increased, a phenomenon known as area summation. Specifically, when the contactor size was doubled, the threshold decreased by approximately 3 dB (a factor of 1.4 @Gescheider2005). These findings focused on the summation rather than suppression between inputs, because suppressive processes are minimal at low intensities near threshold. In summary, although substantial evidence suggests the existence of suppression in vibration combination, there has been relatively little work measuring psychophysical threshold, and a corresponding computational model has yet to be developed.
  1196. Here, we combine psychophysical measurements (Experiment 1) with EEG recordings (Experiment 2) to assess human vibration perception, and fit a computational model to interpret the processes of vibration signal combination and suppression. In Experiment 1, we measure how thresholds vary across different conditions and baseline levels using a two-interval forced-choice (2IFC) task. Our aim is to investigate two processes: the summation effect (by doubling the number of stimulated digits), and the masking effect (by introducing an additional mask stimulus) (see *Psychophysical procedures*). Thresholds decreased by approximately 1 dB when the number of stimulated digits was doubled, a finding consistent with probability summation rather than physiological summation. In psychophysical tasks, probability summation refers to improved detection performance resulting from multiple independent detection opportunities at the decision level [@Tyler2000], whereas physiological summation reflects the neural integration of sensory signals across populations of neurons. Our computational modelling indicated that this effect could potentially be explained without requiring suppression between digits. This is because psychophysical thresholds are determined primarily by the most sensitive subset of neurons relevant to the task, and suppression does not necessarily impact this 2IFC task. In comparison, EEG recordings reflect the overall activity of the entire neuronal population, where suppression between neurons is an important characteristic of sensory perception. Therefore, in our EEG Experiment, we tested these neural mechanisms directly by measuring steady-state somatosensory evoked potentials (SSSEPs). We examined summation by stimulating adjacent digits at the same frequency, and suppression by introducing a second frequency simultaneously (see *EEG procedures*). These different frequencies allowed us to isolate suppressive interactions between digits without the complicating factor of summation. The EEG modelling results indicated an inhibitory weight of suppression of $\omega \sim 0.5$, intermediate between the weights observed for vision ($\omega \sim 1$) and audition ($\omega \sim 0$).
  1197. # Results
  1198. ## Summation and suppression effects on vibration thresholds
  1199. In Experiment 1, participants were required to report which interval included the target stimulus. Individual thresholds were measured using a 3-down-1-up staircase procedure. These data were used to fit psychometric functions, estimating thresholds at 75% correct and slope parameters via cumulative Gaussians. The average thresholds (geometric means) across 8 participants for each condition and baseline intensity level (equivalent to the pedestal contrast in studies of visual contrast discrimination) are shown in [@fig-dippers]a. A 4 (condition) $\times$ 8 (baseline intensity level) repeated measures ANOVA was used to assess statistical differences between factors. We found significant main effects of condition (F = `r dipperF1`, $p$ < 0.001, $\eta_G^2$ = `r dipperng1`, Greenhouse-Geisser corrected) and baseline intensity level (F = `r dipperF2`, $p$ < 0.001, $\eta_G^2$ = `r dipperng2`, Greenhouse-Geisser corrected), as well as a significant interaction between the two factors (F = `r dipperF3`, $p$ < 0.001, $\eta_G^2$ = `r dipperng3`, Greenhouse-Geisser corrected). When plotting the thresholds against baseline intensity level, except for the dichodactyl condition (green diamonds), the other three conditions exhibited a dipper shape. Specifically, at low baseline intensity level (0.5 to 2\%), thresholds decreased with increasing baseline intensity level, indicating a facilitation effect. When the baseline intensity level exceeded 2\%, thresholds increased due to a masking effect, resulting in approximately parallel 'handles' across the pentadactyl (blue circles), dekadactyl (red squares) and half-dekadactyl (orange triangles) conditions. At detection threshold (baseline intensity level = 0\%), the threshold for the dekadactyl condition was around 1 dB lower than for the pentadactyl condition, with no statistically significant difference. This weak summation between digits is lower than that typically attributed to physiological summation (~3-6 dB), suggesting a process of probability summation instead [@Quick1974; @Tyler2000].
  1200. ::: {.content-visible unless-format="docx"}
  1201. ![Thresholds (a) and psychometric slope values (b) from Experiment 1. Data are averaged across N=8 participants, with shaded regions indicating ±1SE across participants.](Figures/Figure1.pdf){#fig-dippers}
  1202. :::
  1203. Above detection threshold, thresholds in the dekadactyl condition were significantly lower (t$_7$ = `r t_value`, $p$ < 0.01, Bonferroni-corrected) than those in the pentadactyl condition. On average, the thresholds decreased by a factor of approximately `r monbinsupra` (`r monbinsupradB` dB). Additionally, thresholds in the half-dekadactyl condition, where the baseline stimulus vibrated ten digits, but the target stimulus only vibrated five digits, were higher than those in the dekadactyl condition. These findings could be interpreted as a summation effect when the target inputs were doubled. However, the psychometric function is linearised from the bottom of the dip onwards, which might increase the effects of probability summation [@Tyler2000]. We return to this point in the modelling section. In the dichodactyl condition, thresholds increased across all baseline intensity levels, with thresholds elevated by a factor of `r dichthdelev` (`r dichthdelevdB` dB) at the highest baseline intensity level (32\%). This result is consistent with a suppression effect when adding a mask between digits, where the target stimulus could only be detected when it approached the baseline intensity.
  1204. The slopes of the psychometric function for each condition are illustrated in [@fig-dippers]b. At detection threshold, all conditions exhibited relatively steep slopes, around $\beta = 4$. At low baseline intensity levels (0 to 4\%), except for in the dichodactyl condition (green triangles), the slopes decreased with increasing baseline intensity level, approaching $\beta = 1$ at a baseline intensity level of 4\%. At higher baseline intensity levels (4\% to 32\%), the slopes for these three conditions remained shallow, consistent with previous studies using the same paradigm in other senses [@Foley1981; @Meese2006]. In contrast, the slopes in the dichodactyl condition remained steep across all baseline intensity levels, with slight variations in the range $2 < \beta < 5$. This pattern differs from that observed in dichoptic masking in vision [@Meese2006; @Baker2024], where slopes were steep at detection threshold, became shallow at lower contrast levels, and then became very steep again at higher contrast levels. The very steep slopes ($\beta \sim 6$) in dichoptic masking are attributed to mandatory physiological summation between the eyes [@Baker2013]. Therefore, the approximately constant slopes observed in the dichodactyl condition suggest that mandatory physiological summation between digits may not occur here.
  1205. ## Summation and suppression of neural responses
  1206. The average amplitude spectra and scalp distributions for four conditions of Experiment 2 are shown in [@fig-EEG]a-d. The steady-state EEG signals were strongest at fronto-central electrodes for both frequencies, aligning with findings from previous studies involving finger vibration [@Porcu2014; @Timora2018]. Therefore, we averaged the EEG responses across six electrodes ($F1$, $F2$, $Fz$, $FC1$, $FC2$, $FCz$) to calculate intensity-response functions at both 23 Hz and 26 Hz.
  1207. ::: {.content-visible unless-format="docx"}
  1208. ![Results of the EEG experiment. Panels (a-d) show Fourier spectra and inset scalp topographies for a subset of four conditions at the highest intensity. Panels (e,f) show intensity-response functions for all conditions at 26Hz (e) and 23Hz (f). Data are averaged across N=30 participants, and shaded regions indicate ±1SE across participants.](Figures/Figure2.pdf){#fig-EEG}
  1209. :::
  1210. In the pentadactyl and dekadactyl conditions, where a 26 Hz stimulus was used, we observed a peak at 26 Hz in both [@fig-EEG]a and b, with a stronger activity at the fronto-central region in the dekadactyl condition. A similar pattern was observed in the cross-pentadactyl condition, where a 23 Hz stimulus resulted in a peak at 23 Hz ([@fig-EEG]c). In the cross-dekadactyl condition ([@fig-EEG]d), when two different frequencies were simultaneously presented, peaks were observed at the original frequencies (26 Hz (F1) and 23 Hz (F2)) as well as at specific intermodulation frequencies (20 Hz ($2 \times F2 - F1$) and 29 Hz ($2 \times F1 - F2$)). Additionally, the distribution of activity across the scalp was similar in the cross-dichodactyl and cross-dekadactyl conditions.
  1211. Responses increased monotonically as a function of vibration intensity for most conditions ([@fig-EEG]e,f). Two 6 (condition) $\times$ 5 (intensity) repeated measures ANOVAs were conducted on the EEG amplitudes at each frequency separately. At 26 Hz, significant main effects of condition (F = `r EEGF1_F1`, $p$ < 0.001, $\eta_G^2$ = `r EEGF1_ng1`, Greenhouse-Geisser corrected), intensity level (F = `r EEGF1_F2`, $p$ < 0.001, $\eta_G^2$ = `r EEGF1_ng2`, Greenhouse-Geisser corrected), and a significant interaction effect (F = `r EEGF1_F3`, $p$ < 0.001, $\eta_G^2$ = `r EEGF1_ng3`) were observed. In the pentadactyl condition (blue circles), EEG amplitudes increased monotonically with increasing intensity level ([@fig-EEG]e). In the dekadactyl condition (red squares), doubling the inputs led to stronger responses. Compared to the pentadactyl condition, average response amplitudes were higher by a factor of `r meanratio` across all intensity levels—reflected as a multiplicative scaling of the red line. This suggests a sub-linear summation, where the combined response is greater than in the pentadactyl condition, but less than would be expected from perfect linear summation. If adjacent digit inputs were combined linearly without suppression, we would expect a doubling of response amplitude (i.e., a factor of ~2). In the dichodactyl condition (green triangles), the target stimulus was presented to Set A, while a fixed vibration at 32\% of maximum intensity was presented to Set B, resulting in a high baseline response, and responses that increased with intensity level. At higher intensity levels (32 to 64\%), the responses in the dichodactyl condition approximated those in the dekadactyl condition. In the cross-dekadactyl (pink pentagons) and cross-dichodactyl (black diamonds) conditions, two different frequencies were presented simultaneously. Instead of summing, they suppressed each other, leading to weaker responses compared to the pentadactyl condition.
  1212. At 23 Hz ([@fig-EEG]f), we also found significant main effects of condition (F = `r EEGF2_F1`, $p$ < 0.001, $\eta_G^2$ = `r EEGF2_ng1`, Greenhouse-Geisser corrected), intensity level (F = `r EEGF2_F2`, $p$ < 0.001, $\eta_G^2$ = `r EEGF2_ng2`), as well as a significant interaction effect (F = `r EEGF2_F3`, $p$ < 0.001, $\eta_G^2$ = `r EEGF2_ng3`, Greenhouse-Geisser corrected). Further evidence of suppression was observed in the cross-dekadactyl condition (pink pentagons), where simultaneous 23 Hz and 26 Hz stimuli led to reduced responses compared to the cross-pentadactyl condition (yellow triangles). In the cross-dichodactyl condition (black diamonds), we observed a strong initial response to the 23 Hz mask at 4\% target intensity level. However, as the intensity of the 26 Hz target increased, the overall response decreased (F = `r EEGF2B_F1`, $p$ < 0.001, $\eta_G^2$ = `r EEGF2B_ng1`, Greenhouse-Geisser corrected). This negative trend suggests that the stronger the 26 Hz target, the more it suppressed the response to the 23 Hz mask [@Busse2009]. The resulting pattern reflects a masking effect and inter-digit suppression, rather than summation, leading to weaker responses as intensity levels increase. As expected, the three conditions involving only a 26 Hz vibration did not show any measurable responses at 23 Hz ([@fig-EEG]f).
  1213. ## Computational modelling results
  1214. We began by fitting the data of Experiment 1 using the model of Meese et al. [@Meese2006], with seven free parameters including the weight of suppression between channels ($\omega$ in @eq-stage1L and @eq-stage1R). Because the combination across channels is linear (following an initial transducer stage), we refer to this as the Linear summation model. The model gave a reasonable fit, as shown in [@fig-models]a, with an RMS error of `r model1rms`dB (see @tbl-parametertable for parameter values). However, two systematic shortcomings are apparent. At detection threshold, the model overestimates the amount of summation (notice the red curve is below the data points for the first three baseline intensity levels). Also, suppression between channels is relatively strong ($\omega$ = `r model1params_w`), but this results in the slope of the dichodactyl condition (green curve) being steeper than in the empirical data (green triangles).
  1215. To address these shortcomings, we added a further parameter to the model - an exponent at the summation stage (see @eq-binsum2), which is the defining feature of the Minkowski summation model. The Minkowski exponent determines how multiple sensory inputs are combined at the perceptual level. At high values, it serves as an approximation of probability summation, which has traditionally been modelled using a MAX operator within a 2IFC signal detection framework [@Tyler2000]. As the Minkowski exponent increases, the model shifts from linear integration toward a MAX-like operation, where the strongest input dominates the response. This provides a flexible way to capture both linear and nonlinear integration, depending on the value of the exponent. The Minkowski summation model provides a much more satisfactory fit (see [@fig-models]b), with an RMS error of `r model2rms`dB, and no obvious systematic issues with the fit. The Minkowski exponent was estimated at $\gamma$ = `r model2params_y` - substantially greater than the implicit value of $\gamma$ = 1 in the Linear summation model, and consistent with the low levels of summation at threshold in the data. Several other parameter values also differ between the models (see @tbl-parametertable), most notably the weight of suppression ($\omega$) reduces to near-zero. With this architecture, threshold elevation in the dichodactyl condition is caused by the MAX-like operation from the high Minkowski exponent (a true MAX operator would have an exponent of $\gamma = \infty$), meaning that explicit suppression is not required.
  1216. To predict the psychometric slopes, we used the estimated parameters from the threshold fitting for each of the two models separately. For each model, we simulated model responses across a range of target stimulus levels. For each target level and condition, we calculated d-prime $(d^{\prime})$ as the signal difference divided by the internal noise, and obtained the predicted probability of a correct response by passing $d^{\prime}$ divided by $\sqrt{2}$ through the cumulative normal distribution. The resulting predicted psychometric function was fitted with a cumulative Gaussian using $psignifit$ 4, and the predicted slope parameters were extracted from the fitted functions. The results of psychometric slope estimates are shown in [@fig-models]c and 3d. Both models capture the general features of the slope data. Slopes start steep ($\beta = 4$) at detection threshold, and are linearised to around $\beta = 1.3$ at higher baseline intensities in all but the dichodactyl condition. The dichodactyl slopes remain steep ($\beta > 2$) across the full range of baseline intensities. We note that the additional nonlinearity in the Minkowski model does cause the slope values around detection threshold to be slightly overestimated, but the general trend is correct. Because these slope values are predicted with no additional free parameters, based on the fits at detection threshold (see also [@Meese2006]), this increases our confidence in the accuracy of the models.
  1217. We also performed maximum likelihood fits to the raw proportion correct data, that constrained the model to the full psychometric function (i.e. capturing both threshold and slope). These fits, summarised in the Supporting Information Appendix, resulted in a consistent pattern of values for the key parameters ($\omega$ and $\gamma$), and qualitatively similar behavior of the two models.
  1218. The absence of suppression in the Minkowski model does not necessarily mean that there is no suppression between digits - since psychophysical responses are assumed to be based on the most sensitive subset of neurons relevant to a given task, it is possible that they directly tap mechanisms that are suppression-free. Our EEG data measure responses from the whole neural population, and so we next model these results to obtain a more general estimate of suppression. The model used to fit the EEG data omits the output nonlinearity (@eq-stage2), and adds a scaling parameter ($R_{max}$), resulting in 5 free parameters. It produced an excellent account of the results at both temporal frequencies, and across all 6 stimulus conditions (see [@fig-models]e,f), with an RMS error of `r EEGRMS`$\mu$V. The model correctly captures the sub-linear summation between channels (i.e. the red curve in [@fig-models]e is less than a factor of two greater than the blue curve), and the suppression evident in the cross-dekadactyl and cross-dichodactyl conditions. The weight of suppression ($\omega$ = `r EEGparams_w`) was intermediate between the two dipper models, as well as being in between previously published values for vision ($\omega$ = 1; [@Baker2017]) and hearing ($\omega$ = 0; [@Baker2020]).
  1219. ```{r}
  1220. #| label: tbl-parametertable
  1221. #| output: true
  1222. #| tbl-cap: 'Summary of fitted model parameters for three computational models. Brackets indicate fixed values, and dashes denote parameters not included in a given model.'
  1223. tabledata <- matrix(0,nrow=3,ncol=11)
  1224. tabledata <- data.frame(tabledata)
  1225. tabledata[1,3] <- as.character(model1params_p)
  1226. tabledata[1,4] <- as.character(model1params_q)
  1227. tabledata[1,5] <- as.character(model1params_m)
  1228. tabledata[1,6] <- as.character(model1params_S)
  1229. tabledata[1,7] <- as.character(model1params_Z)
  1230. tabledata[1,8] <- as.character(model1params_w)
  1231. tabledata[1,9] <- as.character(model1params_k)
  1232. tabledata[1,10] <- paste0('(',as.character(model1params_y),')')
  1233. tabledata[2,3] <- as.character(model2params_p)
  1234. tabledata[2,4] <- as.character(model2params_q)
  1235. tabledata[2,5] <- as.character(model2params_m)
  1236. tabledata[2,6] <- as.character(model2params_S)
  1237. tabledata[2,7] <- as.character(model2params_Z)
  1238. tabledata[2,8] <- as.character(model2params_w)
  1239. tabledata[2,9] <- as.character(model2params_k)
  1240. tabledata[2,10] <- as.character(model2params_y)
  1241. tabledata[3,2] <- as.character(EEGparams_R)
  1242. tabledata[3,5] <- as.character(EEGparams_m)
  1243. tabledata[3,6] <- as.character(EEGparams_S)
  1244. tabledata[3,8] <- as.character(EEGparams_w)
  1245. tabledata[3,9] <- as.character(EEGparams_k)
  1246. tabledata[,11] <- c(as.character(model1rms), as.character(model2rms), as.character(EEGRMS))
  1247. tabledata[,1] <- c('Linear summation', 'Minkowski summation', 'EEG model')
  1248. tabledata[1:2,2] <- '-'
  1249. tabledata[3,c(3,4,7,10)] <- '-'
  1250. colnames(tabledata) <- c('Model',"$R_{max}$",'$p$','$q$','$m$','$S$','$Z$',"$\\omega$",'$k$',"$\\gamma$",'RMSE')
  1251. kableExtra::kbl(tabledata, align='lcccccccccc',booktabs=TRUE,linesep='',escape = FALSE) %>% kable_styling(font_size = 8)
  1252. # %>% kableExtra::kable_styling(latex_options = "HOLD_position")
  1253. ```
  1254. ::: {.content-visible unless-format="docx"}
  1255. ![Summary of model fitting. Panel (a) shows the fit of a model in which summation is linear (following an early transducer), and panel (b) shows the fit of the Minkowski summation model. Panels (c) and (d) show the slope estimates for the Linear summation mode (c) and Minkowski summation model (d). Panels (e) and (f) show the best model fit to the EEG data at 26Hz (c) and 23Hz (d).](Figures/Figure3.pdf){#fig-models}
  1256. :::
  1257. # Discussion
  1258. In this study, we present evidence detailing the processes of signal summation and suppression in vibrotactile signal combination. In the psychophysical experiment, we observed that doubling the inputs resulted in a slight reduction in detection and discrimination thresholds, indicating a weak summation effect. Additionally, adding a masking stimulus led to increased discrimination thresholds, indicating a suppression process. Furthermore, the EEG experiment demonstrated that adding inputs at the same frequency increases brain activity, whereas adding inouts at a different frequency decreases it. These findings allow us to quantify the summation and suppression effect between digits. Our computational model fitting results show that at a population level, responses to two vibrotactile inputs suppress each other, though our psychophysical data are more consistent with probability summation rather than neural/physiological summation. The weight of suppression between digits is around $\omega=0.5$, which is intermediate between the corresponding values observed in visual [@Baker2017] and auditory [@Baker2020] signal combination. Overall, our results suggest the presence of suppression in vibrotactile signal combination, with a suppression effect distinct from that observed in visual and auditory signal combination. In the remainder of this Discussion, we consider the underlying mechanisms of vibrotactile signal combination and the differences across sensory modalities.
  1259. Our results show that the detection threshold in the dekadactyl condition was \sim 1 dB lower than in the pentadactyl condition, although this difference did not reach statistical significance. Nevertheless, the trend is consistent with previous studies [@Gescheider2002; @Gescheider2005; @Verrillo1963] suggesting that doubling inputs can reduce detection thresholds. However, the summation effect observed in our study was weaker than the \sim 3 dB reductions reported in those studies, which used higher-frequency vibrations (>40 Hz) and doubled the contactor size. This discrepancy may be attributed to the different vibration frequencies used in the studies, and our choice to stimulate alternating fingers rather than to vary stimulus area. Some psychophysical studies have proposed that four distinct mechanoreceptive channels mediate vibration perception: Pacinian (P), Non-Pacinian I (NP I), Non-Pacinian II (NP II), and Non-Pacinian III (NP III) [@Bolanowski1988; @Verrillo1963]. Each channel has unique characteristics and responds to specific frequency ranges. For instance, the P channel is more sensitive to higher frequencies (e.g., greater than 40 Hz) and is the only channel that possesses the property of spatial summation [@Bolanowski1988; @Gescheider2002; @Verrillo1965]. In contrast, vibrations between 2 and 40 Hz are mediated by the NP I channel, which has been shown not to exhibit spatial summation [@Bolanowski1988; @Gescheider1994; @Verrillo1963]. Consequently, the weak summation effect observed in our study may be attributed to the 26 Hz vibration, which is determined by the NP I channel that lacks spatial summation. Additionally, unlike the study by @Gescheider2005, which used varying contactor sizes to stimulate random areas within a test region, thereby preventing participants from knowing the exact location of stimulation, our study specifically involved the stimulation of individual digits, with participants aware of which digits were being stimulated in some conditions. This methodological difference likely contributed to the observed discrepancy between the studies.
  1260. Our EEG experiment showed a significant increase in neural responses when doubling the number of stimulated digits at the same frequency, although the increase was less than the twofold change expected if the digits were completely independent. These findings correspond with previous studies, demonstrating that brain responses to concurrent stimuli at the same frequencies are stronger than those elicited by individual stimuli, suggesting a summation effect between inputs [@Hoechstetter2001; @Gandevia1983; @Ishibashi2000]. However, the observed response is less than the anticipated summed response, generally falling between 10\% and 50\% of the expected value, signifying a suppression effect between inputs [@Gandevia1983; @Ching-Liang1995]. In contrast, adding a masking stimulus at a different frequency resulted in a reduction in brain activity. These results provide further evidence of suppression between digits and are consistent with previous neuroimaging studies investigating the interaction between different frequencies [@Severens2010; @Pang2015]. For instance, @Severens2010 examined the suppression effect by vibrating two fingers at the same (18 Hz) or different frequencies (18 vs. 22 Hz, 18 vs. 26 Hz). Their findings revealed that the event-related potential (ERP) and SSSEP responses to simultaneous stimulation of both fingers were significantly lower than the linear sum of individual responses, with no difference in suppression based on frequency variation. This ruled out the hypothesis that suppression is due to neuronal occlusion from overlapping cortical areas (which support adjacent areas of skin), as stronger suppression would be expected at the same frequency if this were the case [@Gandevia1983; @Krause2001]. Instead, the results support the alternative explanation of lateral inhibition, where activation of one cortical neuron suppresses neighbouring neurons’ activity [@Laskin1979; @Brumberg1996; @DiCarlo1998; @Dykes1984; @Mirabella2001]. This inhibitory mechanism has been extensively observed in both animal and human studies [@Iwamura1978; @Iwamura1985; @Biermann1998; @Gandevia1983; @Hoechstetter2001].
  1261. Another interesting finding is that we observed an intermediate level of suppression between vibrotactile stimuli compared with vision and audition. In visual perception, the forward-facing positioning of the eyes results in substantially overlapping visual fields. To merge these overlapping inputs from each eye (monocular vision) into a cohesive single percept (binocular single vision), neural signals from both eyes must exhibit strong mutual inhibition to achieve ‘ocularity invariance’, ensuring the constancy of perception through one or both eyes [@Baker2007]. This is consistent with recent findings indicating that some neurons in the primary visual cortex are monocularly excitable, responding exclusively to the dominant eye, while binocular stimulation can suppress their activity [@Dougherty2019]. In contrast, the lateral placement of the ears results in minimal overlap between auditory inputs, reducing the necessity for strong interaural inhibition to integrate these signals into a unified percept, as is required in visual processing. Instead, binaural perception benefits from the comparison and integration of these disparate inputs to localise sound sources and discern subtle differences in timing and intensity [@Brugge1973; @Benson1976]. Although prior evidence supports the existence of binaural suppression [@Tiihonen1989; @Gransier2017; @Baker2020], the extent of this suppression may differ depending on hemispheric laterality [@Kaneko2003; @Fujiki2002].
  1262. Tactile perception involves the stimulation of each of the ten fingers individually, creating a more complex signal integration compared to binocular or binaural perception. The primary somatosensory cortex (S1) is divided into four distinct Brodmann areas (BA 3a, 3b, 1, and 2), each responsible for mapping different parts of the body surface. Evidence suggests that receptive field of each finger is arranged in somatotopic order in BA 3b, while in BA 1/2, they are arranged in clusters with large overlap [@Kurth2000; @Krause2001; @Ishibashi2000]. Although SSSEPs reflect activity across the whole scalp, EEG [@Pang2015; @Arslanova2022] and MEG [@Ishibashi2000; @Hoechstetter2001; @Tame2015] studies also support overlapping finger representations in S1. Notably, the overlap between adjacent fingers is greater than between non-adjacent ones, indicating that suppression between fingers depends on their spatial proximity. Additionally, hand posture plays a significant role in signal summation and suppression. For instance, a study [@Hamada2003] found that a "CLOSE" posture (thumb and index finger positioned as if to pick something up) produces stronger suppression than an "OPEN" posture (hand fully open). In the present study, finger posture and inter-finger distance were controlled by placing the fingertips in a fixed position on solenoids, suggesting that the amount of suppression may vary depending on finger arrangements or hand posture. Furthermore, studies have shown that blind individuals exhibit stronger somatosensory evoked potentials compared to sighted individuals, potentially indicating an expanded S1 region [@Giriyappa2009; @Burton2004]. This suggests that use-dependent cortical reorganisation may occur across different body regions, and the amount of suppression may vary depending on the body part involved.
  1263. ## Conclusion
  1264. We demonstrate that vibrotactile signal combination involves both probability summation and suppression, as evidenced by psychophysical thresholds and EEG activity. A computational model further characterizes this process, indicating that the strength of suppression between digits is weaker than in vision but stronger than in audition. These findings establish a foundational framework for understanding vibrotactile integration and provide a basis for future research to explore whether the weight of suppression varies across different body parts, offering deeper insights into somatosensory processing and clinical conditions in which tactile processing or body experience is affected (e.g., chronic hand pain).
  1265. # Materials and Methods
  1266. ## Participants
  1267. Eight adult subjects participated in the psychophysics experiment, and thirty-one adult subjects participated in the EEG experiment. All participants self-reported as healthy, with no diagnosed neurological disorders, and no history of exposure to severe hand-transmitted vibration. Both experiments were approved by the Ethics Committee of the Department of Psychology at the University of York (application IDs 2277 and 2303). Written informed consent was obtained from all participants prior to conducting the experiments.
  1268. ## Apparatus & stimuli
  1269. Vibration stimuli were generated by a specially constructed board with ten fixed solenoids ('tactor' devices, from Dancer Design Ltd., Yorkshire, UK) controlled by a computer ([@fig-methods]c). Each solenoid could be independently driven by a pair of 6-channel USB sound cards to vibrate each finger. The outputs of the sound cards were amplified by a 10-channel linear amplifier, which produced a maximum output modulation of $\pm 7.5$V, for a maximum vibration amplitude of $\pm 0.375$N. All the stimuli were generated and presented using MATLAB and Psychtoolbox 3 [@Kleiner2007; @Brainard1997]. Any sounds produced by the vibrations were rendered inaudible by playing a 440 Hz tone on the remaining two sound card outputs, which was delivered to participants over a pair of headphones.
  1270. ::: {.content-visible unless-format="docx"}
  1271. ![Overview of methods details. Panel (a) illustrates the arrangements of baseline (blue circles) and target (red squares) stimuli in the two intervals of a trial in Experiment 1, for four stimulus arrangements (rows). Panel (b) shows an example 'dipper' function. Panel (c) shows the board containing the ten tactors. Panel (d) illustrates the allocation of digits into two sets. Panel (e) illustrates the stimulus conditions used in Experiment 2.](Figures/Figure4.pdf){#fig-methods}
  1272. :::
  1273. EEG signals were recorded using a 64-channel electrode cap and an ANT Neuroscan (ANT Neuro, Netherlands) amplifier sampling at 1kHz. Electrodes were arranged according to the 10-20 system, and impedances were kept below $5k \Omega$. Digital triggers were sent to the EEG amplifier using a USB TTL module (Black Box Toolkit Ltd., UK), signifying the start of the trial. The whole head average was used as a reference for the EEG data, and the ground electrode was located at position $AFz$.
  1274. ## Psychophysical procedures
  1275. In Experiment 1, a two-interval forced-choice (2IFC) task was used to measure detection and discrimination thresholds. During the experiment, participants were instructed to place their ten digits on the corresponding solenoids, and a series of 26Hz vibration stimuli were delivered to their hands. The digits on each hand were coded 1 to 5 from the thumb to the little finger in that order. The digits 1, 3 and 5 on the left hand and 2 and 4 on the right hand are denoted "Set A", and the digits 2 and 4 on the left hand, and 1, 3 and 5 on the right hand are denoted "Set B" (illustrated in [@fig-methods]d).
  1276. Each trial consisted of two 500 ms intervals: one containing the baseline stimulus (equivalent to a pedestal stimulus in studies of visual contrast discrimination) and the other containing the baseline stimulus plus a target increment, separated by a 400 ms interstimulus interval. The next trial began 200 ms after the participant responded. To mask any sound produced by the solenoids and indicate the stimulus intervals, a 440 Hz beep sound was delivered through headphones simultaneously with the vibration stimuli. The order of the two intervals was randomised, and participants were required to determine which interval contained the target increment by pressing a foot pedal. Feedback was provided by a coloured square on the computer screen, with green indicating a correct response and red indicating an incorrect one. The amplitude of the target increment was determined using a pair of 3-down-1-up staircases, with a step size of 3 dB (where dB units are defined as $20 \times log_{10}(100 \times I)$, and $I$ is the stimulus intensity expressed as a proportion of the maximum system output), aiming to distribute trials around the detection threshold at 75\% correct. Threshold measurement was terminated after either 70 trials or 12 reversals, whichever occurred first.
  1277. The baseline and target stimuli were presented under four conditions (illustrated in [@fig-methods]a) and at eight baseline intensity levels (0, 1, 2, 4, 8, 16, 32, 64\%), with the baseline intensity level expressed as a percentage of the maximum vibration (0.375N). In the "pentadactyl" condition (meaning five-fingered in Greek), the baseline and target stimuli vibrated only "Set A" (analogous to the stimulation of one eye or one ear). In the "dekadactyl" (ten-fingered) condition, the baseline and target stimuli vibrated both "Set A" and "Set B" (analogous to the stimulation of both eyes or both ears). Comparing the "pentadactyl" and "dekadactyl" conditions reveals the summation effect of doubling the number of inputs. In the "dichodactyl" condition (named for consistency with dichoptic conditions in vision experiments, where different stimuli are shown to the two eyes), the baseline stimulus vibrated "Set A" and the target stimulus vibrated "Set B", permitting the measurement of masking effects across digits. The "half-dekadactyl" condition involved vibrating all ten digits for the baseline stimulus, while only "Set A" vibrated for the target stimulus, enabling measurement of summation (by comparison with the dekadactyl condition) while controlling the number of digits receiving the baseline stimulus [see also the half-binocular condition of Meese et al. @Meese2006]. In all conditions, the vibration frequency for both baseline and target stimuli was 26 Hz. "Set A" and "Set B" were counterbalanced across trials. Each baseline intensity level was tested in a single block lasting approximately 15 minutes and repeated three times. The entire experiment took around 7 hours per participant, resulting in a total of `r ntotaltrials` trials (`r trialspersubj` per participant on average), and was completed over multiple days.
  1278. ## EEG procedures
  1279. After EEG cap set-up, participants in Experiment 2 were exposed to a series of vibrations under six different conditions (illustrated in [@fig-methods]e) at five intensity levels (4, 8, 16, 32, 64\%). Steady-state somatosensory evoked potential (SSSEP) signals were recorded throughout the experiment. During each trial, participants received an 11-s vibration with a 3-s interstimulus interval, and were required to keep their hands still until designated break periods. The "Set A" and "Set B" allocations were the same as those used in the psychophysical experiment (see [@fig-methods]d) and were counterbalanced across trials. The order of conditions was randomised, and each condition was repeated twice for each set, resulting in a total of 120 trials. The entire experiment lasted approximately 30 minutes, split into four 7-minute blocks with rest breaks between blocks.
  1280. Two different frequencies were used: 26 Hz (F1) and 23 Hz (F2). F1 is considered the primary frequency, as SSSEP responses are greatest around 26Hz when vibration is delivered to the hands [@Snyder1992]. Using two distinct frequencies enables measurement of both the separate responses to each frequency and suppression between them, without confounding effects from summation. In the "pentadactyl" condition, F1 only vibrated "Set A"; in the "dekadactyl" condition, F1 vibrated both "Set A" and "Set B"; in the "dichodactyl" condition, F1 vibrated "Set A" while a mask at 26 Hz with an intensity level of 32\% vibrated "Set B". These three conditions contributed to the observation of summation and suppression effects at the same frequency.
  1281. In the remaining three conditions, a second frequency (F2) was introduced to investigate interactions between different frequencies. In the "cross-pentadactyl" condition, F2 vibrated "Set A", providing a comparison with the "pentadactyl" condition and serving as a baseline for other cross-frequency conditions. In the "cross-dekadactyl" condition, F1 vibrated "Set A" and F2 vibrated "Set B", permitting measurement of suppression effects between different frequencies. In the "cross-dichodactyl" condition, F1 vibrated "Set A" while the F2 mask (intensity level of 32\%) vibrated "Set B", again to measure suppression between digits (see [@fig-methods]e for a diagram of all conditions).
  1282. ## Data analysis
  1283. For both experiments, off-line analysis and statistical testing was performed in Python 3. For psychophysical data, the $psignifit$ 4 package [@Schutt2016] was used to estimate thresholds (at 75\% correct) and slope parameters of the psychometric functions by fitting a cumulative Gaussian, parameterized as:
  1284. $$p = \gamma + (1 - \lambda - \gamma)\Phi(C\frac{x-m}{w})$$ {#eq-cumgaus}
  1285. where $\lambda$ and $\gamma$ define the upper and lower asymptotes (the lapse rate and baseline accuracy), $x$ is the stimulus intensity (in logarithmic units), $m$ is the threshold parameter, and $w$ determines the slope. (Note that $\gamma$ here is unrelated to $\gamma$ in @eq-binsum2, but we retain this notation for consistency with that used in @Schutt2016.) $\Phi$ denotes the cumulative standard normal distribution, and $C$ is a constant ($C = \Phi^{-1}(0.95) - \Phi^{-1}(0.05) =$ `r qnorm(0.95)-qnorm(0.05)`) (for details, see @Schutt2016). We then converted the slope parameter to equivalent Weibull $\beta$ values using the approximation $\beta = 10.3/\sigma$, where $\sigma = w/C$. We performed this conversion because Weibull $\beta$ is a more commonly used measure of slope, which makes for easier comparison with previous studies, and because steep slopes intuitively correspond to large $\beta$ values. Psychometric function fitting was conducted independently for each participant and condition, and we calculated geometric means across participants of the threshold and slope parameters (implemented as the arithmetic mean of logarithmic values).
  1286. For EEG data, all preprocessing was conducted using MNE-Python [@Gramfort2013]. For each trial, the initial 1000 ms post-stimulus presentation was discarded to eliminate onset transients. The remaining 10 s were Fourier transformed and the amplitudes were averaged across repetitions and participants. After removing one outlier, data from 30 participants remained for calculating the intensity-response functions and performing statistical analysis. The amplitudes from six electrodes centred on the area of greatest response ($F1$, $F2$, $Fz$, $FC1$, $FC2$, $FCz$) were then averaged to plot the amplitude spectra and intensity-response functions. The primary dependent variables were the Fourier amplitudes at 26 Hz and 23 Hz.
  1287. ## Computational modelling
  1288. The psychophysical data were fitted using the two stage model of Meese et al [@Meese2006], as outlined in equations 1-4, with 7 free parameters ($m$, $S$, $\omega$, $p$, $q$, $Z$ and $k$). The final parameter ($k$) determines the threshold criteria, such that the model response in the target interval must exceed that in the null interval by $k$ for threshold to be reached. It is proportional to additive noise in the model. We also considered a modified model, in which the summation process described by @eq-binsum involved an additional exponent:
  1289. $$sum = (Stage1_L^\gamma + Stage1_R^\gamma)^{1/\gamma}$$ {#eq-binsum2}
  1290. The Minkowski exponent ($\gamma$) is implicitly 1 in @eq-binsum, but was free to vary in our second model.
  1291. To fit the EEG data, we used a simpler model described by Equations 1-3, but omitting the output nonlinearity described by @eq-stage2, which serves primarily to determine the shape of the dipper function in psychophysical studies, but is not required for EEG data [@Baker2017]. The output was instead defined as:
  1292. $$resp = R_{max} \times sum + k$$ {#eq-stage2b}
  1293. where $R_{max}$ is a scaling parameter on the output, and $k$ again represents additive noise. The simpler model had 5 free parameters ($m$, $S$, $\omega$, $R_{max}$ and $k$).
  1294. We used a downhill simplex algorithm to minimize the squared error between model and data. Fitting for each model was repeated 100 times from random starting vectors, and the parameters that gave the best numerical fit were selected. For the psychophysical data, we first fitted the model to the averaged threshold data by determining the target contrast for each condition that was required to increase the model response by the criterion ($k$). At the suggestion of a reviewer, we additionally performed maximum likelihood fits to the raw proportion correct data. These fits are summarised in the Supporting Information Appendix, and lead to similar conclusions to the modelling reported in the body of the manuscript.
  1295. ## Data and code availability
  1296. All experiment code, raw data, processed data and analysis code are available at the project repository: https://doi.org/10.17605/OSF.IO/M79D2. The linked GitHub repository also contains a fully computationally reproducible version of this manuscript in Quarto format.
  1297. ## Acknowledgements
  1298. We thank Mark Green for constructing the vibration board, and Kirralise Hansford for her support during EEG data collection. This work was supported by the China Scholarship Council [202308510049], and by BBSRC grant BB/V007580/1 awarded to DHB and ARW.The funder had no role in the design of the study, data collection, analysis, or decision to publish.
  1299. # References

TestVersion.qmd, no license · at the source

Overview

Authors: Shasha Wei1, Alex R Wade1,2, Catherine EJ Preston1, Daniel H Baker1
  1. Department of Psychology, University of York, York, United Kingdom
  2. York Biomedical Research Institute, University of York, York, United Kingdom
Institutions: University of York (United Kingdom)
Journal: PloS one, volume 21, issue 6, article e0350140
Dates: received 17 November 2025; accepted 8 May 2026; published online 11 June 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1371/journal.pone.0350140 · PMID 42275386 · PMCID PMC13258021 · OpenAlex W7164346268
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: EEG (modality), human (organism), cognitive (subfield)
MeSH: Evoked Potentials, Somatosensory*, Touch Perception*, Vibration*, Adult, Electroencephalography, Female, Fingers, Humans, Male, Sensory Thresholds, Young Adult (* major topic)
Topic: Multisensory perception and integration (Experimental and Cognitive Psychology, Psychology), according to OpenAlex
Funding: Biotechnology and Biological Sciences Research Council (BB/V007580/1)
Citations: not cited yet (Europe PMC); 65 references in the paper

Abstract

While the brain’s integration of auditory and visual inputs has been extensively investigated, the mechanisms underlying somatosensory signal combination remain less explored. Here, we combine psychophysical thresholds with steady-state somatosensory evoked potentials (SSSEPs) to investigate how vibrotactile inputs are combined across fingers. We find that doubling the number of stimulated digits leads to a weak improvement in detection threshold, consistent with probability summation, whereas introducing a masking stimulus to interleaved digits induces inter-digit suppression. Correspondingly, EEG recordings reveal a ~ 1.4-fold increase in SSSEP amplitude when doubling the number of digits stimulated at the same frequency, reflecting a summation effect. In contrast, SSSEP amplitudes decrease when digits are vibrated at two different frequencies, further supporting the presence of suppression. These results are consistent with a model featuring inhibition between digits and reveal that the weight of suppression is intermediate between that observed in binocular vision and binaural hearing.

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

Repository

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

OSF m79d2

License: none: the authors keep all their rights
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Languages: Quarto (3), MATLAB (2)
Size: 50 files, 5 scripts
Software Heritage: not checked
Found in: “Data Availability”
Holds: environment (docker-compose.yml, docker/Dockerfile), continuous integration, 3 notebooks
Not found: README, license file, CITATION.cff, tests, documentation
Tools: Matplotlib (2 files), MNE-Python (2 files), NumPy (2 files), pandas (2 files), Pingouin (2 files), psignifit (2 files), Psychtoolbox (2 files), SciPy (2 files), statsmodels (2 files)
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
  • 27 September 2026: the link answers (HTTP 200)
5 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:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 5 scripts, each with its path and the digest of its content;
  • 6 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

No dataset and no data link were found in the paper.

Data Availability

All experiment code, raw data, processed data and analysis code are available at the project repository: https://doi.org/10.17605/OSF.IO/M79D2. The linked GitHub repository also contains a fully computationally reproducible version of this paper in Quarto format.

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, issue, pages, dates, 4 authors, 11 MeSH terms, 1 funder, 64 references.

Cite

This paper

Wei, S., Wade, A. R., Preston, C. E., & Baker, D. H. (2026). Signal combination in flutter vibration perception. PloS one, 21(6), e0350140. https://doi.org/10.1371/journal.pone.0350140

BibTeX

@article{wei2026signal,
author = {Wei, Shasha and Wade, Alex R and Preston, Catherine EJ and Baker, Daniel H},
title = {{Signal combination in flutter vibration perception}},
journal = {PloS one},
year = {2026},
month = jun,
volume = {21},
number = {6},
pages = {e0350140},
publisher = {PLOS},
issn = {1932-6203},
doi = {10.1371/journal.pone.0350140},
url = {https://doi.org/10.1371/journal.pone.0350140},
pmid = {42275386},
pmcid = {PMC13258021}
}

RIS

TY - JOUR
AU - Wei, Shasha
AU - Wade, Alex R
AU - Preston, Catherine EJ
AU - Baker, Daniel H
TI - Signal combination in flutter vibration perception
T2 - PloS one
J2 - PLoS One
PY - 2026
DA - 2026/06/11
VL - 21
IS - 6
SP - e0350140
SN - 1932-6203
PB - PLOS
DO - 10.1371/journal.pone.0350140
UR - https://doi.org/10.1371/journal.pone.0350140
LA - en
ER -

CSL-JSON

{
"id": "10.1371/journal.pone.0350140",
"type": "article-journal",
"title": "Signal combination in flutter vibration perception",
"container-title": "PloS one",
"author": [
{
"family": "Wei",
"given": "Shasha"
},
{
"family": "Wade",
"given": "Alex R"
},
{
"family": "Preston",
"given": "Catherine EJ"
},
{
"family": "Baker",
"given": "Daniel H"
}
],
"container-title-short": "PLoS One",
"volume": "21",
"issue": "6",
"page": "e0350140",
"DOI": "10.1371/journal.pone.0350140",
"PMID": "42275386",
"PMCID": "PMC13258021",
"ISSN": "1932-6203",
"publisher": "PLOS",
"URL": "https://doi.org/10.1371/journal.pone.0350140",
"language": "en",
"issued": {
"date-parts": [
[
2026,
6,
11
]
]
}
}

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.1371/journal.pone.0356765 [code]
Binocular combination in the autonomic nervous system.
Journal: PloS one
In common: pandas, SciPy, Matplotlib, 1 other tool, 8 references, 2 authors
[2] doi:10.1038/s41467-026-75662-w [code]
Distinct Roles of Deep and Superficial Cortical Layers in Tone Prediction, Comparison, and Adaptation in Human Auditory Cortices.
Journal: Nature communications
In common: Psychtoolbox, Pingouin, MNE-Python, 5 other tools, cognitive
[3] doi:10.1097/j.pain.0000000000004044 [code]
No effect of rhythmic visual stimulation on experimental pain perception.
Journal: Pain
In common: Pingouin, MNE-Python, statsmodels, 4 other tools, EEG, cognitive, 1 reference
[4] doi:10.1523/eneuro.0041-26.2026 [code]
Ocular Speech Tracking Persists in Blindness, but Its Dynamics and Oculo-Cerebral Connectivity Depend on Visual Status.
Journal: eNeuro
In common: Pingouin, MNE-Python, statsmodels, 4 other tools, cognitive, 1 reference
[5] doi:10.1093/nc/niag029 [code]
A data-driven approach to identifying and evaluating connectivity-based neural correlates of conscious visual perception.
Journal: Neuroscience of consciousness
In common: Pingouin, MNE-Python, statsmodels, 4 other tools, cognitive, 1 reference
[6] doi:10.1126/sciadv.aef0343 [code]
Learning induces activation-mechanism-dependent neural plasticity in an intracortical microstimulation task.
Journal: Science advances
In common: psignifit, statsmodels, pandas, 3 other tools, 1 reference
[7] doi:10.1007/s10548-026-01238-y [code]
Topographic Reorganization of EEG Complexity During Visual Mental Imagery: Insights from Lempel-Ziv Complexity in High-Density EEG.
Journal: Brain topography
In common: Pingouin, MNE-Python, statsmodels, 4 other tools, EEG, cognitive
[8] doi:10.1162/imag.a.105 [code]
Right posterior theta reflects human parahippocampal phase resetting by salient cues during goal-directed navigation
Journal: n/a
In common: Pingouin, MNE-Python, statsmodels, 4 other tools, EEG, cognitive
[9] doi:10.7554/elife.100605 [code]
Age-related changes in ‘cortical’ 1/f dynamics are linked to cardiac activity
Journal: n/a
In common: Pingouin, MNE-Python, statsmodels, 4 other tools, 1 reference
[10] doi:10.2147/opth.s590186 [code]
Differential Effects of Balanced and Imbalanced Binocular Stimulation on Visual Cortex Responses in Amblyopic Children.
Journal: Clinical ophthalmology (Auckland, N.Z.)
In common: Psychtoolbox, pandas, SciPy, 2 other tools, 2 references

Contribute

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

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

Request its removal

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

Discussion, reproductions, activity

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

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

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