OSCR

Effects of Transcranial Direct Current Stimulation over the Left Sensorimotor Cortex on Bimanual Force Control: A Computational and Experimental Investigation.

Code ↔ Paper

10 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 10 matches
  1. [1] § 2. Materials and Methods › 2.8. Computational Model › 2.8.1. State Equations ↔ tDCS_motorcontrol_03012026.py, lines 348–493 · score 0.89 · delayed proprioceptive, K_base, target drift, proprioceptive feedback gain, internal target, G_proprio
  2. [2] § 2. Materials and Methods › 2.8. Computational Model › 2.8.1. State Equations ↔ tDCS_motorcontrol_03012026.py, lines 348–493 · score 0.84 · K_vis, common drive weight, visual feedback gain, 10.2 Hz, noise, tremor
  3. [3] § 2. Materials and Methods › 2.1. Study Design ↔ tDCS_motorcontrol_03012026.py, lines 521–647 · score 0.77 · electrode montage, force matching task, visual feedback, anode, cathode, window
  4. [4] § 2. Materials and Methods › 2.7. Statistical Analysis ↔ tDCS_motorcontrol_03012026.py, lines 930–1007 · score 0.63 · post hoc, retest reliability, Epoch interaction, change scores, Cohen, Holm
  5. [5] § 2. Materials and Methods › 2.5. Outcome Measures › 2.5.3. Inter-Hand Coherence ↔ tDCS_motorcontrol_03012026.py, lines 650–732 · score 0.58 · 7–12 Hz, 3–7 Hz, 0–1 Hz, bands, squared, coherence
  6. [6] § 3. Results › 3.2. Computational Model Results ↔ tDCS_motorcontrol_03012026.py, lines 848–928 · score 0.57 · Dose response, G_proprio, corrective power, computationally, N2, model
  7. [7] § 2. Materials and Methods › 2.6. Primary and Secondary Outcomes ↔ tDCS_motorcontrol_03012026.py, lines 650–732 · score 0.52 · inter hand coherence, force undershoot, spectral power, band, 1–3 Hz, RMSE
  8. [8] § 2. Materials and Methods › 2.7. Statistical Analysis ↔ tDCS_motorcontrol_03012026.py, lines 244–268 · score 0.52 · Pearson correlations, change scores, RMSE, PRE, power, undershoot
  9. [9] § 2. Materials and Methods › 2.3. Transcranial Direct Current Stimulation Protocol ↔ tDCS_motorcontrol_03012026.py, lines 1–83 · score 0.52 · left sensorimotor cortex, stimulator, Transcranial, tDCS
  10. [10] § 3. Results › 3.1. Experimental Results › 3.1.7. Sensitivity and Reliability Analyses ↔ tDCS_motorcontrol_03012026.py, lines 311–340 · score 0.51 · retest reliability, PRE POST, ICC, Coherence, 1–3 Hz, RMSE

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

Python · 1,011 lines · 42 KB · MIT · 10 matches

  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. ================================================================================
  5. Reproducible Analysis Script
  6. Effects of Transcranial Direct Current Stimulation over the Left Sensorimotor
  7. Cortex on Bimanual Force Control: A Computational and Experimental Investigation
  8. Lima, V.M.S., Arthur, E.F., Gonzaga, R.R.D., Diniz, L.F.,
  9. Pedreiro, R.C.M., Pinto Neto, O.
  10. Correspondence: [email hidden]
  11. ================================================================================
  12. INSTRUCTIONS:
  13. 1. Place the data file 'data_tdcs_12212025.csv' and the raw force files
  14. (e.g., DAS_CONSTANTE_21_10_PRE.txt) in a folder of your choice.
  15. 2. Update DATA_PATH below to point to that folder.
  16. 3. Run this script: python reproducible_analysis.py
  17. 4. All figures and statistical output will be saved to DATA_PATH/results/
  18. Requirements:
  19. pip install numpy pandas scipy matplotlib
  20. ================================================================================
  21. """
  22. import numpy as np
  23. import pandas as pd
  24. import matplotlib.pyplot as plt
  25. from matplotlib.patches import FancyBboxPatch
  26. from matplotlib.gridspec import GridSpec
  27. from scipy.signal import butter, filtfilt, welch, coherence
  28. from scipy import stats
  29. from scipy.stats import f as f_dist
  30. import warnings
  31. import os
  32. import sys
  33. warnings.filterwarnings('ignore')
  34. # =============================================================================
  35. # USER CONFIGURATION — CHANGE THIS PATH
  36. # =============================================================================
  37. DATA_PATH = r"C:\Users\osmar\OneDrive\Documents\PESQUISAS\Vinicius"
  38. # Raw force files for Figure 1 (optional — schematic used if not found)
  39. # NOTE: On the authors' system these are in a different directory tree
  40. RAW_DATA_PATH = DATA_PATH
  41. EXAMPLE_PARTICIPANT = "DAS" # participant ID for Figure 1
  42. EXAMPLE_DATE = "21_10" # date string in filename (underscore, not dot)
  43. # =============================================================================
  44. # SETUP
  45. # =============================================================================
  46. DATA_FILE = os.path.join(DATA_PATH, "data_tdcs_12212025.csv")
  47. RESULTS_DIR = os.path.join(DATA_PATH, "results")
  48. os.makedirs(RESULTS_DIR, exist_ok=True)
  49. # Publication-quality figure defaults
  50. plt.rcParams.update({
  51. 'font.family': 'DejaVu Sans',
  52. 'font.size': 12,
  53. 'axes.titlesize': 14,
  54. 'axes.labelsize': 13,
  55. 'xtick.labelsize': 11,
  56. 'ytick.labelsize': 11,
  57. 'legend.fontsize': 10,
  58. 'legend.frameon': False,
  59. 'figure.dpi': 300,
  60. 'savefig.dpi': 300,
  61. 'savefig.bbox': 'tight',
  62. 'axes.spines.top': False,
  63. 'axes.spines.right': False,
  64. })
  65. # =============================================================================
  66. # 1. DATA LOADING AND PREPARATION
  67. # =============================================================================
  68. def load_data(filepath):
  69. """Load experimental data and compute derived metrics."""
  70. if not os.path.exists(filepath):
  71. print(f"\nERROR: Data file not found at:\n {filepath}")
  72. print(f"\nPlease update DATA_PATH at the top of this script.")
  73. sys.exit(1)
  74. df = pd.read_csv(filepath, encoding='utf-8-sig')
  75. # Standardize group label
  76. df['Group'] = df['Group'].replace('Placebo', 'Sham')
  77. # Compute force undershoot (%)
  78. df['Undershoot_pct'] = (
  79. 100 * (df['OF_F_alvo_mean'] - df['OF_Total_mean_raw'])
  80. / df['OF_F_alvo_mean']
  81. )
  82. print(f"Loaded {len(df)} observations from {df['participant'].nunique()} "
  83. f"participants ({', '.join(df['Group'].unique())})")
  84. return df
  85. def load_raw_force_file(filepath):
  86. """Load raw force data from Vernier .txt file."""
  87. with open(filepath, 'r', encoding='utf-8-sig') as f:
  88. lines = f.readlines()
  89. time_v, f1_v, f2_v, ft_v = [], [], [], []
  90. target = None
  91. for line in lines[7:]:
  92. line = line.strip().replace('\r', '')
  93. if not line:
  94. continue
  95. parts = line.split('\t')
  96. if len(parts) >= 4:
  97. try:
  98. time_v.append(float(parts[0].replace(',', '.')))
  99. f1_v.append(float(parts[1].replace(',', '.')))
  100. f2_v.append(float(parts[2].replace(',', '.')))
  101. ft_v.append(float(parts[3].replace(',', '.')))
  102. if len(parts) >= 5 and parts[4].strip():
  103. try:
  104. target = float(parts[4].replace(',', '.'))
  105. except ValueError:
  106. pass
  107. except ValueError:
  108. continue
  109. t_arr = np.array(time_v)
  110. fs = 1.0 / np.mean(np.diff(t_arr)) if len(t_arr) > 1 else 100.0
  111. return {'time': t_arr, 'force1': np.array(f1_v), 'force2': np.array(f2_v),
  112. 'total': np.array(ft_v), 'target': target, 'fs': fs}
  113. def load_participant_raw(participant, date_str):
  114. """Load PRE and POST raw force files for a participant."""
  115. result = {}
  116. for epoch, suffix in [('PRE', 'PRE'), ('POST', 'POS')]:
  117. fpath = os.path.join(RAW_DATA_PATH,
  118. f"{participant}_CONSTANTE_{date_str}_{suffix}.txt")
  119. if os.path.exists(fpath):
  120. result[epoch] = load_raw_force_file(fpath)
  121. return result if result else None
  122. # =============================================================================
  123. # 2. STATISTICAL ANALYSES
  124. # =============================================================================
  125. def compute_descriptives(df):
  126. """Compute cell means and SDs for all primary metrics."""
  127. metrics = {
  128. 'Undershoot_pct': 'Undershoot (%)',
  129. 'OF_Total_RMSE_raw': 'RMSE (N)',
  130. 'OF_Total_P_1_3Hz': 'Power 1-3 Hz (N²/Hz)',
  131. }
  132. rows = []
  133. for col, label in metrics.items():
  134. for grp in ['Sham', 'tDCS']:
  135. for ep in ['PRE', 'POS']:
  136. vals = df[(df['Group'] == grp) & (df['Epoch'] == ep)][col]
  137. rows.append({
  138. 'Metric': label, 'Group': grp, 'Epoch': ep,
  139. 'Mean': vals.mean(), 'SD': vals.std(),
  140. 'SEM': vals.std() / np.sqrt(len(vals)), 'N': len(vals)
  141. })
  142. return pd.DataFrame(rows)
  143. def run_interaction_tests(df):
  144. """
  145. Test Group × Epoch interaction using independent t-test on change scores.
  146. F(1,22) = t² for the between-group comparison of (POST − PRE) differences.
  147. """
  148. metrics = {
  149. 'Undershoot_pct': 'Undershoot (%)',
  150. 'OF_Total_RMSE_raw': 'RMSE (N)',
  151. 'OF_Total_P_1_3Hz': 'Power 1-3 Hz',
  152. }
  153. results = []
  154. for col, label in metrics.items():
  155. wide = df.pivot_table(index='participant', columns='Epoch',
  156. values=col, aggfunc='first')
  157. wide['Group'] = (df.drop_duplicates('participant')
  158. .set_index('participant')['Group'])
  159. wide['change'] = wide['POS'] - wide['PRE']
  160. tdcs = wide[wide['Group'] == 'tDCS']['change']
  161. sham = wide[wide['Group'] == 'Sham']['change']
  162. t_stat, p_val = stats.ttest_ind(tdcs, sham)
  163. results.append({
  164. 'Metric': label,
  165. 'tDCS_change': f"{tdcs.mean():.3f} ± {tdcs.std():.3f}",
  166. 'Sham_change': f"{sham.mean():.3f} ± {sham.std():.3f}",
  167. 'F(1,22)': t_stat ** 2,
  168. 'p_interaction': p_val,
  169. })
  170. return pd.DataFrame(results)
  171. def run_posthoc_paired(df):
  172. """Within-group paired t-tests (PRE vs POST) with Holm correction."""
  173. metrics = {
  174. 'Undershoot_pct': 'Undershoot (%)',
  175. 'OF_Total_RMSE_raw': 'RMSE (N)',
  176. 'OF_Total_P_1_3Hz': 'Power 1-3 Hz',
  177. }
  178. results = []
  179. for col, label in metrics.items():
  180. wide = df.pivot_table(index='participant', columns='Epoch',
  181. values=col, aggfunc='first')
  182. wide['Group'] = (df.drop_duplicates('participant')
  183. .set_index('participant')['Group'])
  184. for grp in ['tDCS', 'Sham']:
  185. sub = wide[wide['Group'] == grp]
  186. t_stat, p_val = stats.ttest_rel(sub['PRE'], sub['POS'])
  187. diff = sub['POS'] - sub['PRE']
  188. d = diff.mean() / diff.std()
  189. results.append({
  190. 'Metric': label, 'Group': grp,
  191. 'PRE_mean': sub['PRE'].mean(), 'PRE_sd': sub['PRE'].std(),
  192. 'POST_mean': sub['POS'].mean(), 'POST_sd': sub['POS'].std(),
  193. 't': t_stat, 'df': len(sub) - 1,
  194. 'p_uncorrected': p_val, 'Cohen_d': d,
  195. })
  196. res_df = pd.DataFrame(results)
  197. # Holm–Bonferroni correction across all 6 tests
  198. sorted_idx = res_df['p_uncorrected'].argsort().values
  199. n = len(res_df)
  200. p_holm = np.ones(n)
  201. for rank, idx in enumerate(sorted_idx):
  202. p_holm[idx] = min(res_df['p_uncorrected'].iloc[idx] * (n - rank), 1.0)
  203. for i in range(1, n):
  204. p_holm[sorted_idx[i]] = max(p_holm[sorted_idx[i]], p_holm[sorted_idx[i - 1]])
  205. res_df['p_holm'] = p_holm
  206. return res_df
  207. def run_correlations(df):
  208. """Pearson correlations between change scores, by group."""
  209. wide_u = df.pivot_table(index='participant', columns='Epoch',
  210. values='Undershoot_pct', aggfunc='first')
  211. wide_p = df.pivot_table(index='participant', columns='Epoch',
  212. values='OF_Total_P_1_3Hz', aggfunc='first')
  213. wide_r = df.pivot_table(index='participant', columns='Epoch',
  214. values='OF_Total_RMSE_raw', aggfunc='first')
  215. groups = (df.drop_duplicates('participant')
  216. .set_index('participant')['Group'])
  217. delta_u = wide_u['POS'] - wide_u['PRE']
  218. delta_p = wide_p['POS'] - wide_p['PRE']
  219. delta_r = wide_r['POS'] - wide_r['PRE']
  220. results = []
  221. for grp in ['tDCS', 'Sham']:
  222. mask = groups == grp
  223. r1, p1 = stats.pearsonr(delta_p[mask], delta_u[mask])
  224. r2, p2 = stats.pearsonr(delta_p[mask], delta_r[mask])
  225. results.append({'Group': grp, 'Comparison': 'ΔPower vs ΔUndershoot',
  226. 'r': r1, 'p': p1})
  227. results.append({'Group': grp, 'Comparison': 'ΔPower vs ΔRMSE',
  228. 'r': r2, 'p': p2})
  229. return pd.DataFrame(results)
  230. def run_ancova(df):
  231. """ANCOVA: POST ~ Group + PRE (baseline covariate)."""
  232. metrics = {
  233. 'Undershoot_pct': 'Undershoot (%)',
  234. 'OF_Total_RMSE_raw': 'RMSE (N)',
  235. 'OF_Total_P_1_3Hz': 'Power 1-3 Hz',
  236. }
  237. results = []
  238. for col, label in metrics.items():
  239. wide = df.pivot_table(index='participant', columns='Epoch',
  240. values=col, aggfunc='first')
  241. wide['Group'] = (df.drop_duplicates('participant')
  242. .set_index('participant')['Group'])
  243. wide['Group_code'] = (wide['Group'] == 'tDCS').astype(float)
  244. X = np.column_stack([
  245. np.ones(len(wide)),
  246. wide['Group_code'].values,
  247. wide['PRE'].values,
  248. ])
  249. y = wide['POS'].values
  250. # Manual OLS for minimal dependencies
  251. beta = np.linalg.lstsq(X, y, rcond=None)[0]
  252. y_hat = X @ beta
  253. residuals = y - y_hat
  254. n, k = X.shape
  255. mse = np.sum(residuals ** 2) / (n - k)
  256. se = np.sqrt(mse * np.diag(np.linalg.inv(X.T @ X)))
  257. t_vals = beta / se
  258. p_vals = 2 * stats.t.sf(np.abs(t_vals), df=n - k)
  259. results.append({
  260. 'Metric': label,
  261. 'Group_beta': beta[1], 'Group_SE': se[1],
  262. 'Group_t': t_vals[1], 'Group_p': p_vals[1],
  263. 'Baseline_beta': beta[2], 'Baseline_p': p_vals[2],
  264. })
  265. return pd.DataFrame(results)
  266. def compute_reliability(df):
  267. """Test-retest reliability (ICC) from Sham group PRE-POST."""
  268. metrics = {
  269. 'Undershoot_pct': 'Undershoot (%)',
  270. 'OF_Total_RMSE_raw': 'RMSE (N)',
  271. 'OF_Total_P_1_3Hz': 'Power 1-3 Hz',
  272. 'OF_Coh_1_3Hz': 'Coherence 1-3 Hz',
  273. }
  274. results = []
  275. sham = df[df['Group'] == 'Sham']
  276. for col, label in metrics.items():
  277. wide = sham.pivot_table(index='participant', columns='Epoch',
  278. values=col, aggfunc='first')
  279. pre, post = wide['PRE'].values, wide['POS'].values
  280. n = len(pre)
  281. # ICC(3,1) — two-way mixed, single measures, consistency
  282. grand_mean = np.mean(np.concatenate([pre, post]))
  283. ss_between = 2 * np.sum((np.mean([pre, post], axis=0) - grand_mean) ** 2)
  284. ss_within = np.sum((pre - np.mean([pre, post], axis=0)) ** 2 +
  285. (post - np.mean([pre, post], axis=0)) ** 2)
  286. ms_between = ss_between / (n - 1)
  287. ms_within = ss_within / n
  288. icc = (ms_between - ms_within) / (ms_between + ms_within)
  289. results.append({'Metric': label, 'ICC': icc, 'N': n})
  290. return pd.DataFrame(results)
  291. # =============================================================================
  292. # 3. COMPUTATIONAL MODEL
  293. # =============================================================================
  294. def butter_lowpass(x, fs, fc, order=4):
  295. """Apply a low-pass Butterworth filter."""
  296. b, a = butter(order, fc / (fs / 2), btype='low')
  297. return filtfilt(b, a, x)
  298. def simulate_bimanual_force(G_proprio=0.5, target_N=45.0, duration=40.0,
  299. fs=100.0, seed=None):
  300. """
  301. Closed-loop computational model of bimanual isometric force control.
  302. Matches the model described in Section 2.6 of the manuscript:
  303. - Vision ON (t < 20 s): visual feedback control with gain K_vis
  304. - Vision OFF (t >= 20 s): proprioceptive feedback with gain G_proprio,
  305. internal target drift (lambda_drift_base = 0.0045), and
  306. proprioceptive delay (delta)
  307. - Between-trial variability in lambda_drift and K_correction
  308. Parameters
  309. ----------
  310. G_proprio : float
  311. Proprioceptive feedback gain [0.2, 0.8]. Sham ≈ 0.25, tDCS ≈ 0.70.
  312. target_N : float
  313. Per-hand target force in Newtons (total target = 2 × target_N).
  314. duration : float
  315. Trial duration in seconds.
  316. fs : float
  317. Sampling rate in Hz.
  318. seed : int or None
  319. Random seed for reproducibility.
  320. Returns
  321. -------
  322. dict with keys: time, force_L, force_R, total_force, target,
  323. undershoot_pct, rmse, power_1_3Hz, freqs_psd, psd,
  324. freqs_coh, coh_spectrum, G_proprio
  325. """
  326. if seed is not None:
  327. np.random.seed(seed)
  328. dt = 1.0 / fs
  329. n_samples = int(duration * fs)
  330. t = np.arange(n_samples) / fs
  331. target_total = target_N * 2
  332. force_L = np.zeros(n_samples)
  333. force_R = np.zeros(n_samples)
  334. # ---- Fixed parameters (Table in Section 2.6) ----
  335. K_vis = 0.12 # Visual feedback gain
  336. K_base = 0.15 # Base correction gain
  337. w_common = 0.15 # Common drive weight
  338. delay_samples = int(0.1 * fs) # Proprioceptive delay δ = 100 ms
  339. sigma_proprio = 0.15 # Proprioceptive noise SD
  340. sigma_motor_von = 0.22 # Motor noise during Vision ON
  341. sigma_motor_voff = 0.25 # Motor noise during Vision OFF
  342. # ---- Between-trial variability (Section 2.6) ----
  343. lambda_drift_base = 0.0045 * (1 - 0.5 * G_proprio)
  344. lambda_drift = max(0.0005,
  345. lambda_drift_base + np.random.randn() * 0.0008)
  346. K_correction = max(0.02,
  347. K_base * G_proprio + np.random.randn() * 0.008)
  348. # ---- Shared signals ----
  349. common_drive = butter_lowpass(np.random.randn(n_samples) * 0.25, fs, 2)
  350. tremor_L = 0.06 * np.sin(2 * np.pi * 10.0 * t +
  351. np.random.rand() * 2 * np.pi)
  352. tremor_R = 0.06 * np.sin(2 * np.pi * 10.2 * t +
  353. np.random.rand() * 2 * np.pi)
  354. # ---- Simulation ----
  355. ramp_end = int(3 * fs)
  356. vision_off_start = int(20 * fs)
  357. internal_target = target_N
  358. for i in range(1, n_samples):
  359. if i < ramp_end:
  360. # Ramp-up phase
  361. frac = i / ramp_end
  362. force_L[i] = target_N * frac + np.random.randn() * 0.15
  363. force_R[i] = target_N * frac + np.random.randn() * 0.15
  364. elif i < vision_off_start:
  365. # Vision ON — Eq. (1) in manuscript
  366. err_L = target_N - force_L[i - 1]
  367. err_R = target_N - force_R[i - 1]
  368. force_L[i] = (force_L[i - 1]
  369. + K_vis * err_L
  370. + common_drive[i] * w_common
  371. + np.random.randn() * sigma_motor_von
  372. + tremor_L[i])
  373. force_R[i] = (force_R[i - 1]
  374. + K_vis * err_R
  375. + common_drive[i] * w_common
  376. + np.random.randn() * sigma_motor_von
  377. + tremor_R[i])
  378. else:
  379. # Vision OFF — Eqs. (2-4) in manuscript
  380. internal_target *= (1 - lambda_drift * dt)
  381. # Delayed proprioceptive estimate
  382. idx_delay = max(0, i - delay_samples)
  383. proprio_L = force_L[idx_delay] + np.random.randn() * sigma_proprio
  384. proprio_R = force_R[idx_delay] + np.random.randn() * sigma_proprio
  385. err_L = internal_target - proprio_L
  386. err_R = internal_target - proprio_R
  387. force_L[i] = (force_L[i - 1]
  388. + K_correction * err_L
  389. + common_drive[i] * w_common
  390. + np.random.randn() * sigma_motor_voff
  391. + tremor_L[i])
  392. force_R[i] = (force_R[i - 1]
  393. + K_correction * err_R
  394. + common_drive[i] * w_common
  395. + np.random.randn() * sigma_motor_voff
  396. + tremor_R[i])
  397. force_L[i] = np.clip(force_L[i], target_N * 0.7, target_N * 1.3)
  398. force_R[i] = np.clip(force_R[i], target_N * 0.7, target_N * 1.3)
  399. # ---- Compute metrics from Vision OFF analysis window (23–40 s) ----
  400. total_force = force_L + force_R
  401. of_start = int(23 * fs)
  402. of_end = int(40 * fs)
  403. of_force = total_force[of_start:of_end]
  404. undershoot = 100 * (target_total - np.mean(of_force)) / target_total
  405. rmse = np.sqrt(np.mean((of_force - target_total) ** 2))
  406. freqs_psd, psd = welch(of_force - np.mean(of_force), fs=fs,
  407. nperseg=min(1024, len(of_force)), noverlap=512)
  408. mask_1_3 = (freqs_psd >= 1) & (freqs_psd <= 3)
  409. _trapz = np.trapezoid if hasattr(np, 'trapezoid') else np.trapz
  410. power_1_3 = _trapz(psd[mask_1_3], freqs_psd[mask_1_3]) if mask_1_3.sum() > 1 else 0
  411. freqs_coh, coh = coherence(force_L[of_start:of_end],
  412. force_R[of_start:of_end],
  413. fs=fs, nperseg=min(512, len(of_force)))
  414. return {
  415. 'time': t, 'force_L': force_L, 'force_R': force_R,
  416. 'total_force': total_force, 'target': target_total,
  417. 'undershoot_pct': undershoot, 'rmse': rmse,
  418. 'power_1_3Hz': power_1_3,
  419. 'freqs_psd': freqs_psd, 'psd': psd,
  420. 'freqs_coh': freqs_coh, 'coh_spectrum': coh,
  421. 'G_proprio': G_proprio,
  422. }
  423. def run_dose_response(G_values, n_seeds=20, target_N=45.0):
  424. """Run simulations across a range of G_proprio values."""
  425. rows = []
  426. for G in G_values:
  427. for seed in range(n_seeds):
  428. sim = simulate_bimanual_force(G_proprio=G, target_N=target_N,
  429. seed=seed * 1000 + int(G * 10000))
  430. rows.append({
  431. 'G_proprio': G, 'seed': seed,
  432. 'undershoot_pct': sim['undershoot_pct'],
  433. 'rmse': sim['rmse'],
  434. 'power_1_3Hz': sim['power_1_3Hz'],
  435. })
  436. return pd.DataFrame(rows)
  437. # =============================================================================
  438. # 4. FIGURES
  439. # =============================================================================
  440. # Color palette
  441. C_SHAM = '#3182bd'
  442. C_TDCS = '#e31a1c'
  443. def fig1_experimental_design(raw_data, save_path):
  444. """Figure 1: Experimental design (montage + timeline + real force trace)."""
  445. print(" Creating Figure 1: Experimental Design...")
  446. fig = plt.figure(figsize=(12, 8))
  447. gs = GridSpec(2, 2, height_ratios=[1, 1.2], width_ratios=[1, 1.2])
  448. # Panel A: tDCS Montage schematic
  449. ax = fig.add_subplot(gs[0, 0])
  450. ax.set_xlim(-1.5, 1.5); ax.set_ylim(-1.5, 1.5)
  451. ax.set_aspect('equal'); ax.axis('off')
  452. ax.set_title('A. tDCS Electrode Montage', fontweight='bold')
  453. head = plt.Circle((0, 0), 1, fill=False, color='black', linewidth=2)
  454. ax.add_patch(head)
  455. ax.plot([0, 0], [1, 1.2], 'k-', lw=2)
  456. ax.text(0, 1.3, 'Nose', ha='center', fontsize=9)
  457. anode = plt.Circle((-0.25, 0.2), 0.22, color='red', alpha=0.7)
  458. ax.add_patch(anode)
  459. ax.text(-0.25, 0.2, '+', ha='center', va='center', fontsize=20,
  460. color='white', fontweight='bold')
  461. ax.text(-0.25, -0.12, 'Left of Cz\n(Anode)', ha='center', fontsize=9)
  462. cathode = plt.Circle((0.5, 0.7), 0.15, color='blue', alpha=0.7)
  463. ax.add_patch(cathode)
  464. ax.text(0.5, 0.7, '\u2013', ha='center', va='center', fontsize=18,
  465. color='white', fontweight='bold')
  466. ax.text(0.72, 0.85, 'Fp2\n(Cathode)', ha='center', fontsize=9)
  467. ax.plot(-1, 0, 'ko', ms=4); ax.text(-1.15, 0, 'T3', ha='right', fontsize=8)
  468. ax.plot(1, 0, 'ko', ms=4); ax.text(1.15, 0, 'T4', ha='left', fontsize=8)
  469. ax.text(-1.3, -0.8, "2 mA, 20 min\n35 cm\u00B2 electrodes\nSaline sponges",
  470. fontsize=9, bbox=dict(boxstyle='round', fc='lightgray', alpha=0.8),
  471. va='top')
  472. # Panel B: Task Timeline
  473. ax = fig.add_subplot(gs[0, 1])
  474. ax.set_xlim(-2, 42); ax.set_ylim(-0.5, 2); ax.axis('off')
  475. ax.set_title('B. Task Timeline', fontweight='bold')
  476. ax.add_patch(FancyBboxPatch((0, 0.5), 20, 1, boxstyle="round,pad=0.02",
  477. fc='lightblue', ec='blue', lw=2))
  478. ax.text(10, 1, 'Vision ON\n(0\u201320 s)', ha='center', va='center',
  479. fontsize=11, fontweight='bold')
  480. ax.add_patch(FancyBboxPatch((20, 0.5), 20, 1, boxstyle="round,pad=0.02",
  481. fc='lightyellow', ec='orange', lw=2))
  482. ax.text(30, 1, 'Vision OFF\n(20\u201340 s)', ha='center', va='center',
  483. fontsize=11, fontweight='bold')
  484. ax.add_patch(FancyBboxPatch((23, 0.1), 17, 0.35, boxstyle="round,pad=0.02",
  485. fc='lightgreen', ec='green', lw=1.5))
  486. ax.text(31.5, 0.27, 'Analysis Window (23\u201340 s)', ha='center',
  487. va='center', fontsize=9)
  488. ax.annotate('', xy=(42, 0), xytext=(-2, 0),
  489. arrowprops=dict(arrowstyle='->', color='black', lw=1.5))
  490. ax.text(42, -0.15, 'Time (s)', ha='right', fontsize=10)
  491. for t_mark in [0, 3, 20, 23, 40]:
  492. ax.plot([t_mark, t_mark], [-0.1, 0.1], 'k-', lw=1)
  493. ax.text(t_mark, -0.25, str(t_mark), ha='center', fontsize=9)
  494. # Panel C: Real (or schematic) force trace
  495. ax = fig.add_subplot(gs[1, :])
  496. if raw_data is not None and 'POST' in raw_data:
  497. data = raw_data['POST']
  498. time_arr = data['time']
  499. total = data['total']
  500. target = data['target']
  501. fs_raw = data['fs']
  502. total_sm = butter_lowpass(total, fs_raw, 5)
  503. mask40 = time_arr <= 40
  504. time_arr, total_sm = time_arr[mask40], total_sm[mask40]
  505. ax.set_title('C. Bimanual Force-Matching Task (Representative Trial)',
  506. fontweight='bold')
  507. ax.axvspan(0, 20, alpha=0.15, color='blue', label='Vision ON')
  508. ax.axvspan(20, 40, alpha=0.15, color='orange', label='Vision OFF')
  509. ax.plot(time_arr, total_sm, 'b-', lw=1, label='Total Force')
  510. ax.axhline(target, color='green', ls='--', lw=2,
  511. label=f'Target ({target:.1f} N)')
  512. of_mask = (time_arr >= 23) & (time_arr <= 40)
  513. if of_mask.sum() > 0:
  514. mean_of = np.mean(total_sm[of_mask])
  515. u_pct = 100 * (target - mean_of) / target
  516. ax.annotate('', xy=(32, mean_of-10), xytext=(32, target-10),
  517. arrowprops=dict(arrowstyle='<->', color='red', lw=2.5))
  518. ax.text(33.5, (mean_of + target) / 2-10,
  519. f'Undershoot\n{u_pct:.1f}%', fontsize=14,
  520. color='red', va='center', fontweight='bold')
  521. ax.axhline(mean_of, xmin=0.575, xmax=1.0, color='red', ls=':',
  522. lw=1.5, alpha=0.7)
  523. ax.text(10, target + 4, 'Visual feedback\navailable',
  524. ha='center', fontsize=10)
  525. ax.text(30, target + 4, 'Proprioceptive\nfeedback only',
  526. ha='center', fontsize=10)
  527. ax.set_ylim(np.min(total_sm) - 5, target + 40)
  528. else:
  529. ax.set_title('C. Bimanual Force-Matching Task (Schematic)',
  530. fontweight='bold')
  531. fs_s = 100; t_s = np.arange(0, 40, 1 / fs_s); tgt = 45.0
  532. force = np.zeros_like(t_s)
  533. force[t_s < 3] = tgt * (t_s[t_s < 3] / 3)
  534. m_on = (t_s >= 3) & (t_s < 20)
  535. force[m_on] = tgt + np.random.randn(m_on.sum()) * 0.8
  536. force[m_on] = butter_lowpass(force[m_on], fs_s, 3)
  537. force[m_on] += tgt - np.mean(force[m_on])
  538. m_off = t_s >= 20
  539. force[m_off] = tgt - 0.15 * (t_s[m_off] - 20) + np.random.randn(m_off.sum()) * 1
  540. force[m_off] = butter_lowpass(force[m_off], fs_s, 3)
  541. force = butter_lowpass(force, fs_s, 5)
  542. ax.axvspan(0, 20, alpha=0.15, color='blue', label='Vision ON')
  543. ax.axvspan(20, 40, alpha=0.15, color='orange', label='Vision OFF')
  544. ax.plot(t_s, force, 'b-', lw=1.5, label='Total Force')
  545. ax.axhline(tgt, color='green', ls='--', lw=2, label='Target (30% MVC)')
  546. m_of = np.mean(force[t_s >= 23])
  547. ax.annotate('', xy=(35, m_of), xytext=(35, tgt),
  548. arrowprops=dict(arrowstyle='<->', color='red', lw=2))
  549. ax.text(36.5, (m_of + tgt) / 2, 'Undershoot', fontsize=14,
  550. color='red', va='center')
  551. ax.text(10, tgt + 3, 'Visual feedback\navailable', ha='center',
  552. fontsize=10)
  553. ax.text(30, tgt + 3, 'Proprioceptive\nfeedback only', ha='center',
  554. fontsize=10)
  555. ax.set_ylim(38, 50)
  556. ax.set_xlabel('Time (s)')
  557. ax.set_ylabel('Force (N)')
  558. ax.set_xlim(0, 42)
  559. ax.legend(loc='lower right')
  560. plt.tight_layout()
  561. fpath = os.path.join(save_path, 'Figure1_Experimental_Design.png')
  562. plt.savefig(fpath, facecolor='white')
  563. plt.savefig(fpath.replace('.png', '.svg'), format='svg')
  564. print(f" Saved: {fpath}")
  565. plt.close()
  566. def fig2_experimental_results(df, save_path):
  567. """Figure 2: Experimental results (Group × Epoch bar plots)."""
  568. print(" Creating Figure 2: Experimental Results...")
  569. fig, axes = plt.subplots(2, 2, figsize=(10, 8))
  570. epochs = ['PRE', 'POS']
  571. x = np.array([0, 1])
  572. w = 0.35
  573. metric_specs = [
  574. ('Undershoot_pct', 'Undershoot (%)', 'A. Force Undershoot', axes[0, 0]),
  575. ('OF_Total_RMSE_raw', 'RMSE (N)', 'B. Root Mean Square Error', axes[0, 1]),
  576. ('OF_Total_P_1_3Hz', 'Power (N²/Hz)', 'C. Spectral Power (1–3 Hz)', axes[1, 0]),
  577. ]
  578. for col, ylabel, title, ax in metric_specs:
  579. for gi, (grp, color) in enumerate(
  580. [('Sham', C_SHAM), ('tDCS', C_TDCS)]):
  581. means, sems = [], []
  582. for ep in epochs:
  583. vals = df[(df['Group'] == grp) & (df['Epoch'] == ep)][col]
  584. means.append(vals.mean())
  585. sems.append(vals.std() / np.sqrt(len(vals)))
  586. offset = -w / 2 if gi == 0 else w / 2
  587. ax.bar(x + offset, means, w, yerr=sems, capsize=5,
  588. color=color, alpha=0.6, label=grp, edgecolor='black',
  589. error_kw={'elinewidth': 1.5, 'capthick': 1.5})
  590. # Add within-tDCS significance bracket
  591. posthoc = run_posthoc_paired(df)
  592. row = posthoc[(posthoc['Metric'] == ylabel.split(' (')[0] +
  593. (' (%)' if '%' in ylabel else ' (N)' if 'N' in ylabel else ''))
  594. & (posthoc['Group'] == 'tDCS')]
  595. # Simpler approach: just mark tDCS POST bar
  596. tdcs_post = df[(df['Group'] == 'tDCS') & (df['Epoch'] == 'POS')][col]
  597. tdcs_pre = df[(df['Group'] == 'tDCS') & (df['Epoch'] == 'PRE')][col]
  598. t_val, p_val = stats.ttest_rel(tdcs_pre, tdcs_post)
  599. if p_val < 0.05:
  600. ymax = max(tdcs_pre.mean(), tdcs_post.mean()) + \
  601. max(tdcs_pre.std(), tdcs_post.std()) / np.sqrt(12)
  602. ax.plot([0 + w/2, 1 + w/2], [ymax * 1.08, ymax * 1.08],
  603. 'k-', linewidth=1.5)
  604. stars = '***' if p_val < 0.001 else '**' if p_val < 0.01 else '*'
  605. ax.text(0.5 + w/2, ymax * 1.10, stars, ha='center', fontsize=14)
  606. ax.set_ylabel(ylabel)
  607. ax.set_title(title, fontweight='bold')
  608. ax.set_xticks(x)
  609. ax.set_xticklabels(['PRE', 'POST'])
  610. ax.legend()
  611. # Panel D: Coherence
  612. ax = axes[1, 1]
  613. bands = ['0–1 Hz', '1–3 Hz', '3–7 Hz', '7–12 Hz']
  614. coh_cols = ['OF_Coh_0_1Hz', 'OF_Coh_1_3Hz', 'OF_Coh_3_7Hz', 'OF_Coh_7_12Hz']
  615. x_coh = np.arange(len(bands))
  616. bw = 0.2
  617. for ei, (ep, alpha) in enumerate(zip(['PRE', 'POS'], [0.4, 0.8])):
  618. for gi, (grp, color) in enumerate([('Sham', C_SHAM), ('tDCS', C_TDCS)]):
  619. means = [df[(df['Group'] == grp) & (df['Epoch'] == ep)][c].mean()
  620. for c in coh_cols]
  621. sems = [df[(df['Group'] == grp) & (df['Epoch'] == ep)][c].std()
  622. / np.sqrt(12) for c in coh_cols]
  623. offset = (-1.5 + ei + gi * 2) * bw
  624. label = f'{grp} {ep.replace("POS", "POST")}'
  625. ax.bar(x_coh + offset, means, bw, yerr=sems, capsize=3,
  626. color=color, alpha=alpha, label=label, edgecolor='black',
  627. error_kw={'elinewidth': 1, 'capthick': 1})
  628. ax.set_ylabel('Coherence')
  629. ax.set_title('D. Inter-hand Coherence', fontweight='bold')
  630. ax.set_xticks(x_coh)
  631. ax.set_xticklabels(bands)
  632. ax.legend(ncol=2, fontsize=9)
  633. ax.set_ylim(0, 0.7)
  634. plt.tight_layout()
  635. fpath = os.path.join(save_path, 'Figure2_Experimental_Results.png')
  636. plt.savefig(fpath, facecolor='white')
  637. plt.savefig(fpath.replace('.png', '.svg'), format='svg')
  638. print(f" Saved: {fpath}")
  639. plt.close()
  640. def fig3_correlations(df, save_path):
  641. """Figure 3: Individual-level correlations (ΔPower vs ΔUndershoot/ΔRMSE)."""
  642. print(" Creating Figure 3: Correlations...")
  643. wide_u = df.pivot_table(index='participant', columns='Epoch',
  644. values='Undershoot_pct', aggfunc='first')
  645. wide_p = df.pivot_table(index='participant', columns='Epoch',
  646. values='OF_Total_P_1_3Hz', aggfunc='first')
  647. wide_r = df.pivot_table(index='participant', columns='Epoch',
  648. values='OF_Total_RMSE_raw', aggfunc='first')
  649. groups = df.drop_duplicates('participant').set_index('participant')['Group']
  650. delta_u = wide_u['POS'] - wide_u['PRE']
  651. delta_p = wide_p['POS'] - wide_p['PRE']
  652. delta_r = wide_r['POS'] - wide_r['PRE']
  653. fig, axes = plt.subplots(1, 2, figsize=(12, 5))
  654. colors = {'Sham': C_SHAM, 'tDCS': C_TDCS}
  655. for ax, (delta_y, ylabel, title) in zip(axes, [
  656. (delta_u, 'Δ Undershoot (%)', 'A. Δ Power vs Δ Undershoot'),
  657. (delta_r, 'Δ RMSE (N)', 'B. Δ Power vs Δ RMSE'),
  658. ]):
  659. for grp in ['Sham', 'tDCS']:
  660. mask = groups == grp
  661. xv, yv = delta_p[mask], delta_y[mask]
  662. ax.scatter(xv, yv, c=colors[grp], s=100, edgecolors='k',
  663. label=grp, zorder=5)
  664. if len(xv) > 2:
  665. slope, intercept, r, p, _ = stats.linregress(xv, yv)
  666. xline = np.array([xv.min(), xv.max()])
  667. ls = '-' if p < 0.05 else '--'
  668. ax.plot(xline, intercept + slope * xline,
  669. color=colors[grp], linestyle=ls, alpha=0.7, lw=2)
  670. sig = '*' if p < 0.05 else ''
  671. ax.text(0.98, 0.95 if grp == 'tDCS' else 0.85,
  672. f'{grp}: r = {r:.2f}, p = {p:.3f}{sig}',
  673. transform=ax.transAxes, ha='right', fontsize=10,
  674. color=colors[grp])
  675. ax.axhline(0, color='gray', ls=':', alpha=0.5)
  676. ax.axvline(0, color='gray', ls=':', alpha=0.5)
  677. ax.set_xlabel('Δ Power 1–3 Hz (N²/Hz)')
  678. ax.set_ylabel(ylabel)
  679. ax.set_title(title, fontweight='bold')
  680. ax.legend()
  681. plt.tight_layout()
  682. fpath = os.path.join(save_path, 'Figure3_Correlations.png')
  683. plt.savefig(fpath, facecolor='white')
  684. plt.savefig(fpath.replace('.png', '.svg'), format='svg')
  685. print(f" Saved: {fpath}")
  686. plt.close()
  687. def fig4_model_results(df, save_path):
  688. """Figure 4: Computational model simulated time series (2 panels)."""
  689. print(" Creating Figure 4: Model Results...")
  690. sim_sham = simulate_bimanual_force(G_proprio=0.25, seed=42)
  691. sim_tdcs = simulate_bimanual_force(G_proprio=0.70, seed=42)
  692. t = sim_sham['time']
  693. target = sim_sham['target']
  694. fig, axes = plt.subplots(1, 2, figsize=(13, 5))
  695. # Panel A: Full time series
  696. ax = axes[0]
  697. ax.axvspan(0, 20, alpha=0.12, color='blue', label='Vision ON')
  698. ax.axvspan(20, 40, alpha=0.12, color='orange', label='Vision OFF')
  699. ax.plot(t, sim_sham['total_force'], color=C_SHAM, lw=0.8, alpha=0.8,
  700. label='Sham (G = 0.25)')
  701. ax.plot(t, sim_tdcs['total_force'], color=C_TDCS, lw=0.8, alpha=0.8,
  702. label='tDCS (G = 0.70)')
  703. ax.axhline(target, color='green', ls='--', lw=2, label='Target')
  704. ax.set_xlabel('Time (s)')
  705. ax.set_ylabel('Total Force (N)')
  706. ax.set_title('A. Simulated Force Time Series', fontweight='bold')
  707. ax.legend(loc='lower right', fontsize=9)
  708. ax.set_xlim(0, 40)
  709. ax.set_ylim(target * 0.87, target * 1.07)
  710. # Panel B: Vision OFF detail
  711. ax = axes[1]
  712. of_mask = (t >= 23) & (t <= 40)
  713. ax.plot(t[of_mask], sim_sham['total_force'][of_mask], color=C_SHAM,
  714. lw=1.2, label='Sham')
  715. ax.plot(t[of_mask], sim_tdcs['total_force'][of_mask], color=C_TDCS,
  716. lw=1.2, label='tDCS')
  717. ax.axhline(target, color='green', ls='--', lw=2)
  718. m_s = np.mean(sim_sham['total_force'][of_mask])
  719. m_t = np.mean(sim_tdcs['total_force'][of_mask])
  720. u_s = 100 * (target - m_s) / target
  721. u_t = 100 * (target - m_t) / target
  722. ax.axhline(m_s, color=C_SHAM, ls=':', alpha=0.7,
  723. label=f'Sham mean: {m_s:.1f} N ({u_s:.1f}%)')
  724. ax.axhline(m_t, color=C_TDCS, ls=':', alpha=0.7,
  725. label=f'tDCS mean: {m_t:.1f} N ({u_t:.1f}%)')
  726. ax.set_xlabel('Time (s)')
  727. ax.set_ylabel('Total Force (N)')
  728. ax.set_title('B. Vision OFF Epoch Detail', fontweight='bold')
  729. ax.legend(fontsize=9)
  730. ax.set_ylim(target * 0.90, target * 1.05)
  731. plt.tight_layout()
  732. fpath = os.path.join(save_path, 'Figure4_Model_Results.png')
  733. plt.savefig(fpath, facecolor='white')
  734. plt.savefig(fpath.replace('.png', '.svg'), format='svg')
  735. print(f" Saved: {fpath}")
  736. plt.close()
  737. def fig5_model_dose_response(save_path):
  738. """Figure 5: Computational model dose-response."""
  739. print(" Creating Figure 5: Model Dose-Response...")
  740. G_values = [0.20, 0.25, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80]
  741. sweep = run_dose_response(G_values, n_seeds=20)
  742. grouped = sweep.groupby('G_proprio')
  743. G_vals = sorted(sweep['G_proprio'].unique())
  744. fig, axes = plt.subplots(1, 3, figsize=(15, 5))
  745. for ax, (metric, ylabel, title, scale) in zip(axes, [
  746. ('undershoot_pct', 'Undershoot (%)', 'A. Undershoot vs G_proprio', 1),
  747. ('power_1_3Hz', 'Power 1–3 Hz (×10 N²/Hz)',
  748. 'B. Corrective Power vs G_proprio', 10),
  749. ]):
  750. means = [grouped.get_group(g)[metric].mean() * scale for g in G_vals]
  751. stds = [grouped.get_group(g)[metric].std() * scale for g in G_vals]
  752. ax.errorbar(G_vals, means, yerr=stds, fmt='o-', capsize=5,
  753. color='gray', lw=2, ms=8, alpha=0.8, capthick=1.5,
  754. elinewidth=1.5, label='Model sweep')
  755. # Highlight Sham and tDCS
  756. for G, color, marker, label in [
  757. (0.25, C_SHAM, 'o', 'Sham (G = 0.25)'),
  758. (0.70, C_TDCS, 's', 'tDCS (G = 0.70)'),
  759. ]:
  760. g = grouped.get_group(G)[metric]
  761. ax.errorbar([G], [g.mean() * scale], yerr=[g.std() * scale],
  762. fmt=marker, capsize=8, color=color, ms=14,
  763. markeredgewidth=2, markeredgecolor='black',
  764. capthick=2.5, elinewidth=2.5, label=label)
  765. ax.set_xlabel('G_proprio (Proprioceptive Gain)')
  766. ax.set_ylabel(ylabel)
  767. ax.set_title(title, fontweight='bold')
  768. ax.legend()
  769. ax.grid(True, alpha=0.3)
  770. # Panel C: Combined dose-response
  771. ax = axes[2]
  772. ax2 = ax.twinx()
  773. means_u = [grouped.get_group(g)['undershoot_pct'].mean() for g in G_vals]
  774. means_p = [grouped.get_group(g)['power_1_3Hz'].mean() * 10 for g in G_vals]
  775. l1, = ax.plot(G_vals, means_u, 'o-', color=C_SHAM, lw=2, ms=7,
  776. label='Undershoot (%)')
  777. l2, = ax2.plot(G_vals, means_p, 's-', color=C_TDCS, lw=2, ms=7,
  778. label='Power 1–3 Hz (×10)')
  779. for G, label_txt in [(0.25, 'Sham'), (0.70, 'tDCS')]:
  780. g = grouped.get_group(G)
  781. ax.plot(G, g['undershoot_pct'].mean(), 'o', color=C_SHAM, ms=14,
  782. markeredgecolor='black', markeredgewidth=2, zorder=10)
  783. ax2.plot(G, g['power_1_3Hz'].mean() * 10, 's', color=C_TDCS, ms=14,
  784. markeredgecolor='black', markeredgewidth=2, zorder=10)
  785. ax.annotate(label_txt, xy=(G, g['undershoot_pct'].mean()),
  786. xytext=(0, 12), textcoords='offset points',
  787. ha='center', fontweight='bold', fontsize=11)
  788. ax.set_xlabel('G_proprio (Proprioceptive Gain)')
  789. ax.set_ylabel('Undershoot (%)', color=C_SHAM)
  790. ax2.set_ylabel('Power 1–3 Hz (×10)', color=C_TDCS)
  791. ax.set_title('C. Dose-Response Relationship', fontweight='bold')
  792. ax.tick_params(axis='y', labelcolor=C_SHAM)
  793. ax2.tick_params(axis='y', labelcolor=C_TDCS)
  794. ax.legend([l1, l2], [l1.get_label(), l2.get_label()], loc='center right')
  795. ax.grid(True, alpha=0.3)
  796. plt.tight_layout()
  797. fpath = os.path.join(save_path, 'Figure5_Model_DoseResponse.png')
  798. plt.savefig(fpath, facecolor='white')
  799. plt.savefig(fpath.replace('.png', '.svg'), format='svg')
  800. print(f" Saved: {fpath}")
  801. plt.close()
  802. # =============================================================================
  803. # 5. MAIN EXECUTION
  804. # =============================================================================
  805. def main():
  806. print("=" * 75)
  807. print("REPRODUCIBLE ANALYSIS — tDCS Bimanual Force Control")
  808. print("=" * 75)
  809. # ---- Load data ----
  810. df = load_data(DATA_FILE)
  811. print()
  812. # ---- Statistical analyses ----
  813. print("Running statistical analyses...")
  814. desc = compute_descriptives(df)
  815. print("\n--- Descriptive Statistics ---")
  816. print(desc.to_string(index=False))
  817. interactions = run_interaction_tests(df)
  818. print("\n--- Group × Epoch Interactions ---")
  819. print(interactions.to_string(index=False))
  820. posthoc = run_posthoc_paired(df)
  821. print("\n--- Post-hoc Paired t-tests (Holm-corrected) ---")
  822. cols = ['Metric', 'Group', 'PRE_mean', 'POST_mean', 't', 'df',
  823. 'p_uncorrected', 'p_holm', 'Cohen_d']
  824. print(posthoc[cols].to_string(index=False))
  825. corr = run_correlations(df)
  826. print("\n--- Correlations (change scores) ---")
  827. print(corr.to_string(index=False))
  828. ancova = run_ancova(df)
  829. print("\n--- ANCOVA (POST ~ Group + Baseline) ---")
  830. print(ancova.to_string(index=False))
  831. reliability = compute_reliability(df)
  832. print("\n--- Test-Retest Reliability (Sham ICC) ---")
  833. print(reliability.to_string(index=False))
  834. # ---- Save statistical tables ----
  835. desc.to_csv(os.path.join(RESULTS_DIR, 'descriptives.csv'), index=False)
  836. interactions.to_csv(os.path.join(RESULTS_DIR, 'interactions.csv'), index=False)
  837. posthoc.to_csv(os.path.join(RESULTS_DIR, 'posthoc.csv'), index=False)
  838. corr.to_csv(os.path.join(RESULTS_DIR, 'correlations.csv'), index=False)
  839. ancova.to_csv(os.path.join(RESULTS_DIR, 'ancova.csv'), index=False)
  840. reliability.to_csv(os.path.join(RESULTS_DIR, 'reliability.csv'), index=False)
  841. print(f"\nStatistical tables saved to: {RESULTS_DIR}")
  842. # ---- Generate figures ----
  843. print("\nGenerating figures...")
  844. # Figure 1: Experimental Design (uses raw force files if available)
  845. raw_data = None
  846. try:
  847. raw_data = load_participant_raw(EXAMPLE_PARTICIPANT,
  848. EXAMPLE_DATE)
  849. if raw_data:
  850. print(f" Loaded raw data for {EXAMPLE_PARTICIPANT} "
  851. f"({len(raw_data)} epochs)")
  852. except Exception as e:
  853. print(f" Raw data not found ({e}); using schematic for Figure 1")
  854. fig1_experimental_design(raw_data, RESULTS_DIR)
  855. # Figure 3: Experimental Results
  856. fig2_experimental_results(df, RESULTS_DIR)
  857. # Figure 4: Correlations
  858. fig3_correlations(df, RESULTS_DIR)
  859. # Figure 5: Computational Model Results
  860. fig4_model_results(df, RESULTS_DIR)
  861. # Figure 6: Model Dose-Response
  862. fig5_model_dose_response(RESULTS_DIR)
  863. print("\n" + "=" * 75)
  864. print("ANALYSIS COMPLETE")
  865. print(f"All outputs saved to: {RESULTS_DIR}")
  866. print("=" * 75)
  867. if __name__ == "__main__":
  868. main()

tDCS_motorcontrol_03012026.py at commit 5053987, under MIT · at the source

Overview

Authors: Vinicius de Moura Silva Lima1, Eduarda Faria Arthur1,2, Rafaela Rodrigues Dousseau Gonzaga1,2, Luan Faria Diniz1, Rodrigo Cunha de Mello Pedreiro3,4, Osmar Pinto Neto1,2,5
  1. Biomedical Engineering Department, Anhembi Morumbi University, São José dos Campos 12247-016, SP, Brazil; (V.d.M.S.L.); (E.F.A.); (R.R.D.G.); (L.F.D.)
  2. Arena235Research Lab., São José dos Campos 12246-876, SP, Brazil
  3. School of Physical Education and Sports, Federal University of Rio de Janeiro, Rio de Janeiro 21941-901, RJ, Brazil
  4. Physical Education Department, Estácio de Sá University, Teresópolis 25963-150, RJ, Brazil
  5. Kinesiology Department, California State University San Marcos, San Marcos, CA 92096, USA
Journal: Bioengineering (Basel, Switzerland), volume 13, issue 5, article 502
Dates: received 2 March 2026; accepted 23 April 2026; published online 26 April 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.3390/bioengineering13050502 · PMID 42194259 · PMCID PMC13203247 · OpenAlex W7159775081
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: other (modality), human (organism), systems (subfield)
Methods: Spectral & time-frequency, Statistics, Connectivity, Preprocessing
Keywords: transcranial direct current stimulation, primary motor cortex, lateral premotor cortex, supplementary motor area, bimanual coordination, proprioception, force control, computational modeling
Topic: Transcranial Magnetic Stimulation Studies (Neurology, Neuroscience), according to OpenAlex
Funding: Anima Institute (#12/2025)
Citations: not cited yet (Europe PMC); 30 references in the paper

Abstract

Transcranial direct current stimulation (tDCS) over motor–premotor regions may modulate motor performance, though underlying mechanisms remain unclear. Twenty-four athletes (9 females, 15 males) were randomly assigned to receive anodal tDCS (2 mA, 20 min) over the left sensorimotor cortex (n = 12) or sham stimulation (n = 12). Participants performed a bimanual isometric force-matching task at 30% maximal voluntary contraction, with visual feedback initially provided and then removed. Force undershoot, root mean square error (RMSE), spectral power (1–3 Hz), and inter-hand coherence were analyzed. A computational model was developed to test whether enhanced proprioceptive feedback processing could account for observed effects. Following tDCS, force undershoot decreased significantly (p = 0.002, d = −1.15) and RMSE improved (p = 0.010, d = −0.91). Spectral power in the 1–3 Hz band increased (p = 0.012, d = 0.87), suggesting enhanced corrective oscillations. These within-group changes were absent in the sham group (all p > 0.20), although Group × Epoch interactions did not reach significance (all p > 0.05), likely due to limited statistical power. Inter-hand coherence remained unchanged. The computational model demonstrated that enhanced proprioceptive feedback gain qualitatively reproduces the observed behavioral pattern. Anodal tDCS over the left sensorimotor/premotor region may enhance bimanual force control under conditions requiring proprioceptive feedback. Replication with larger samples is needed to confirm between-group specificity.

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

Repositories

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

Zenodo 19745973

License: CC-BY-4.0
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: “Data Availability Statement”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: Matplotlib (1 file), NumPy (1 file), pandas (1 file), SciPy (1 file)
Availability: 1 check, the latest on 30 September 2026: the link answers (HTTP 200)
  • 30 September 2026: the link answers (HTTP 200)
3 files
At the source:

osmar235/tdcs_left_sensorimotor_cortex_bimanual_force_control

License: MIT
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Commit: 5053987cc6098c994c615ca0555a0dd364601fdc, 1 March 2026
Languages: Python (1)
Size: 8 files, 1 script
Software Heritage: not archived
Found in: the Zenodo archive record
Holds: README, license file
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: Matplotlib (1 file), NumPy (1 file), pandas (1 file), SciPy (1 file)
Availability: 1 check, the latest on 30 September 2026: the link answers
  • 30 September 2026: the link answers
3 files

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

Tracing map

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

What the map holds:

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

Anonymized experimental data and computational model code are available at Zenodo (DOI: 10.5281/zenodo.19745973) and can also be obtained upon request from the corresponding author.

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

Recorded: type, language, journal, volume, issue, pages, dates, 6 authors, 8 keywords, 1 funder, 30 references.

Cite

This paper

Lima, V. d. M. S., Arthur, E. F., Gonzaga, R. R. D., Diniz, L. F., Pedreiro, R. C. d. M., & Pinto Neto, O. (2026). Effects of Transcranial Direct Current Stimulation over the Left Sensorimotor Cortex on Bimanual Force Control: A Computational and Experimental Investigation. Bioengineering (Basel, Switzerland), 13(5), 502. https://doi.org/10.3390/bioengineering13050502

BibTeX

@article{lima2026effects,
author = {Lima, Vinicius de Moura Silva and Arthur, Eduarda Faria and Gonzaga, Rafaela Rodrigues Dousseau and Diniz, Luan Faria and Pedreiro, Rodrigo Cunha de Mello and Pinto Neto, Osmar},
title = {{Effects of Transcranial Direct Current Stimulation over the Left Sensorimotor Cortex on Bimanual Force Control: A Computational and Experimental Investigation}},
journal = {Bioengineering (Basel, Switzerland)},
year = {2026},
month = apr,
volume = {13},
number = {5},
pages = {502},
publisher = {Multidisciplinary Digital Publishing Institute (MDPI)},
issn = {2306-5354},
doi = {10.3390/bioengineering13050502},
url = {https://doi.org/10.3390/bioengineering13050502},
pmid = {42194259},
pmcid = {PMC13203247}
}

RIS

TY - JOUR
AU - Lima, Vinicius de Moura Silva
AU - Arthur, Eduarda Faria
AU - Gonzaga, Rafaela Rodrigues Dousseau
AU - Diniz, Luan Faria
AU - Pedreiro, Rodrigo Cunha de Mello
AU - Pinto Neto, Osmar
TI - Effects of Transcranial Direct Current Stimulation over the Left Sensorimotor Cortex on Bimanual Force Control: A Computational and Experimental Investigation
T2 - Bioengineering (Basel, Switzerland)
J2 - Bioengineering (Basel)
PY - 2026
DA - 2026/04/26
VL - 13
IS - 5
SP - 502
SN - 2306-5354
PB - Multidisciplinary Digital Publishing Institute (MDPI)
DO - 10.3390/bioengineering13050502
UR - https://doi.org/10.3390/bioengineering13050502
LA - en
ER -

CSL-JSON

{
"id": "10.3390/bioengineering13050502",
"type": "article-journal",
"title": "Effects of Transcranial Direct Current Stimulation over the Left Sensorimotor Cortex on Bimanual Force Control: A Computational and Experimental Investigation",
"container-title": "Bioengineering (Basel, Switzerland)",
"author": [
{
"family": "Lima",
"given": "Vinicius de Moura Silva"
},
{
"family": "Arthur",
"given": "Eduarda Faria"
},
{
"family": "Gonzaga",
"given": "Rafaela Rodrigues Dousseau"
},
{
"family": "Diniz",
"given": "Luan Faria"
},
{
"family": "Pedreiro",
"given": "Rodrigo Cunha de Mello"
},
{
"family": "Pinto Neto",
"given": "Osmar"
}
],
"container-title-short": "Bioengineering (Basel)",
"volume": "13",
"issue": "5",
"page": "502",
"DOI": "10.3390/bioengineering13050502",
"PMID": "42194259",
"PMCID": "PMC13203247",
"ISSN": "2306-5354",
"publisher": "Multidisciplinary Digital Publishing Institute (MDPI)",
"URL": "https://doi.org/10.3390/bioengineering13050502",
"language": "en",
"issued": {
"date-parts": [
[
2026,
4,
26
]
]
}
}

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.1113/jp291217 [code]
Transcranial direct current stimulation enhances delayed retention after 5 days of lower-limb motor skill learning.
Journal: The Journal of physiology
In common: Matplotlib, NumPy, other, 3 references
[2] doi:10.1162/imag.a.1211 [code]
Effects of electric field direction on TMS-based motor cortex mapping.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: pandas, SciPy, Matplotlib, 1 other tool, other, systems, 1 reference
[3] doi:10.1038/s41598-026-45549-3
Effects of single-session anodal transcranial direct current stimulation (tDCS) on cognitive and motor performance in athletes and healthy adults: a systematic review and meta-analysis.
Journal: Scientific reports
In common: other, 3 references
[4] doi:10.1038/s41467-026-75799-8 [code]
Behaviourally driven closed-loop beta-tACS enhances beta activity and motor behaviour.
Journal: Nature communications
In common: other, systems, 3 references
[5] doi:10.1038/s41467-026-72917-4 [code]
OFC-induced network modularity improves positive symptoms and attentional alertness in schizophrenia: a combined rTMS-fMRI study.
Journal: Nature communications
In common: pandas, SciPy, Matplotlib, 1 other tool, other, 1 reference
[6] doi:10.1162/imag.a.1222 [code]
Network-based near-scalp personalized brain stimulation targets.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: SciPy, Matplotlib, NumPy, other, systems, 1 reference
[7] doi:10.3390/s26103131 [code]
Neuromagnetism "On the Cheap": Evaluating a Combined Cylindrical Shield and Partial-Coverage OPM-MEG System for Detecting Sensorimotor Responses in Humans.
Journal: Sensors (Basel, Switzerland)
In common: pandas, SciPy, Matplotlib, 1 other tool, systems, 1 reference
[8] doi:10.1162/imag.a.1256 [code]
Gamer in the scanner: Event-related analysis of fMRI activity during retro videogame play guided by automated annotations of game content.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: pandas, SciPy, Matplotlib, 1 other tool, 1 reference
[9] doi:10.3389/fnins.2026.1803897 [code]
Multimodal imaging-based targeting approach for network-level brain stimulation.
Journal: Frontiers in neuroscience
In common: SciPy, Matplotlib, NumPy, other, systems, 1 reference
[10] doi:10.1093/psyrad/kkag015 [code]
Neurocomputational mechanisms of reward-based online mood regulation in adolescents with bipolar disorder and major depressive disorder.
Journal: Psychoradiology
In common: pandas, SciPy, Matplotlib, 1 other tool, 1 reference

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.