OSCR

The mPFC-reuniens-hippocampus pathway links brain circuitry and neural plasticity in antidepressant response.

Code ↔ Paper

4 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 4 matches · 1 of them tie a paragraph to a whole file, not to given lines: a weak match, whose lines are not tinted
  1. [1] § Methods › In vivo Local Field Potential (LFP) recordings ↔ LFP_analysis.py, lines 126–153 · score 0.87 · 13–30 Hz, 4–12 Hz, high gamma, low gamma, downsampled, LFP
  2. [2] § Methods › Fiber photometry: data acquisition and analysis ↔ FiberPhotometryAnalysisTool/functions/analysis_data_loader.py, the whole file · a weak match · score 0.68 · find_peaks, peak amplitude, Episodes, GuPPy, Fiber photometry, duration
  3. [3] § Methods › Fiber photometry: data acquisition and analysis ↔ FiberPhotometryAnalysisTool/screens/plotting_screen.py, lines 16–106 · score 0.67 · find_peaks, peak amplitude, Episodes, GuPPy, Fiber photometry, duration
  4. [4] § Methods › Fiber photometry: data acquisition and analysis ↔ matlab_demodulation_script.m, lines 1–40 · score 0.57 · LabVIEW, fiber photometry, channel, signal

Paper

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

The paper is loaded when this pane is shown.

The authors' code

Python · 1,792 lines · 66 KB · MIT · 1 match

  1. """
  2. Source-data script for the quantitative panels of Supplementary Fig. 9.
  3. - Registration of channels and regions
  4. - Match behaviour and ephys files
  5. - NOE extraction and delta statistics
  6. - HAB running band-coherence extraction and statistics
  7. - HAB running power spectra
  8. """
  9. import os
  10. import re
  11. import glob
  12. import itertools
  13. from collections import defaultdict
  14. import matplotlib
  15. matplotlib.rcParams["pdf.fonttype"] = 42
  16. matplotlib.rcParams["svg.fonttype"] = "none"
  17. import matplotlib.pyplot as plt
  18. import numpy as np
  19. import pandas as pd
  20. from open_ephys.analysis import Session
  21. from scipy import signal
  22. from scipy.signal import hilbert, welch
  23. from scipy.signal.windows import dpss
  24. from scipy.stats import friedmanchisquare, wilcoxon
  25. np.random.seed(1)
  26. CHANNEL_TO_REGION = {
  27. "HCl": "HC",
  28. "HCr": "HC",
  29. "PFCl": "PFC",
  30. "PFCr": "PFC",
  31. "RE": "RE",
  32. }
  33. CHANNEL_TO_HEMI = {
  34. "HCl": "L",
  35. "HCr": "R",
  36. "PFCl": "L",
  37. "PFCr": "R",
  38. "RE": "M",
  39. }
  40. PAIR_POOL_MAP = {}
  41. for hemi_letter, suffix in [("L", "l"), ("R", "r")]:
  42. PAIR_POOL_MAP[tuple(sorted([f"HC{suffix}", f"PFC{suffix}"]))] = ("HC-PFC", hemi_letter)
  43. PAIR_POOL_MAP[tuple(sorted([f"HC{suffix}", "RE"]))] = ("HC-RE", hemi_letter)
  44. PAIR_POOL_MAP[tuple(sorted([f"PFC{suffix}", "RE"]))] = ("PFC-RE", hemi_letter)
  45. REGION_FIGURE_LABEL = {"HC": "vHIPP", "PFC": "mPFC", "RE": "RE"}
  46. PAIR_FIGURE_LABEL = {
  47. "HC-PFC": "mPFC-vHIPP",
  48. "HC-RE": "RE-vHIPP",
  49. "PFC-RE": "mPFC-RE",
  50. }
  51. PAC_DIRECTION_LABEL = {
  52. "HC-PFC": ("vHIPP", "mPFC"),
  53. "HC-RE": ("vHIPP", "RE"),
  54. "PFC-RE": ("mPFC", "RE"),
  55. }
  56. CONDITION_GROUPS = {
  57. "naive": ["naive1", "naive2"],
  58. "CDM": ["CDM_1", "CDM_2"],
  59. "C21": ["C21_1"],
  60. }
  61. CONDITION_LABEL = {"naive": "naive", "CDM": "post-CDM", "C21": "post-C21"}
  62. CONDITION_COLOR = {"naive": "#E69F00", "CDM": "#D55E00", "C21": "#56B4E9"}
  63. PSD_REGIONS = ["PFC", "RE", "HC"]
  64. COHERENCE_PAIRS = ["PFC-RE", "HC-RE", "HC-PFC"]
  65. BAR_PANELS_RUNNING = [
  66. {
  67. "panel": "m",
  68. "target": "HC-PFC",
  69. "metric": "coh",
  70. "band": "beta",
  71. "title": "Running - mPFC-vHIPP coherence β",
  72. },
  73. {
  74. "panel": "n",
  75. "target": "HC-RE",
  76. "metric": "coh",
  77. "band": "lowGamma",
  78. "title": "Running - RE-vHIPP coherence low-γ",
  79. },
  80. {
  81. "panel": "o",
  82. "target": "HC-PFC",
  83. "metric": "coh",
  84. "band": "lowGamma",
  85. "title": "Running - mPFC-vHIPP coherence low-γ",
  86. },
  87. ]
  88. BAR_PANELS_NOE = [
  89. {
  90. "panel": "p",
  91. "target": "PFC-RE",
  92. "metric": "pac_mi",
  93. "band": "theta_lowGamma",
  94. "title": "NOE - mPFC-RE θ-lowγ PAC-mi",
  95. },
  96. {
  97. "panel": "q",
  98. "target": "HC-RE",
  99. "metric": "coh",
  100. "band": "lowGamma",
  101. "title": "NOE - RE-vHIPP coherence lowγ",
  102. },
  103. {
  104. "panel": "r",
  105. "target": "HC-PFC",
  106. "metric": "pac_mi",
  107. "band": "theta_lowGamma",
  108. "title": "NOE - mPFC-vHIPP θ-lowγ PAC-mi",
  109. },
  110. ]
  111. def make_cfg():
  112. cfg = {
  113. "rootFoldEphys": r"D:\LTP_analysis\ProcessedData",
  114. "rootFoldVideo": r"D:\LTP_analysis\ProcessedData\VideoRec",
  115. "resultsDir": r"D:\LTP_analysis\results",
  116. "miceAvail": ["10084", "10085", "male_7pins", "male_9pins", "10418", "100134"],
  117. "batch": [1, 1, 1, 1, 2, 2],
  118. "conditions": ["naive1", "naive2", "CDM_1", "CDM_2", "C21_1", "C21_2"],
  119. "test": "NOE",
  120. "speedThresh_cm_s": 4.0,
  121. "px_to_cm": 1 / 8,
  122. "minEpochDur_s_NOE": 1.0,
  123. "minEpochDur_s_HAB": 0.5,
  124. "doDownsample": True,
  125. "fsTarget": 1000,
  126. "doNotch": False,
  127. "lineFreq": 50,
  128. "notchQ": 35,
  129. "freqRange": (1, 150),
  130. "bands": {
  131. "theta": (4, 12),
  132. "beta": (13, 30),
  133. "lowGamma": (30, 55),
  134. "highGamma": (65, 100),
  135. },
  136. }
  137. cfg["metaXlsx"] = os.path.join(cfg["rootFoldEphys"], "LFP_organization_bothbatches.xlsx")
  138. return cfg
  139. def pool_pair_name(a, b):
  140. return PAIR_POOL_MAP.get(tuple(sorted([a, b])), (None, None))
  141. def pool_channel_name(ch):
  142. return CHANNEL_TO_REGION.get(ch, ch), CHANNEL_TO_HEMI.get(ch, "?")
  143. def should_skip(mouse, cond, test):
  144. if mouse == "10418" and cond in ["C21_1", "C21_2"]:
  145. return True
  146. return False
  147. def _norm_str(s):
  148. return str(s).strip()
  149. def find_meta_row(meta, mouse, cond, test):
  150. m = meta.copy()
  151. m["mouse_id"] = m["mouse_id"].astype(str).str.strip()
  152. m["condition"] = m["condition"].astype(str).str.strip()
  153. m["test"] = m["test"].astype(str).str.strip()
  154. mask = (
  155. (m["mouse_id"] == _norm_str(mouse))
  156. & (m["condition"] == _norm_str(cond))
  157. & (m["test"] == _norm_str(test))
  158. )
  159. idx = np.where(mask.values)[0]
  160. if len(idx) == 0:
  161. return None, "no_metadata_match"
  162. return int(idx[0]), "ok"
  163. def parse_nose_xy(nose_series):
  164. x = np.zeros(len(nose_series), dtype=float)
  165. y = np.zeros(len(nose_series), dtype=float)
  166. for i, s in enumerate(nose_series):
  167. nums = [float(v) for v in re.findall(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", str(s))]
  168. if len(nums) >= 2:
  169. x[i] = nums[0]
  170. y[i] = nums[1]
  171. return x, y
  172. def idx_to_epochs(time_vec, idx, min_dur):
  173. idx = np.asarray(idx, dtype=int)
  174. if idx.size == 0:
  175. return np.array([]), np.array([])
  176. d = np.diff(idx)
  177. split = np.where(d > 1)[0]
  178. starts = np.r_[0, split + 1]
  179. ends = np.r_[split, len(idx) - 1]
  180. ep_s = time_vec[idx[starts]]
  181. ep_e = time_vec[idx[ends]]
  182. keep = (ep_e - ep_s) >= min_dur
  183. return ep_s[keep], ep_e[keep]
  184. def resolve_video_dir(video_root, video_folder, batch_id):
  185. vf = str(video_folder)
  186. if os.path.isabs(vf) and os.path.exists(vf):
  187. return vf
  188. candidate = os.path.join(video_root, vf)
  189. if os.path.exists(candidate):
  190. return candidate
  191. base = os.path.basename(vf.rstrip("\\/"))
  192. candidate2 = os.path.join(video_root, f"Batch{batch_id}", base)
  193. if os.path.exists(candidate2):
  194. return candidate2
  195. return candidate
  196. def load_behavior_epochs(meta_row, test_name, cfg):
  197. video_dir = resolve_video_dir(cfg["rootFoldVideo"], meta_row["video_folder"], int(meta_row["batch_id"]))
  198. trial = str(meta_row["trial_number"])
  199. trial_suffix = trial[1:] if len(trial) > 1 else trial
  200. xls_name = f"processed_interpolated_T{trial_suffix}_V2.xlsx"
  201. xls_path = os.path.join(video_dir, xls_name)
  202. if not os.path.exists(xls_path):
  203. pattern = os.path.join(video_dir, f"processed_interpolated_T*{trial_suffix}*_V2.xlsx")
  204. cand = glob.glob(pattern)
  205. if cand:
  206. xls_path = cand[0]
  207. if not os.path.exists(xls_path):
  208. return None, None, None, f"behavior_file_missing:{xls_path}"
  209. b = pd.read_excel(xls_path)
  210. if "Timestamp_sec" not in b.columns:
  211. return None, None, None, "behavior_missing_Timestamp_sec"
  212. b.loc[b["Timestamp_sec"] <= 1, "Timestamp_sec"] = np.nan
  213. b = b.dropna(subset=["Timestamp_sec"]).copy()
  214. if len(b) == 0:
  215. return None, None, None, "behavior_empty_after_crop"
  216. b["Timestamp_sec"] = b["Timestamp_sec"] - b["Timestamp_sec"].iloc[0]
  217. if test_name == "NOE":
  218. if "region1_other" not in b.columns:
  219. return b, np.array([]), np.array([]), "missing_region1_other"
  220. idx = np.where(pd.to_numeric(b["region1_other"], errors="coerce").fillna(0).values > 0)[0]
  221. ep_s, ep_e = idx_to_epochs(b["Timestamp_sec"].values, idx, cfg["minEpochDur_s_NOE"])
  222. return b, ep_s, ep_e, "ok"
  223. stop_idx = np.where(b["Timestamp_sec"].values <= 600)[0]
  224. if len(stop_idx) == 0:
  225. return b, np.array([]), np.array([]), "hab_no_10min"
  226. stop10 = stop_idx[-1]
  227. if "nose" not in b.columns:
  228. return b, np.array([]), np.array([]), "missing_nose"
  229. noseX, noseY = parse_nose_xy(b["nose"].iloc[: stop10 + 1].values)
  230. dX = np.diff(noseX) * (40.0 / 680.0)
  231. dY = np.diff(noseY) * (30.0 / 481.0)
  232. dist = np.sqrt(dX ** 2 + dY ** 2)
  233. dt = np.diff(b["Timestamp_sec"].iloc[: stop10 + 1].values)
  234. dt[dt <= 0] = np.nan
  235. vel = dist / dt
  236. vel = np.nan_to_num(vel, nan=0.0, posinf=0.0, neginf=0.0)
  237. vel = np.r_[0, vel, 0]
  238. is_run = vel > cfg["speedThresh_cm_s"]
  239. idx = np.where(is_run)[0]
  240. ts = b["Timestamp_sec"].iloc[: len(vel)].values
  241. ep_s, ep_e = idx_to_epochs(ts, idx, cfg["minEpochDur_s_HAB"])
  242. return b, ep_s, ep_e, "ok"
  243. def preprocess_signal(x, fs, cfg):
  244. y = np.asarray(x, dtype=float)
  245. fs_out = float(fs)
  246. if cfg["doDownsample"] and fs_out > cfg["fsTarget"]:
  247. down = int(round(fs_out / cfg["fsTarget"]))
  248. y = signal.resample_poly(y, 1, down)
  249. fs_out = fs_out / down
  250. if cfg["doNotch"]:
  251. f0 = cfg["lineFreq"]
  252. q = cfg["notchQ"]
  253. if fs_out > (2 * (f0 + 2)):
  254. b, a = signal.iirnotch(w0=f0, Q=q, fs=fs_out)
  255. y = signal.filtfilt(b, a, y)
  256. y = signal.detrend(y, type="constant")
  257. return y, fs_out
  258. def reject_artifact_windows(x, centers, win_sec, fs, thresh_sd=4.0):
  259. if len(centers) == 0:
  260. return [], 0
  261. half = int(round(fs * win_sec / 2))
  262. n = len(x)
  263. amps = []
  264. valid_centers = []
  265. for c in centers:
  266. s, e = int(c - half), int(c + half)
  267. if s >= 0 and e <= n:
  268. amps.append(np.max(np.abs(x[s:e])))
  269. valid_centers.append(c)
  270. if len(amps) == 0:
  271. return [], 0
  272. amps = np.array(amps)
  273. med = np.median(amps)
  274. mad = np.median(np.abs(amps - med))
  275. robust_sd = 1.4826 * mad
  276. cutoff = med + thresh_sd * robust_sd
  277. keep = amps < cutoff
  278. clean = [c for c, k in zip(valid_centers, keep) if k]
  279. n_rejected = int(np.sum(~keep))
  280. return clean, n_rejected
  281. def compute_speed_on_lfp_time(bdf, t_lfp, px_to_cm=None):
  282. if "nose" not in bdf.columns:
  283. return np.zeros(len(t_lfp), dtype=float)
  284. px_to_cm_x = 40.0 / 680.0
  285. px_to_cm_y = 30.0 / 481.0
  286. bts = bdf["Timestamp_sec"].values.astype(float)
  287. nX, nY = parse_nose_xy(bdf["nose"].values)
  288. dX = np.diff(nX) * px_to_cm_x
  289. dY = np.diff(nY) * px_to_cm_y
  290. dt = np.diff(bts)
  291. dt[dt <= 0] = np.nan
  292. vel = np.sqrt(dX ** 2 + dY ** 2) / dt
  293. vel = np.nan_to_num(vel, nan=0.0, posinf=0.0, neginf=0.0)
  294. vel_ts = (bts[:-1] + bts[1:]) / 2.0
  295. speed_lfp = np.interp(t_lfp, vel_ts, vel, left=0.0, right=0.0)
  296. return speed_lfp
  297. def build_hab_speed_lfp_xy(bdf, t_lfp):
  298. px_to_cm_x = 40.0 / 680.0
  299. px_to_cm_y = 30.0 / 481.0
  300. bts = bdf["Timestamp_sec"].values.astype(float)
  301. nX, nY = parse_nose_xy(bdf["nose"].values)
  302. dX = np.diff(nX) * px_to_cm_x
  303. dY = np.diff(nY) * px_to_cm_y
  304. dt_b = np.diff(bts)
  305. dt_b[dt_b <= 0] = np.nan
  306. vel_b = np.sqrt(dX ** 2 + dY ** 2) / dt_b
  307. vel_b = np.nan_to_num(vel_b, nan=0.0, posinf=0.0, neginf=0.0)
  308. vel_ts = (bts[:-1] + bts[1:]) / 2.0
  309. return np.interp(t_lfp, vel_ts, vel_b, left=0.0, right=0.0)
  310. def tile_bout_to_centers(s_event, e_event, fs, n, win_sec, overlap=0.5):
  311. half = int(round(fs * win_sec / 2))
  312. if half < 2 or (2 * half + 1) >= n:
  313. return []
  314. bout_dur = float(e_event) - float(s_event)
  315. if bout_dur < win_sec:
  316. return []
  317. step = win_sec * (1.0 - overlap)
  318. centers = []
  319. t_center = float(s_event) + win_sec / 2.0
  320. while (t_center + win_sec / 2.0) <= (float(e_event) + 1e-9):
  321. t_lo = t_center - win_sec / 2.0
  322. t_hi = t_center + win_sec / 2.0
  323. if t_lo < float(s_event) - 1e-9 or t_hi > float(e_event) + 1e-9:
  324. t_center += step
  325. continue
  326. c = int(round(t_center * fs))
  327. if c >= half and c < (n - half - 1):
  328. centers.append(c)
  329. t_center += step
  330. return centers
  331. def _merge_close_epochs(ep_s, ep_e, max_gap_s, min_dur_s):
  332. if len(ep_s) == 0:
  333. return np.array([]), np.array([])
  334. order = np.argsort(ep_s)
  335. ep_s = np.asarray(ep_s[order], dtype=float)
  336. ep_e = np.asarray(ep_e[order], dtype=float)
  337. ms, me = [ep_s[0]], [ep_e[0]]
  338. for i in range(1, len(ep_s)):
  339. if ep_s[i] - me[-1] <= max_gap_s:
  340. me[-1] = max(me[-1], ep_e[i])
  341. else:
  342. ms.append(ep_s[i])
  343. me.append(ep_e[i])
  344. ms = np.array(ms)
  345. me = np.array(me)
  346. keep = (me - ms) >= min_dur_s
  347. return ms[keep], me[keep]
  348. def _interval_to_mask(time_vec, starts, ends):
  349. m = np.zeros(len(time_vec), dtype=bool)
  350. for s, e in zip(np.asarray(starts, dtype=float), np.asarray(ends, dtype=float)):
  351. m |= (time_vec >= s) & (time_vec <= e)
  352. return m
  353. def _object_mask_from_behavior(bdf):
  354. if "region1_other" in bdf.columns:
  355. return pd.to_numeric(bdf["region1_other"], errors="coerce").fillna(0).values > 0
  356. return np.zeros(len(bdf), dtype=bool)
  357. def build_speed_matched_baseline_mask(t_lfp, speed_lfp, event_mask, obj_mask_lfp, fs, win_sec, n_sd=1.0):
  358. n = len(t_lfp)
  359. half = int(round(fs * win_sec / 2))
  360. exp_speeds = speed_lfp[event_mask]
  361. if len(exp_speeds) < 5:
  362. return np.zeros(n, dtype=bool)
  363. mu = float(np.nanmean(exp_speeds))
  364. sd = float(np.nanstd(exp_speeds))
  365. if sd == 0:
  366. sd = max(0.5, 0.05 * mu)
  367. speed_lo = max(0.0, mu - n_sd * sd)
  368. speed_hi = mu + n_sd * sd
  369. speed_ok = (speed_lfp >= speed_lo) & (speed_lfp <= speed_hi)
  370. not_event = ~event_mask
  371. not_obj = ~obj_mask_lfp if obj_mask_lfp is not None else np.ones(n, dtype=bool)
  372. edge_ok = np.zeros(n, dtype=bool)
  373. edge_ok[half : n - half - 1] = True
  374. return speed_ok & not_event & not_obj & edge_ok
  375. def sample_baseline_centers(baseline_mask, n_needed, win_sec, fs, rng=None):
  376. if rng is None:
  377. rng = np.random.default_rng(seed=42)
  378. candidates = np.where(baseline_mask)[0]
  379. if len(candidates) == 0:
  380. return np.array([], dtype=int)
  381. min_sep = max(1, int(round(win_sec * fs)))
  382. order = rng.permutation(len(candidates))
  383. chosen = []
  384. for idx in order:
  385. c = int(candidates[idx])
  386. if all(abs(c - p) >= min_sep for p in chosen):
  387. chosen.append(c)
  388. if len(chosen) >= n_needed:
  389. break
  390. return np.array(sorted(chosen), dtype=int)
  391. def butter_bandpass(fs, band, order=3):
  392. lo = max(0.5, float(band[0]))
  393. hi = min(float(band[1]), fs / 2 - 1)
  394. if hi <= lo:
  395. return None, None
  396. b, a = signal.butter(order, [lo, hi], btype="bandpass", fs=fs)
  397. return b, a
  398. def window_slices(centers, half_win, n):
  399. out = []
  400. for c in centers:
  401. s = int(c - half_win)
  402. e = int(c + half_win + 1)
  403. if s >= 0 and e <= n:
  404. out.append((s, e))
  405. return out
  406. def coherence_band_mean(x, y, fs, centers, win_sec, band, NW=2):
  407. n = len(x)
  408. half = int(round(fs * win_sec / 2))
  409. vals = []
  410. for s, e in window_slices(centers, half, n):
  411. segx, segy = x[s:e], y[s:e]
  412. N = len(segx)
  413. if N < 32:
  414. continue
  415. K = max(1, 2 * NW - 1)
  416. tapers = dpss(N, NW, K)
  417. nfft = int(2 ** np.ceil(np.log2(N)))
  418. freqs = np.fft.rfftfreq(nfft, 1.0 / fs)
  419. Sxx = np.zeros(len(freqs))
  420. Syy = np.zeros(len(freqs))
  421. Sxy = np.zeros(len(freqs), dtype=complex)
  422. for taper in tapers:
  423. fx = np.fft.rfft(segx * taper, n=nfft)
  424. fy = np.fft.rfft(segy * taper, n=nfft)
  425. Sxx += np.abs(fx) ** 2
  426. Syy += np.abs(fy) ** 2
  427. Sxy += fx * np.conj(fy)
  428. Sxx /= K
  429. Syy /= K
  430. Sxy /= K
  431. denom = Sxx * Syy
  432. coh = np.where(denom > 0, np.abs(Sxy) ** 2 / denom, 0.0)
  433. keep = (freqs >= band[0]) & (freqs <= band[1])
  434. if np.any(keep):
  435. vals.append(float(np.nanmean(coh[keep])))
  436. return float(np.nanmean(vals)) if vals else np.nan
  437. def pac_mi(phase_sig, amp_sig, fs, centers, win_sec, phase_band, amp_band, n_bins=18):
  438. bp1 = butter_bandpass(fs, phase_band)
  439. bp2 = butter_bandpass(fs, amp_band)
  440. if bp1[0] is None or bp2[0] is None:
  441. return np.nan
  442. n = len(phase_sig)
  443. half = int(round(fs * win_sec / 2))
  444. bins = np.linspace(-np.pi, np.pi, n_bins + 1)
  445. vals = []
  446. for s, e in window_slices(centers, half, n):
  447. ph = np.angle(hilbert(signal.filtfilt(bp1[0], bp1[1], phase_sig[s:e])))
  448. amp = np.abs(hilbert(signal.filtfilt(bp2[0], bp2[1], amp_sig[s:e])))
  449. mean_amp = np.array(
  450. [
  451. np.mean(amp[(ph >= bins[bi]) & (ph < bins[bi + 1])])
  452. if np.any((ph >= bins[bi]) & (ph < bins[bi + 1]))
  453. else 0
  454. for bi in range(n_bins)
  455. ]
  456. )
  457. pa = mean_amp / (np.sum(mean_amp) + 1e-12)
  458. H = -np.sum(pa * np.log(pa + 1e-12))
  459. Hmax = np.log(n_bins)
  460. vals.append((Hmax - H) / (Hmax + 1e-12))
  461. return float(np.nanmean(vals)) if vals else np.nan
  462. def fdr_bh(pvals):
  463. p = np.array(pvals, dtype=float)
  464. n = len(p)
  465. if n == 0:
  466. return p
  467. order = np.argsort(p)
  468. ranked = p[order]
  469. q = np.empty(n, dtype=float)
  470. prev = 1.0
  471. for i in range(n - 1, -1, -1):
  472. prev = min(prev, ranked[i] * n / (i + 1))
  473. q[i] = prev
  474. q_out = np.empty(n, dtype=float)
  475. q_out[order] = np.minimum(q, 1.0)
  476. return q_out
  477. def load_channel_table(cfg):
  478. channel_data = pd.read_excel(cfg["metaXlsx"], sheet_name="electrodes")
  479. channel_data["animal"] = channel_data["animal"].astype(str).str.strip()
  480. return channel_data
  481. def load_meta_table(cfg, batch_id):
  482. return pd.read_excel(cfg["metaXlsx"], sheet_name=f"batch{batch_id}")
  483. def load_ephys_channels(cfg, meta_row, batch_id, channel_data, mouse):
  484. this_data_dir = os.path.join(
  485. cfg["rootFoldEphys"], "EphysRec", f"Batch{batch_id}", str(meta_row["ephy_folder"])
  486. )
  487. session_obj = Session(this_data_dir)
  488. rec = session_obj.recordnodes[0].recordings[0]
  489. stream_name = list(rec.continuous.keys())[0]
  490. cont = rec.continuous[stream_name]
  491. fs_raw = float(cont.metadata.sample_rate)
  492. t = np.asarray(cont.timestamps)
  493. eventline = rec.events.copy()
  494. st = pd.to_numeric(eventline["state"], errors="coerce").fillna(0).astype(int)
  495. smp = pd.to_numeric(eventline["sample_number"], errors="coerce")
  496. if np.sum(st == 1) < 1 or np.sum(st == 0) < 1:
  497. raise RuntimeError("bad_ttl_pattern")
  498. start_video = max(0, int(smp[st == 1].iloc[0] - np.min(t) * fs_raw + 1))
  499. stop_video = min(len(t) - 1, int(smp[st == 0].iloc[0] - np.min(t) * fs_raw + 1))
  500. if stop_video <= start_video:
  501. raise RuntimeError("empty_ttl_crop")
  502. ani_idx = np.where(channel_data["animal"].str.lower().values == str(mouse).strip().lower())[0]
  503. if len(ani_idx) == 0:
  504. raise RuntimeError("animal_not_in_electrode_sheet")
  505. ai = int(ani_idx[0])
  506. samples = np.asarray(cont.samples)
  507. time_first = samples.shape[0] == len(t)
  508. ref = (
  509. (samples[start_video:stop_video, 23] if time_first else samples[23, start_video:stop_video])
  510. if (batch_id == 1 and (samples.shape[1 if time_first else 0] > 23))
  511. else 0
  512. )
  513. channels = {}
  514. for name in ["HCl", "HCr", "RE", "PFCl", "PFCr"]:
  515. ch_id_raw = channel_data.loc[ai, name] if name in channel_data.columns else -1
  516. m_id = re.findall(r"[-+]?\d+", str(ch_id_raw))
  517. ch_id = int(m_id[0]) if m_id else -1
  518. if ch_id < 0:
  519. channels[name] = np.array([])
  520. continue
  521. ch_idx = ch_id - 1
  522. if time_first:
  523. channels[name] = (
  524. np.asarray(samples[start_video:stop_video, ch_idx], dtype=float) - ref
  525. if 0 <= ch_idx < samples.shape[1]
  526. else np.array([])
  527. )
  528. else:
  529. channels[name] = (
  530. np.asarray(samples[ch_idx, start_video:stop_video], dtype=float) - ref
  531. if 0 <= ch_idx < samples.shape[0]
  532. else np.array([])
  533. )
  534. return channels, fs_raw
  535. def preprocess_channels(channels, fs_raw, cfg, max_time_s=None):
  536. valid_names = [k for k, v in channels.items() if isinstance(v, np.ndarray) and v.size > 0]
  537. proc = {}
  538. fs = None
  539. for name in valid_names:
  540. proc[name], fs_this = preprocess_signal(channels[name], fs_raw, cfg)
  541. if fs is None:
  542. fs = fs_this
  543. if not proc:
  544. return {}, np.nan, np.array([])
  545. n = min(len(v) for v in proc.values())
  546. if max_time_s is not None:
  547. n = min(n, int(max_time_s * fs))
  548. for name in proc:
  549. proc[name] = proc[name][:n]
  550. t_lfp = np.arange(n) / fs
  551. return proc, fs, t_lfp
  552. def compute_group_summary(subject_df, value_col):
  553. rows = []
  554. group_cols = [c for c in subject_df.columns if c not in [value_col, "subject"]]
  555. for keys, g in subject_df.groupby(group_cols, sort=False):
  556. if not isinstance(keys, tuple):
  557. keys = (keys,)
  558. row = dict(zip(group_cols, keys))
  559. vals = g[value_col].values.astype(float)
  560. n = len(vals)
  561. row["n_subjects"] = n
  562. row["mean"] = float(np.nanmean(vals))
  563. row["sem"] = float(np.nanstd(vals, ddof=1) / np.sqrt(n)) if n > 1 else np.nan
  564. rows.append(row)
  565. return pd.DataFrame(rows)
  566. def save_dataframe(df, path):
  567. os.makedirs(os.path.dirname(path), exist_ok=True)
  568. df.to_csv(path, index=False)
  569. def compute_triplet_stats(
  570. results_long,
  571. out_dir,
  572. prefix,
  573. epoch_keep,
  574. domain_keep,
  575. metric_keep,
  576. bands_keep,
  577. win_sec,
  578. pool_hemispheres=False,
  579. require_both_hemis=False,
  580. c21_conds=None,
  581. normalize_method="zscore",
  582. apply_fdr=False,
  583. min_n_event_windows=0,
  584. ):
  585. if c21_conds is None:
  586. c21_conds = ["C21_1"]
  587. R0 = results_long.copy()
  588. if len(domain_keep) > 0:
  589. R0 = R0[R0["domain"].isin(domain_keep)].copy()
  590. if len(epoch_keep) > 0:
  591. R0 = R0[R0["epoch"].isin(epoch_keep)].copy()
  592. if len(metric_keep) > 0:
  593. R0 = R0[R0["metric"].isin(metric_keep)].copy()
  594. if bands_keep != "ALL":
  595. R0 = R0[R0["band"].isin(bands_keep)].copy()
  596. if win_sec > 0:
  597. R0 = R0[np.isclose(R0["win_sec"].astype(float), float(win_sec))].copy()
  598. if min_n_event_windows > 0 and "n_event_windows" in R0.columns:
  599. R0 = R0[
  600. pd.to_numeric(R0["n_event_windows"], errors="coerce").fillna(0).astype(int) >= int(min_n_event_windows)
  601. ].copy()
  602. if len(R0) == 0:
  603. raise RuntimeError(f"{prefix}: no rows left after filtering.")
  604. base_keys = ["win_sec", "epoch", "domain", "target", "metric", "band"]
  605. if pool_hemispheres:
  606. side = R0.groupby(["mouse", "condition"] + base_keys, as_index=False).agg(
  607. value=("value", "mean"),
  608. n_hemi=("hemisphere", "nunique"),
  609. )
  610. if require_both_hemis:
  611. is_lr_target = side["target"].isin(["HC", "PFC"])
  612. side = side[(~is_lr_target) | (side["n_hemi"] == 2)].copy()
  613. side["subject"] = side["mouse"].astype(str)
  614. else:
  615. side = R0.groupby(["mouse", "hemisphere", "condition"] + base_keys, as_index=False).agg(
  616. value=("value", "mean")
  617. )
  618. side["subject"] = side["mouse"].astype(str) + "|" + side["hemisphere"].astype(str)
  619. merge_keys = ["subject"] + base_keys
  620. def group_cond(df, conds, label):
  621. x = df[df["condition"].isin(conds)].copy()
  622. return x.groupby(merge_keys, as_index=False)["value"].mean().rename(columns={"value": label})
  623. naive = group_cond(side, CONDITION_GROUPS["naive"], "naive")
  624. cdm = group_cond(side, CONDITION_GROUPS["CDM"], "cdm")
  625. c21 = group_cond(side, c21_conds, "c21")
  626. merged_raw = naive.merge(cdm, on=merge_keys, how="inner").merge(c21, on=merge_keys, how="inner")
  627. if len(merged_raw) == 0:
  628. raise RuntimeError(f"{prefix}: no matched Naive/CDM/C21 rows after merging.")
  629. merged_norm = merged_raw.copy()
  630. if normalize_method == "zscore":
  631. for feat_keys, g in merged_norm.groupby(base_keys, sort=False):
  632. feat_vals = feat_keys if isinstance(feat_keys, tuple) else (feat_keys,)
  633. for subj in g["subject"].unique():
  634. mask = merged_norm["subject"] == subj
  635. for bk, bv in zip(base_keys, feat_vals):
  636. mask = mask & (merged_norm[bk] == bv)
  637. vals = merged_norm.loc[mask, ["naive", "cdm", "c21"]].values.flatten().astype(float)
  638. mu = np.nanmean(vals)
  639. sd = np.nanstd(vals)
  640. if sd > 0:
  641. merged_norm.loc[mask, "naive"] = (merged_norm.loc[mask, "naive"] - mu) / sd
  642. merged_norm.loc[mask, "cdm"] = (merged_norm.loc[mask, "cdm"] - mu) / sd
  643. merged_norm.loc[mask, "c21"] = (merged_norm.loc[mask, "c21"] - mu) / sd
  644. norm_label = "z-score"
  645. elif normalize_method == "none":
  646. norm_label = "raw"
  647. else:
  648. raise RuntimeError(f"Unsupported normalization: {normalize_method}")
  649. rows = []
  650. for keys, g in merged_norm.groupby(base_keys, sort=False):
  651. if not isinstance(keys, tuple):
  652. keys = (keys,)
  653. win_sec_i, epoch_i, domain_i, target_i, metric_i, band_i = keys
  654. vn = g["naive"].values.astype(float)
  655. vc = g["cdm"].values.astype(float)
  656. v2 = g["c21"].values.astype(float)
  657. n_subjects = len(g)
  658. if n_subjects < 3:
  659. continue
  660. try:
  661. chi2, p_friedman = friedmanchisquare(vn, vc, v2)
  662. except Exception:
  663. chi2, p_friedman = np.nan, np.nan
  664. try:
  665. _, p_naive_cdm = wilcoxon(vn, vc, alternative="two-sided")
  666. except Exception:
  667. p_naive_cdm = np.nan
  668. try:
  669. cdm_direction = np.nanmean(vc) - np.nanmean(vn)
  670. if cdm_direction > 0:
  671. _, p_cdm_c21 = wilcoxon(vc, v2, alternative="greater")
  672. else:
  673. _, p_cdm_c21 = wilcoxon(vc, v2, alternative="less")
  674. except Exception:
  675. p_cdm_c21 = np.nan
  676. try:
  677. _, p_naive_c21 = wilcoxon(vn, v2, alternative="two-sided")
  678. except Exception:
  679. p_naive_c21 = np.nan
  680. d1 = vc - vn
  681. d2 = v2 - vc
  682. rows.append(
  683. {
  684. "win_sec": float(win_sec_i),
  685. "epoch": epoch_i,
  686. "domain": domain_i,
  687. "target": target_i,
  688. "metric": metric_i,
  689. "band": band_i,
  690. "n_subjects": int(n_subjects),
  691. "mean_naive": float(np.nanmean(vn)),
  692. "mean_cdm": float(np.nanmean(vc)),
  693. "mean_c21": float(np.nanmean(v2)),
  694. "chi2_friedman": chi2,
  695. "p_friedman": p_friedman,
  696. "p_naive_cdm": p_naive_cdm,
  697. "p_cdm_c21": p_cdm_c21,
  698. "p_naive_c21": p_naive_c21,
  699. "restores_frac": float(np.mean(np.sign(d1) == -np.sign(d2))),
  700. "opposite_sign_group": bool(np.sign(np.nanmean(d1)) == -np.sign(np.nanmean(d2))),
  701. }
  702. )
  703. stats_all = pd.DataFrame(rows)
  704. if len(stats_all) == 0:
  705. raise RuntimeError(f"{prefix}: no analyzable groups.")
  706. if apply_fdr:
  707. for pcol in ["p_friedman", "p_naive_cdm", "p_cdm_c21", "p_naive_c21"]:
  708. valid = stats_all[pcol].notna()
  709. if valid.sum() > 0:
  710. stats_all.loc[valid, f"{pcol}_fdr"] = fdr_bh(stats_all.loc[valid, pcol].values)
  711. stats_all = stats_all.sort_values("p_friedman", na_position="last").reset_index(drop=True)
  712. suffix_hemi = "pooledHemi" if pool_hemispheres else "splitHemi"
  713. suffix_norm = normalize_method
  714. save_dataframe(stats_all, os.path.join(out_dir, f"{prefix}_stats_{suffix_hemi}_{suffix_norm}.csv"))
  715. save_dataframe(merged_raw, os.path.join(out_dir, f"{prefix}_plot_data_raw_{suffix_hemi}_{suffix_norm}.csv"))
  716. save_dataframe(merged_norm, os.path.join(out_dir, f"{prefix}_plot_data_norm_{suffix_hemi}_{suffix_norm}.csv"))
  717. return {
  718. "stats": stats_all,
  719. "plot_data_raw": merged_raw,
  720. "plot_data_norm": merged_norm,
  721. "norm_label": norm_label,
  722. "suffix_hemi": suffix_hemi,
  723. "suffix_norm": suffix_norm,
  724. }
  725. def run_noe_extraction(cfg, out_dir):
  726. channel_data = load_channel_table(cfg)
  727. win_sec = 0.5
  728. win_overlap = 0.5
  729. min_epoch_s = 0.5
  730. max_gap_s = 0.75
  731. min_exploration_s = 3.0
  732. min_bouts_per_session = 2
  733. baseline_n_sd = 1.0
  734. reject_baseline_if_object = True
  735. random_seed = 42
  736. artifact_thresh_sd = 4.0
  737. rng = np.random.default_rng(seed=random_seed)
  738. conn_bands = ["theta", "beta", "lowGamma"]
  739. pac_pairs = [("theta", "lowGamma")]
  740. all_rows = []
  741. logs = []
  742. bout_qc_rows = []
  743. orig_min_epoch = cfg["minEpochDur_s_NOE"]
  744. cfg["minEpochDur_s_NOE"] = 0.01
  745. for mi, mouse in enumerate(cfg["miceAvail"]):
  746. batch_id = int(cfg["batch"][mi])
  747. meta = load_meta_table(cfg, batch_id)
  748. for cond in cfg["conditions"]:
  749. test = "NOE"
  750. session_id = f"{mouse}|{cond}|{test}"
  751. if should_skip(mouse, cond, test):
  752. logs.append({"session_id": session_id, "mouse": mouse, "batch": batch_id, "condition": cond, "test": test, "status": "skipped_rule"})
  753. continue
  754. row_id, msg = find_meta_row(meta, mouse, cond, test)
  755. if row_id is None:
  756. logs.append({"session_id": session_id, "mouse": mouse, "batch": batch_id, "condition": cond, "test": test, "status": msg})
  757. continue
  758. md = meta.loc[row_id].copy()
  759. md["batch_id"] = batch_id
  760. try:
  761. channels, fs_raw = load_ephys_channels(cfg, md, batch_id, channel_data, mouse)
  762. except Exception as e:
  763. logs.append({"session_id": session_id, "mouse": mouse, "batch": batch_id, "condition": cond, "test": test, "status": f"load_ephys_failed:{e}"})
  764. continue
  765. bdf, ep_s, ep_e, bmsg = load_behavior_epochs(md, test, cfg)
  766. if bdf is None:
  767. logs.append({"session_id": session_id, "mouse": mouse, "batch": batch_id, "condition": cond, "test": test, "status": bmsg})
  768. continue
  769. n_raw = len(ep_s)
  770. ep_s, ep_e = _merge_close_epochs(ep_s, ep_e, max_gap_s, min_epoch_s)
  771. n_after = len(ep_s)
  772. total_exploration_s = float(np.sum(ep_e - ep_s)) if n_after > 0 else 0.0
  773. if n_after < min_bouts_per_session or total_exploration_s < min_exploration_s:
  774. reason = f"too_few_bouts:{n_after}" if n_after < min_bouts_per_session else f"too_little_exploration:{total_exploration_s:.1f}s"
  775. logs.append(
  776. {
  777. "session_id": session_id,
  778. "mouse": mouse,
  779. "batch": batch_id,
  780. "condition": cond,
  781. "test": test,
  782. "status": reason,
  783. "n_raw_bouts": n_raw,
  784. "n_merged_bouts": n_after,
  785. "total_exploration_s": total_exploration_s,
  786. }
  787. )
  788. continue
  789. proc, fs, t_lfp = preprocess_channels(channels, fs_raw, cfg)
  790. if not proc or len(t_lfp) < int(fs):
  791. logs.append({"session_id": session_id, "mouse": mouse, "batch": batch_id, "condition": cond, "test": test, "status": "too_short"})
  792. continue
  793. n = len(t_lfp)
  794. event_mask = _interval_to_mask(t_lfp, ep_s, ep_e)
  795. speed_lfp = compute_speed_on_lfp_time(bdf, t_lfp, cfg["px_to_cm"])
  796. obj_mask_lfp = None
  797. if reject_baseline_if_object:
  798. bts = bdf["Timestamp_sec"].values.astype(float)
  799. obj_beh = _object_mask_from_behavior(bdf).astype(float)
  800. obj_mask_lfp = (
  801. np.interp(t_lfp, bts, obj_beh, left=0.0, right=0.0) > 0.5
  802. if len(bts) > 1
  803. else np.zeros(n, dtype=bool)
  804. )
  805. event_centers_list = []
  806. per_bout_qc = []
  807. for s_evt, e_evt in zip(ep_s.astype(float), ep_e.astype(float)):
  808. centers_this_bout = tile_bout_to_centers(s_evt, e_evt, fs, n, win_sec=win_sec, overlap=win_overlap)
  809. event_centers_list.extend(centers_this_bout)
  810. per_bout_qc.append(
  811. {
  812. "session_id": session_id,
  813. "mouse": mouse,
  814. "condition": cond,
  815. "bout_start_s": float(s_evt),
  816. "bout_end_s": float(e_evt),
  817. "bout_duration_s": float(e_evt - s_evt),
  818. "n_windows_tiled": len(centers_this_bout),
  819. }
  820. )
  821. event_centers = np.array(sorted(event_centers_list), dtype=int)
  822. if len(event_centers) == 0:
  823. logs.append({"session_id": session_id, "mouse": mouse, "batch": batch_id, "condition": cond, "test": test, "status": "no_event_windows"})
  824. continue
  825. artifact_keep = np.ones(len(event_centers), dtype=bool)
  826. for _, x_ch in proc.items():
  827. clean_centers_ch, _ = reject_artifact_windows(x_ch, list(event_centers), win_sec, fs, thresh_sd=artifact_thresh_sd)
  828. clean_set = set(clean_centers_ch)
  829. for ci, c in enumerate(event_centers):
  830. if c not in clean_set:
  831. artifact_keep[ci] = False
  832. event_centers = event_centers[artifact_keep]
  833. n_artifact_rejected = int(np.sum(~artifact_keep))
  834. n_event_windows = len(event_centers)
  835. if n_event_windows == 0:
  836. logs.append({"session_id": session_id, "mouse": mouse, "batch": batch_id, "condition": cond, "test": test, "status": "all_event_windows_rejected"})
  837. continue
  838. baseline_mask = build_speed_matched_baseline_mask(
  839. t_lfp,
  840. speed_lfp,
  841. event_mask,
  842. obj_mask_lfp,
  843. fs=fs,
  844. win_sec=win_sec,
  845. n_sd=baseline_n_sd,
  846. )
  847. n_pool = int(np.sum(baseline_mask))
  848. baseline_centers = sample_baseline_centers(baseline_mask, n_needed=n_event_windows, win_sec=win_sec, fs=fs, rng=rng)
  849. baseline_keep = np.ones(len(baseline_centers), dtype=bool)
  850. for _, x_ch in proc.items():
  851. clean_centers_ch, _ = reject_artifact_windows(x_ch, list(baseline_centers), win_sec, fs, thresh_sd=artifact_thresh_sd)
  852. clean_set = set(clean_centers_ch)
  853. for ci, c in enumerate(baseline_centers):
  854. if c not in clean_set:
  855. baseline_keep[ci] = False
  856. baseline_centers = baseline_centers[baseline_keep]
  857. n_baseline_windows = len(baseline_centers)
  858. n_valid_pairs = min(n_event_windows, n_baseline_windows)
  859. bout_qc_rows.extend(per_bout_qc)
  860. epoch_centers = {"event": event_centers, "baseline": baseline_centers}
  861. epoch_metrics = {}
  862. for epoch_name, centers in epoch_centers.items():
  863. m = {}
  864. if len(centers) == 0:
  865. epoch_metrics[epoch_name] = m
  866. continue
  867. for a, b in itertools.combinations(sorted(proc.keys()), 2):
  868. canonical, pair_hemi = pool_pair_name(a, b)
  869. if canonical is None:
  870. continue
  871. xa, xb = proc[a], proc[b]
  872. for bname in conn_bands:
  873. val = coherence_band_mean(xa, xb, fs, centers, win_sec, cfg["bands"][bname])
  874. m[("pair", canonical, pair_hemi, "coh", bname)] = val
  875. for phase_band_name, amp_band_name in pac_pairs:
  876. mi_val = pac_mi(
  877. xa,
  878. xb,
  879. fs,
  880. centers,
  881. win_sec,
  882. cfg["bands"][phase_band_name],
  883. cfg["bands"][amp_band_name],
  884. )
  885. m[("pair", canonical, pair_hemi, "pac_mi", f"{phase_band_name}_{amp_band_name}")] = mi_val
  886. epoch_metrics[epoch_name] = m
  887. for key, val in m.items():
  888. domain, target, hemi, metric, band = key
  889. phase_region, amp_region = PAC_DIRECTION_LABEL.get(target, ("", ""))
  890. all_rows.append(
  891. {
  892. "session_id": session_id,
  893. "mouse": mouse,
  894. "batch": batch_id,
  895. "condition": cond,
  896. "test": test,
  897. "epoch": epoch_name,
  898. "win_sec": win_sec,
  899. "domain": domain,
  900. "target": target,
  901. "target_figure_label": PAIR_FIGURE_LABEL.get(target, target),
  902. "hemisphere": hemi,
  903. "metric": metric,
  904. "band": band,
  905. "phase_region": phase_region if metric == "pac_mi" else "",
  906. "amplitude_region": amp_region if metric == "pac_mi" else "",
  907. "value": float(val) if np.isfinite(val) else np.nan,
  908. "notes": "ok",
  909. "n_raw_bouts": int(n_raw),
  910. "n_merged_bouts": int(n_after),
  911. "total_exploration_s": float(total_exploration_s),
  912. "n_event_windows": int(n_event_windows),
  913. "n_baseline_windows": int(n_baseline_windows),
  914. "n_valid_pairs": int(n_valid_pairs),
  915. "baseline_pool_size": int(n_pool),
  916. "n_artifact_rejected": int(n_artifact_rejected),
  917. }
  918. )
  919. me = epoch_metrics.get("event", {})
  920. mb = epoch_metrics.get("baseline", {})
  921. for kk in set(me.keys()) & set(mb.keys()):
  922. ve, vb = me[kk], mb[kk]
  923. vd = ve - vb if np.isfinite(ve) and np.isfinite(vb) else np.nan
  924. domain, target, hemi, metric, band = kk
  925. phase_region, amp_region = PAC_DIRECTION_LABEL.get(target, ("", ""))
  926. all_rows.append(
  927. {
  928. "session_id": session_id,
  929. "mouse": mouse,
  930. "batch": batch_id,
  931. "condition": cond,
  932. "test": test,
  933. "epoch": "delta",
  934. "win_sec": win_sec,
  935. "domain": domain,
  936. "target": target,
  937. "target_figure_label": PAIR_FIGURE_LABEL.get(target, target),
  938. "hemisphere": hemi,
  939. "metric": metric,
  940. "band": band,
  941. "phase_region": phase_region if metric == "pac_mi" else "",
  942. "amplitude_region": amp_region if metric == "pac_mi" else "",
  943. "value": float(vd) if np.isfinite(vd) else np.nan,
  944. "notes": "event_minus_speedMatchedBaseline",
  945. "n_raw_bouts": int(n_raw),
  946. "n_merged_bouts": int(n_after),
  947. "total_exploration_s": float(total_exploration_s),
  948. "n_event_windows": int(n_event_windows),
  949. "n_baseline_windows": int(n_baseline_windows),
  950. "n_valid_pairs": int(n_valid_pairs),
  951. "baseline_pool_size": int(n_pool),
  952. "n_artifact_rejected": int(n_artifact_rejected),
  953. }
  954. )
  955. logs.append(
  956. {
  957. "session_id": session_id,
  958. "mouse": mouse,
  959. "batch": batch_id,
  960. "condition": cond,
  961. "test": test,
  962. "status": "ok",
  963. "n_raw_bouts": int(n_raw),
  964. "n_merged_bouts": int(n_after),
  965. "total_exploration_s": float(total_exploration_s),
  966. "n_event_windows": int(n_event_windows),
  967. "n_baseline_windows": int(n_baseline_windows),
  968. "n_valid_pairs": int(n_valid_pairs),
  969. "baseline_pool_size": int(n_pool),
  970. "n_artifact_rejected": int(n_artifact_rejected),
  971. }
  972. )
  973. cfg["minEpochDur_s_NOE"] = orig_min_epoch
  974. results_long = pd.DataFrame(all_rows)
  975. session_log = pd.DataFrame(logs)
  976. bout_qc = pd.DataFrame(bout_qc_rows)
  977. save_dataframe(results_long, os.path.join(out_dir, "NOE_results_long.csv"))
  978. save_dataframe(session_log, os.path.join(out_dir, "NOE_session_log.csv"))
  979. save_dataframe(bout_qc, os.path.join(out_dir, "NOE_bout_qc.csv"))
  980. return results_long, session_log, bout_qc
  981. def run_hab_running_band_coherence(cfg, out_dir):
  982. channel_data = load_channel_table(cfg)
  983. win_sec = 0.5
  984. win_overlap = 0.5
  985. max_time_s = 600
  986. speed_run_thresh = 5.0
  987. min_windows = 3
  988. artifact_thresh = 4.0
  989. long_rows = []
  990. session_log = []
  991. for mi, mouse in enumerate(cfg["miceAvail"]):
  992. batch_id = int(cfg["batch"][mi])
  993. meta = load_meta_table(cfg, batch_id)
  994. for cond in cfg["conditions"]:
  995. test = "HAB"
  996. session_id = f"{mouse}|{cond}|{test}"
  997. if should_skip(mouse, cond, test):
  998. session_log.append({"session_id": session_id, "status": "skipped_rule"})
  999. continue
  1000. row_id, msg = find_meta_row(meta, mouse, cond, test)
  1001. if row_id is None:
  1002. session_log.append({"session_id": session_id, "status": msg})
  1003. continue
  1004. md = meta.loc[row_id].copy()
  1005. md["batch_id"] = batch_id
  1006. try:
  1007. channels, fs_raw = load_ephys_channels(cfg, md, batch_id, channel_data, mouse)
  1008. except Exception as e:
  1009. session_log.append({"session_id": session_id, "status": f"ephys_failed:{e}"})
  1010. continue
  1011. video_dir = resolve_video_dir(cfg["rootFoldVideo"], md["video_folder"], batch_id)
  1012. trial = str(md["trial_number"])
  1013. trial_suffix = trial[1:] if len(trial) > 1 else trial
  1014. xls_name = f"processed_interpolated_T{trial_suffix}_V2.xlsx"
  1015. xls_path = os.path.join(video_dir, xls_name)
  1016. if not os.path.exists(xls_path):
  1017. pattern = os.path.join(video_dir, f"processed_interpolated_T*{trial_suffix}*_V2.xlsx")
  1018. cand = glob.glob(pattern)
  1019. if cand:
  1020. xls_path = cand[0]
  1021. if not os.path.exists(xls_path):
  1022. session_log.append({"session_id": session_id, "status": "behavior_missing"})
  1023. continue
  1024. bdf = pd.read_excel(xls_path)
  1025. if "Timestamp_sec" not in bdf.columns or "nose" not in bdf.columns:
  1026. session_log.append({"session_id": session_id, "status": "missing_columns"})
  1027. continue
  1028. bdf.loc[bdf["Timestamp_sec"] <= 1, "Timestamp_sec"] = np.nan
  1029. bdf = bdf.dropna(subset=["Timestamp_sec"]).copy()
  1030. if len(bdf) == 0:
  1031. session_log.append({"session_id": session_id, "status": "empty_behavior"})
  1032. continue
  1033. bdf["Timestamp_sec"] = bdf["Timestamp_sec"] - bdf["Timestamp_sec"].iloc[0]
  1034. bdf = bdf[bdf["Timestamp_sec"] <= max_time_s].copy()
  1035. if len(bdf) < 100:
  1036. session_log.append({"session_id": session_id, "status": "too_short"})
  1037. continue
  1038. proc, fs, t_lfp = preprocess_channels(channels, fs_raw, cfg, max_time_s=max_time_s)
  1039. if not proc or len(t_lfp) < int(fs * 2):
  1040. session_log.append({"session_id": session_id, "status": "too_short_lfp"})
  1041. continue
  1042. speed_lfp = build_hab_speed_lfp_xy(bdf, t_lfp)
  1043. n = len(t_lfp)
  1044. half = int(round(fs * win_sec / 2))
  1045. step = int(win_sec * (1 - win_overlap) * fs)
  1046. all_centers = np.arange(half, n - half, step, dtype=int)
  1047. if len(all_centers) < 10:
  1048. session_log.append({"session_id": session_id, "status": "too_few_windows"})
  1049. continue
  1050. win_speeds = np.array([np.mean(speed_lfp[max(0, c - half) : min(n, c + half)]) for c in all_centers])
  1051. clean_mask = np.ones(len(all_centers), dtype=bool)
  1052. for _, x_ch in proc.items():
  1053. cl, _ = reject_artifact_windows(x_ch, list(all_centers), win_sec, fs, artifact_thresh)
  1054. cl_set = set(cl)
  1055. for j, c in enumerate(all_centers):
  1056. if c not in cl_set:
  1057. clean_mask[j] = False
  1058. clean_centers = all_centers[clean_mask]
  1059. clean_speeds = win_speeds[clean_mask]
  1060. run_centers = clean_centers[clean_speeds > speed_run_thresh]
  1061. if len(run_centers) < min_windows:
  1062. session_log.append({"session_id": session_id, "status": "too_few_running_windows"})
  1063. continue
  1064. valid_names = [k for k, v in proc.items() if isinstance(v, np.ndarray) and v.size > 0]
  1065. pair_list = list(itertools.combinations(valid_names, 2))
  1066. for chA, chB in pair_list:
  1067. pair_name, pair_hemi = pool_pair_name(chA, chB)
  1068. if pair_name is None:
  1069. continue
  1070. xA, xB = proc[chA], proc[chB]
  1071. for bname in ["theta", "beta", "lowGamma"]:
  1072. val = coherence_band_mean(xA, xB, fs, run_centers, win_sec, cfg["bands"][bname])
  1073. if np.isfinite(val):
  1074. long_rows.append(
  1075. {
  1076. "session_id": session_id,
  1077. "mouse": mouse,
  1078. "condition": cond,
  1079. "hemisphere": pair_hemi,
  1080. "domain": "pair",
  1081. "target": pair_name,
  1082. "target_figure_label": PAIR_FIGURE_LABEL.get(pair_name, pair_name),
  1083. "epoch": "running",
  1084. "win_sec": win_sec,
  1085. "n_windows": len(run_centers),
  1086. "metric": "coh",
  1087. "band": bname,
  1088. "value": float(val),
  1089. }
  1090. )
  1091. session_log.append(
  1092. {
  1093. "session_id": session_id,
  1094. "mouse": mouse,
  1095. "condition": cond,
  1096. "status": "ok",
  1097. "n_run": len(run_centers),
  1098. "mean_speed": float(np.mean(clean_speeds)) if len(clean_speeds) else np.nan,
  1099. }
  1100. )
  1101. results_long = pd.DataFrame(long_rows)
  1102. session_log = pd.DataFrame(session_log)
  1103. save_dataframe(results_long, os.path.join(out_dir, "HAB_running_band_coherence_results_long.csv"))
  1104. save_dataframe(session_log, os.path.join(out_dir, "HAB_running_band_coherence_session_log.csv"))
  1105. return results_long, session_log
  1106. def run_hab_psd_and_coherence_spectra(cfg, out_dir):
  1107. channel_data = load_channel_table(cfg)
  1108. win_sec_welch = 1.0
  1109. overlap_welch = 0.5
  1110. freq_range = (1, 80)
  1111. speed_thresh = 5.0
  1112. psd_store = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
  1113. coh_store = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
  1114. freqs_ref_psd = None
  1115. freqs_ref_coh = None
  1116. cond_map = {c: label for label, conds in CONDITION_GROUPS.items() for c in conds}
  1117. for mi, mouse in enumerate(cfg["miceAvail"]):
  1118. batch_id = int(cfg["batch"][mi])
  1119. meta = load_meta_table(cfg, batch_id)
  1120. for cond in cfg["conditions"]:
  1121. if cond not in cond_map:
  1122. continue
  1123. cond_label = cond_map[cond]
  1124. test = "HAB"
  1125. session_id = f"{mouse}|{cond}|{test}"
  1126. if should_skip(mouse, cond, test):
  1127. continue
  1128. row_id, _ = find_meta_row(meta, mouse, cond, test)
  1129. if row_id is None:
  1130. continue
  1131. md = meta.loc[row_id].copy()
  1132. md["batch_id"] = batch_id
  1133. try:
  1134. channels, fs_raw = load_ephys_channels(cfg, md, batch_id, channel_data, mouse)
  1135. except Exception:
  1136. continue
  1137. bdf, _, _, bmsg = load_behavior_epochs(md, test, cfg)
  1138. if bdf is None:
  1139. continue
  1140. proc, fs, t_lfp = preprocess_channels(channels, fs_raw, cfg)
  1141. if not proc:
  1142. continue
  1143. speed_lfp = compute_speed_on_lfp_time(bdf, t_lfp, cfg["px_to_cm"])
  1144. running_mask = speed_lfp >= speed_thresh
  1145. if running_mask.sum() < int(win_sec_welch * fs):
  1146. continue
  1147. nperseg = int(win_sec_welch * fs)
  1148. noverlap = int(nperseg * overlap_welch)
  1149. for ch_name, x in proc.items():
  1150. if ch_name not in CHANNEL_TO_REGION:
  1151. continue
  1152. region, hemi = pool_channel_name(ch_name)
  1153. if region not in PSD_REGIONS:
  1154. continue
  1155. store_key = f"{mouse}|{hemi}"
  1156. x_run = x[running_mask]
  1157. if len(x_run) < nperseg:
  1158. continue
  1159. freqs, psd = welch(
  1160. x_run,
  1161. fs=fs,
  1162. nperseg=nperseg,
  1163. noverlap=noverlap,
  1164. window="hann",
  1165. scaling="density",
  1166. )
  1167. if freqs_ref_psd is None:
  1168. freqs_ref_psd = freqs
  1169. psd_store[cond_label][region][store_key].append(psd)
  1170. for a, b in itertools.combinations(sorted(proc.keys()), 2):
  1171. pair_name, pair_hemi = pool_pair_name(a, b)
  1172. if pair_name is None:
  1173. continue
  1174. xa = proc[a][running_mask]
  1175. xb = proc[b][running_mask]
  1176. if len(xa) < nperseg or len(xb) < nperseg:
  1177. continue
  1178. freqs_c, coh = signal.coherence(
  1179. xa,
  1180. xb,
  1181. fs=fs,
  1182. nperseg=nperseg,
  1183. noverlap=noverlap,
  1184. window="hann",
  1185. )
  1186. if freqs_ref_coh is None:
  1187. freqs_ref_coh = freqs_c
  1188. store_key = f"{mouse}|{pair_hemi}"
  1189. coh_store[cond_label][pair_name][store_key].append(coh)
  1190. if freqs_ref_psd is None:
  1191. raise RuntimeError("No HAB running PSD could be computed.")
  1192. if freqs_ref_coh is None:
  1193. raise RuntimeError("No HAB running coherence spectrum could be computed.")
  1194. freq_mask_psd = (freqs_ref_psd >= freq_range[0]) & (freqs_ref_psd <= freq_range[1])
  1195. freq_mask_coh = (freqs_ref_coh >= freq_range[0]) & (freqs_ref_coh <= freq_range[1])
  1196. psd_subject_rows = []
  1197. for cond_label, reg_dict in psd_store.items():
  1198. for region, subj_dict in reg_dict.items():
  1199. for subj_key, psd_list in subj_dict.items():
  1200. subj_mean = np.nanmean(np.stack(psd_list, axis=0), axis=0)
  1201. for f, v in zip(freqs_ref_psd[freq_mask_psd], subj_mean[freq_mask_psd]):
  1202. psd_subject_rows.append(
  1203. {
  1204. "analysis": "running_psd",
  1205. "condition_group": cond_label,
  1206. "condition_label": CONDITION_LABEL[cond_label],
  1207. "region": region,
  1208. "region_figure_label": REGION_FIGURE_LABEL[region],
  1209. "subject": subj_key,
  1210. "frequency_hz": float(f),
  1211. "value": float(v),
  1212. }
  1213. )
  1214. psd_subject = pd.DataFrame(psd_subject_rows)
  1215. psd_summary = compute_group_summary(psd_subject, "value")
  1216. coh_subject_rows = []
  1217. for cond_label, pair_dict in coh_store.items():
  1218. for pair_name, subj_dict in pair_dict.items():
  1219. for subj_key, coh_list in subj_dict.items():
  1220. subj_mean = np.nanmean(np.stack(coh_list, axis=0), axis=0)
  1221. for f, v in zip(freqs_ref_coh[freq_mask_coh], subj_mean[freq_mask_coh]):
  1222. coh_subject_rows.append(
  1223. {
  1224. "analysis": "running_coherence_spectrum",
  1225. "condition_group": cond_label,
  1226. "condition_label": CONDITION_LABEL[cond_label],
  1227. "target": pair_name,
  1228. "target_figure_label": PAIR_FIGURE_LABEL[pair_name],
  1229. "subject": subj_key,
  1230. "frequency_hz": float(f),
  1231. "value": float(v),
  1232. }
  1233. )
  1234. coh_subject = pd.DataFrame(coh_subject_rows)
  1235. coh_summary = compute_group_summary(coh_subject, "value")
  1236. save_dataframe(psd_subject, os.path.join(out_dir, "running_psd_subject_curves.csv"))
  1237. save_dataframe(psd_summary, os.path.join(out_dir, "running_psd_group_summary.csv"))
  1238. save_dataframe(coh_subject, os.path.join(out_dir, "running_coherence_spectrum_subject_curves.csv"))
  1239. save_dataframe(coh_summary, os.path.join(out_dir, "running_coherence_spectrum_group_summary.csv"))
  1240. return psd_subject, psd_summary, coh_subject, coh_summary
  1241. def _star(p):
  1242. if not np.isfinite(p):
  1243. return "ns"
  1244. if p < 0.001:
  1245. return "***"
  1246. if p < 0.01:
  1247. return "**"
  1248. if p < 0.05:
  1249. return "*"
  1250. return "ns"
  1251. def export_selected_bar_panel_csvs(panel_specs, stats_dict, out_dir, prefix):
  1252. stats_all = stats_dict["stats"]
  1253. plot_data_norm = stats_dict["plot_data_norm"]
  1254. plot_data_raw = stats_dict["plot_data_raw"]
  1255. combined_rows = []
  1256. for spec in panel_specs:
  1257. mask = (
  1258. (stats_all["domain"] == "pair")
  1259. & (stats_all["target"] == spec["target"])
  1260. & (stats_all["metric"] == spec["metric"])
  1261. & (stats_all["band"] == spec["band"])
  1262. )
  1263. stat_row = stats_all.loc[mask].copy()
  1264. if len(stat_row) == 0:
  1265. continue
  1266. stat_row = stat_row.iloc[[0]].copy()
  1267. stat_row["panel"] = spec["panel"]
  1268. stat_row["figure_title"] = spec["title"]
  1269. plot_mask = (
  1270. np.isclose(plot_data_norm["win_sec"].astype(float), float(stat_row["win_sec"].iloc[0]))
  1271. & (plot_data_norm["epoch"] == stat_row["epoch"].iloc[0])
  1272. & (plot_data_norm["domain"] == stat_row["domain"].iloc[0])
  1273. & (plot_data_norm["target"] == stat_row["target"].iloc[0])
  1274. & (plot_data_norm["metric"] == stat_row["metric"].iloc[0])
  1275. & (plot_data_norm["band"] == stat_row["band"].iloc[0])
  1276. )
  1277. d_norm = plot_data_norm.loc[plot_mask].copy()
  1278. d_raw = plot_data_raw.loc[plot_mask].copy()
  1279. panel_rows = []
  1280. for _, row in d_norm.iterrows():
  1281. raw_row = d_raw.loc[d_raw["subject"] == row["subject"]]
  1282. raw_vals = raw_row.iloc[0] if len(raw_row) else None
  1283. for cond_key in ["naive", "cdm", "c21"]:
  1284. out_row = {
  1285. "panel": spec["panel"],
  1286. "figure_title": spec["title"],
  1287. "subject": row["subject"],
  1288. "target": row["target"],
  1289. "target_figure_label": PAIR_FIGURE_LABEL.get(row["target"], row["target"]),
  1290. "metric": row["metric"],
  1291. "band": row["band"],
  1292. "condition_group": cond_key.upper() if cond_key != "naive" else "naive",
  1293. "condition_label": CONDITION_LABEL["naive"] if cond_key == "naive" else CONDITION_LABEL[cond_key.upper()],
  1294. "value_norm": float(row[cond_key]),
  1295. "value_raw": float(raw_vals[cond_key]) if raw_vals is not None else np.nan,
  1296. }
  1297. if row["metric"] == "pac_mi":
  1298. phase_region, amp_region = PAC_DIRECTION_LABEL.get(row["target"], ("", ""))
  1299. out_row["phase_region"] = phase_region
  1300. out_row["amplitude_region"] = amp_region
  1301. panel_rows.append(out_row)
  1302. panel_df = pd.DataFrame(panel_rows)
  1303. save_dataframe(panel_df, os.path.join(out_dir, f"{prefix}_panel_{spec['panel']}.csv"))
  1304. save_dataframe(stat_row, os.path.join(out_dir, f"{prefix}_panel_{spec['panel']}_stats.csv"))
  1305. combined_rows.append(panel_df)
  1306. if combined_rows:
  1307. save_dataframe(pd.concat(combined_rows, ignore_index=True), os.path.join(out_dir, f"{prefix}_all_panels_long.csv"))
  1308. def export_curve_panel_csvs(panel_key, subject_df, summary_df, out_dir):
  1309. save_dataframe(subject_df, os.path.join(out_dir, f"{panel_key}_subject_curves.csv"))
  1310. save_dataframe(summary_df, os.path.join(out_dir, f"{panel_key}_group_summary.csv"))
  1311. def add_pairwise_brackets(ax, stat_row, y_top, y_step):
  1312. p_nc = float(stat_row.get("p_naive_cdm", np.nan))
  1313. p_cc = float(stat_row.get("p_cdm_c21", np.nan))
  1314. p_n2 = float(stat_row.get("p_naive_c21", np.nan))
  1315. level = y_top
  1316. if np.isfinite(p_nc) and p_nc < 0.05:
  1317. ax.plot([0, 0, 1, 1], [level, level + 0.04 * y_step, level + 0.04 * y_step, level], "k-", lw=0.8)
  1318. ax.text(0.5, level + 0.05 * y_step, _star(p_nc), ha="center", va="bottom", fontsize=9)
  1319. level += 0.12 * y_step
  1320. if np.isfinite(p_cc) and p_cc < 0.05:
  1321. ax.plot([1, 1, 2, 2], [level, level + 0.04 * y_step, level + 0.04 * y_step, level], "k-", lw=0.8)
  1322. ax.text(1.5, level + 0.05 * y_step, _star(p_cc), ha="center", va="bottom", fontsize=9)
  1323. level += 0.12 * y_step
  1324. if np.isfinite(p_n2) and p_n2 < 0.05:
  1325. ax.plot([0, 0, 2, 2], [level, level + 0.04 * y_step, level + 0.04 * y_step, level], "k-", lw=0.8)
  1326. ax.text(1.0, level + 0.05 * y_step, _star(p_n2), ha="center", va="bottom", fontsize=9)
  1327. def plot_selected_bar_panel(ax, spec, stats_dict):
  1328. stats_all = stats_dict["stats"]
  1329. plot_data_norm = stats_dict["plot_data_norm"]
  1330. mask = (
  1331. (stats_all["domain"] == "pair")
  1332. & (stats_all["target"] == spec["target"])
  1333. & (stats_all["metric"] == spec["metric"])
  1334. & (stats_all["band"] == spec["band"])
  1335. )
  1336. if mask.sum() == 0:
  1337. ax.set_axis_off()
  1338. ax.text(0.5, 0.5, f"Missing panel {spec['panel']}", ha="center", va="center")
  1339. return
  1340. stat_row = stats_all.loc[mask].iloc[0]
  1341. plot_mask = (
  1342. np.isclose(plot_data_norm["win_sec"].astype(float), float(stat_row["win_sec"]))
  1343. & (plot_data_norm["epoch"] == stat_row["epoch"])
  1344. & (plot_data_norm["domain"] == stat_row["domain"])
  1345. & (plot_data_norm["target"] == stat_row["target"])
  1346. & (plot_data_norm["metric"] == stat_row["metric"])
  1347. & (plot_data_norm["band"] == stat_row["band"])
  1348. )
  1349. d = plot_data_norm.loc[plot_mask].copy()
  1350. if len(d) == 0:
  1351. ax.set_axis_off()
  1352. ax.text(0.5, 0.5, f"No data for panel {spec['panel']}", ha="center", va="center")
  1353. return
  1354. x = np.array([0, 1, 2], dtype=float)
  1355. values = np.c_[d["naive"].values.astype(float), d["cdm"].values.astype(float), d["c21"].values.astype(float)]
  1356. means = np.nanmean(values, axis=0)
  1357. ax.bar(x, means, width=0.55, color=[CONDITION_COLOR["naive"], CONDITION_COLOR["CDM"], CONDITION_COLOR["C21"]], edgecolor="black", linewidth=0.8, zorder=1)
  1358. for row in values:
  1359. ax.plot(x, row, "-o", color="0.35", lw=0.8, ms=3.5, mfc="white", zorder=2)
  1360. ax.scatter([0], [row[0]], s=12, color=CONDITION_COLOR["naive"], zorder=3, edgecolor="black", linewidth=0.2)
  1361. ax.scatter([1], [row[1]], s=12, color=CONDITION_COLOR["CDM"], zorder=3, edgecolor="black", linewidth=0.2)
  1362. ax.scatter([2], [row[2]], s=12, color=CONDITION_COLOR["C21"], zorder=3, edgecolor="black", linewidth=0.2)
  1363. all_vals = values.flatten()
  1364. ymax = float(np.nanmax(all_vals))
  1365. ymin = float(np.nanmin(all_vals))
  1366. dy = (ymax - ymin) if ymax > ymin else (abs(ymax) * 0.2 + 1e-3)
  1367. add_pairwise_brackets(ax, stat_row, ymax + 0.18 * dy, dy)
  1368. ax.axhline(0, color="black", lw=0.9)
  1369. ax.set_xlim(-0.6, 2.6)
  1370. ax.set_xticks([0, 1, 2])
  1371. ax.set_xticklabels([CONDITION_LABEL["naive"], CONDITION_LABEL["CDM"], CONDITION_LABEL["C21"]], rotation=0, fontsize=8)
  1372. ax.set_ylabel("z-score", fontsize=8)
  1373. ax.set_title(spec["title"], fontsize=9, fontweight="bold")
  1374. ax.spines["top"].set_visible(False)
  1375. ax.spines["right"].set_visible(False)
  1376. ax.tick_params(labelsize=8)
  1377. def add_band_guides(ax, freq_max):
  1378. guides = [(4, "θ"), (12, "β"), (30, "low-γ"), (60, "high-γ")]
  1379. for x, label in guides:
  1380. if x <= freq_max:
  1381. ax.axvline(x, ls=":", lw=0.8, color="0.55", zorder=0)
  1382. labels = [(2.5, "δ"), (8, "θ"), (21, "β"), (45, "low-γ"), (70, "high-γ")]
  1383. for x, label in labels:
  1384. if x <= freq_max:
  1385. ax.text(x, 1.01, label, transform=ax.get_xaxis_transform(), ha="center", va="bottom", fontsize=8)
  1386. def plot_curve_panel(ax, summary_df, value_col, x_col, title, ylabel, ylim=None, log_y=False):
  1387. for cond_key in ["naive", "CDM", "C21"]:
  1388. cond_data = summary_df[summary_df["condition_group"] == cond_key].copy()
  1389. if len(cond_data) == 0:
  1390. continue
  1391. x = cond_data[x_col].values.astype(float)
  1392. y = cond_data["mean"].values.astype(float)
  1393. sem = cond_data["sem"].values.astype(float)
  1394. order = np.argsort(x)
  1395. x = x[order]
  1396. y = y[order]
  1397. sem = sem[order]
  1398. if log_y:
  1399. ax.semilogy(x, y, color=CONDITION_COLOR[cond_key], lw=1.8, label=CONDITION_LABEL[cond_key])
  1400. ax.fill_between(x, np.clip(y - sem, 1e-20, None), y + sem, color=CONDITION_COLOR[cond_key], alpha=0.22)
  1401. else:
  1402. ax.plot(x, y, color=CONDITION_COLOR[cond_key], lw=1.8, label=CONDITION_LABEL[cond_key])
  1403. ax.fill_between(x, y - sem, y + sem, color=CONDITION_COLOR[cond_key], alpha=0.22)
  1404. add_band_guides(ax, float(np.nanmax(summary_df[x_col].values.astype(float))))
  1405. ax.set_xlabel("Frequency (Hz)", fontsize=8)
  1406. ax.set_ylabel(ylabel, fontsize=8)
  1407. ax.set_title(title, fontsize=9, fontweight="bold")
  1408. ax.spines["top"].set_visible(False)
  1409. ax.spines["right"].set_visible(False)
  1410. ax.tick_params(labelsize=8)
  1411. if ylim is not None:
  1412. ax.set_ylim(ylim)
  1413. def make_quantitative_figure(psd_summary, coh_summary, hab_stats, noe_stats, out_dir):
  1414. fig, axes = plt.subplots(4, 3, figsize=(12.5, 14.5), constrained_layout=True)
  1415. for j, region in enumerate(["PFC", "RE", "HC"]):
  1416. d = psd_summary[psd_summary["region"] == region].copy()
  1417. plot_curve_panel(
  1418. axes[0, j],
  1419. d,
  1420. value_col="mean",
  1421. x_col="frequency_hz",
  1422. title=f"Power spectrum - {REGION_FIGURE_LABEL[region]}",
  1423. ylabel="PSD (V²/Hz)",
  1424. log_y=True,
  1425. )
  1426. axes[0, j].set_xlim(1, 80)
  1427. axes[0, j].legend(frameon=False, fontsize=7)
  1428. for j, pair_name in enumerate(["PFC-RE", "HC-RE", "HC-PFC"]):
  1429. d = coh_summary[coh_summary["target"] == pair_name].copy()
  1430. plot_curve_panel(
  1431. axes[1, j],
  1432. d,
  1433. value_col="mean",
  1434. x_col="frequency_hz",
  1435. title=f"Coherence - {PAIR_FIGURE_LABEL[pair_name]}",
  1436. ylabel="Coherence",
  1437. log_y=False,
  1438. )
  1439. axes[1, j].set_xlim(1, 80)
  1440. axes[1, j].set_ylim(0, 1)
  1441. axes[1, j].legend(frameon=False, fontsize=7)
  1442. for j, spec in enumerate(BAR_PANELS_RUNNING):
  1443. plot_selected_bar_panel(axes[2, j], spec, hab_stats)
  1444. for j, spec in enumerate(BAR_PANELS_NOE):
  1445. plot_selected_bar_panel(axes[3, j], spec, noe_stats)
  1446. panel_letters = [
  1447. ["g", "h", "i"],
  1448. ["j", "k", "l"],
  1449. ["m", "n", "o"],
  1450. ["p", "q", "r"],
  1451. ]
  1452. for i in range(4):
  1453. for j in range(3):
  1454. axes[i, j].text(-0.15, 1.06, panel_letters[i][j], transform=axes[i, j].transAxes, fontsize=12, fontweight="bold")
  1455. for ext in ["png", "pdf", "svg"]:
  1456. path = os.path.join(out_dir, f"Figure5_quantitative_panels.{ext}")
  1457. fig.savefig(path, dpi=300 if ext == "png" else None, bbox_inches="tight")
  1458. plt.close(fig)
  1459. def main():
  1460. cfg = make_cfg()
  1461. output_root = os.path.join(cfg["resultsDir"], "figure5_source_data")
  1462. noe_dir = os.path.join(output_root, "NOE")
  1463. hab_dir = os.path.join(output_root, "HAB_running")
  1464. panel_dir = os.path.join(output_root, "panels")
  1465. os.makedirs(noe_dir, exist_ok=True)
  1466. os.makedirs(hab_dir, exist_ok=True)
  1467. os.makedirs(panel_dir, exist_ok=True)
  1468. noe_results_long, noe_session_log, noe_bout_qc = run_noe_extraction(cfg, noe_dir)
  1469. noe_stats = compute_triplet_stats(
  1470. results_long=noe_results_long,
  1471. out_dir=noe_dir,
  1472. prefix="NOE_delta_pair_coh_pac",
  1473. epoch_keep=["delta"],
  1474. domain_keep=["pair"],
  1475. metric_keep=["coh", "pac_mi"],
  1476. bands_keep=["theta", "beta", "lowGamma", "theta_lowGamma"],
  1477. win_sec=0.5,
  1478. pool_hemispheres=False,
  1479. require_both_hemis=False,
  1480. c21_conds=["C21_1"],
  1481. normalize_method="zscore",
  1482. apply_fdr=False,
  1483. min_n_event_windows=2,
  1484. )
  1485. hab_running_results_long, hab_running_session_log = run_hab_running_band_coherence(cfg, hab_dir)
  1486. hab_stats = compute_triplet_stats(
  1487. results_long=hab_running_results_long,
  1488. out_dir=hab_dir,
  1489. prefix="HAB_running_pair_coherence",
  1490. epoch_keep=["running"],
  1491. domain_keep=["pair"],
  1492. metric_keep=["coh"],
  1493. bands_keep=["theta", "beta", "lowGamma"],
  1494. win_sec=0.5,
  1495. pool_hemispheres=False,
  1496. require_both_hemis=False,
  1497. c21_conds=["C21_1"],
  1498. normalize_method="zscore",
  1499. apply_fdr=False,
  1500. min_n_event_windows=0,
  1501. )
  1502. psd_subject, psd_summary, coh_subject, coh_summary = run_hab_psd_and_coherence_spectra(cfg, hab_dir)
  1503. export_selected_bar_panel_csvs(BAR_PANELS_RUNNING, hab_stats, panel_dir, "running")
  1504. export_selected_bar_panel_csvs(BAR_PANELS_NOE, noe_stats, panel_dir, "noe")
  1505. export_curve_panel_csvs("power_spectrum", psd_subject, psd_summary, panel_dir)
  1506. export_curve_panel_csvs("coherence_spectrum", coh_subject, coh_summary, panel_dir)
  1507. make_quantitative_figure(psd_summary, coh_summary, hab_stats, noe_stats, panel_dir)
  1508. print(f"NOE rows: {len(noe_results_long)}")
  1509. print(f"HAB running rows: {len(hab_running_results_long)}")
  1510. print(f"PSD subject rows: {len(psd_subject)}")
  1511. print(f"Coherence spectrum subject rows: {len(coh_subject)}")
  1512. print(f"Output root: {output_root}")
  1513. if __name__ == "__main__":
  1514. main()

LFP_analysis.py at commit 8ce9fb2, under MIT · at the source

Overview

Authors: Maxime Veleanu1, Louise Schuberth1,2,3, Antje Kilias4, Jakob Weber1, Jan Warneke1, Lovis Würz1, Tim Schwär1, Rebecca Heck1, Joelle Müller1, David H. Sarrazin5, Marguerite Anselin1, Lukas Rutke1, Guillermo Suarez Marchi1, Stella Zimmermann1, Zoe Borgeest1, Martin Balzinger5,6, Alina Blendinger5, Anna Catarata1, Samira Assaad Dib1, Yaroslav Sych5
and 6 other authorsThibault Cholvin4, Marlene Bartos4, Katharina Domschke1,7, Claus Normann1, Stefan Vestring1, Tsvetan Serchov1,5,6
  1. Department of Psychiatry and Psychotherapy, Medical Center—University of Freiburg, Faculty of Medicine, University of Freiburg,Freiburg, Germany
  2. Spemann Graduate School of Biology and Medicine (SGBM), Freiburg, Germany
  3. Faculty of Biology, University of Freiburg,Freiburg, Germany
  4. Institute for Physiology I, Medical Faculty, University of Freiburg,Freiburg, Germany
  5. Centre National de la Recherche Scientifique (CNRS), Institute of Cellular and Integrative Neurosciences (INCI) UPR, University of Strasbourg,Strasbourg, 3212 France
  6. University of Strasbourg Institute for Advanced Study (USIAS),Strasbourg, France
  7. German Center for Mental Health (DZPG), Partner Site Berlin/Potsdam,Berlin, Germany
Journal: Nature communications, volume 17, issue 1, article 9361
Dates: received 31 March 2025; accepted 17 August 2026; published online 1 September 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1038/s41467-026-77248-y · PMID 42680757 · PMCID PMC13534506 · OpenAlex W7204912409
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: mouse (organism), depression (population)
Methods: Spectral & time-frequency, Connectivity, Statistics, Preprocessing, Evoked potentials, fMRI & imaging, Smoothing, state filtering, decompositions
Keywords: Neural circuits, Depression, Synaptic plasticity, Stress and resilience
MeSH: Antidepressive Agents*, Depression*, Hippocampus*, Midline Thalamic Nuclei*, Neuronal Plasticity*, Prefrontal Cortex*, Animals, Disease Models, Animal, Ketamine, Long-Term Potentiation, Male, Mice, Mice, Inbred C57BL, Neural Pathways (* major topic)
Topic: Neurotransmitter Receptor Influence on Behavior (Cellular and Molecular Neuroscience, Neuroscience), according to OpenAlex
Funding: Berta-Ottenstein Program for Clinician Scientists, Faculty of Medicine, University of Freiburg, Germany; Hans A. Krebs Medical Scientist Fellowship Program, University of Freiburg, Germany; Deutsche Forschungsgemeinschaft (DFG) (DFG NO370/7-1, SE 2666/2-1, SE 2666/2-3); Centre National de la Recherche Scientifique (CNRS) (CNRS UPR3212); Université de Strasbourg (Unistra) (2021-2028)
Citations: not cited yet (Europe PMC); 103 references in the paper
Research resources: Wild-type C57BL/6 J mice RRID:IMSR_JAX:000664

Abstract

The pathophysiology of depression involves multiple biological processes, including circuit dysfunction and impaired neuroplasticity, yet an integrative view linking these processes remains elusive. Here, we identify a convergent circuit for antidepressant response and plasticity modulation. We demonstrate that chemogenetic activation of the infralimbic cortex (IL) exerts rapid antidepressant-like effects across multiple behavioral domains in a mouse model of stress-induced depression. IL stimulation exerts top-down control over the hippocampus, enhancing structural plasticity, restoring long-term potentiation deficits and improving state-dependent network dynamics in the ventral hippocampus (vHIPP). We identify the thalamic nucleus reuniens (RE) as a necessary mediator of these effects. Notably, direct inhibition of RE, its inputs from IL or projections to vHIPP, blocks both IL stimulation-induced antidepressant response and the therapeutic and neuroplastic effects of ketamine. Our findings demonstrate that the functional IL → RE→vHIPP circuit plays a central role in the antidepressant response, linking circuit activity, hippocampal plasticity, and depressive-like behaviors.

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

Repositories

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

mveleanu/veleanu_et_al

License: MIT
State: the link answers, verified on 26 September 2026
Evidence: files inventoried
Commit: 8ce9fb28c1b42d135b796a2780b46aee923ac44b, 30 March 2026
Languages: Python (7), MATLAB (2)
Size: 98 files, 9 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (6 files), pandas (6 files), Matplotlib (3 files), SciPy (3 files), h5py (1 file), Signal Processing Toolbox (1 file), Open Ephys analysis tools (1 file)
Availability: 1 check, the latest on 26 September 2026: the link answers
  • 26 September 2026: the link answers
11 files

Zenodo 21111701

License: MIT
State: the link answers, verified on 26 September 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: “Code availability”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (6 files), pandas (6 files), Matplotlib (3 files), SciPy (3 files), h5py (1 file), Signal Processing Toolbox (1 file), Open Ephys analysis tools (1 file)
Availability: 1 check, the latest on 26 September 2026: the link answers (HTTP 200)
  • 26 September 2026: the link answers (HTTP 200)
11 files
At the source:

Code availability

Custom Python scripts used for analyzing fiber photometry and LFP data are available in our GitHub repository (https://github.com/mveleanu/veleanu_et_al) and archived on Zenodo (https://doi.org/10.5281/zenodo.21111701)103.

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

Tracing map

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

What the map holds:

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

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

Data

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

Data availability

The data generated in this study are provided in the Supplementary Information and the Source Data file. Source data are provided with this paper.

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

Versions

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

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 26 authors, 4 keywords, 14 MeSH terms, 5 funders, 102 references, 1 RRID.

Cite

This paper

Veleanu, M., Schuberth, L., Kilias, A., Weber, J., Warneke, J., Würz, L., Schwär, T., Heck, R., Müller, J., Sarrazin, D. H., Anselin, M., Rutke, L., Suarez Marchi, G., Zimmermann, S., Borgeest, Z., Balzinger, M., Blendinger, A., Catarata, A., Dib, S. A., . . . Serchov, T. (2026). The mPFC-reuniens-hippocampus pathway links brain circuitry and neural plasticity in antidepressant response. Nature communications, 17(1), 9361. https://doi.org/10.1038/s41467-026-77248-y

BibTeX

@article{veleanu2026mpfc,
author = {Veleanu, Maxime and Schuberth, Louise and Kilias, Antje and Weber, Jakob and Warneke, Jan and Würz, Lovis and Schwär, Tim and Heck, Rebecca and Müller, Joelle and Sarrazin, David H. and Anselin, Marguerite and Rutke, Lukas and Suarez Marchi, Guillermo and Zimmermann, Stella and Borgeest, Zoe and Balzinger, Martin and Blendinger, Alina and Catarata, Anna and Dib, Samira Assaad and Sych, Yaroslav and Cholvin, Thibault and Bartos, Marlene and Domschke, Katharina and Normann, Claus and Vestring, Stefan and Serchov, Tsvetan},
title = {{The mPFC-reuniens-hippocampus pathway links brain circuitry and neural plasticity in antidepressant response}},
journal = {Nature communications},
year = {2026},
month = sep,
volume = {17},
number = {1},
pages = {9361},
publisher = {Nature Publishing Group},
issn = {2041-1723},
doi = {10.1038/s41467-026-77248-y},
url = {https://doi.org/10.1038/s41467-026-77248-y},
pmid = {42680757},
pmcid = {PMC13534506}
}

RIS

TY - JOUR
AU - Veleanu, Maxime
AU - Schuberth, Louise
AU - Kilias, Antje
AU - Weber, Jakob
AU - Warneke, Jan
AU - Würz, Lovis
AU - Schwär, Tim
AU - Heck, Rebecca
AU - Müller, Joelle
AU - Sarrazin, David H.
AU - Anselin, Marguerite
AU - Rutke, Lukas
AU - Suarez Marchi, Guillermo
AU - Zimmermann, Stella
AU - Borgeest, Zoe
AU - Balzinger, Martin
AU - Blendinger, Alina
AU - Catarata, Anna
AU - Dib, Samira Assaad
AU - Sych, Yaroslav
AU - Cholvin, Thibault
AU - Bartos, Marlene
AU - Domschke, Katharina
AU - Normann, Claus
AU - Vestring, Stefan
AU - Serchov, Tsvetan
TI - The mPFC-reuniens-hippocampus pathway links brain circuitry and neural plasticity in antidepressant response
T2 - Nature communications
J2 - Nat Commun
PY - 2026
DA - 2026/09/01
VL - 17
IS - 1
SP - 9361
SN - 2041-1723
PB - Nature Publishing Group
DO - 10.1038/s41467-026-77248-y
UR - https://doi.org/10.1038/s41467-026-77248-y
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41467-026-77248-y",
"type": "article-journal",
"title": "The mPFC-reuniens-hippocampus pathway links brain circuitry and neural plasticity in antidepressant response",
"container-title": "Nature communications",
"author": [
{
"family": "Veleanu",
"given": "Maxime"
},
{
"family": "Schuberth",
"given": "Louise"
},
{
"family": "Kilias",
"given": "Antje"
},
{
"family": "Weber",
"given": "Jakob"
},
{
"family": "Warneke",
"given": "Jan"
},
{
"family": "Würz",
"given": "Lovis"
},
{
"family": "Schwär",
"given": "Tim"
},
{
"family": "Heck",
"given": "Rebecca"
},
{
"family": "Müller",
"given": "Joelle"
},
{
"family": "Sarrazin",
"given": "David H."
},
{
"family": "Anselin",
"given": "Marguerite"
},
{
"family": "Rutke",
"given": "Lukas"
},
{
"family": "Suarez Marchi",
"given": "Guillermo"
},
{
"family": "Zimmermann",
"given": "Stella"
},
{
"family": "Borgeest",
"given": "Zoe"
},
{
"family": "Balzinger",
"given": "Martin"
},
{
"family": "Blendinger",
"given": "Alina"
},
{
"family": "Catarata",
"given": "Anna"
},
{
"family": "Dib",
"given": "Samira Assaad"
},
{
"family": "Sych",
"given": "Yaroslav"
},
{
"family": "Cholvin",
"given": "Thibault"
},
{
"family": "Bartos",
"given": "Marlene"
},
{
"family": "Domschke",
"given": "Katharina"
},
{
"family": "Normann",
"given": "Claus"
},
{
"family": "Vestring",
"given": "Stefan"
},
{
"family": "Serchov",
"given": "Tsvetan"
}
],
"container-title-short": "Nat Commun",
"volume": "17",
"issue": "1",
"page": "9361",
"DOI": "10.1038/s41467-026-77248-y",
"PMID": "42680757",
"PMCID": "PMC13534506",
"ISSN": "2041-1723",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41467-026-77248-y",
"language": "en",
"issued": {
"date-parts": [
[
2026,
9,
1
]
]
}
}

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.1038/s41467-026-76574-5 [code]
Prefrontal-raphe-habenular circuit drives fear memory recall and updating.
Journal: Nature communications
In common: Signal Processing Toolbox, mouse, 5 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, h5py, pandas, 3 other tools, mouse, 1 reference
[3] doi:10.1016/j.isci.2026.116825 [code]
Social hierarchy shapes behavioral and transcriptional responses to chronic stress and ketamine in male mice.
Journal: iScience
In common: h5py, pandas, SciPy, 2 other tools, depression, mouse, 2 references
[4] doi:10.1038/s41398-026-04128-w
A nucleus accumbens-projecting prefrontal cortex circuit underlies chronic social stress-induced depression-like behaviors.
Journal: Translational psychiatry
In common: depression, mouse, 4 references
[5] doi:10.1016/j.celrep.2026.117590 [code]
Impaired behavioral inhibition in Fmr1 KO mice is linked to disrupted visual cortex theta oscillations.
Journal: Cell reports
In common: Open Ephys analysis tools, pandas, SciPy, 2 other tools, mouse, 1 reference
[6] doi:10.1186/s12974-026-03890-4 [code]
Neuronal toll-like receptor-4 regulation of matrix metalloproteinase-9 activity mediates dentate circuit dysfunction after traumatic brain injury.
Journal: Journal of neuroinflammation
In common: Open Ephys analysis tools, h5py, pandas, 3 other tools, mouse
[7] doi:10.1038/s41593-026-02255-7 [code]
Neural circuits encode prior knowledge of temporal statistics.
Journal: Nature neuroscience
In common: Open Ephys analysis tools, Signal Processing Toolbox, pandas, 3 other tools, mouse
[8] doi:10.1038/s41398-026-04025-2 [code]
Brain energetic landscapes shape state dysregulation in major depressive disorder: a morphological network controllability perspective.
Journal: Translational psychiatry
In common: h5py, pandas, SciPy, 2 other tools, depression, 2 references
[9] doi:10.1038/s44220-026-00680-y [code]
The neuroimaging correlates of depression established across six large-scale population datasets.
Journal: Nature. Mental health
In common: pandas, SciPy, Matplotlib, 1 other tool, depression, 3 references
[10] doi:10.1038/s41467-026-76581-6 [code]
Thalamocortical bursts encode reward contingencies and drive associative learning.
Journal: Nature communications
In common: h5py, Signal Processing Toolbox, pandas, 3 other tools, mouse, 1 reference

Contribute

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

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

Request its removal

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

Discussion, reproductions, activity

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

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

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