OSCR

Neural population dynamics of direct electrical stimulation of neocortex.

Code ↔ Paper

7 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 7 matches
  1. [1] § STAR★METHODS › QUANTIFICATION AND STATISTICAL ANALYSIS › Evoked potential analysis › Aligning and averaging the evoked potential ↔ figure2.ipynb, lines 805–922 · score 0.62 · CCF distance, peak aligned, normalized distance, evoked potential, boundaries, amplitude
  2. [2] § STAR★METHODS › METHOD DETAILS › Visual and electrical stimulation behavioral detection assay › Software and hardware implementation: ↔ dev.py, lines 242–287 · score 0.59 · spout positioning, Pyglet, solenoid, lick, monitors, delivery
  3. [3] § STAR★METHODS › METHOD DETAILS › Visual and electrical stimulation behavioral detection assay › Task protocol: ↔ plotter.py, lines 54–179 · score 0.59 · water delivery, daily, days, weight, curves, reward
  4. [4] § STAR★METHODS › QUANTIFICATION AND STATISTICAL ANALYSIS › Anisotropic volumetric analyses › Convex-hull comparison to an isotropic sphere hull ↔ figure3.ipynb, lines 1754–1827 · score 0.54 · convex hull, sphere hull, tessellated, Figure 3
  5. [5] § RESULTS › Evoked potential spatial extent increases sub-linearly with amplitude ↔ figure2.ipynb, lines 78–136 · score 0.54 · radial distance, point source model, meters, evoked potentials, Figure 2
  6. [6] § RESULTS › Anisotropic volumetric profile of the induced electrical field ↔ figure3.ipynb, lines 891–980 · score 0.50 · Euclidean distance, fitted spheres, mass, contact, radius, Figure 3
  7. [7] § STAR★METHODS › METHOD DETAILS › Visual and electrical stimulation behavioral detection assay › Trial structure: ↔ params.py, lines 26–77 · score 0.50 · variable, lapse, alarm, licking, Catch, 1.5 s

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

Jupyter notebook · 2,260 lines · 88 KB · GPL-3.0 · 2 matches

  1. # %%
  2. import pandas as pd
  3. import numpy as np
  4. import matplotlib.pyplot as plt
  5. import os,glob
  6. import seaborn as sns
  7. from open_ephys.analysis import Session
  8. from pynwb import NWBHDF5IO, NWBFile
  9. from pynwb.file import Subject
  10. from datetime import datetime
  11. from dateutil.tz import tzlocal
  12. from jlh_ephys.analysis import Analysis
  13. from jlh_ephys.utils import choose_stim_parameter, OE
  14. from jlh_ephys.psth_raster import psth_arr
  15. #from jlh_ephys import spatial_tools as st ## compatability issues maybe need to install from allensdk.core.mouse_connectivity_cache import MouseConnectivityCache in its own env
  16. from jlh_ephys.raw import find_artifact_start, align_data, raw_heatmap
  17. from ccf_3D.tools import herbs_processing as hp
  18. from ccf_3D.tools.metrics import distance
  19. import matplotlib
  20. matplotlib.rcParams['pdf.fonttype'] = 42
  21. matplotlib.rcParams['ps.fonttype'] = 42
  22. from scipy.ndimage import gaussian_filter1d
  23. # %%
  24. jlh31 = Analysis('jlh31', '2023-01-24') # all probes, many params and sites
  25. jlh32 = Analysis('jlh32', '2023-02-18') # all probes, many params and sites
  26. jlh33 = Analysis('jlh33', '2023-02-22') # probe B and C, many params and trains in diff recordings
  27. jlh34 = Analysis('jlh34', '2023-05-15') # all 3 many params
  28. jlh39 = Analysis('jlh39', '2023-08-16') # 3 probes for bi, no A for mono
  29. jlh40 = Analysis('jlh40', '2023-08-21') # probeB and probe C only: monopolar -- with diff pulse width
  30. jlh48 = Analysis('jlh48', '2024-11-05') # bb test, sorted only no bb portion (full bi and mono)
  31. jlh49 = Analysis('jlh49v1', '2024-07-11') # bb test, just no bb portion (full bi only)
  32. jlh53 = Analysis('jlh53concat', '2024-09-03') # regular, all 3 probes, probe A broke sometime.
  33. recordings = [jlh31, jlh32, jlh33, jlh34, jlh39, jlh40, jlh48, jlh49, jlh53]
  34. for r in recordings:
  35. r.get_brain_regs()
  36. r.get_electrode_coords()
  37. nn_contacts_labels = [6,11,3,14,1,16,2,15,5,12,4,13,7,10,8,9]
  38. EP_path = r"C:\Users\jordan\Documents\combined_figs\evoked_potentials"
  39. amp_alphas = {5: 0.3, 25: 0.5, 50: 0.7, 100: 1.0}
  40. bipolar_color = '#82A69D'
  41. cathodal_color = '#161943'
  42. anodal_color = '#D99551'
  43. # %%
  44. distances = {}
  45. distances_norm = {}
  46. for r in recordings:
  47. contact = r.trials.contact_negative[0]
  48. distances[r.mouse] = r.get_dists(contact)
  49. temp_norm_dist = {}
  50. for probe, dists in r.distances.items():
  51. # normalize min dist to 0
  52. min = np.min(dists)
  53. dists_norm = dists - min
  54. # make prior to crossing point negative and after positive
  55. cross_index = np.where(dists_norm == 0)[0][0] # this gives the first index where data is non-positive
  56. dists_norm[cross_index:] = -np.abs(dists_norm[cross_index:])
  57. temp_norm_dist[probe] = dists_norm
  58. dists[cross_index:] = -np.abs(dists[cross_index:])
  59. r.distances[probe] = dists
  60. distances_norm[r.mouse] = temp_norm_dist
  61. # %% [markdown]
  62. # # point source modeling (generating a null hypothesis) Figure 2a-b
  63. # %%
  64. rho = 5.56
  65. currents_uA = [5, 25, 50, 100]
  66. currents_A = [i * 1e-6 for i in currents_uA]
  67. # Radial distances (meters)
  68. r_min = 0.5e-4 # 0.05 mm or 50 microns
  69. r_max = 2e-3 # 1 mm
  70. N_points = 600
  71. r_values = np.linspace(r_min, r_max, N_points)
  72. fig = plt.figure(figsize=(3.54, 3.54))
  73. grays = ['0.8', '0.5', '0.3', '0.1']
  74. half_maxs_dist = [] # duh they're all the same LOL ignore this
  75. full_maxs_dists = []
  76. full_max_threshold = 0.1
  77. for i, I in enumerate(currents_A):
  78. # Compute potential (in Volts)
  79. V = (rho * I) / (4 * np.pi * r_values)
  80. # Distance in mm for plotting
  81. r_mm = r_values * 1e3
  82. hundred_microns = np.abs(r_mm - 0.100).argmin()
  83. max = V[hundred_microns]
  84. half_max = max / 2
  85. half_max_dist = np.abs(V - half_max).argmin()
  86. half_maxs_dist.append(r_mm[half_max_dist])
  87. full_max_dist = np.abs(V - full_max_threshold).argmin()
  88. full_maxs_dists.append(r_mm[full_max_dist])
  89. # Plot: x is potential (V), y is distance (mm)
  90. label_str = f"I = {I*1e6:.0f} µA"
  91. plt.plot(V, r_mm, linewidth=2, color=grays[i], label=label_str)
  92. plt.plot(V, -r_mm, linewidth=2, color=grays[i]) # mirror around 0 for ±r
  93. plt.xlabel('Potential V (V)', fontsize=10)
  94. plt.ylabel('Distance r (mm)', fontsize=10)
  95. plt.title('Point-Source Potential in a Homogeneous Medium', fontsize=10)
  96. # Match the original axis ranges:
  97. # Potential from 0 to 1, distance from -1 mm to 1 mm
  98. plt.xlim(0, 1)
  99. plt.ylim(-1, 1)
  100. plt.legend(fontsize=8)
  101. plt.tight_layout()
  102. save_str = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials\point_source_modeling\potential_vs_distance.png'
  103. #plt.savefig(save_str)
  104. save_str = save_str = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials\point_source_modeling\potential_vs_distance.pdf'
  105. #plt.savefig(save_str)
  106. full_maxs_dists
  107. # %%
  108. # point-source spatial extent vs current using analytic threshold crossing
  109. rho = 5.56 # resistivity (Ω·m)
  110. currents_uA = [5, 25, 50, 100] # µA
  111. currents_A = np.array(currents_uA) * 1e-6
  112. V_thresh = 0.05 # volts; "near zero" threshold (try 0.02–0.1)
  113. r_thresh_m = (rho * currents_A) / (4 * np.pi * V_thresh)
  114. r_thresh_mm = r_thresh_m * 1e3
  115. fig, ax = plt.subplots(figsize=(2, 2.5))
  116. ax.plot(currents_uA, r_thresh_mm, '-', lw=2, color = 'k')
  117. ax.scatter(currents_uA, r_thresh_mm, color = 'k', s=28)
  118. ax.set_xlabel('current (µA)')
  119. ax.set_ylabel(r'$r_{\mathrm{th}}$ (mm)')
  120. ax.set_title(f'spatial extent at V_th = {V_thresh:.3f} V')
  121. ax.spines['top'].set_visible(False)
  122. ax.spines['right'].set_visible(False)
  123. ax.grid(False)
  124. ax.set_yticks([0, 0.5, 1.0])
  125. ax.set_xticks([0, 25, 50, 100])
  126. plt.tight_layout()
  127. save_str = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials\point_source_modeling\potential_vs_distance_spatialextent.pdf'
  128. #plt.savefig(save_str)
  129. # %%
  130. # %% [markdown]
  131. # # algorithm for calculating spatial boundaries of evoked potentials (load data from csv of pre-calculated)
  132. # %%
  133. ## note this is will only work if you have raw data which is available upon request
  134. # thus this is mainly demonstrative -- I provide a curated dataframe of the results where all figures were calculated
  135. from scipy.ndimage import gaussian_filter1d
  136. pre = -0.3
  137. post = 5
  138. snapshots = [1] # ms
  139. snapshot_idxs = [int(pre / 1000 * 30_000) + int(s / 1000 * 30_000) for s in snapshots]
  140. snap_alphas = [1]
  141. data_list = []
  142. for r in recordings:
  143. for probe in r.probes:
  144. if r.mouse in ['jlh33', 'jlh40']: # this fixes the issue with recorded vs geometric probes
  145. if probe == 'probeB':
  146. raw_probe = 'probeA'
  147. elif probe == 'probeC':
  148. raw_probe = 'probeB'
  149. else:
  150. raw_probe = probe
  151. channels = len(r.probe_coords[probe]) + 20
  152. for run in r.trials.run.unique():
  153. fig, axs = plt.subplots(1, 2, figsize=(11, 8))
  154. stim_times = r.trials[r.trials.run == run].start_time.values
  155. title = f'{probe}_Run{run} {r.parameters[run]}'
  156. data = r.raw.get_chunk(raw_probe, stim_times, pre=pre + 3, post=post + 3, chs=np.arange(channels))
  157. try:
  158. aligned_data = align_data(data, pre, post, channels, threshold=500, median_subtraction=False)
  159. except:
  160. print(f'Error in recording {r.mouse} probe {probe} amp {amp}')
  161. aligned_data = data
  162. dists = distances[r.mouse][probe]
  163. sub_chs = np.arange(len(dists), len(dists) + 15)
  164. aligned_sub_data = aligned_data - np.median(aligned_data[:,:, sub_chs], axis=2)[:,:, np.newaxis]
  165. # Use the axs object for the current amp
  166. raw_heatmap(aligned_sub_data, pre=pre, post=post, dists=dists, vmin=-500, vmax=500,
  167. save=False, title=f'{r.parameters[run]}', ax=axs[0])
  168. mean_aligned_sub_data = np.mean(aligned_sub_data, axis=0)
  169. for snap_idx, (snapshot_time, idx) in enumerate(zip(snapshots, snapshot_idxs)):
  170. data_snap = aligned_data[:, idx, :]
  171. data_snap = np.abs(data_snap - np.median(data_snap[:, sub_chs], axis=1)[:, np.newaxis])
  172. data_snap[:,191] = data_snap[:,192] # ch 191 seems to be the reference?
  173. # algo for slope detection
  174. smoothed_gauss = gaussian_filter1d(data_snap, sigma=2, axis=1)
  175. mean_data = np.mean(data_snap, axis=0)
  176. mean_gauss = np.mean(smoothed_gauss, axis=0)
  177. sd_gauss = np.std(smoothed_gauss, axis=0)
  178. baseline_mean = np.mean(mean_gauss[-15:])
  179. baseline_std = np.std(mean_gauss[-15:])
  180. max_idx = np.argmax(mean_gauss[30:])+30
  181. max_volt = mean_gauss[max_idx]
  182. max_threshold = 40
  183. min_voltage = 20
  184. half_max = max_volt / 2
  185. if max_volt > max_threshold:
  186. uppers = mean_gauss[max_idx:]
  187. lowers = mean_gauss[:max_idx] # doesn't allow bottom 30 channels to be considered
  188. upper_chs_outside_potential = np.where(uppers < min_voltage)[0]
  189. if len(upper_chs_outside_potential) > 0:
  190. upper_bound = (upper_chs_outside_potential[0] - 2) + max_idx #minus two to get channel safely within range
  191. half_max_upper = np.where(uppers > half_max)[0][-1] + max_idx
  192. else:
  193. upper_bound = -1
  194. half_max_upper = -1
  195. lower_chs_outside_potential = np.where(lowers < min_voltage)[0]
  196. if len(lower_chs_outside_potential) > 0:
  197. lower_bound = lower_chs_outside_potential[-1] + 2 #plus 2 to get channel safely within range
  198. half_max_lower = np.where(lowers < half_max)[0][-1]
  199. else:
  200. lower_bound = -1
  201. half_max_lower = -1
  202. else:
  203. lower_bound, upper_bound = -1,-1
  204. axs[1].plot(mean_data, np.arange(0,channels), color='k', label='Raw Mean')
  205. axs[1].plot(mean_gauss, np.arange(0,channels), color='blue', label='Gaussian Mean')
  206. axs[1].fill_betweenx(np.arange(0,channels), mean_gauss - sd_gauss, mean_gauss + sd_gauss, color='blue', alpha=0.3, label='SD')
  207. axs[1].set_ylabel('Channel')
  208. axs[1].set_xlabel('Voltage')
  209. axs[1].set_title('snapshots')
  210. axs[1].set_yticks(np.arange(0,channels,10))
  211. axs[1].legend()
  212. if max_volt > max_threshold:
  213. axs[1].axhline(lower_bound, color='cyan', linestyle='--', label='Slope Lower Bound')
  214. axs[1].axhline(upper_bound, color='cyan', linestyle='--', label='Slope Upper Bound')
  215. axs[1].axhline(half_max_lower, color = 'orange', linestyle = '--', label = 'Half Max Lower')
  216. axs[1].axhline(half_max_upper, color = 'orange', linestyle = '--', label = 'Half Max Upper')
  217. axs[1].axhline(max_idx, color='black', linestyle='--', label='Max')
  218. axs[0].axhline(lower_bound, color='cyan', linestyle='--')
  219. axs[0].axhline(upper_bound, color='cyan', linestyle='--')
  220. axs[0].axhline(half_max_lower, color = 'orange', linestyle = '--')
  221. axs[0].axhline(half_max_upper, color = 'orange', linestyle = '--',)
  222. axs[0].axhline(max_idx, color='black', linestyle='--')
  223. fig.suptitle(title, size=20)
  224. plt.tight_layout(rect=[0, 0.03, 1, 0.95])
  225. base_path = os.path.join(EP_path, "ap_potential_w_voltageline")
  226. sub_folder = os.path.join(base_path, "all_runs_med_sub_spatialdetection", 'abs_volt_threshold_from_max40_min20_halfmax_sigma3', '1ms', r.mouse)
  227. if not os.path.exists(sub_folder):
  228. os.makedirs(sub_folder)
  229. #plt.savefig(os.path.join(sub_folder, f'{r.mouse}_{probe}_run{run}.pdf'))
  230. #plt.savefig(os.path.join(sub_folder, f'{r.mouse}_{probe}_run{run}.png'))
  231. plt.close()
  232. # Append all relevant details to the list
  233. data_list.append({
  234. 'recording': r.mouse,
  235. 'probe': probe,
  236. 'run': run,
  237. 'stim_parameters': r.parameters[run],
  238. 'max_idx': max_idx,
  239. 'max_volt': max_volt,
  240. 'half_max': half_max,
  241. 'half_max_upper_idx': half_max_upper,
  242. 'half_max_lower_idx': half_max_lower,
  243. 'lower_bound_idx': lower_bound,
  244. 'upper_bound_idx': upper_bound,
  245. 'mean_gauss': mean_gauss.tolist(),
  246. 'std_gauss': sd_gauss.tolist(),
  247. 'mean_aligned_sub_data': mean_aligned_sub_data
  248. })
  249. # Convert the list to a dataframe
  250. df_results_w_array = pd.DataFrame(data_list)
  251. # %%
  252. # load the results from the EP boundaries algorithm with some intermediaries
  253. import pickle
  254. df_results = pd.read_pickle(os.path.join('intermediates', 'EP_boundaries_w_array_distances_curated.pkl'))
  255. recording_names = ['jlh31', 'jlh32', 'jlh33', 'jlh34', 'jlh39', 'jlh40', 'jlh48', 'jlh49v1', 'jlh53concat']
  256. recordings = [jlh31, jlh32, jlh33, jlh34, jlh39, jlh40, jlh48, jlh49, jlh53]
  257. # Create the recording map
  258. recording_map = {name: recording for name, recording in zip(recording_names, recordings)}
  259. with open(r'intermediates\mean_aligned_sub_data.pkl', 'rb') as f:
  260. array_list = pickle.load(f)
  261. df_results['mean_aligned_sub_data'] = array_list
  262. # %% [markdown]
  263. # # average EP heatmaps (Figure 2 e)
  264. # %%
  265. # note, requires raw data to run this code, which is available upon request
  266. from scipy.ndimage import gaussian_filter
  267. # average fields by polarity and amplitude
  268. polarities = ['bipolar', 'monopolar']
  269. fig, axs = plt.subplots(4, 4, figsize=(15, 12)) # 3 rows (probes), 4 columns (amplitudes)
  270. fig.suptitle(f'1ms aligned spatial response by polarities', fontsize=14)
  271. probes = ['probeA', 'probeB', 'probeC']
  272. amps = [-5, -25, -50, -100, 5, 25, 50, 100]
  273. pad = 400
  274. mean_array_bycondition = {}
  275. pre = -0.3
  276. post = 5
  277. df_results_w_array = df_results.copy()
  278. for polarity in polarities:
  279. for i, amp in enumerate(amps):
  280. if amp < 0 and polarity == 'bipolar':
  281. row = 0
  282. elif amp > 0 and polarity == 'bipolar':
  283. row = 1
  284. elif amp < 0 and polarity == 'monopolar':
  285. row = 2
  286. elif amp > 0 and polarity == 'monopolar':
  287. row = 3
  288. if np.abs(amp) == 5:
  289. col = 0
  290. elif np.abs(amp) == 25:
  291. col = 1
  292. elif np.abs(amp) == 50:
  293. col = 2
  294. elif np.abs(amp) == 100:
  295. col = 3
  296. ax = axs[row, col]
  297. # Filter data for the current probe and amplitude
  298. df = df_results_w_array[
  299. (df_results_w_array['max_volt'] > 40) &
  300. (df_results_w_array['amplitude'] == amp) &
  301. (df_results_w_array['polarity'] == polarity) &
  302. (df_results_w_array['half_max_lower_idx'] != -1) &
  303. (df_results_w_array['half_max_upper_idx'] != -1) &
  304. (df_results_w_array['lower_bound_idx'] != -1) &
  305. (df_results_w_array['upper_bound_idx'] != -1)
  306. ].reset_index()
  307. # Check if the dataframe is empty
  308. if df.empty:
  309. ax.set_title(f'{amp}uA {probe} (No Data)')
  310. ax.axis('off')
  311. continue
  312. combined_array = np.zeros((len(df), df.iloc[0]['mean_aligned_sub_data'].shape[0], 800)) #800 for padded
  313. for _, dfrow in df.iterrows():
  314. array = dfrow['mean_aligned_sub_data']
  315. gauss_array = gaussian_filter1d(array, sigma=1, axis=1)
  316. ## pad it
  317. max_idx = dfrow['max_idx']
  318. upper_gauss = gauss_array[:, max_idx:]
  319. lower_gauss = gauss_array[:, :max_idx]
  320. if upper_gauss.shape[1] < pad:
  321. upper_pad = [np.nan] * (pad - upper_gauss.shape[1])
  322. padded_upper_gauss = np.zeros((upper_gauss.shape[0], pad))
  323. for i in range(upper_gauss.shape[0]):
  324. padded_upper_gauss[i] = np.concatenate([upper_gauss[i], upper_pad])
  325. if lower_gauss.shape[1] < pad:
  326. lower_pad = [np.nan] * (pad - lower_gauss.shape[1])
  327. padded_lower_gauss = np.zeros((lower_gauss.shape[0], pad))
  328. for i in range(lower_gauss.shape[0]):
  329. padded_lower_gauss[i] = np.concatenate([lower_pad, lower_gauss[i]])
  330. padded_gauss = np.concatenate([padded_lower_gauss, padded_upper_gauss], axis=1)
  331. combined_array[_, :, :] = padded_gauss
  332. mean_array_bycondition[f'{polarity}_{amp}'] = combined_array #3D n (probe, run, etc) x samples x channels
  333. data_to_plot = np.nanmean(combined_array, axis=0).T
  334. data_to_plot = gaussian_filter(data_to_plot, sigma=2)
  335. #data_to_plot = gaussian_filter1d(data_to_plot, sigma=2, axis=1)
  336. #data_to_plot = gaussian_filter1d(data_to_plot, sigma = 2, axis=0)
  337. time_ms = np.linspace(-pre, post, data_to_plot.shape[0])
  338. ax.imshow(data_to_plot, aspect='auto', vmax=200, vmin=-200, origin='lower', cmap='vlag',
  339. extent=[time_ms[0], time_ms[-1], 0, data_to_plot.shape[0]])
  340. ax.set_ylim(300, 500)
  341. ax.set_title(f'{amp}uA {polarity}')
  342. ax.set_xlim(-pre, 3)
  343. if row == 3:
  344. ax.set_xlabel('Time (ms)')
  345. else:
  346. ax.set_xticks([])
  347. if col == 0:
  348. ax.set_ylabel('Channel')
  349. ax.set_yticklabels(np.arange(-100, 101, 50))
  350. else:
  351. ax.set_yticks([])
  352. #path = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials'
  353. #plt.savefig(os.path.join(path, 'avg_heatmaps_by_polarity_amp_mean_smoothed_3ms_100ch_updated.pdf'))
  354. #plt.savefig(os.path.join(path, 'avg_heatmaps_by_polarity_amp_mean_smoothed_3ms_100ch_updated.png'))
  355. # %% [markdown]
  356. # # average EP at 1 ms across distances (Figure 2f and Supp)
  357. # %%
  358. polarities = ['bipolar', 'monopolar']
  359. fig, axs = plt.subplots(4, 4, figsize=(7, 7)) # 3 rows (probes), 4 columns (amplitudes)
  360. fig.suptitle(f'1ms aligned spatial response by polarities', fontsize=14)
  361. probes = ['probeA', 'probeB', 'probeC']
  362. amps = [-5, -25, -50, -100, 5, 25, 50, 100]
  363. mean_boundaries_bycondition = {}
  364. for polarity in polarities:
  365. for i, amp in enumerate(amps):
  366. if amp < 0 and polarity == 'bipolar':
  367. row = 0
  368. elif amp > 0 and polarity == 'bipolar':
  369. row = 1
  370. elif amp < 0 and polarity == 'monopolar':
  371. row = 2
  372. elif amp > 0 and polarity == 'monopolar':
  373. row = 3
  374. if np.abs(amp) == 5:
  375. col = 0
  376. elif np.abs(amp) == 25:
  377. col = 1
  378. elif np.abs(amp) == 50:
  379. col = 2
  380. elif np.abs(amp) == 100:
  381. col = 3
  382. ax = axs[row, col]
  383. # Filter data for the current probe and amplitude
  384. df = df_results[
  385. (df_results['max_volt'] > 40) &
  386. (df_results['amplitude'] == amp) &
  387. (df_results['polarity'] == polarity) &
  388. (df_results['half_max_lower_idx'] != -1) &
  389. (df_results['half_max_upper_idx'] != -1) &
  390. (df_results['lower_bound_idx'] != -1) &
  391. (df_results['upper_bound_idx'] != -1)
  392. ]
  393. # Check if the dataframe is empty
  394. if df.empty:
  395. ax.set_title(f'{amp}uA {probe} (No Data)')
  396. ax.axis('off')
  397. continue
  398. # Create a matrix of aligned responses
  399. aligned_matrix = np.array(df['aligned_mean_gauss'].tolist())
  400. mean_aligned_matrix = np.nanmean(aligned_matrix, axis=0)
  401. std_aligned_matrix = np.nanstd(aligned_matrix, axis=0)
  402. sem_aligned_matrix = std_aligned_matrix / np.sqrt(np.sum(~np.isnan(aligned_matrix), axis=0)) # SEM
  403. normalized_chs = np.arange(-400, 400, 1)
  404. normalized_distances = np.arange(-4000, 4000, 10)
  405. upper_bound_idx = int(np.mean(df['upper_bound_idx_aligned'].values))
  406. lower_bound_idx = int(np.mean(df['lower_bound_idx_aligned'].values))
  407. half_max_upper_idx = int(np.mean(df['half_max_upper_idx_aligned'].values))
  408. half_max_lower_idx = int(np.mean(df['half_max_lower_idx_aligned'].values))
  409. mean_boundaries_bycondition[f'{polarity}_{amp}'] = {
  410. 'upper_bound': normalized_distances[upper_bound_idx],
  411. 'lower_bound': normalized_distances[lower_bound_idx],
  412. 'half_max_upper': normalized_distances[half_max_upper_idx],
  413. 'half_max_lower': normalized_distances[half_max_lower_idx],
  414. 'mean_max': mean_aligned_matrix[400],
  415. 'mean_AUC_fullEP': np.trapz(mean_aligned_matrix[lower_bound_idx:upper_bound_idx]),
  416. 'mean_AUC_halfEP': np.trapz(mean_aligned_matrix[half_max_lower_idx:half_max_upper_idx])
  417. }
  418. ax.axhline(normalized_distances[lower_bound_idx], color='blue', linestyle='--', label='Slope Lower Bound')
  419. ax.axhline(normalized_distances[upper_bound_idx], color='blue', linestyle='--', label='Slope Upper Bound')
  420. ax.axhline(normalized_distances[half_max_lower_idx], color = 'orange', linestyle = '--', label = 'Half Max Lower')
  421. ax.axhline(normalized_distances[half_max_upper_idx], color = 'orange', linestyle = '--', label = 'Half Max Upper')
  422. # Plot the SEM error cloud
  423. ax.fill_betweenx(
  424. normalized_distances,
  425. mean_aligned_matrix - sem_aligned_matrix,
  426. mean_aligned_matrix + sem_aligned_matrix,
  427. color='gray', alpha=0.3, label='SEM'
  428. )
  429. # Plot the mean aligned matrix
  430. ax.plot(mean_aligned_matrix, normalized_distances, color='black', linewidth=2, label='Mean')
  431. # Set titles and labels
  432. if row == 0:
  433. ax.set_title(f'{amp}uA')
  434. if col == 0:
  435. ax.set_ylabel(f'{polarity}', fontsize=11)
  436. else:
  437. ax.set_yticks([]) # Hide y-ticks for all columns except the first
  438. if row == 3:
  439. ax.set_xlabel('uV', fontsize=8)
  440. else:
  441. ax.set_xticks([]) # Hide x-ticks for all rows except the last
  442. # Add legend only to the last subplot
  443. if row == 3 and col == 3:
  444. ax.legend(loc='upper right', fontsize=8)
  445. ax.set_xlim([0, 800])
  446. ax.set_ylim([-1500, 1500])
  447. plt.tight_layout()
  448. path = os.path.join(r'C:\Users\jordan\Documents\combined_figs\evoked_potentials')
  449. #plt.savefig(os.path.join(path, 'spatial_response_by_polarity.png'))
  450. #plt.savefig(os.path.join(path, 'spatial_response_by_polarity.pdf'))
  451. # %%
  452. mean_boundaries_bycondition = {}
  453. fig, axs = plt.subplots(1, 3, figsize=(8, 3)) # 2 rows for polarities, 2 columns for amplitude sign
  454. fig.suptitle('1ms aligned spatial response by polarities', fontsize=12)
  455. colors = [cathodal_color, anodal_color, bipolar_color]
  456. amps = [-5, -25, -50, -100]
  457. for i, pol in enumerate(['cathodal', 'anodal', 'bipolar']):
  458. ax = axs[i]
  459. if pol == 'bipolar':
  460. polarity = 'bipolar'
  461. else:
  462. polarity = 'monopolar'
  463. for k, amp in enumerate(amps):
  464. if pol == 'anodal':
  465. amp = np.abs(amp) # Make positive for anodal
  466. df = df_results[
  467. (df_results['max_volt'] > 40) &
  468. (df_results['amplitude'] == amp) &
  469. (df_results['polarity'] == polarity) &
  470. (df_results['half_max_lower_idx'] != -1) &
  471. (df_results['half_max_upper_idx'] != -1) &
  472. (df_results['lower_bound_idx'] != -1) &
  473. (df_results['upper_bound_idx'] != -1)
  474. ]
  475. if df.empty:
  476. print('skipping empty dataframe for', amp, pol)
  477. continue # Skip this iteration if the dataframe is empty
  478. aligned_matrix = np.array(df['aligned_mean_gauss'].tolist())
  479. mean_aligned_matrix = np.nanmean(aligned_matrix, axis=0)
  480. sem_aligned_matrix = np.nanstd(aligned_matrix, axis=0) / np.sqrt(np.sum(~np.isnan(aligned_matrix), axis=0))
  481. normalized_distances = np.arange(-4000, 4000, 10)
  482. # Plot SEM error cloud
  483. ax.fill_betweenx(
  484. normalized_distances,
  485. mean_aligned_matrix - sem_aligned_matrix,
  486. mean_aligned_matrix + sem_aligned_matrix,
  487. color='gray', alpha=0.2 # Use different colors for each amplitude
  488. )
  489. # Plot the mean response
  490. ax.plot(mean_aligned_matrix, normalized_distances, label=f'{amp}uA', color=colors[i], alpha = 0.3 + (k * 0.2))
  491. ax.set_xlim([0, 800])
  492. ax.set_ylim([-1000, 1000])
  493. ax.set_title(f'{pol}')
  494. ax.set_xlabel('uV')
  495. ax.set_ylabel('Distance (um)')
  496. ax.legend(loc='upper right', fontsize=8)
  497. # Adjust layout and save figures
  498. plt.tight_layout()
  499. #path = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials'
  500. #plt.savefig(os.path.join(path, 'spatial_response_by_polarity_and_sign_100ch.png'))
  501. #plt.savefig(os.path.join(path, 'spatial_response_by_polarity_and_sign_100ch.pdf'))
  502. # %% [markdown]
  503. # # spatial extents superficial and deep (Figure 2g and Supp CCF vs peak-aligned)
  504. # %%
  505. # ccf distances and peak aligned distances plots
  506. from scipy.stats import sem
  507. from scipy.stats import linregress
  508. for dist_type in ['ccf', 'peak_aligned']:
  509. fig, axs = plt.subplots(1, 3, figsize=(8, 3)) # 2 rows for polarities, 2 columns for amplitude sign
  510. fig.suptitle(f'{dist_type} distances 1D spatial bounds (up vs down) w fits', fontsize=12)
  511. colors = [cathodal_color, anodal_color, bipolar_color]
  512. amps = [-5, -25, -50, -100]
  513. for i, pol in enumerate(['cathodal', 'anodal', 'bipolar']):
  514. ax = axs[i]
  515. ax.set_title(pol, color = colors[i])
  516. if pol == 'bipolar':
  517. polarity = 'bipolar'
  518. else:
  519. polarity = 'monopolar'
  520. all_upper_dists = []
  521. all_lower_dists = []
  522. all_amps = []
  523. for k, amp in enumerate(amps):
  524. if pol == 'anodal':
  525. amp = np.abs(amp) # Make positive for anodal
  526. ax.set_xlabel('Amplitude (uA)')
  527. ax.set_ylabel('Distance (units)')
  528. ax.invert_yaxis()
  529. df = df_results[
  530. (df_results['max_volt'] > 40) &
  531. (df_results['amplitude'] == amp) &
  532. (df_results['polarity'] == polarity) &
  533. (df_results['half_max_lower_idx'] != -1) &
  534. (df_results['half_max_upper_idx'] != -1) &
  535. (df_results['lower_bound_idx'] != -1) &
  536. (df_results['upper_bound_idx'] != -1)
  537. ]
  538. if dist_type == 'ccf':
  539. upper_dists = df['upper_bound_dist'].values
  540. lower_dists = df['lower_bound_dist'].values
  541. elif dist_type == 'peak_aligned':
  542. normalized_distances = np.arange(-4000, 4000, 10)[::-1]
  543. upper_bound_idxs = df['upper_bound_idx_aligned'].values
  544. lower_bound_idxs = df['lower_bound_idx_aligned'].values
  545. upper_dists = [normalized_distances[idx] for idx in upper_bound_idxs]
  546. lower_dists = [normalized_distances[idx] for idx in lower_bound_idxs]
  547. # Collect data for scatter plot
  548. all_upper_dists.extend(upper_dists)
  549. all_lower_dists.extend(lower_dists)
  550. all_amps.extend([amp] * len(upper_dists))
  551. # Scatter individual data points
  552. ax.scatter([amp] * len(upper_dists), upper_dists, color=colors[i], s = 3, alpha=0.8, zorder=2)
  553. ax.scatter([amp] * len(lower_dists), lower_dists, color=colors[i], s = 3, alpha=0.8, zorder=2)
  554. # print mean and std of upper and lower distances
  555. mean_upper = np.mean(all_upper_dists)
  556. mean_lower = np.mean(all_lower_dists)
  557. std_upper = np.std(all_upper_dists)
  558. std_lower = np.std(all_lower_dists)
  559. print(f'{pol} {dist_type} {amp}uA: Upper Mean: {mean_upper:.2f}, Std: {std_upper:.2f}; Lower Mean: {mean_lower:.2f}, Std: {std_lower:.2f}')
  560. # Fit polynomial regression to the data
  561. if len(all_amps) > 1: # Ensure we have enough data points
  562. upper_coeffs = np.polyfit(all_amps, all_upper_dists, deg=2)
  563. lower_coeffs = np.polyfit(all_amps, all_lower_dists, deg=2)
  564. upper_poly = np.poly1d(upper_coeffs)
  565. lower_poly = np.poly1d(lower_coeffs)
  566. # Generate smooth curves for plotting
  567. amp_range = np.linspace(np.min(all_amps), np.max(all_amps), 100)
  568. upper_fit = upper_poly(amp_range)
  569. lower_fit = lower_poly(amp_range)
  570. # Calculate standard error of the mean (SEM) for the fits
  571. upper_fit_err = sem(all_upper_dists)
  572. lower_fit_err = sem(all_lower_dists)
  573. # Plot polynomial regression lines
  574. ax.plot(amp_range, upper_fit, color='darkblue', label='Upper Bound Fit', zorder=4)
  575. ax.plot(amp_range, lower_fit, color='darkred', label='Lower Bound Fit', zorder=4)
  576. # Add error bands
  577. ax.fill_between(amp_range, upper_fit - upper_fit_err, upper_fit + upper_fit_err, color='blue', alpha=0.2, label='Upper Fit Error', zorder=1)
  578. ax.fill_between(amp_range, lower_fit - lower_fit_err, lower_fit + lower_fit_err, color='red', alpha=0.2, label='Lower Fit Error', zorder=1)
  579. x = all_amps
  580. y_upper = all_upper_dists
  581. y_lower = all_lower_dists
  582. # Linear fit (Upper)
  583. slope_u, intercept_u, r_u, pval_u, stderr_u = linregress(x, y_upper)
  584. r2_linear_upper = r_u ** 2
  585. print(f"Linear Fit (Upper): R² = {r2_linear_upper:.2f}, Slope = {slope_u:.2f}, Intercept = {intercept_u:.2f}, p = {pval_u:.2e}")
  586. # Quadratic fit (Upper)
  587. coeffs_quad_upper = np.polyfit(x, y_upper, 2)
  588. p_quad_upper = np.poly1d(coeffs_quad_upper)
  589. y_fit_quad_upper = p_quad_upper(x)
  590. r2_quad_upper = 1 - (np.sum((y_upper - y_fit_quad_upper)**2) / np.sum((y_upper - np.mean(y_upper))**2))
  591. print(f"Quadratic Fit (Upper): R² = {r2_quad_upper:.2f}, Coeffs = {coeffs_quad_upper}")
  592. # Linear fit (Lower)
  593. slope_l, intercept_l, r_l, pval_l, stderr_l = linregress(x, y_lower)
  594. r2_linear_lower = r_l ** 2
  595. print(f"Linear Fit (Lower): R² = {r2_linear_lower:.2f}, Slope = {slope_l:.2f}, Intercept = {intercept_l:.2f}, p = {pval_l:.2e}")
  596. # Quadratic fit (Lower)
  597. coeffs_quad_lower = np.polyfit(x, y_lower, 2)
  598. p_quad_lower = np.poly1d(coeffs_quad_lower)
  599. y_fit_quad_lower = p_quad_lower(x)
  600. r2_quad_lower = 1 - (np.sum((y_lower - y_fit_quad_lower)**2) / np.sum((y_lower - np.mean(y_lower))**2))
  601. print(f"Quadratic Fit (Lower): R² = {r2_quad_lower:.2f}, Coeffs = {coeffs_quad_lower}")
  602. # Add color-coded text annotations for R² values
  603. ax.text(
  604. 0.6, 0.90,
  605. f"Quad: R²: {r2_quad_upper:.2f}",
  606. color='blue', fontsize=5, verticalalignment='top', transform=ax.transAxes
  607. )
  608. ax.text(
  609. 0.6, 0.80,
  610. f"Lin: R²: {r2_linear_upper:.2f}",
  611. color='darkblue', fontsize=5, verticalalignment='top', transform=ax.transAxes
  612. )
  613. ax.text(
  614. 0.6, 0.10,
  615. f"Quad: R²: {r2_quad_lower:.2f}",
  616. color='red', fontsize=5, verticalalignment='top', transform=ax.transAxes)
  617. ax.text(
  618. 0.6, 0.20,
  619. f"Lin: R²: {r2_linear_lower:.2f}",
  620. color='darkred', fontsize=5, verticalalignment='top', transform=ax.transAxes)
  621. if pol == 'anodal':
  622. ax.set_xticks(np.abs(amps))
  623. else:
  624. ax.set_xticks(amps)
  625. ax.invert_xaxis() # Invert x-axis for negative amplitudes
  626. ax.invert_yaxis()
  627. # Adjust layout and save the figure
  628. plt.tight_layout(rect=[0, 0, 1, 0.96])
  629. path = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials'
  630. #plt.savefig(os.path.join(path, f'distances_{dist_type}_with_fit_updated.png'))
  631. #plt.savefig(os.path.join(path, f'distances_{dist_type}_with_fit_updated.pdf'))
  632. # %%
  633. # ccf distances and peak aligned distances plots
  634. from scipy.stats import sem, linregress
  635. for dist_type in ['ccf', 'peak_aligned']:
  636. fig, axs = plt.subplots(1, 3, figsize=(8, 3))
  637. fig.suptitle(f'{dist_type} distances 1D spatial bounds (up vs down) — linear fits', fontsize=12)
  638. colors = [cathodal_color, anodal_color, bipolar_color]
  639. amps = [-5, -25, -50, -100]
  640. for i, pol in enumerate(['cathodal', 'anodal', 'bipolar']):
  641. ax = axs[i]
  642. ax.set_title(pol, color=colors[i])
  643. polarity = 'bipolar' if pol == 'bipolar' else 'monopolar'
  644. all_upper_dists = []
  645. all_lower_dists = []
  646. all_amps = []
  647. mean_upper_per_amp = []
  648. mean_lower_per_amp = []
  649. sem_upper_per_amp = []
  650. sem_lower_per_amp = []
  651. for k, amp in enumerate(amps):
  652. if pol == 'anodal':
  653. amp = np.abs(amp)
  654. df = df_results[
  655. (df_results['max_volt'] > 40) &
  656. (df_results['amplitude'] == amp) &
  657. (df_results['polarity'] == polarity) &
  658. (df_results['half_max_lower_idx'] != -1) &
  659. (df_results['half_max_upper_idx'] != -1) &
  660. (df_results['lower_bound_idx'] != -1) &
  661. (df_results['upper_bound_idx'] != -1)
  662. ]
  663. if dist_type == 'ccf':
  664. upper_dists = df['upper_bound_dist'].values
  665. lower_dists = df['lower_bound_dist'].values
  666. else:
  667. normalized_distances = np.arange(-4000, 4000, 10)[::-1]
  668. upper_dists = [normalized_distances[idx] for idx in df['upper_bound_idx_aligned'].values]
  669. lower_dists = [normalized_distances[idx] for idx in df['lower_bound_idx_aligned'].values]
  670. # scatter points
  671. ax.scatter([amp] * len(upper_dists), upper_dists, color=colors[i], s=3, alpha=0.6, zorder=2)
  672. ax.scatter([amp] * len(lower_dists), lower_dists, color=colors[i], s=3, alpha=0.6, zorder=2)
  673. # accumulate
  674. all_upper_dists.extend(upper_dists)
  675. all_lower_dists.extend(lower_dists)
  676. all_amps.extend([amp] * len(upper_dists))
  677. # store mean ± sem for plotting mean dots
  678. mean_upper_per_amp.append(np.mean(upper_dists))
  679. mean_lower_per_amp.append(np.mean(lower_dists))
  680. sem_upper_per_amp.append(sem(upper_dists))
  681. sem_lower_per_amp.append(sem(lower_dists))
  682. print(f'{pol} {dist_type} {amp}uA: upper mean={np.mean(upper_dists):.2f}, lower mean={np.mean(lower_dists):.2f}')
  683. # plot means with error bars
  684. ax.errorbar(amps if pol != 'anodal' else np.abs(amps),
  685. mean_upper_per_amp, yerr=sem_upper_per_amp,
  686. fmt='o', color='lightgray', markersize=4, label='upper mean', zorder=4)
  687. ax.errorbar(amps if pol != 'anodal' else np.abs(amps),
  688. mean_lower_per_amp, yerr=sem_lower_per_amp,
  689. fmt='o', color='black', markersize=4, label='lower mean', zorder=4)
  690. # linear fits
  691. if len(all_amps) > 1:
  692. x = np.asarray(all_amps)
  693. y_upper = np.asarray(all_upper_dists)
  694. y_lower = np.asarray(all_lower_dists)
  695. # linear regressions
  696. slope_u, intercept_u, r_u, p_u, _ = linregress(x, y_upper)
  697. slope_l, intercept_l, r_l, p_l, _ = linregress(x, y_lower)
  698. r2_u = r_u ** 2
  699. r2_l = r_l ** 2
  700. x_line = np.linspace(np.min(x), np.max(x), 100)
  701. ax.plot(x_line, slope_u * x_line + intercept_u, color='lightgray', linewidth=1.2)
  702. ax.plot(x_line, slope_l * x_line + intercept_l, color='black', linewidth=1.2)
  703. print(f"{pol} upper linear: R²={r2_u:.2f}, slope={slope_u:.2f}, p={p_u:.2e}")
  704. print(f"{pol} lower linear: R²={r2_l:.2f}, slope={slope_l:.2f}, p={p_l:.2e}")
  705. # annotate
  706. ax.text(0.60, 0.85, f"Upper R²={r2_u:.2f}", color='lightgray', fontsize=6, va='top', transform=ax.transAxes)
  707. ax.text(0.60, 0.20, f"Lower R²={r2_l:.2f}", color='black', fontsize=6, va='top', transform=ax.transAxes)
  708. # aesthetics
  709. if pol == 'anodal':
  710. ax.set_xticks(np.abs(amps))
  711. else:
  712. ax.set_xticks(amps)
  713. ax.invert_xaxis()
  714. ax.set_xlabel('amplitude (uA)')
  715. ax.set_ylabel('boundary distance')
  716. ax.spines['top'].set_visible(False)
  717. ax.spines['right'].set_visible(False)
  718. ax.invert_yaxis()
  719. ax.set_ylim([1500, -1500])
  720. ax.set_yticks([1500, 1000, 500, 0, -500, -1000, -1500], ['1500', '', '', '0', '', '', '-1500'])
  721. plt.tight_layout(rect=[0, 0, 1, 0.96])
  722. path = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials'
  723. # plt.savefig(os.path.join(path, f'distances_{dist_type}_linear_means.png'))
  724. # plt.savefig(os.path.join(path, f'distances_{dist_type}_linear_means.pdf'))
  725. # %%
  726. # full version: signed means ± SEM (clouds) and absolute linear fits (offset scatters)
  727. from scipy.stats import sem, linregress
  728. for dist_type in ['ccf', 'peak_aligned']:
  729. colors = [cathodal_color, anodal_color, bipolar_color]
  730. amps_base = [-5, -25, -50, -100]
  731. # ========== PLOT 1: signed means ± SEM as error "clouds" with individual points ==========
  732. fig1, axs1 = plt.subplots(1, 3, figsize=(8, 3))
  733. fig1.suptitle(f'{dist_type} — signed boundary distances (mean ± SEM clouds)', fontsize=12)
  734. for i, pol in enumerate(['cathodal', 'anodal', 'bipolar']):
  735. ax = axs1[i]
  736. ax.set_title(pol, color=colors[i])
  737. polarity = 'bipolar' if pol == 'bipolar' else 'monopolar'
  738. amps = np.abs(amps_base) if pol == 'anodal' else amps_base
  739. mean_upper, mean_lower = [], []
  740. sem_upper, sem_lower = [], []
  741. for amp_in in amps_base:
  742. amp = abs(amp_in) if pol == 'anodal' else amp_in
  743. df = df_results[
  744. (df_results['max_volt'] > 40) &
  745. (df_results['amplitude'] == amp) &
  746. (df_results['polarity'] == polarity) &
  747. (df_results['half_max_lower_idx'] != -1) &
  748. (df_results['half_max_upper_idx'] != -1) &
  749. (df_results['lower_bound_idx'] != -1) &
  750. (df_results['upper_bound_idx'] != -1)
  751. ]
  752. if dist_type == 'ccf':
  753. upper_dists = df['upper_bound_dist'].values
  754. lower_dists = df['lower_bound_dist'].values
  755. else:
  756. normalized_distances = np.arange(-4000, 4000, 10)[::-1]
  757. upper_dists = [normalized_distances[idx] for idx in df['upper_bound_idx_aligned'].values]
  758. lower_dists = [normalized_distances[idx] for idx in df['lower_bound_idx_aligned'].values]
  759. ax.scatter([amp]*len(upper_dists), upper_dists, color=colors[i], s=4, alpha=0.4, zorder=1)
  760. ax.scatter([amp]*len(lower_dists), lower_dists, color=colors[i], s=4, alpha=0.4, zorder=1)
  761. mean_upper.append(np.mean(upper_dists))
  762. mean_lower.append(np.mean(lower_dists))
  763. sem_upper.append(sem(upper_dists))
  764. sem_lower.append(sem(lower_dists))
  765. # shaded SEM "clouds"
  766. ax.plot(amps, mean_upper, color='gray', lw=1.2)
  767. ax.fill_between(amps,
  768. np.array(mean_upper)-np.array(sem_upper),
  769. np.array(mean_upper)+np.array(sem_upper),
  770. color='gray', alpha=0.25, label='upper ± sem')
  771. ax.plot(amps, mean_lower, color='black', lw=1.2)
  772. ax.fill_between(amps,
  773. np.array(mean_lower)-np.array(sem_lower),
  774. np.array(mean_lower)+np.array(sem_lower),
  775. color='black', alpha=0.25, label='lower ± sem')
  776. # formatting
  777. if pol == 'anodal':
  778. ax.set_xticks(np.abs(amps_base))
  779. else:
  780. ax.set_xticks(amps_base)
  781. ax.invert_xaxis()
  782. ax.set_xlabel('amplitude (µA)')
  783. if i == 0:
  784. ax.set_ylabel('boundary distance (µm)')
  785. ax.spines['top'].set_visible(False)
  786. ax.spines['right'].set_visible(False)
  787. ax.invert_yaxis()
  788. ax.set_ylim([1500, -1500])
  789. ax.set_yticks([1500, 1000, 500, 0, -500, -1000, -1500],
  790. ['1500', '', '', '0', '', '', '-1500'])
  791. if i == 0:
  792. ax.legend(frameon=False, fontsize=7, loc='lower left')
  793. plt.tight_layout(rect=[0, 0, 1, 0.95])
  794. #plt.savefig(os.path.join(path, f'{dist_type}_means_sem_cloud.png'))
  795. #plt.savefig(os.path.join(path, f'{dist_type}_means_sem_cloud.pdf'))
  796. # ========== PLOT 2: absolute distances with linear fits + offset scatters ==========
  797. fig2, axs2 = plt.subplots(1, 3, figsize=(8, 3))
  798. fig2.suptitle(f'{dist_type} — absolute boundary distances (linear fits + offset scatters)', fontsize=12)
  799. offset = 3.0 # µA offset between upper and lower scatters
  800. for i, pol in enumerate(['cathodal', 'anodal', 'bipolar']):
  801. ax = axs2[i]
  802. ax.set_title(pol, color=colors[i])
  803. polarity = 'bipolar' if pol == 'bipolar' else 'monopolar'
  804. amps = np.abs(amps_base) if pol == 'anodal' else amps_base
  805. x_upper, y_upper = [], []
  806. x_lower, y_lower = [], []
  807. for amp_in in amps_base:
  808. amp = abs(amp_in) if pol == 'anodal' else amp_in
  809. df = df_results[
  810. (df_results['max_volt'] > 40) &
  811. (df_results['amplitude'] == amp) &
  812. (df_results['polarity'] == polarity) &
  813. (df_results['half_max_lower_idx'] != -1) &
  814. (df_results['half_max_upper_idx'] != -1) &
  815. (df_results['lower_bound_idx'] != -1) &
  816. (df_results['upper_bound_idx'] != -1)
  817. ]
  818. if dist_type == 'ccf':
  819. upper_dists = df['upper_bound_dist'].values
  820. lower_dists = df['lower_bound_dist'].values
  821. else:
  822. normalized_distances = np.arange(-4000, 4000, 10)[::-1]
  823. upper_dists = [normalized_distances[idx] for idx in df['upper_bound_idx_aligned'].values]
  824. lower_dists = [normalized_distances[idx] for idx in df['lower_bound_idx_aligned'].values]
  825. au = np.abs(upper_dists)
  826. al = np.abs(lower_dists)
  827. x_upper.extend([amp]*len(au))
  828. y_upper.extend(au)
  829. x_lower.extend([amp]*len(al))
  830. y_lower.extend(al)
  831. # offset scatter positions
  832. ax.scatter(np.array([amp]*len(au)) + offset, au,
  833. marker='o', s=24, color='gray', alpha=0.6, edgecolor='none', zorder=2, label=None)
  834. ax.scatter(np.array([amp]*len(al)) - offset, al,
  835. marker='s', s=24, color='black', alpha=0.6, edgecolor='none', zorder=2, label=None)
  836. # regression fits
  837. if len(x_upper) > 1 and len(x_lower) > 1:
  838. xu, yu = np.array(x_upper, float), np.array(y_upper, float)
  839. xl, yl = np.array(x_lower, float), np.array(y_lower, float)
  840. su, bu, ru, pu, _ = linregress(xu, yu)
  841. sl, bl, rl, pl, _ = linregress(xl, yl)
  842. x_line = np.linspace(np.min(amps), np.max(amps), 100)
  843. ax.plot(x_line, sl * x_line + bl, color='black', lw=1.5, ls='-', label='|lower| fit')
  844. ax.plot(x_line, su * x_line + bu, color='gray', lw=1.5, ls='--', label='|upper| fit')
  845. print(f"{pol} {dist_type}: upper R²={ru**2:.2f}, lower R²={rl**2:.2f}")
  846. if pol == 'anodal':
  847. ax.set_xticks(np.abs(amps_base))
  848. else:
  849. ax.set_xticks(amps_base)
  850. ax.invert_xaxis()
  851. ax.set_xlabel('amplitude (µA)')
  852. if i == 0:
  853. ax.set_ylabel('absolute boundary distance (µm)')
  854. ax.spines['top'].set_visible(False)
  855. ax.spines['right'].set_visible(False)
  856. ax.set_ylim(0, 1500)
  857. ax.set_yticks([0, 500, 1000, 1500])
  858. if i == 0:
  859. ax.legend(frameon=False, fontsize=7, loc='upper left')
  860. plt.tight_layout(rect=[0, 0, 1, 0.95])
  861. path = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials'
  862. #plt.savefig(os.path.join(path, f'{dist_type}_abs_linear_fit_offset.png'))
  863. #plt.savefig(os.path.join(path, f'{dist_type}_abs_linear_fit_offset.pdf'))
  864. # %%
  865. # config
  866. dist_type = 'ccf' # 'ccf' or 'peak_aligned'
  867. title = f'50 µA radius across pols'
  868. # helper: pull combined absolute distances (upper + lower) at 50 µA for one polarity
  869. def _pull_combined_50(df_results, dist_type, pol_name):
  870. # map to df polarity/amp
  871. if pol_name == 'bipolar':
  872. polarity = 'bipolar'; amp = -50
  873. elif pol_name == 'anodal':
  874. polarity = 'monopolar'; amp = 50
  875. else:
  876. polarity = 'monopolar'; amp = -50 # cathodal
  877. df = df_results[
  878. (df_results['max_volt'] > 40) &
  879. (df_results['amplitude'] == amp) &
  880. (df_results['polarity'] == polarity) &
  881. (df_results['half_max_lower_idx'] != -1) &
  882. (df_results['half_max_upper_idx'] != -1) &
  883. (df_results['lower_bound_idx'] != -1) &
  884. (df_results['upper_bound_idx'] != -1)
  885. ].copy()
  886. # get distances per side
  887. if dist_type == 'ccf':
  888. upper = df['upper_bound_dist'].to_numpy(dtype=float)
  889. lower = df['lower_bound_dist'].to_numpy(dtype=float)
  890. else: # 'peak_aligned'
  891. # aligned distances are in 10 µm steps from -4000..+3990 reversed
  892. normalized_distances = np.arange(-4000, 4000, 10)[::-1]
  893. upper = np.array([normalized_distances[idx] for idx in df['upper_bound_idx_aligned'].to_numpy()], dtype=float)
  894. lower = np.array([normalized_distances[idx] for idx in df['lower_bound_idx_aligned'].to_numpy()], dtype=float)
  895. # combine as magnitudes; n doubles (upper+lower treated as separate observations)
  896. pooled = np.concatenate([np.abs(upper), np.abs(lower)])
  897. return pooled
  898. # build tidy df
  899. rows = []
  900. for pol in ['cathodal', 'anodal', 'bipolar']:
  901. vals = _pull_combined_50(df_results, dist_type, pol)
  902. rows += [{'polarity': pol, 'dist_abs': v} for v in vals]
  903. plot_df = pd.DataFrame(rows)
  904. # plot: strip + mean±sem in your stim colors
  905. fig, ax = plt.subplots(figsize=(2, 3.0))
  906. ax.set_title(title, fontsize=9)
  907. ax.set_xlabel('polarity (50 µA overall)')
  908. ax.set_ylabel('distance (µm)')
  909. for pol, col in [('cathodal', cathodal_color), ('anodal', anodal_color), ('bipolar', bipolar_color)]:
  910. sub = plot_df[plot_df['polarity'] == pol]
  911. sns.stripplot(
  912. data=sub, x='polarity', y='dist_abs',
  913. jitter=0.25, alpha=0.65, size=3,
  914. color=col, ax=ax
  915. )
  916. # overlay mean ± sem
  917. g = plot_df.groupby('polarity')['dist_abs']
  918. xcats = ['cathodal', 'anodal', 'bipolar']
  919. xlocs = np.arange(len(xcats))
  920. means = [g.get_group(cat).mean() for cat in xcats]
  921. sems = [g.get_group(cat).sem() for cat in xcats]
  922. for xi, cat, m, s in zip(xlocs, xcats, means, sems):
  923. ax.errorbar(
  924. xi, m, yerr=s, fmt='D-', lw=1.2, ms=5,
  925. color={'cathodal': cathodal_color, 'anodal': anodal_color, 'bipolar': bipolar_color}[cat],
  926. capsize=3, zorder=3
  927. )
  928. ax.set_yticks([0, 1000, 2000])
  929. # styling
  930. ax.spines['top'].set_visible(False)
  931. ax.spines['right'].set_visible(False)
  932. # optional fixed ylim for comparability:
  933. # ax.set_ylim(0, 1500)
  934. plt.tight_layout()
  935. path = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials'
  936. #plt.savefig(os.path.join(path, f'ccf_dist_50ua_avgdist.png'))
  937. #plt.savefig(os.path.join(path, f'ccf_dist_50ua_avgdist.pdf'))
  938. # %%
  939. import pandas as pd
  940. import numpy as np
  941. from statsmodels.formula.api import ols
  942. import statsmodels.api as sm
  943. # Filter for valid ccf entries
  944. df_ccf = df_results[
  945. (df_results['max_volt'] > 40) &
  946. (df_results['half_max_lower_idx'] != -1) &
  947. (df_results['half_max_upper_idx'] != -1) &
  948. (df_results['lower_bound_idx'] != -1) &
  949. (df_results['upper_bound_idx'] != -1) &
  950. (df_results['amplitude'] != 10) &
  951. (df_results['amplitude'] != -10) # Exclude 10 uA
  952. ]
  953. # Build long-form dataframe
  954. records = []
  955. for _, row in df_ccf.iterrows():
  956. for bound_type, dist_col in zip(['upper', 'lower'], ['upper_bound_dist', 'lower_bound_dist']):
  957. records.append({
  958. 'distance': row[dist_col],
  959. 'amplitude': np.abs(row['amplitude']),
  960. 'polarity': (
  961. 'bipolar' if row['polarity'] == 'bipolar'
  962. else 'anodal' if row['amplitude'] > 0
  963. else 'cathodal'
  964. ),
  965. 'bound_type': bound_type
  966. })
  967. df_long = pd.DataFrame(records)
  968. df_long['amplitude'] = df_long['amplitude'].astype('category')
  969. df_long['polarity'] = df_long['polarity'].astype('category')
  970. df_long['bound_type'] = df_long['bound_type'].astype('category')
  971. for bound in ['upper', 'lower']:
  972. print(f"\n---- {bound.upper()} BOUND ----")
  973. df_bound = df_long[df_long['bound_type'] == bound]
  974. model = ols('distance ~ C(polarity) * amplitude', data=df_bound).fit()
  975. anova_table = sm.stats.anova_lm(model, typ=2)
  976. print(anova_table)
  977. model = ols('distance ~ C(polarity) * amplitude * C(bound_type)', data=df_long).fit()
  978. anova_table = sm.stats.anova_lm(model, typ=2)
  979. print(anova_table)
  980. # %%
  981. # Treat amplitude as continuous — make sure it's numeric
  982. df_cathodal['amplitude'] = df_cathodal['amplitude'].astype(float)
  983. # Run model with continuous amplitude and interaction with bound_type
  984. model = ols('distance ~ amplitude * C(bound_type)', data=df_cathodal).fit()
  985. anova_table = sm.stats.anova_lm(model, typ=2)
  986. print(anova_table)
  987. # %% [markdown]
  988. # # half spatial extents superficial and deep (Figure 2g and Supp CCF vs peak-aligned)
  989. # %%
  990. # ccf distances and peak aligned distances plots
  991. from scipy.stats import sem
  992. for dist_type in ['ccf', 'peak_aligned']:
  993. fig, axs = plt.subplots(1, 3, figsize=(8, 3)) # 2 rows for polarities, 2 columns for amplitude sign
  994. fig.suptitle(f'{dist_type} distances 1D half spatial bounds (up vs down) w fits', fontsize=12)
  995. colors = [cathodal_color, anodal_color, bipolar_color]
  996. for i, pol in enumerate(['cathodal', 'anodal', 'bipolar']):
  997. ax = axs[i]
  998. ax.set_title(pol, color = colors[i])
  999. if pol == 'bipolar':
  1000. polarity = 'bipolar'
  1001. else:
  1002. polarity = 'monopolar'
  1003. all_upper_dists = []
  1004. all_lower_dists = []
  1005. all_amps = []
  1006. all_mean_uppers = []
  1007. all_mean_lowers = []
  1008. all_std_uppers = []
  1009. all_std_lowers = []
  1010. all_sem_uppers = []
  1011. all_sem_lowers = []
  1012. amps = [-5, -25, -50, -100]
  1013. for k, amp in enumerate(amps):
  1014. if pol == 'anodal':
  1015. amp = np.abs(amp) # Make positive for anodal
  1016. ax.set_xlabel('Amplitude (uA)')
  1017. ax.set_ylabel('Distance (units)')
  1018. #ax.invert_yaxis()
  1019. df = df_results[
  1020. (df_results['max_volt'] > 40) &
  1021. (df_results['amplitude'] == amp) &
  1022. (df_results['polarity'] == polarity) &
  1023. (df_results['half_max_lower_idx'] != -1) &
  1024. (df_results['half_max_upper_idx'] != -1) &
  1025. (df_results['lower_bound_idx'] != -1) &
  1026. (df_results['upper_bound_idx'] != -1)
  1027. ]
  1028. if dist_type == 'ccf':
  1029. upper_dists = df['half_max_upper_dist'].values
  1030. lower_dists = df['half_max_lower_dist'].values
  1031. elif dist_type == 'peak_aligned':
  1032. normalized_distances = np.arange(-4000, 4000, 10)[::-1]
  1033. upper_bound_idxs = df['half_max_upper_idx_aligned'].values
  1034. lower_bound_idxs = df['half_max_lower_idx_aligned'].values
  1035. upper_dists = [normalized_distances[idx] for idx in upper_bound_idxs]
  1036. lower_dists = [normalized_distances[idx] for idx in lower_bound_idxs]
  1037. # Collect data for scatter plot
  1038. all_upper_dists.extend(upper_dists)
  1039. all_lower_dists.extend(lower_dists)
  1040. all_amps.extend([amp] * len(upper_dists))
  1041. # Scatter individual data points
  1042. ax.scatter([amp] * len(upper_dists), upper_dists, color=colors[i], s = 3, alpha=0.8, zorder=2)
  1043. ax.scatter([amp] * len(lower_dists), lower_dists, color=colors[i], s = 3, alpha=0.8, zorder=2)
  1044. # print mean and std of upper and lower distances
  1045. mean_upper = np.mean(all_upper_dists)
  1046. mean_lower = np.mean(all_lower_dists)
  1047. std_upper = np.std(all_upper_dists)
  1048. std_lower = np.std(all_lower_dists)
  1049. sem_upper = sem(all_upper_dists)
  1050. sem_lower = sem(all_lower_dists)
  1051. all_mean_uppers.append(mean_upper)
  1052. all_mean_lowers.append(mean_lower)
  1053. all_std_uppers.append(std_upper)
  1054. all_std_lowers.append(std_lower)
  1055. all_sem_uppers.append(sem_upper)
  1056. all_sem_lowers.append(sem_lower)
  1057. print(f'{pol} {dist_type} {amp}uA: Upper Mean: {mean_upper:.2f}, Std: {std_upper:.2f}; Lower Mean: {mean_lower:.2f}, Std: {std_lower:.2f}')
  1058. if pol == 'anodal':
  1059. amps = [5, 25, 50, 100]
  1060. else:
  1061. amps = [-5, -25, -50, -100]
  1062. ax.plot(amps, all_mean_uppers, color = colors[i], marker = 'o', markersize = 4, linewidth = 1)
  1063. ax.plot(amps, all_mean_lowers, color = colors[i], marker = 'o', markersize = 4, linewidth = 1)
  1064. ax.fill_between(amps,
  1065. np.array(all_mean_uppers) - np.array(all_sem_uppers),
  1066. np.array(all_mean_uppers) + np.array(all_sem_uppers),
  1067. color=colors[i], alpha=0.2, label='Upper Bound SEM', zorder=1)
  1068. ax.fill_between(amps,
  1069. np.array(all_mean_lowers) - np.array(all_sem_lowers),
  1070. np.array(all_mean_lowers) + np.array(all_sem_lowers),
  1071. color=colors[i], alpha=0.2, label='Lower Bound SEM', zorder=1)
  1072. # Fit polynomial regression to the data
  1073. if len(all_amps) > 1: # Ensure we have enough data points
  1074. upper_coeffs = np.polyfit(all_amps, all_upper_dists, deg=2)
  1075. lower_coeffs = np.polyfit(all_amps, all_lower_dists, deg=2)
  1076. upper_poly = np.poly1d(upper_coeffs)
  1077. lower_poly = np.poly1d(lower_coeffs)
  1078. # Generate smooth curves for plotting
  1079. amp_range = np.linspace(np.min(all_amps), np.max(all_amps), 100)
  1080. upper_fit = upper_poly(amp_range)
  1081. lower_fit = lower_poly(amp_range)
  1082. # Calculate standard error of the mean (SEM) for the fits
  1083. upper_fit_err = sem(all_upper_dists)
  1084. lower_fit_err = sem(all_lower_dists)
  1085. # Plot polynomial regression lines
  1086. #ax.plot(amp_range, upper_fit, color='darkblue', label='Upper Bound Fit', zorder=4)
  1087. #ax.plot(amp_range, lower_fit, color='darkred', label='Lower Bound Fit', zorder=4)
  1088. # Add error bands
  1089. #ax.fill_between(amp_range, upper_fit - upper_fit_err, upper_fit + upper_fit_err, color='blue', alpha=0.2, label='Upper Fit Error', zorder=1)
  1090. #ax.fill_between(amp_range, lower_fit - lower_fit_err, lower_fit + lower_fit_err, color='red', alpha=0.2, label='Lower Fit Error', zorder=1)
  1091. x = all_amps
  1092. y_upper = all_upper_dists
  1093. y_lower = all_lower_dists
  1094. # Fit linear regression for upper bounds
  1095. coeffs_linear_upper = np.polyfit(x, y_upper, 1)
  1096. p_linear_upper = np.poly1d(coeffs_linear_upper)
  1097. y_fit_linear_upper = p_linear_upper(x)
  1098. r2_linear_upper = 1 - (np.sum((y_upper - y_fit_linear_upper)**2) / np.sum((y_upper - np.mean(y_upper))**2))
  1099. # Fit quadratic regression for upper bounds
  1100. coeffs_quad_upper = np.polyfit(x, y_upper, 2)
  1101. p_quad_upper = np.poly1d(coeffs_quad_upper)
  1102. y_fit_quad_upper = p_quad_upper(x)
  1103. r2_quad_upper = 1 - (np.sum((y_upper - y_fit_quad_upper)**2) / np.sum((y_upper - np.mean(y_upper))**2))
  1104. # Fit linear regression for lower bounds
  1105. coeffs_linear_lower = np.polyfit(x, y_lower, 1)
  1106. p_linear_lower = np.poly1d(coeffs_linear_lower)
  1107. y_fit_linear_lower = p_linear_lower(x)
  1108. r2_linear_lower = 1 - (np.sum((y_lower - y_fit_linear_lower)**2) / np.sum((y_lower - np.mean(y_lower))**2))
  1109. # Fit quadratic regression for lower bounds
  1110. coeffs_quad_lower = np.polyfit(x, y_lower, 2)
  1111. p_quad_lower = np.poly1d(coeffs_quad_lower)
  1112. y_fit_quad_lower = p_quad_lower(x)
  1113. r2_quad_lower = 1 - (np.sum((y_lower - y_fit_quad_lower)**2) / np.sum((y_lower - np.mean(y_lower))**2))
  1114. # Add color-coded text annotations for R² values
  1115. # Upper bounds
  1116. #ax.text(
  1117. # 0.6, 0.90,
  1118. # f"Quad: R²: {r2_quad_upper:.2f}",
  1119. # color='blue', fontsize=5, verticalalignment='top', transform=ax.transAxes
  1120. #)
  1121. #ax.text(
  1122. # 0.6, 0.80,
  1123. # f"Lin: R²: {r2_linear_upper:.2f}",
  1124. # color='darkblue', fontsize=5, verticalalignment='top', transform=ax.transAxes
  1125. #)
  1126. #ax.text(
  1127. # 0.6, 0.10,
  1128. # f"Quad: R²: {r2_quad_lower:.2f}",
  1129. # color='red', fontsize=5, verticalalignment='top', transform=ax.transAxes)
  1130. #ax.text(
  1131. # 0.6, 0.20,
  1132. # f"Lin: R²: {r2_linear_lower:.2f}",
  1133. # color='darkred', fontsize=5, verticalalignment='top', transform=ax.transAxes)
  1134. if pol == 'anodal':
  1135. ax.set_xticks(np.abs(amps))
  1136. else:
  1137. ax.set_xticks(amps)
  1138. ax.invert_xaxis() # Invert x-axis for negative amplitudes
  1139. #ax.invert_yaxis()
  1140. ax.set_ylim([-1000, 1000])
  1141. ax.set_yticks([1000, 500, 0, -500, -1000], ['1', '', '0', '', '-1'])
  1142. ax.invert_yaxis()
  1143. # Adjust layout and save the figure
  1144. plt.tight_layout(rect=[0, 0, 1, 0.96])
  1145. path = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials'
  1146. #plt.savefig(os.path.join(path, f'half_distances_{dist_type}_updated.png'))
  1147. #plt.savefig(os.path.join(path, f'half_distances_{dist_type}_updated.pdf'))
  1148. # %%
  1149. import pandas as pd
  1150. import numpy as np
  1151. from statsmodels.formula.api import ols
  1152. import statsmodels.api as sm
  1153. # Filter for valid ccf entries
  1154. df_ccf = df_results[
  1155. (df_results['max_volt'] > 40) &
  1156. (df_results['half_max_lower_idx'] != -1) &
  1157. (df_results['half_max_upper_idx'] != -1) &
  1158. (df_results['lower_bound_idx'] != -1) &
  1159. (df_results['upper_bound_idx'] != -1) &
  1160. (df_results['amplitude'] != 10) &
  1161. (df_results['amplitude'] != -10) # Exclude 10 uA
  1162. ]
  1163. # Build long-form dataframe
  1164. records = []
  1165. for _, row in df_ccf.iterrows():
  1166. for bound_type, dist_col in zip(['upper', 'lower'], ['half_max_upper_idx_aligned', 'half_max_lower_idx_aligned']):
  1167. records.append({
  1168. 'distance': normalized_distances[row[dist_col]],
  1169. 'amplitude': np.abs(row['amplitude']),
  1170. 'polarity': (
  1171. 'bipolar' if row['polarity'] == 'bipolar'
  1172. else 'anodal' if row['amplitude'] > 0
  1173. else 'cathodal'
  1174. ),
  1175. 'bound_type': bound_type
  1176. })
  1177. df_long = pd.DataFrame(records)
  1178. df_long['amplitude'] = df_long['amplitude'].astype('category')
  1179. df_long['polarity'] = df_long['polarity'].astype('category')
  1180. df_long['bound_type'] = df_long['bound_type'].astype('category')
  1181. for bound in ['upper', 'lower']:
  1182. print(f"\n---- {bound.upper()} BOUND ----")
  1183. df_bound = df_long[df_long['bound_type'] == bound]
  1184. model = ols('distance ~ C(polarity) * amplitude', data=df_bound).fit()
  1185. anova_table = sm.stats.anova_lm(model, typ=2)
  1186. print(anova_table)
  1187. model = ols('distance ~ C(polarity) * amplitude * C(bound_type)', data=df_long).fit()
  1188. anova_table = sm.stats.anova_lm(model, typ=2)
  1189. print(anova_table)
  1190. # %%
  1191. 6.040685e-03
  1192. # %%
  1193. df.columns
  1194. # %%
  1195. # ccf distances and peak aligned distances plots
  1196. from scipy.stats import sem
  1197. for dist_type in ['ccf', 'peak_aligned']:
  1198. fig, axs = plt.subplots(1, 3, figsize=(8, 3)) # 2 rows for polarities, 2 columns for amplitude sign
  1199. fig.suptitle(f'{dist_type} distances 1D half spatial bounds (up vs down) w fits', fontsize=12)
  1200. colors = [cathodal_color, anodal_color, bipolar_color]
  1201. for i, pol in enumerate(['cathodal', 'anodal', 'bipolar']):
  1202. ax = axs[i]
  1203. ax.set_title(pol, color = colors[i])
  1204. if pol == 'bipolar':
  1205. polarity = 'bipolar'
  1206. else:
  1207. polarity = 'monopolar'
  1208. all_upper_dists = []
  1209. all_lower_dists = []
  1210. all_amps = []
  1211. all_mean_uppers = []
  1212. all_mean_lowers = []
  1213. all_std_uppers = []
  1214. all_std_lowers = []
  1215. all_sem_uppers = []
  1216. all_sem_lowers = []
  1217. amps = [-5, -25, -50, -100]
  1218. for k, amp in enumerate(amps):
  1219. if pol == 'anodal':
  1220. amp = np.abs(amp) # Make positive for anodal
  1221. ax.set_xlabel('Amplitude (uA)')
  1222. ax.set_ylabel('Distance (units)')
  1223. #ax.invert_yaxis()
  1224. df = df_results[
  1225. (df_results['max_volt'] > 40) &
  1226. (df_results['amplitude'] == amp) &
  1227. (df_results['polarity'] == polarity) &
  1228. (df_results['half_max_lower_idx'] != -1) &
  1229. (df_results['half_max_upper_idx'] != -1) &
  1230. (df_results['lower_bound_idx'] != -1) &
  1231. (df_results['upper_bound_idx'] != -1)
  1232. ]
  1233. if dist_type == 'ccf':
  1234. upper_dists = df['upper_bound_dist'].values
  1235. lower_dists = df['lower_bound_dist'].values
  1236. elif dist_type == 'peak_aligned':
  1237. normalized_distances = np.arange(-4000, 4000, 10)[::-1]
  1238. upper_bound_idxs = df['upper_bound_idx_aligned'].values
  1239. lower_bound_idxs = df['lower_bound_idx_aligned'].values
  1240. upper_dists = [normalized_distances[idx] for idx in upper_bound_idxs]
  1241. lower_dists = [normalized_distances[idx] for idx in lower_bound_idxs]
  1242. # Collect data for scatter plot
  1243. all_upper_dists.extend(upper_dists)
  1244. all_lower_dists.extend(lower_dists)
  1245. all_amps.extend([amp] * len(upper_dists))
  1246. # Scatter individual data points
  1247. ax.scatter([amp] * len(upper_dists), upper_dists, color=colors[i], s = 3, alpha=0.8, zorder=2)
  1248. ax.scatter([amp] * len(lower_dists), lower_dists, color=colors[i], s = 3, alpha=0.8, zorder=2)
  1249. # print mean and std of upper and lower distances
  1250. mean_upper = np.mean(all_upper_dists)
  1251. mean_lower = np.mean(all_lower_dists)
  1252. std_upper = np.std(all_upper_dists)
  1253. std_lower = np.std(all_lower_dists)
  1254. sem_upper = sem(all_upper_dists)
  1255. sem_lower = sem(all_lower_dists)
  1256. all_mean_uppers.append(mean_upper)
  1257. all_mean_lowers.append(mean_lower)
  1258. all_std_uppers.append(std_upper)
  1259. all_std_lowers.append(std_lower)
  1260. all_sem_uppers.append(sem_upper)
  1261. all_sem_lowers.append(sem_lower)
  1262. print(f'{pol} {dist_type} {amp}uA: Upper Mean: {mean_upper:.2f}, Std: {std_upper:.2f}; Lower Mean: {mean_lower:.2f}, Std: {std_lower:.2f}')
  1263. if pol == 'anodal':
  1264. amps = [5, 25, 50, 100]
  1265. else:
  1266. amps = [-5, -25, -50, -100]
  1267. ax.plot(amps, all_mean_uppers, color = colors[i], marker = 'o', markersize = 4, linewidth = 1)
  1268. ax.plot(amps, all_mean_lowers, color = colors[i], marker = 'o', markersize = 4, linewidth = 1)
  1269. ax.fill_between(amps,
  1270. np.array(all_mean_uppers) - np.array(all_sem_uppers),
  1271. np.array(all_mean_uppers) + np.array(all_sem_uppers),
  1272. color=colors[i], alpha=0.2, label='Upper Bound SEM', zorder=1)
  1273. ax.fill_between(amps,
  1274. np.array(all_mean_lowers) - np.array(all_sem_lowers),
  1275. np.array(all_mean_lowers) + np.array(all_sem_lowers),
  1276. color=colors[i], alpha=0.2, label='Lower Bound SEM', zorder=1)
  1277. # Fit polynomial regression to the data
  1278. if len(all_amps) > 1: # Ensure we have enough data points
  1279. upper_coeffs = np.polyfit(all_amps, all_upper_dists, deg=2)
  1280. lower_coeffs = np.polyfit(all_amps, all_lower_dists, deg=2)
  1281. upper_poly = np.poly1d(upper_coeffs)
  1282. lower_poly = np.poly1d(lower_coeffs)
  1283. # Generate smooth curves for plotting
  1284. amp_range = np.linspace(np.min(all_amps), np.max(all_amps), 100)
  1285. upper_fit = upper_poly(amp_range)
  1286. lower_fit = lower_poly(amp_range)
  1287. # Calculate standard error of the mean (SEM) for the fits
  1288. upper_fit_err = sem(all_upper_dists)
  1289. lower_fit_err = sem(all_lower_dists)
  1290. # Plot polynomial regression lines
  1291. #ax.plot(amp_range, upper_fit, color='darkblue', label='Upper Bound Fit', zorder=4)
  1292. #ax.plot(amp_range, lower_fit, color='darkred', label='Lower Bound Fit', zorder=4)
  1293. # Add error bands
  1294. #ax.fill_between(amp_range, upper_fit - upper_fit_err, upper_fit + upper_fit_err, color='blue', alpha=0.2, label='Upper Fit Error', zorder=1)
  1295. #ax.fill_between(amp_range, lower_fit - lower_fit_err, lower_fit + lower_fit_err, color='red', alpha=0.2, label='Lower Fit Error', zorder=1)
  1296. x = all_amps
  1297. y_upper = all_upper_dists
  1298. y_lower = all_lower_dists
  1299. # Fit linear regression for upper bounds
  1300. coeffs_linear_upper = np.polyfit(x, y_upper, 1)
  1301. p_linear_upper = np.poly1d(coeffs_linear_upper)
  1302. y_fit_linear_upper = p_linear_upper(x)
  1303. r2_linear_upper = 1 - (np.sum((y_upper - y_fit_linear_upper)**2) / np.sum((y_upper - np.mean(y_upper))**2))
  1304. # Fit quadratic regression for upper bounds
  1305. coeffs_quad_upper = np.polyfit(x, y_upper, 2)
  1306. p_quad_upper = np.poly1d(coeffs_quad_upper)
  1307. y_fit_quad_upper = p_quad_upper(x)
  1308. r2_quad_upper = 1 - (np.sum((y_upper - y_fit_quad_upper)**2) / np.sum((y_upper - np.mean(y_upper))**2))
  1309. # Fit linear regression for lower bounds
  1310. coeffs_linear_lower = np.polyfit(x, y_lower, 1)
  1311. p_linear_lower = np.poly1d(coeffs_linear_lower)
  1312. y_fit_linear_lower = p_linear_lower(x)
  1313. r2_linear_lower = 1 - (np.sum((y_lower - y_fit_linear_lower)**2) / np.sum((y_lower - np.mean(y_lower))**2))
  1314. # Fit quadratic regression for lower bounds
  1315. coeffs_quad_lower = np.polyfit(x, y_lower, 2)
  1316. p_quad_lower = np.poly1d(coeffs_quad_lower)
  1317. y_fit_quad_lower = p_quad_lower(x)
  1318. r2_quad_lower = 1 - (np.sum((y_lower - y_fit_quad_lower)**2) / np.sum((y_lower - np.mean(y_lower))**2))
  1319. # Add color-coded text annotations for R² values
  1320. # Upper bounds
  1321. #ax.text(
  1322. # 0.6, 0.90,
  1323. # f"Quad: R²: {r2_quad_upper:.2f}",
  1324. # color='blue', fontsize=5, verticalalignment='top', transform=ax.transAxes
  1325. #)
  1326. #ax.text(
  1327. # 0.6, 0.80,
  1328. # f"Lin: R²: {r2_linear_upper:.2f}",
  1329. # color='darkblue', fontsize=5, verticalalignment='top', transform=ax.transAxes
  1330. #)
  1331. #ax.text(
  1332. # 0.6, 0.10,
  1333. # f"Quad: R²: {r2_quad_lower:.2f}",
  1334. # color='red', fontsize=5, verticalalignment='top', transform=ax.transAxes)
  1335. #ax.text(
  1336. # 0.6, 0.20,
  1337. # f"Lin: R²: {r2_linear_lower:.2f}",
  1338. # color='darkred', fontsize=5, verticalalignment='top', transform=ax.transAxes)
  1339. if pol == 'anodal':
  1340. ax.set_xticks(np.abs(amps))
  1341. else:
  1342. ax.set_xticks(amps)
  1343. ax.invert_xaxis() # Invert x-axis for negative amplitudes
  1344. #ax.invert_yaxis()
  1345. ax.set_ylim([-1500, 1500])
  1346. ax.set_yticks([1500, 1000, 500, 0, -500, -1000,-1500], ['1.5', '', '', '0', '', '', '-1.5'])
  1347. ax.invert_yaxis()
  1348. # Adjust layout and save the figure
  1349. plt.tight_layout(rect=[0, 0, 1, 0.96])
  1350. path = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials'
  1351. #plt.savefig(os.path.join(path, f'distances_{dist_type}_updated.png'))
  1352. #plt.savefig(os.path.join(path, f'distances_{dist_type}_updated.pdf'))
  1353. # %%
  1354. min_ch_list = []
  1355. for _, row in df_results.iterrows():
  1356. recording = row['recording']
  1357. probe = row['probe']
  1358. r = recording_map[recording]
  1359. dists = distances[recording][probe]
  1360. arg_min = np.argmin(np.abs(dists))
  1361. min_ch_list.append(arg_min)
  1362. df_results['closest_ch_ccf'] = min_ch_list
  1363. # %%
  1364. df = df_results.copy()
  1365. df = df[
  1366. (df['max_volt'] > 40) &
  1367. (df['half_max_lower_idx'] != -1) &
  1368. (df['half_max_upper_idx'] != -1) &
  1369. (df['lower_bound_idx'] != -1) &
  1370. (df['upper_bound_idx'] != -1)
  1371. ].reset_index()
  1372. df['normalized_closest_ch'] = df['closest_ch_ccf'] - df['max_idx']
  1373. fig = plt.figure(figsize=(3, 3))
  1374. swarm_plot = sns.swarmplot(data=df, x='recording', y='normalized_closest_ch', hue = 'recording', palette='viridis', size = 2)
  1375. # Set labels with specified font size
  1376. plt.xlabel('Recording', fontsize=10)
  1377. plt.xticks(rotation=45)
  1378. plt.ylabel('closest ccf channel - max_response ch', fontsize=10)
  1379. plt.title('Normalized Closest Channel by Recording', fontsize=12)
  1380. #plt.savefig(os.path.join(path, 'normalized_closest_ch_by_recording.png'))
  1381. #plt.savefig(os.path.join(path, 'normalized_closest_ch_by_recording.pdf'))
  1382. from scipy.stats import f_oneway
  1383. # Make sure amplitude is treated as a group
  1384. grouped = df.groupby('recording')['normalized_closest_ch']
  1385. # Extract list of arrays (one per amplitude)
  1386. groups = [group.values for _, group in grouped]
  1387. # Run ANOVA
  1388. fval, pval = f_oneway(*groups)
  1389. print(f"One-way ANOVA: F = {fval:.4f}, p = {pval:.4e}")
  1390. from statsmodels.stats.multicomp import pairwise_tukeyhsd
  1391. tukey = pairwise_tukeyhsd(endog=df['normalized_closest_ch'],
  1392. groups=df['recording'],
  1393. alpha=0.05)
  1394. print(tukey.summary())
  1395. # %%
  1396. df = df_results.copy()
  1397. df = df[
  1398. (df['max_volt'] > 40) &
  1399. (df['half_max_lower_idx'] != -1) &
  1400. (df['half_max_upper_idx'] != -1) &
  1401. (df['lower_bound_idx'] != -1) &
  1402. (df['upper_bound_idx'] != -1) &
  1403. (df['amplitude'] != 10) &
  1404. (df['amplitude'] != -10)
  1405. ].reset_index()
  1406. df['normalized_closest_ch'] = df['closest_ch_ccf'] - df['max_idx']
  1407. fig = plt.figure(figsize = (3, 3))
  1408. swarm_plot = sns.swarmplot(data=df, x='amplitude', y='normalized_closest_ch', palette='viridis', s = 2)
  1409. # Set labels with specified font size
  1410. plt.xlabel('amplitude', fontsize=10)
  1411. plt.xticks(rotation=45)
  1412. plt.ylabel('closest ccf channel - max_response ch', fontsize=10)
  1413. plt.title('Normalized Closest Channel by amplitude', fontsize=12)
  1414. from scipy.stats import f_oneway
  1415. # Make sure amplitude is treated as a group
  1416. grouped = df.groupby('amplitude')['normalized_closest_ch']
  1417. # Extract list of arrays (one per amplitude)
  1418. groups = [group.values for _, group in grouped]
  1419. # Run ANOVA
  1420. fval, pval = f_oneway(*groups)
  1421. print(f"One-way ANOVA: F = {fval:.4f}, p = {pval:.4e}")
  1422. # %%
  1423. df = df_results.copy()
  1424. df = df[
  1425. (df['max_volt'] > 40) &
  1426. (df['half_max_lower_idx'] != -1) &
  1427. (df['half_max_upper_idx'] != -1) &
  1428. (df['lower_bound_idx'] != -1) &
  1429. (df['upper_bound_idx'] != -1)
  1430. ].reset_index()
  1431. df['normalized_closest_ch'] = df['closest_ch_ccf'] - df['max_idx']
  1432. fig = plt.figure(figsize = (2, 3))
  1433. df['group'] = 'all'
  1434. swarm_plot = sns.swarmplot(data=df, x='group', y='normalized_closest_ch', hue = 'group', palette='viridis', s = 2)
  1435. # Set labels with specified font size
  1436. plt.xlabel('amplitude', fontsize=10)
  1437. plt.xticks(rotation=45)
  1438. plt.ylabel('closest ccf channel - max_response ch', fontsize=10)
  1439. plt.title('Normalized Closest Channel by amplitude', fontsize=12)
  1440. # print the mean and std and sem of the normalized closest channel
  1441. mean_closest_ch = df['normalized_closest_ch'].mean()
  1442. std_closest_ch = df['normalized_closest_ch'].std()
  1443. sem_closest_ch = df['normalized_closest_ch'].sem()
  1444. print(f'Mean normalized closest channel: {mean_closest_ch:.2f}, Std: {std_closest_ch:.2f} SEM: {sem_closest_ch:.2f}')
  1445. plt.axhline(0, color='black', linestyle='--', linewidth=0.5)
  1446. path = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials'
  1447. #plt.savefig(os.path.join(path, 'normalized_closest_ch_all.png'))
  1448. #plt.savefig(os.path.join(path, 'normalized_closest_ch_all.pdf'))
  1449. # %% [markdown]
  1450. # # peak voltage, half and full AUC regressions for amplitudes
  1451. # %%
  1452. df.head()
  1453. # %%
  1454. #scatters
  1455. fig, axs = plt.subplots(3, 3, figsize=(4, 4))
  1456. amplitudes = [-5, -25, -50, -100]
  1457. for col, pol in enumerate(['cathodal', 'anodal', 'bipolar']):
  1458. if pol == 'bipolar':
  1459. polarity = 'bipolar'
  1460. else:
  1461. polarity = 'monopolar'
  1462. if pol == 'anodal':
  1463. amps = np.abs(amplitudes)
  1464. else:
  1465. amps = amplitudes
  1466. df = df_results[
  1467. (df_results['max_volt'] > 40) &
  1468. (df_results['amplitude'].isin(amps)) &
  1469. (df_results['polarity'] == polarity) &
  1470. (df_results['half_max_lower_idx'] != -1) &
  1471. (df_results['half_max_upper_idx'] != -1) &
  1472. (df_results['lower_bound_idx'] != -1) &
  1473. (df_results['upper_bound_idx'] != -1)
  1474. ]
  1475. variables = ['max_volt', 'half_max_AUC', 'full_AUC']
  1476. titles = ['Max Voltage by Amplitude', 'Half Max AUC by Amplitude', 'Full AUC by Amplitude']
  1477. for i, (var, title) in enumerate(zip(variables, titles)):
  1478. row = i
  1479. ax = axs[row, col]
  1480. # Scatter plot of individual points
  1481. sns.scatterplot(
  1482. data=df, x='amplitude', y=var, color = colors[col], s=6, ax=ax, legend=False, alpha=1
  1483. )
  1484. # fit first and second order regression lines style
  1485. sns.regplot(
  1486. data=df, x='amplitude', y=var, scatter=False, ci=68, order = 2, line_kws={'color': 'black', 'linewidth': 0.5}, ax=ax
  1487. )
  1488. sns.regplot(
  1489. data=df, x='amplitude', y=var, scatter=False, ci=68, order = 1, line_kws={'color': 'blue', 'linewidth': 0.5}, ax=ax
  1490. )
  1491. # print mean and std of the variables
  1492. for amp in [-5, -25, -50, -100]:
  1493. if pol == 'anodal':
  1494. amp = np.abs(amp)
  1495. amp_df = df[df['amplitude'] == amp]
  1496. mean_value = amp_df[var].mean()
  1497. std_value = amp_df[var].std()
  1498. print(f'{pol}_{amp}: {var} mean ± std: {mean_value:.2f} ± {std_value:.2f}')
  1499. #if row == 0:
  1500. #ax.set_title(title, fontsize=6)
  1501. if row == 2:
  1502. ax.set_xlabel('Amplitude (uA)', fontsize=6)
  1503. ax.set_xticks(amps, amps, fontsize = 6)
  1504. else:
  1505. ax.set_xlabel('')
  1506. ax.set_xticks(amps, ['', '', '', ''], fontsize = 6)
  1507. filtered_df = df[['amplitude', var]].dropna()
  1508. x = filtered_df['amplitude']
  1509. y = filtered_df[var]
  1510. # Fit linear regression (order=1)
  1511. coeffs_linear = np.polyfit(x, y, 1)
  1512. p_linear = np.poly1d(coeffs_linear)
  1513. y_fit_linear = p_linear(x)
  1514. r2_linear = 1 - (np.sum((y - y_fit_linear)**2) / np.sum((y - np.mean(y))**2))
  1515. # Fit quadratic regression (order=2)
  1516. coeffs_quad = np.polyfit(x, y, 2)
  1517. p_quad = np.poly1d(coeffs_quad)
  1518. y_fit_quad = p_quad(x)
  1519. r2_quad = 1 - (np.sum((y - y_fit_quad)**2) / np.sum((y - np.mean(y))**2))
  1520. ax.text(0.05, 0.95, f"Linear R² = {r2_linear:.2f}\nQuadratic R²= {r2_quad:.2f}",
  1521. transform=ax.transAxes, fontsize=4, verticalalignment='top')
  1522. if pol != 'anodal':
  1523. ax.invert_xaxis()
  1524. if var == 'max_volt':
  1525. ax.set_ylim([0, 2000])
  1526. if col == 0:
  1527. ax.set_ylabel('Max Voltage (uV)', fontsize=6)
  1528. ax.set_yticks([0, 1000, 2000], [0, 1, 2], fontsize = 6)
  1529. else:
  1530. ax.set_ylabel('')
  1531. ax.set_yticks([0, 1000, 2000], ['', '', ''], fontsize = 6)
  1532. elif var == 'half_max_AUC':
  1533. ax.set_ylim([0, 80000])
  1534. if col == 0:
  1535. ax.set_ylabel('Half Max AUC (mV*ms)', fontsize=6)
  1536. ax.set_yticks([0, 40000, 80000], [0, 4, 8], fontsize = 6)
  1537. else:
  1538. ax.set_ylabel('')
  1539. ax.set_yticks([0, 40000, 80000], ['', '', ''], fontsize = 6)
  1540. elif var == 'full_AUC':
  1541. ax.set_ylim([0, 120000])
  1542. if col == 0:
  1543. ax.set_yticks([0, 60000, 120000], [0, 6, 12], fontsize = 6)
  1544. ax.set_ylabel('Full AUC (mV*ms)', fontsize=6)
  1545. else:
  1546. ax.set_yticks([0, 60000, 120000], ['', '', ''], fontsize = 6)
  1547. ax.set_ylabel('')
  1548. ax.spines['top'].set_visible(False)
  1549. ax.spines['right'].set_visible(False)
  1550. # Adjust layout and save the figure
  1551. #plt.suptitle()
  1552. plt.tight_layout()
  1553. #plt.savefig(os.path.join(path, f'max_auc_scatter_with_means__poly_updated.png'))
  1554. #plt.savefig(os.path.join(path, f'max_auc_scatter_with_means__poly_updated.pdf'))
  1555. # %% [markdown]
  1556. # # comparing LFP and AP
  1557. # %%
  1558. r = jlh31
  1559. probe = 'probeB'
  1560. amp = -100
  1561. channels = 300
  1562. stim_times = choose_stim_parameter(r.trials, amp=amp, pulse_number=1, pulse_duration=100, polarity='monopolar')[0:75]
  1563. fig, axes = plt.subplots(1,2)
  1564. ## AP plot
  1565. pre = -0.3
  1566. post = 5
  1567. data = r.raw.get_chunk(probe, stim_times, pre=pre + 3, post=post + 3, chs=np.arange(channels))
  1568. aligned_data = align_data(data, pre, post, channels, threshold=500, median_subtraction=False)
  1569. dists = distances[r.mouse][probe]
  1570. # Use the axs object for the current amp
  1571. sub_chs = np.arange(len(dists), len(dists) + 20)
  1572. aligned_sub_data = aligned_data - np.median(aligned_data[:,:, sub_chs], axis=2)[:,:, np.newaxis]
  1573. ax = axes[0]
  1574. raw_heatmap(aligned_sub_data, pre=pre, post=post, dists=dists, vmin=-300, vmax=300,
  1575. save=False, title=f'{amp} uA', ax=ax)
  1576. # LFP
  1577. pre = 0
  1578. post = 100
  1579. data = r.raw.get_chunk(probe, stim_times, pre=pre, post=post, band = 'lfp', chs=np.arange(channels))
  1580. aligned_data = data
  1581. dists = distances[r.mouse][probe]
  1582. # Use the axs object for the current amp
  1583. sub_chs = np.arange(len(dists), len(dists) + 40)
  1584. aligned_sub_data = aligned_data - np.median(aligned_data[:,:, sub_chs], axis=2)[:,:, np.newaxis]
  1585. ax = axes[1]
  1586. raw_heatmap(aligned_sub_data, pre=pre, post=post, dists=dists, vmin=-800, vmax=800,
  1587. save=False, title=f'{amp} uA', ax=ax)
  1588. plt.tight_layout()
  1589. # %% [markdown]
  1590. # # volumes
  1591. # %%
  1592. from scipy.spatial import ConvexHull
  1593. import numpy as np
  1594. def calculate_volume(points):
  1595. """
  1596. Calculate the volume of a 3D shape defined by 6 points using a convex hull.
  1597. Parameters:
  1598. points (ndarray): A (6, 3) numpy array where each row represents a 3D point (x, y, z).
  1599. Returns:
  1600. float: The volume of the convex hull enclosing the points.
  1601. """
  1602. # Check if the input is valid
  1603. if points.shape != (6, 3):
  1604. raise ValueError("Input must be a (6, 3) array of 3D points.")
  1605. # Compute the convex hull
  1606. hull = ConvexHull(points) # points should be (6,3) array
  1607. volume = hull.volume
  1608. # Return the volume of the convex hull
  1609. return hull.volume
  1610. # %%
  1611. #3D stuff... this requires measurements for all 3 probes...
  1612. polarities = ['bipolar', 'monopolar']
  1613. probes = ['probeA', 'probeB', 'probeC']
  1614. amps = [-5, -25, -50, -100, 5, 25, 50, 100]
  1615. volume_dict = {}
  1616. points_dict = {}
  1617. points_dict_rec = {}
  1618. rec_dict = {}
  1619. for polarity in polarities:
  1620. for i, amp in enumerate(amps):
  1621. # Filter data for the current probe and amplitude
  1622. df = df_results[
  1623. (df_results['max_volt'] > 40) &
  1624. (df_results['amplitude'] == amp) &
  1625. (df_results['polarity'] == polarity) &
  1626. (df_results['lower_bound_idx'] != -1) &
  1627. (df_results['upper_bound_idx'] != -1)
  1628. ]
  1629. #print(df['recording'].unique())
  1630. for record in df['recording'].unique():
  1631. probes = df[df['recording'] == record]['probe'].unique()
  1632. if len(probes) == 3:
  1633. key = f'{polarity}_{amp}'
  1634. r = recording_map[record]
  1635. a_df = df[(df['recording'] == record) & (df['probe'] == 'probeA')]
  1636. a_upper_ch = a_df['upper_bound_idx'].values[0]
  1637. a_lower_ch = a_df['lower_bound_idx'].values[0]
  1638. try:
  1639. a_up_ccf = r.probe_coords['probeA'][a_upper_ch]
  1640. except:
  1641. a_up_cff = r.probe_coords['probeA'][-1]
  1642. a_low_ccf = r.probe_coords['probeA'][a_lower_ch]
  1643. b_df = df[(df['recording'] == record) & (df['probe'] == 'probeB')]
  1644. b_upper_ch = b_df['upper_bound_idx'].values[0]
  1645. b_lower_ch = b_df['lower_bound_idx'].values[0]
  1646. try:
  1647. b_up_ccf = r.probe_coords['probeB'][b_upper_ch]
  1648. except:
  1649. b_up_ccf = r.probe_coords['probeB'][-1]
  1650. b_low_ccf = r.probe_coords['probeB'][b_lower_ch]
  1651. c_df = df[(df['recording'] == record) & (df['probe'] == 'probeC')]
  1652. c_upper_ch = c_df['upper_bound_idx'].values[0]
  1653. c_lower_ch = c_df['lower_bound_idx'].values[0]
  1654. try:
  1655. c_up_ccf = r.probe_coords['probeC'][c_upper_ch]
  1656. except:
  1657. c_up_ccf = r.probe_coords['probeC'][-1]
  1658. c_low_ccf = r.probe_coords['probeC'][c_lower_ch]
  1659. points = np.array([a_up_ccf, a_low_ccf, b_up_ccf, b_low_ccf, c_up_ccf, c_low_ccf])
  1660. hull = ConvexHull(points)
  1661. volume = hull.volume
  1662. if key not in volume_dict:
  1663. volume_dict[key] = []
  1664. volume_dict[key].append(volume)
  1665. if key not in points_dict:
  1666. points_dict[key] = []
  1667. points_dict[key].append(points)
  1668. key_rec = f'{record}_{polarity}_{amp}'
  1669. if key_rec not in points_dict_rec:
  1670. points_dict_rec[key_rec] = []
  1671. points_dict_rec[key_rec].append(points)
  1672. if key not in rec_dict:
  1673. rec_dict[key] = []
  1674. rec_dict[key].append(record)
  1675. # %%
  1676. volume_dict_mm3 = {key: np.array(values) / 1e9 for key, values in volume_dict.items()}
  1677. mc_25 = volume_dict_mm3['monopolar_-25']
  1678. mc_50 = volume_dict_mm3['monopolar_-50']
  1679. mc_100 = volume_dict_mm3['monopolar_-100']
  1680. mc_x = [-25, -50, -100]
  1681. mc_y = [np.mean(mc_25), np.mean(mc_50), np.mean(mc_100)]
  1682. mc_err = [sem(mc_25), sem(mc_50), sem(mc_100)]
  1683. # print mean +/- std of monopolar volumes
  1684. print(f'Cathodal Mean Volumes: -25uA: {np.mean(mc_25):.2f}+/-{np.std(mc_25):.2f} mm³, -50uA: {np.mean(mc_50):.2f}+/-{np.std(mc_50):.2f}mm³, -100uA: {np.mean(mc_100):.2f}+/-{np.std(mc_100):.2f} mm³')
  1685. fig, ax = plt.subplots(1, 1, figsize=(2, 3.5))
  1686. # Line and error fill for monopolar (cathodal)
  1687. ax.plot(mc_x, mc_y, label='Cathodal', color=cathodal_color, marker='o')
  1688. ax.fill_between(mc_x, np.array(mc_y) - np.array(mc_err), np.array(mc_y) + np.array(mc_err),
  1689. color=cathodal_color, alpha=0.2)
  1690. # Plot individual values with jitter
  1691. jitter_strength = 1.1
  1692. for x, values in zip(mc_x, [mc_25, mc_50, mc_100]):
  1693. jittered_x = np.random.normal(x, scale=jitter_strength, size=len(values))
  1694. ax.scatter(jittered_x, values, color=cathodal_color, s=5, alpha=0.7, zorder=3)
  1695. # Labels and formatting
  1696. ax.set_title('Mean Volume')
  1697. ax.set_xlabel('Amplitude (uA)')
  1698. ax.set_ylabel('Volume (mm³)')
  1699. ax.invert_xaxis()
  1700. ax.set_xticks([-25, -50, -100])
  1701. ax.set_ylim(0, 1)
  1702. ax.set_yticks([0, 0.5, 1])
  1703. ax.spines['top'].set_visible(False)
  1704. ax.spines['right'].set_visible(False)
  1705. plt.tight_layout()
  1706. path = r'C:\Users\jordan\Documents\combined_figs\evoked_potentials'
  1707. #plt.savefig(os.path.join(path, 'monopolar_EPvolume.png'))
  1708. #plt.savefig(os.path.join(path, 'monopolar_EPvolume.pdf'))
  1709. # %%
  1710. from scipy.stats import linregress
  1711. import numpy as np
  1712. # Stack all individual volumes
  1713. all_volumes = np.concatenate([mc_25, mc_50, mc_100])
  1714. all_amplitudes = np.concatenate([
  1715. np.full_like(mc_25, -25),
  1716. np.full_like(mc_50, -50),
  1717. np.full_like(mc_100, -100)
  1718. ])
  1719. # Run linear regression
  1720. slope, intercept, r_value, p_value, std_err = linregress(np.abs(all_amplitudes), all_volumes)
  1721. # Print regression results
  1722. print(f"Linear Regression: Volume = {slope:.4f} * Amplitude + {intercept:.4f}")
  1723. print(f"R² = {r_value**2:.4f}, p = {p_value:.4e}")
  1724. # %%
  1725. 9.1036e-02
  1726. # %%
  1727. # Quadratic fit
  1728. quad_coeffs = np.polyfit(np.abs(all_amplitudes), all_volumes, deg=2)
  1729. quad_model = np.poly1d(quad_coeffs)
  1730. # Predicted values
  1731. y_quad_fit = quad_model(np.abs(all_amplitudes))
  1732. # R² for quadratic
  1733. ss_res_quad = np.sum((all_volumes - y_quad_fit) ** 2)
  1734. ss_tot = np.sum((all_volumes - np.mean(all_volumes)) ** 2)
  1735. r2_quad = 1 - (ss_res_quad / ss_tot)
  1736. print(f"Quadratic Fit: Volume = {quad_coeffs[0]:.4e} * A² + {quad_coeffs[1]:.4e} * A + {quad_coeffs[2]:.4e}")
  1737. print(f"R² (Quadratic) = {r2_quad:.4f}")
  1738. # %%
  1739. from scipy.stats import sem
  1740. # Convert µm³ to mm³ (1 mm³ = 10^9 µm³)
  1741. volume_dict_mm3 = {key: np.array(values) / 1e9 for key, values in volume_dict.items()}
  1742. bc_25 = volume_dict_mm3['bipolar_-25']
  1743. bc_50 = volume_dict_mm3['bipolar_-50']
  1744. bc_100 = volume_dict_mm3['bipolar_-100']
  1745. mc_25 = volume_dict_mm3['monopolar_-25']
  1746. mc_50 = volume_dict_mm3['monopolar_-50']
  1747. mc_100 = volume_dict_mm3['monopolar_-100']
  1748. ma_25 = volume_dict_mm3['monopolar_25']
  1749. ma_50 = volume_dict_mm3['monopolar_50']
  1750. ma_100 = volume_dict_mm3['monopolar_100']
  1751. bc_x = [-25, -50, -100]
  1752. bc_y = [np.mean(bc_25), np.mean(bc_50), np.mean(bc_100)]
  1753. bc_err = [sem(bc_25), sem(bc_50), sem(bc_100)]
  1754. mc_x = [-25, -50, -100]
  1755. mc_y = [np.mean(mc_25), np.mean(mc_50), np.mean(mc_100)]
  1756. mc_err = [sem(mc_25), sem(mc_50), sem(mc_100)]
  1757. ma_x = [25, 50, 100]
  1758. ma_y = [np.mean(ma_25), np.mean(ma_50), np.mean(ma_100)]
  1759. ma_err = [sem(ma_25), sem(ma_50), sem(ma_100)]
  1760. print(f'Cathodal Mean Volumes: -25uA: {np.mean(mc_25):.2f}+/-{np.std(mc_25):.2f} mm³, -50uA: {np.mean(mc_50):.2f}+/-{np.std(mc_50):.2f}mm³, -100uA: {np.mean(mc_100):.2f}+/-{np.std(mc_100):.2f} mm³')
  1761. print(f'Anodal Mean Volumes: 25uA: {np.mean(ma_25):.2f}+/-{np.std(ma_25):.2f} mm³, 50uA: {np.mean(ma_50):.2f}+/-{np.std(ma_50):.2f}mm³, 100uA: {np.mean(ma_100):.2f}+/-{np.std(ma_100):.2f} mm³')
  1762. print(f'Bipolar Mean Volumes: -25uA: {np.mean(bc_25):.2f}+/-{np.std(bc_25):.2f} mm³, -50uA: {np.mean(bc_50):.2f}+/-{np.std(bc_50):.2f}mm³, -100uA: {np.mean(bc_100):.2f}+/-{np.std(bc_100):.2f} mm³')
  1763. fig, ax = plt.subplots(1, 1, figsize=(3.5, 3.5))
  1764. # Bipolar
  1765. ax.plot(bc_x, bc_y, label='Bipolar', color=bipolar_color, marker='o')
  1766. ax.fill_between(bc_x, np.array(bc_y) - np.array(bc_err), np.array(bc_y) + np.array(bc_err), color=bipolar_color, alpha=0.2)
  1767. # Cathodal
  1768. ax.plot(mc_x, mc_y, label='Cathodal', color=cathodal_color, marker='o')
  1769. ax.fill_between(mc_x, np.array(mc_y) - np.array(mc_err), np.array(mc_y) + np.array(mc_err), color=cathodal_color, alpha=0.2)
  1770. # Anodal
  1771. ax.plot(ma_x, ma_y, label='Anodal', color=anodal_color, marker='o')
  1772. ax.fill_between(ma_x, np.array(ma_y) - np.array(ma_err), np.array(ma_y) + np.array(ma_err), color=anodal_color, alpha=0.2)
  1773. # Labels and legend
  1774. ax.set_title('Mean Volume')
  1775. ax.set_xlabel('Amplitude (uA)')
  1776. ax.set_ylabel('Volume (mm³)')
  1777. #ax.legend(loc='upper right')
  1778. ax.set_xticks([-100, -50, -25, 25, 50, 100])
  1779. ax.set_ylim(0, 1)
  1780. ax.set_yticks([0, 0.5, 1])
  1781. ax.spines['top'].set_visible(False)
  1782. ax.spines['right'].set_visible(False)
  1783. plt.tight_layout()
  1784. #plt.savefig(os.path.join(path, 'volume_by_amplitude.png'))
  1785. #plt.savefig(os.path.join(path, 'volume_by_amplitude.pdf'))
  1786. # %%
  1787. import statsmodels.api as sm
  1788. from statsmodels.formula.api import ols
  1789. model = ols("volume ~ C(polarity) * amplitude", data=df_vol).fit()
  1790. anova_table = sm.stats.anova_lm(model, typ=2)
  1791. print(anova_table)
  1792. # %%

figure2.ipynb at commit c4aaa60, under GPL-3.0 · at the source

Overview

Authors: Jordan L Hickman1,2,3, Grant Hughes1,3, Eashan Sahai1, Moriah Miles1, Daniel J Denman1,4,5
  1. Department of Biophysics and Physiology, University of Colorado Anschutz Medical Campus, Aurora, CO, USA
  2. Medical Scientist Training Program, University of Colorado Anschutz Medical Campus, Aurora, CO, USA
  3. Neuroscience Graduate Program, University of Colorado Anschutz Medical Campus, Aurora, CO, USA
  4. Department of Ophthalmology, University of Colorado Anschutz Medical Campus, Aurora, CO, USA
  5. Lead contact
Institutions: University of Colorado Anschutz (United States)
Journal: Cell reports, volume 45, issue 6, article 117420
Dates: published online 28 May 2026; in print 23 June 2026
Type: Research article · Language: English
License: CC BY-NC-ND
Identifiers: DOI 10.1016/j.celrep.2026.117420 · PMID 42207642 · PMCID PMC13404931 · OpenAlex W4415660446
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: extracellular electrophysiology (units, LFP) (modality), mouse (organism), systems (subfield)
Methods: Statistics, Smoothing, state filtering, decompositions, Machine learning, Single-unit activity, calcium imaging, Physiology & signal measures, Connectivity
Keywords: Visual cortex, Electrical stimulation, Neural Dynamics, Neuromodulation, Intracortical Microstimulation, Icms, Neuropixels, Cp: Neuroscience, Perceptual Detection
MeSH: Electric Stimulation*, Neocortex*, Neurons*, Action Potentials, Animals, Male, Mice, Mice, Inbred C57BL, Pyramidal Cells, Visual Cortex (* major topic)
Topic: Neuroscience and Neural Engineering (Cellular and Molecular Neuroscience, Neuroscience), according to OpenAlex
Funding: National Institute of Neurological Disorders and Stroke (R01NS120850); National Eye Institute (R00EY028612); NINDS NIH HHS (R01 NS120850); NEI NIH HHS (R00 EY028612); National Institutes of Health
Citations: not cited yet (Europe PMC); 122 references in the paper
Research resources: electrophysiology data in NWB format RRID:SCR_015242

Abstract

The abstract is not reproduced here: the paper's license (CC BY-NC-ND) does not allow it. Read it in the paper, at the publisher or on Europe PMC.

Repositories

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

denmanlab/am4100_code

License: MIT
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: b1587c33836b7c8626e5b3816ff4d4753b978043, 22 May 2024
Languages: MATLAB (30), Python (2), Jupyter (1)
Size: 53 files, 33 scripts
Software Heritage: archived
Found in: the text, “Footnotes”
Holds: license file, 1 notebook
Not found: README, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
34 files

denmanlab/mouse_behavior

License: none: the authors keep all their rights
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: 59fb7046918c4f9cb361b8df584a525af5a0cc2e, 1 August 2025
Languages: Python (17), Jupyter (1)
Size: 60 files, 18 scripts
Software Heritage: not archived
Found in: the text, “Software and hardware implementation:”
Holds: README, 1 notebook
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (10 files), pandas (10 files), Matplotlib (7 files), imageio (1 file), OpenCV (1 file), Pillow (1 file), seaborn (1 file)
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
19 files

denmanlab/estim_populations

License: GPL-3.0
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: c4aaa608314c8e8e91aee422baa69cd1b443a682, 29 October 2025
Languages: Python (12), Jupyter (7)
Size: 97 files, 19 scripts
Software Heritage: not archived
Found in: the text, “Dataset”
Holds: README, license file, environment (requirements.txt), 7 notebooks
Not found: CITATION.cff, tests, continuous integration, documentation
Tools: Matplotlib (3 files), NumPy (3 files), Neurodata Without Borders (PyNWB, MatNWB) (3 files), Open Ephys analysis tools (3 files), pandas (3 files), SciPy (3 files), seaborn (3 files), statsmodels (3 files), scikit-image (1 file)
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
5 files

marcomusy/vedo

License: MIT
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: 88f8b2f9c5c6267b5dceda0e4ab0e15b3130910a, 4 August 2026
Languages: Python (559), Jupyter (11), Shell (10), JavaScript (2)
Size: 642 files, 582 scripts
Software Heritage: archived
Found in: the text, “3D plotting in anatomical brain space”
Holds: README, license file, environment (pyproject.toml), tests, continuous integration, documentation, 11 notebooks
Not found: CITATION.cff
Tools: NumPy (221 files), SciPy (29 files), Pillow (12 files), Matplotlib (8 files), pandas (4 files), scikit-image (2 files), imageio (1 file), JAX (1 file), napari (1 file), NetworkX (1 file), OpenCV (1 file), scikit-learn (1 file)
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
584 files

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:

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

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

Data

Datasets cited

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 2, 28 September 2026

  • Publisher: n/a → Cell Press

Version 1, 28 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 5 authors, 9 keywords, 10 MeSH terms, 5 funders, 120 references, 1 RRID.

Cite

This paper

Hickman, J. L., Hughes, G., Sahai, E., Miles, M., & Denman, D. J. (2026). Neural population dynamics of direct electrical stimulation of neocortex. Cell reports, 45(6), 117420. https://doi.org/10.1016/j.celrep.2026.117420

BibTeX

@article{hickman2026neural,
author = {Hickman, Jordan L and Hughes, Grant and Sahai, Eashan and Miles, Moriah and Denman, Daniel J},
title = {{Neural population dynamics of direct electrical stimulation of neocortex}},
journal = {Cell reports},
year = {2026},
month = may,
volume = {45},
number = {6},
pages = {117420},
publisher = {Cell Press},
issn = {2211-1247},
doi = {10.1016/j.celrep.2026.117420},
url = {https://doi.org/10.1016/j.celrep.2026.117420},
pmid = {42207642},
pmcid = {PMC13404931}
}

RIS

TY - JOUR
AU - Hickman, Jordan L
AU - Hughes, Grant
AU - Sahai, Eashan
AU - Miles, Moriah
AU - Denman, Daniel J
TI - Neural population dynamics of direct electrical stimulation of neocortex
T2 - Cell reports
J2 - Cell Rep
PY - 2026
DA - 2026/05/28
VL - 45
IS - 6
SP - 117420
SN - 2211-1247
PB - Cell Press
DO - 10.1016/j.celrep.2026.117420
UR - https://doi.org/10.1016/j.celrep.2026.117420
LA - en
ER -

CSL-JSON

{
"id": "10.1016/j.celrep.2026.117420",
"type": "article-journal",
"title": "Neural population dynamics of direct electrical stimulation of neocortex",
"container-title": "Cell reports",
"author": [
{
"family": "Hickman",
"given": "Jordan L"
},
{
"family": "Hughes",
"given": "Grant"
},
{
"family": "Sahai",
"given": "Eashan"
},
{
"family": "Miles",
"given": "Moriah"
},
{
"family": "Denman",
"given": "Daniel J"
}
],
"container-title-short": "Cell Rep",
"volume": "45",
"issue": "6",
"page": "117420",
"DOI": "10.1016/j.celrep.2026.117420",
"PMID": "42207642",
"PMCID": "PMC13404931",
"ISSN": "2211-1247",
"publisher": "Cell Press",
"URL": "https://doi.org/10.1016/j.celrep.2026.117420",
"language": "en",
"issued": {
"date-parts": [
[
2026,
5,
28
]
]
}
}

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.1126/sciadv.aef0343 [code]
Learning induces activation-mechanism-dependent neural plasticity in an intracortical microstimulation task.
Journal: Science advances
In common: Pillow, statsmodels, seaborn, 5 other tools, 14 references
[2] doi:10.1038/s41593-026-02262-8 [code]
Cheese3D enables sensitive detection and analysis of whole-face movement in mice.
Journal: Nature neuroscience
In common: Open Ephys analysis tools, napari, JAX, 9 other tools, systems, mouse
[3] doi:10.7554/elife.109717 [code]
Retrosplenial cortex enables context-dependent goal-directed sensorimotor transformation.
Journal: eLife
In common: Neurodata Without Borders (PyNWB, MatNWB), imageio, OpenCV, 9 other tools, systems, mouse, 2 references
[4] doi: [code]
Real-time closed-loop feedback system for mouse mesoscale cortical signal and movement control
Journal: eLife
In common: napari, imageio, OpenCV, 9 other tools, mouse, 2 references
[5] doi:10.1038/s41593-026-02232-0 [code]
Entorhinal cortex represents task-relevant remote locations independently of CA1.
Journal: Nature neuroscience
In common: Neurodata Without Borders (PyNWB, MatNWB), NetworkX, OpenCV, 9 other tools, systems, mouse, 2 references
[6] doi: [code]
Naturalistic behavior and self-generated neural activity predictive of self-correction
Journal: bioRxiv : the preprint server for biology
In common: Neurodata Without Borders (PyNWB, MatNWB), JAX, NetworkX, 10 other tools
[7] doi:10.1371/journal.pcbi.1014571 [code]
SynAPSeg: A novel dataset and image analysis framework for deep learning-based synapse detection and quantification.
Journal: PLoS computational biology
In common: napari, imageio, NetworkX, 10 other tools
[8] doi:10.1016/j.crmeth.2026.101421 [code]
EthoPy provides an accessible platform for reproducible behavioral neuroscience.
Journal: Cell reports methods
In common: Neurodata Without Borders (PyNWB, MatNWB), imageio, OpenCV, 6 other tools, mouse, 3 references
[9] doi:10.1038/s42003-026-10957-8 [code]
Brain defence by the extracellular matrix protein Cochlin.
Journal: Communications biology
In common: JAX, NetworkX, OpenCV, 9 other tools, mouse
[10] doi:10.3389/fncom.2026.1876230 [code]
Linking spatially distributed neuronal activation overlap to the limits of perceptual discrimination in rodent primary somatosensory cortex.
Journal: Frontiers in computational neuroscience
In common: Matplotlib, NumPy, 8 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.