OSCR

Procognitive restoration of PV neuron plasticity in neurodevelopmental disorders.

Code ↔ Paper

3 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 3 matches
  1. [1] § Methods › Ripple analysis ↔ Ripples_Z-scored_Single Trial Script.py, lines 877–938 · score 0.91 · detected ripple event, peak LFP amplitude, bandpass filtered, ripple band, peak power, CA1 LFP
  2. [2] § Methods › Extracting NREM periods ↔ Ripples_Z-scored_Single Trial Script.py, lines 99–102 · score 0.76 · 2–16 Hz, 0–300 Hz, theta dominance, 5–10 Hz, sleep, power
  3. [3] § Extended Data ↔ Ripples_Z-scored_Single Trial Script.py, lines 110–114 · score 0.68 · 100–250 Hz, ripple duration, ripple frequency, envelope, peak, filtered

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 · 938 lines · 52 KB · CC-BY-4.0 · 3 matches

  1. # -*- coding: utf-8 -*-
  2. """
  3. Created on Tue May 20 16:30:53 2025
  4. @author: HT_bo
  5. """
  6. import numpy as np
  7. import scipy.io
  8. import scipy.signal
  9. from scipy.stats import zscore
  10. import pandas as pd
  11. import matplotlib.pyplot as plt
  12. # from open_ephys.analysis import Session # Bypassing for manual load
  13. import os
  14. from pathlib import Path
  15. import sys
  16. import json # For reading structure.oebin
  17. # --- Add Tkinter for file dialogs ---
  18. import tkinter as tk
  19. from tkinter import filedialog
  20. # ------------------------------------------------------------------------------
  21. # Configuration
  22. # ------------------------------------------------------------------------------
  23. # --- Setup Tkinter for file dialogs (main window won't be shown) ---
  24. root_tk = tk.Tk()
  25. root_tk.withdraw() # Hide the main tkinter window
  26. # --- Prompt user for input paths ---
  27. print("Please select the main recording session directory (e.g., '.../experiment1/recording1')")
  28. session_dir_str = filedialog.askdirectory(title="Select Recording Session Directory (e.g., .../experiment1/recording1)")
  29. if not session_dir_str:
  30. print("No session directory selected. Exiting.")
  31. sys.exit()
  32. session_dir = Path(session_dir_str)
  33. print(f"Selected session directory: {session_dir}")
  34. # --- Extract path part for filenames ---
  35. try:
  36. # Assuming session_dir is like .../Record Node 101/experiment1/recording1
  37. # We want the name of the folder 3 levels up (e.g., "WT,tdTomato,264,pre")
  38. path_part_for_filename = session_dir.parent.parent.parent.name
  39. if not path_part_for_filename:
  40. path_part_for_filename = session_dir.name # Fallback 1
  41. print(f"Using path part for filenames: {path_part_for_filename}")
  42. except AttributeError:
  43. print("Warning: Could not derive desired path part from 3 levels up. Using recording name as fallback.")
  44. path_part_for_filename = session_dir.name # Fallback 2
  45. except Exception as e:
  46. print(f"Warning: Error deriving path part: {e}. Using recording name as fallback.")
  47. path_part_for_filename = session_dir.name
  48. try:
  49. RECORDING_NAME = session_dir.name # e.g., "recording1"
  50. EXPERIMENT_NAME = session_dir.parent.name # e.g., "experiment1"
  51. except Exception: # Fallback if path is too short
  52. RECORDING_NAME = path_part_for_filename # If session_dir itself was the base element for path_part
  53. EXPERIMENT_NAME = "experiment"
  54. print("Please select the MAT file containing electrode mapping (e.g., ..._Behavior_and_Optogenetics_TimeStamps.mat)")
  55. mat_file_path_str = filedialog.askopenfilename(
  56. title="Select Electrode Mapping MAT File",
  57. filetypes=(("MAT files", "*.mat"), ("All files", "*.*"))
  58. )
  59. if not mat_file_path_str:
  60. print("No MAT file selected. Exiting.")
  61. sys.exit()
  62. MAT_FILE_PATH = Path(mat_file_path_str)
  63. print(f"Selected MAT file: {MAT_FILE_PATH}")
  64. OUTPUT_DIR_BASE = session_dir.resolve() # Output will be inside the selected session_dir
  65. os.makedirs(OUTPUT_DIR_BASE, exist_ok=True)
  66. print(f"Output base directory: {OUTPUT_DIR_BASE}")
  67. # --- Channel & Region Mapping ---
  68. CA1_REGION_IDS = [281, 282, 283, 284] # Example: Your Region IDs for CA1 areas from MAT file
  69. DG_REGION_ID = 261 # Example: Your Region ID for the DG noise channel from MAT file. Set to None if no DG/noise channel.
  70. # !!! IMPORTANT: OFFSET FOR LOGICAL TO PHYSICAL CHANNEL MAPPING !!!
  71. # Based on: "lfp channels were recorded from 9 to 24 which would be considered Channel 1 -16 in timestamps.mat file"
  72. # This means logical channel 1 (from MAT file) is physical channel 9 (CH9 in structure.oebin).
  73. # So, physical_channel = logical_channel + LOGICAL_TO_PHYSICAL_OFFSET
  74. LOGICAL_TO_PHYSICAL_OFFSET = 8
  75. # --- LFP Processing Parameters ---
  76. TARGET_FS_DOWNSAMPLED = 1000.0 # Hz - Desired sampling rate after decimation
  77. # --- Sleep State Detection Parameters ---
  78. SPECTROGRAM_WINDOW_SEC = 10.0 # s
  79. SPECTROGRAM_STEP_SEC = 1.0 # s
  80. SCORING_BUFFER_SEC = 5 # s, min duration to confirm state change
  81. # --- Frequency Bands for Sleep Scoring Features ---
  82. PCA_SPECTROGRAM_FREQ_RANGE = (0, 300) # Hz, for PCA input
  83. THETA_DOMINANCE_THETA_BAND = (5, 10) # Hz
  84. THETA_DOMINANCE_TOTAL_BAND = (2, 16) # Hz (for normalization of theta power)
  85. # --- NREM Estimation Options ---
  86. USE_DT_RATIO_FOR_NREM = True # <--- Set to True to use Delta/Theta ratio for NREM detection
  87. DT_RATIO_THRESHOLD = 1.5 # Threshold for Delta/Theta ratio
  88. DT_DELTA_BAND = (0.5, 4) # Hz, Delta band for ratio calculation
  89. DT_THETA_BAND = (5, 10) # Hz, Theta band for ratio calculation
  90. # --- Ripple Detection Parameters ---
  91. RIPPLE_THRESHOLDS = (2, 5) # (low_z_power, high_z_power_peak)
  92. RIPPLE_DURATIONS = (20, 20, 150) # (min_isi_ms, min_dur_ms, max_dur_ms)
  93. RIPPLE_FREQ_RANGE = (100, 250) # For ripple *detection*
  94. RIPPLE_ENVELOPE_FILTER_HZ = (1, 20)
  95. # --- Plotting Parameters ---
  96. RIPPLE_PLOT_WINDOW_MS = 50 # Window around ripple peak for spectrogram/PSD (+/- ms)
  97. SPECTROGRAM_CMAP = 'viridis'
  98. # ------------------------------------------------------------------------------
  99. # Helper Functions
  100. # ------------------------------------------------------------------------------
  101. def load_electrode_mapping(mat_file_path_local):
  102. """Loads ElectrodeVsRegisteredAreasNum from a .mat file."""
  103. try:
  104. mat_data = scipy.io.loadmat(mat_file_path_local)
  105. if 'ElectrodeVsRegisteredAreasNum' in mat_data:
  106. mapping = mat_data['ElectrodeVsRegisteredAreasNum']
  107. return pd.DataFrame(mapping, columns=['Channel', 'RegionID'])
  108. else:
  109. print(f"Error: 'ElectrodeVsRegisteredAreasNum' not found in {mat_file_path_local}")
  110. print(f"Available keys: {list(mat_data.keys())}")
  111. return None
  112. except FileNotFoundError:
  113. print(f"Error: MAT file not found at {mat_file_path_local}")
  114. return None
  115. except Exception as e:
  116. print(f"Error loading MAT file: {e}")
  117. return None
  118. def butter_bandpass_filter(data, lowcut, highcut, fs, order=3, axis=0):
  119. nyq = 0.5 * fs
  120. low = lowcut / nyq
  121. high = highcut / nyq
  122. if low <= 0: low = 1e-6
  123. if high >= 1: high = 1 - 1e-6
  124. if low >= high:
  125. if lowcut == 0 and highcut > 0 and highcut < nyq :
  126. b, a = scipy.signal.butter(order, high, btype='lowpass')
  127. elif lowcut > 0 and lowcut < nyq and highcut >= nyq * 0.999 :
  128. b, a = scipy.signal.butter(order, low, btype='highpass')
  129. else:
  130. print(f" Problematic band definition for bandpass: low {lowcut}, high {highcut}. Returning data copy.")
  131. return data.copy()
  132. else:
  133. b, a = scipy.signal.butter(order, [low, high], btype='band')
  134. try:
  135. y = scipy.signal.filtfilt(b, a, data.astype(np.float64), axis=axis)
  136. return y
  137. except ValueError as e:
  138. print(f"Filter error in butter_bandpass_filter: {e} with low={low}, high={high}. Returning unfiltered data.")
  139. return data.copy()
  140. def compute_spectrogram_custom(data, fs, window_size_sec, step_size_sec):
  141. print(f"Computing spectrogram: fs={fs:.2f}, window={window_size_sec}s, step={step_size_sec}s")
  142. if data is None or data.ndim != 1 or len(data) == 0:
  143. print("Error: Spectrogram input data must be 1D and not empty.")
  144. return None, None, None
  145. window_samples = int(round(window_size_sec * fs))
  146. step_samples = int(round(step_size_sec * fs))
  147. if window_samples <= 0 or step_samples <= 0:
  148. print("Error: Window or step size results in non-positive samples.")
  149. return None, None, None
  150. if window_samples > len(data):
  151. # print(f"Warning: Window size ({window_samples}) > data length ({len(data)}). Adjusting window to data length.")
  152. window_samples = len(data) # Adjust window if too long
  153. noverlap = window_samples - step_samples
  154. noverlap = max(0, noverlap)
  155. if noverlap >= window_samples and window_samples > 0 :
  156. noverlap = window_samples - 1
  157. elif noverlap >= window_samples and window_samples == 0:
  158. print("Error: window_samples is 0 in spectrogram.")
  159. return None, None, None
  160. frequencies, times, Sxx = scipy.signal.spectrogram(
  161. data.astype(np.float64), fs=fs, window='hann',
  162. nperseg=window_samples, noverlap=noverlap,
  163. scaling='density', mode='psd'
  164. )
  165. # print(f"Spectrogram computed. Shape: {Sxx.shape}")
  166. return Sxx, frequencies, times
  167. def compute_pca_custom(spectrogram_data_for_pca):
  168. # print(f"Computing PCA on spectrogram of shape: {spectrogram_data_for_pca.shape}")
  169. if spectrogram_data_for_pca is None or spectrogram_data_for_pca.shape[0] < 2 or spectrogram_data_for_pca.shape[1] < 2:
  170. print("Error: Invalid data for PCA (None, empty, or too small).")
  171. return None
  172. data_for_pca = spectrogram_data_for_pca.T
  173. zscored_data = zscore(data_for_pca, axis=0, nan_policy='omit')
  174. zscored_data = np.nan_to_num(zscored_data, nan=0.0, posinf=np.nanmax(zscored_data[np.isfinite(zscored_data)]) if np.any(np.isfinite(zscored_data)) else 0.0, neginf=np.nanmin(zscored_data[np.isfinite(zscored_data)]) if np.any(np.isfinite(zscored_data)) else 0.0)
  175. from sklearn.decomposition import PCA
  176. pca = PCA(n_components=1)
  177. try:
  178. pc1 = pca.fit_transform(zscored_data)
  179. except ValueError as e:
  180. print(f"Error during PCA fit_transform: {e}")
  181. # print(" Data for PCA (first 5 rows after nan_to_num): \n", zscored_data[:5,:])
  182. return None
  183. # print(f"PCA computed. PC1 shape: {pc1.shape}, Explained var: {pca.explained_variance_ratio_[0]:.4f}")
  184. return pc1.squeeze()
  185. def compute_theta_dominance_custom(spectrogram_data, frequencies, theta_band_config, total_band_config):
  186. # print(f"Computing Theta Dominance. Spec shape: {spectrogram_data.shape}")
  187. if spectrogram_data is None or frequencies is None or spectrogram_data.size == 0 or frequencies.size == 0:
  188. print("Error: Invalid input for theta dominance calculation.")
  189. return None
  190. theta_band_indices = np.where((frequencies >= theta_band_config[0]) & (frequencies <= theta_band_config[1]))[0]
  191. total_band_indices = np.where((frequencies >= total_band_config[0]) & (frequencies <= total_band_config[1]))[0]
  192. if len(theta_band_indices) == 0 or len(total_band_indices) == 0:
  193. print(f"Warning: Theta band ({theta_band_config} Hz) or total power band ({total_band_config} Hz) not found in frequencies for theta dominance.")
  194. return np.full(spectrogram_data.shape[1], np.nan)
  195. epsilon = 1e-12
  196. theta_power = np.nanmean(spectrogram_data[theta_band_indices, :], axis=0)
  197. total_power = np.nanmean(spectrogram_data[total_band_indices, :], axis=0)
  198. theta_dominance = np.full_like(theta_power, np.nan)
  199. valid_mask = (total_power > epsilon) & np.isfinite(theta_power) & np.isfinite(total_power)
  200. theta_dominance[valid_mask] = theta_power[valid_mask] / total_power[valid_mask]
  201. return theta_dominance
  202. def score_sleep_states_custom(pc1, theta_dominance, times, step_size_sec, buffer_sec):
  203. print("\n--- Scoring Sleep States (LFP-based only) ---")
  204. if pc1 is None or theta_dominance is None or times is None:
  205. print("Error: Missing PC1, Theta Dominance, or Times for scoring.")
  206. return None, {}
  207. num_time_points = len(pc1)
  208. if not (len(theta_dominance) == num_time_points and len(times) == num_time_points):
  209. print(f"Error: Length mismatch in scoring inputs. PC1:{len(pc1)}, Theta:{len(theta_dominance)}, Times:{len(times)}")
  210. return None, {}
  211. pc1_finite = pc1[np.isfinite(pc1)]
  212. theta_finite = theta_dominance[np.isfinite(theta_dominance)]
  213. nrem_threshold_pc1 = np.nanpercentile(pc1_finite, 75) if len(pc1_finite) > 0 else np.nan
  214. rem_threshold_theta = np.nanpercentile(theta_finite, 75) if len(theta_finite) > 0 else np.nan
  215. if np.isnan(nrem_threshold_pc1) or np.isnan(rem_threshold_theta):
  216. print("Error: Could not calculate NREM/REM thresholds (NaN).")
  217. print(f" PC1 finite len: {len(pc1_finite)}, Theta finite len: {len(theta_finite)}")
  218. return None, {}
  219. print(f"NREM Threshold (PC1 >): {nrem_threshold_pc1:.3f}")
  220. print(f"REM Threshold (Theta Dominance >): {rem_threshold_theta:.3f}")
  221. sleep_states = np.zeros(num_time_points, dtype=int)
  222. current_state = 0
  223. buffer_samples = int(round(buffer_sec / step_size_sec))
  224. if buffer_samples < 1: buffer_samples = 1
  225. nrem_counter, rem_counter, awake_counter = 0, 0, 0
  226. for i in range(num_time_points):
  227. pc1_val, theta_val = pc1[i], theta_dominance[i]
  228. is_nrem_like = np.isfinite(pc1_val) and pc1_val > nrem_threshold_pc1
  229. is_rem_like = np.isfinite(theta_val) and theta_val > rem_threshold_theta
  230. if current_state == 0: # Awake
  231. if is_nrem_like: nrem_counter += 1; rem_counter = 0; awake_counter=0
  232. elif is_rem_like: rem_counter += 1; nrem_counter = 0; awake_counter=0
  233. else: nrem_counter = 0; rem_counter = 0;
  234. if nrem_counter >= buffer_samples: current_state = 1; nrem_counter=0; rem_counter=0; awake_counter=0
  235. elif rem_counter >= buffer_samples: current_state = 2; nrem_counter=0; rem_counter=0; awake_counter=0
  236. elif current_state == 1: # NREM
  237. if is_rem_like and (not is_nrem_like): rem_counter +=1; nrem_counter=0; awake_counter = 0
  238. elif not is_nrem_like : awake_counter += 1; rem_counter = 0; nrem_counter=0;
  239. else: nrem_counter +=1; awake_counter = 0; rem_counter = 0
  240. if rem_counter >= buffer_samples : current_state = 2; rem_counter=0; nrem_counter=0; awake_counter=0
  241. elif awake_counter >= buffer_samples : current_state = 0; awake_counter=0; nrem_counter=0; rem_counter=0
  242. elif current_state == 2: # REM
  243. if not is_rem_like: awake_counter += 1; rem_counter=0; nrem_counter=0;
  244. else: rem_counter +=1; awake_counter = 0; nrem_counter=0;
  245. if awake_counter >= buffer_samples: current_state = 0; awake_counter=0; rem_counter=0; nrem_counter=0
  246. sleep_states[i] = current_state
  247. print("Sleep scoring complete.")
  248. thresholds_used = {'nrem_pc1': nrem_threshold_pc1, 'rem_theta': rem_threshold_theta}
  249. return sleep_states, thresholds_used
  250. def find_swr_custom(lfp, timestamps, fs, thresholds, durations, freq_range, envelope_filter_hz, noise_lfp=None):
  251. if lfp is None or len(lfp) == 0:
  252. # print("Warning: Empty LFP segment passed to find_swr_custom.")
  253. return pd.DataFrame()
  254. low_thresh, high_thresh = thresholds
  255. min_isi_ms, min_dur_ms, max_dur_ms = durations
  256. lfp_float64 = lfp.astype(np.float64)
  257. noise_lfp_float64 = noise_lfp.astype(np.float64) if noise_lfp is not None else None
  258. filtered_lfp = butter_bandpass_filter(lfp_float64, freq_range[0], freq_range[1], fs, order=3)
  259. rectified = filtered_lfp ** 2
  260. envelope_raw = butter_bandpass_filter(rectified, envelope_filter_hz[0], envelope_filter_hz[1], fs, order=3)
  261. if len(envelope_raw) > 0 and not np.all(np.isnan(envelope_raw)):
  262. median_env = np.nanmedian(envelope_raw)
  263. mad_env = np.nanmedian(np.abs(envelope_raw - median_env))
  264. if mad_env == 0 or np.isnan(mad_env): mad_env = 1e-9
  265. envelope_z = 0.6745 * (envelope_raw - median_env) / mad_env
  266. else:
  267. return pd.DataFrame()
  268. above_thresh = envelope_z > low_thresh
  269. rising = np.where(np.diff(above_thresh.astype(int)) == 1)[0] + 1
  270. falling = np.where(np.diff(above_thresh.astype(int)) == -1)[0] + 1
  271. if len(rising) == 0 or len(falling) == 0: return pd.DataFrame()
  272. if falling[0] < rising[0]:
  273. first_valid_falling_idx = np.searchsorted(falling, rising[0])
  274. if first_valid_falling_idx == len(falling): return pd.DataFrame()
  275. falling = falling[first_valid_falling_idx:]
  276. if len(rising) == 0 or len(falling) == 0: return pd.DataFrame()
  277. if rising[-1] > falling[-1]:
  278. last_valid_rising_idx = np.searchsorted(rising, falling[-1], side='right')
  279. if last_valid_rising_idx == 0 : return pd.DataFrame()
  280. rising = rising[:last_valid_rising_idx]
  281. min_len_edges = min(len(rising), len(falling))
  282. rising, falling = rising[:min_len_edges], falling[:min_len_edges]
  283. if len(rising) == 0: return pd.DataFrame()
  284. events = np.column_stack((rising, falling))
  285. valid_event_mask = events[:,1] > events[:,0]
  286. events = events[valid_event_mask]
  287. if events.shape[0] == 0: return pd.DataFrame()
  288. merged_events = []
  289. if len(events) > 0:
  290. current_event = list(events[0])
  291. min_isi_samples = int(min_isi_ms / 1000 * fs)
  292. for next_start, next_end in events[1:]:
  293. if current_event[1] >= next_start :
  294. current_event[1] = max(current_event[1], next_end)
  295. elif (next_start - current_event[1]) < min_isi_samples:
  296. current_event[1] = next_end
  297. else:
  298. if current_event[1] > current_event[0]: merged_events.append(current_event)
  299. current_event = [next_start, next_end]
  300. if current_event[1] > current_event[0]: merged_events.append(current_event)
  301. if not merged_events: return pd.DataFrame()
  302. merged_events = np.array(merged_events)
  303. final_ripples = []
  304. z_filtered_lfp_for_amplitude = np.array([])
  305. if len(filtered_lfp) > 0 and not np.all(np.isnan(filtered_lfp)):
  306. try:
  307. z_filtered_lfp_for_amplitude = zscore(filtered_lfp, nan_policy='omit')
  308. z_filtered_lfp_for_amplitude = np.nan_to_num(z_filtered_lfp_for_amplitude)
  309. except Exception as e_zscore_filt:
  310. print(f"Warning: zscore failed for filtered_lfp: {e_zscore_filt}")
  311. z_filtered_lfp_for_amplitude = filtered_lfp # Fallback to non-zscored if error
  312. noise_envelope_z = None
  313. if noise_lfp_float64 is not None and len(noise_lfp_float64) == len(lfp_float64):
  314. noise_filtered = butter_bandpass_filter(noise_lfp_float64, freq_range[0], freq_range[1], fs, order=3)
  315. noise_rectified = noise_filtered**2
  316. noise_envelope_raw = butter_bandpass_filter(noise_rectified, envelope_filter_hz[0], envelope_filter_hz[1], fs, order=3)
  317. if len(noise_envelope_raw)>0 and not np.all(np.isnan(noise_envelope_raw)):
  318. median_noise_env = np.nanmedian(noise_envelope_raw)
  319. mad_noise_env = np.nanmedian(np.abs(noise_envelope_raw - median_noise_env))
  320. if mad_noise_env == 0 or np.isnan(mad_noise_env): mad_noise_env = 1e-9
  321. noise_envelope_z = 0.6745 * (noise_envelope_raw - median_noise_env) / mad_noise_env
  322. else: noise_envelope_z = None
  323. for start_idx, end_idx in merged_events:
  324. if start_idx >= end_idx: continue
  325. segment_envelope_z = envelope_z[start_idx:end_idx]
  326. if len(segment_envelope_z) == 0: continue
  327. max_power_z_in_segment = np.max(segment_envelope_z)
  328. if max_power_z_in_segment >= high_thresh:
  329. peak_power_idx_in_segment = np.argmax(segment_envelope_z)
  330. peak_sample_abs_power = start_idx + peak_power_idx_in_segment
  331. duration_s = (end_idx - start_idx) / fs
  332. if not (min_dur_ms / 1000 <= duration_s <= max_dur_ms / 1000):
  333. continue
  334. if noise_envelope_z is not None:
  335. if end_idx > len(noise_envelope_z):
  336. pass # print(f"Warning: Ripple end {end_idx} > noise_env len {len(noise_envelope_z)}. Skip noise check.")
  337. elif np.any(noise_envelope_z[start_idx:end_idx] > high_thresh):
  338. continue
  339. peak_val_z_lfp_amp = np.nan
  340. peak_sample_abs_lfp = np.nan
  341. if len(z_filtered_lfp_for_amplitude) > 0 and end_idx <= len(z_filtered_lfp_for_amplitude) and start_idx < len(z_filtered_lfp_for_amplitude):
  342. segment_z_lfp_amp = z_filtered_lfp_for_amplitude[start_idx:end_idx]
  343. if len(segment_z_lfp_amp) > 0:
  344. abs_max_idx_in_segment_amp = np.argmax(np.abs(segment_z_lfp_amp))
  345. peak_val_z_lfp_amp = segment_z_lfp_amp[abs_max_idx_in_segment_amp]
  346. peak_sample_abs_lfp = start_idx + abs_max_idx_in_segment_amp
  347. current_ts_start = timestamps[start_idx] if start_idx < len(timestamps) else np.nan
  348. current_ts_end = timestamps[end_idx-1] if end_idx > 0 and end_idx-1 < len(timestamps) else np.nan
  349. current_ts_peak_power = timestamps[peak_sample_abs_power] if peak_sample_abs_power < len(timestamps) else np.nan
  350. current_ts_peak_lfp = timestamps[int(peak_sample_abs_lfp)] if pd.notna(peak_sample_abs_lfp) and int(peak_sample_abs_lfp) < len(timestamps) else np.nan
  351. final_ripples.append({
  352. 'start_sample': start_idx, 'end_sample': end_idx,
  353. 'peak_sample_power': peak_sample_abs_power,
  354. 'peak_power_zscore': max_power_z_in_segment,
  355. 'peak_sample_lfp': peak_sample_abs_lfp,
  356. 'peak_lfp_amplitude_zscore': peak_val_z_lfp_amp,
  357. 'start_time': current_ts_start, 'end_time': current_ts_end,
  358. 'peak_time_power': current_ts_peak_power, 'peak_time_lfp': current_ts_peak_lfp
  359. })
  360. return pd.DataFrame(final_ripples)
  361. def plot_ripple_details(lfp_ca1_avg_for_plot, fs_for_plot, ripple_events_df_for_plot,
  362. window_ms, ripple_band_for_plot, output_dir_plot, session_name_plot):
  363. if ripple_events_df_for_plot.empty:
  364. print("No ripples to plot.")
  365. return
  366. print(f"Generating ripple-triggered plots for {len(ripple_events_df_for_plot)} events...")
  367. window_plot_samples = int(window_ms * fs_for_plot / 1000)
  368. all_ripple_spectrograms, all_ripple_psds = [], []
  369. valid_ripple_count_for_plot = 0
  370. representative_freqs_spec_rip, representative_times_spec_rip_centered, representative_spec_freq_mask_rip = None, None, None
  371. freqs_psd_rip_for_plot = None
  372. for idx, ripple in ripple_events_df_for_plot.iterrows():
  373. peak_idx_abs = ripple['peak_sample_lfp']
  374. if pd.isna(peak_idx_abs): continue
  375. peak_idx_abs = int(peak_idx_abs)
  376. start_plot = peak_idx_abs - window_plot_samples
  377. end_plot = peak_idx_abs + window_plot_samples
  378. if start_plot < 0 or end_plot >= len(lfp_ca1_avg_for_plot): continue
  379. segment_for_plot = lfp_ca1_avg_for_plot[start_plot:end_plot].astype(np.float64)
  380. if len(segment_for_plot) != 2 * window_plot_samples: continue
  381. valid_ripple_count_for_plot += 1
  382. nperseg_spec = min(len(segment_for_plot), max(32, int(fs_for_plot / ripple_band_for_plot[0] * 2.5)))
  383. noverlap_spec = nperseg_spec // 2
  384. if nperseg_spec <= noverlap_spec : noverlap_spec = max(0, nperseg_spec -1)
  385. if nperseg_spec == 0 : continue
  386. try:
  387. current_freqs_spec_rip, current_times_spec_rip, Sxx_rip = scipy.signal.spectrogram(
  388. segment_for_plot, fs=fs_for_plot, window='hann', nperseg=nperseg_spec, noverlap=noverlap_spec,
  389. scaling='density', mode='psd'
  390. )
  391. current_spec_freq_mask_rip = (current_freqs_spec_rip >= ripple_band_for_plot[0]) & (current_freqs_spec_rip <= ripple_band_for_plot[1])
  392. if np.any(current_spec_freq_mask_rip) and Sxx_rip[current_spec_freq_mask_rip, :].size > 0 :
  393. all_ripple_spectrograms.append(10 * np.log10(Sxx_rip[current_spec_freq_mask_rip, :] + 1e-12))
  394. if representative_freqs_spec_rip is None:
  395. representative_freqs_spec_rip = current_freqs_spec_rip
  396. representative_spec_freq_mask_rip = current_spec_freq_mask_rip
  397. representative_times_spec_rip_centered = (current_times_spec_rip - current_times_spec_rip.mean()) * 1000
  398. except ValueError as e:
  399. continue
  400. nperseg_psd_rip = min(len(segment_for_plot), 256)
  401. current_freqs_psd_rip, Pxx_rip = scipy.signal.welch(segment_for_plot, fs=fs_for_plot, nperseg=nperseg_psd_rip, scaling='density')
  402. all_ripple_psds.append(Pxx_rip)
  403. if freqs_psd_rip_for_plot is None :
  404. freqs_psd_rip_for_plot = current_freqs_psd_rip
  405. if not all_ripple_spectrograms or not all_ripple_psds:
  406. print("Not enough valid ripple data for average plots after processing segments.")
  407. return
  408. print(f" Aggregating {len(all_ripple_spectrograms)} spectrograms and {len(all_ripple_psds)} PSDs for averaging.")
  409. min_time_bins_spec = min(s.shape[1] for s in all_ripple_spectrograms) if all_ripple_spectrograms else 0
  410. if min_time_bins_spec == 0:
  411. print(" Spectrograms have inconsistent time bins or are empty. Cannot average for plot.")
  412. return
  413. all_ripple_spectrograms_trimmed = [s[:, :min_time_bins_spec] for s in all_ripple_spectrograms]
  414. avg_ripple_spectrogram = np.mean(all_ripple_spectrograms_trimmed, axis=0)
  415. avg_ripple_psd = np.mean(all_ripple_psds, axis=0)
  416. avg_ripple_psd_db = 10 * np.log10(avg_ripple_psd + 1e-12)
  417. avg_ripple_psd_zscore = zscore(avg_ripple_psd_db, nan_policy='omit') if len(avg_ripple_psd_db) > 1 else avg_ripple_psd_db
  418. avg_ripple_psd_zscore = np.nan_to_num(avg_ripple_psd_zscore)
  419. fig_rip, axes_rip = plt.subplots(2, 1, figsize=(10, 8))
  420. plot_freqs_spec_final = representative_freqs_spec_rip[representative_spec_freq_mask_rip] if representative_freqs_spec_rip is not None and representative_spec_freq_mask_rip is not None and np.any(representative_spec_freq_mask_rip) and len(representative_freqs_spec_rip[representative_spec_freq_mask_rip]) == avg_ripple_spectrogram.shape[0] else np.linspace(ripple_band_for_plot[0],ripple_band_for_plot[1], avg_ripple_spectrogram.shape[0])
  421. plot_times_spec_final = representative_times_spec_rip_centered[:min_time_bins_spec] if representative_times_spec_rip_centered is not None and len(representative_times_spec_rip_centered) >= min_time_bins_spec else np.linspace(-window_ms, window_ms, min_time_bins_spec)
  422. if plot_freqs_spec_final.shape[0] != avg_ripple_spectrogram.shape[0] :
  423. plot_freqs_spec_final = np.linspace(ripple_band_for_plot[0], ripple_band_for_plot[1], avg_ripple_spectrogram.shape[0])
  424. if plot_times_spec_final.shape[0] != avg_ripple_spectrogram.shape[1] :
  425. plot_times_spec_final = np.linspace(-window_ms, window_ms, avg_ripple_spectrogram.shape[1])
  426. im = axes_rip[0].pcolormesh(plot_times_spec_final, plot_freqs_spec_final, avg_ripple_spectrogram,
  427. shading='gouraud', cmap=SPECTROGRAM_CMAP)
  428. axes_rip[0].set_ylabel(f'Frequency ({ripple_band_for_plot[0]}-{ripple_band_for_plot[1]} Hz)')
  429. axes_rip[0].set_xlabel('Time from LFP peak (ms)')
  430. axes_rip[0].set_title(f'Average Ripple Spectrogram (N={valid_ripple_count_for_plot})')
  431. axes_rip[0].axvline(0, color='r', linestyle='--', alpha=0.7)
  432. fig_rip.colorbar(im, ax=axes_rip[0], label='Power (dB/Hz)')
  433. if freqs_psd_rip_for_plot is None and all_ripple_psds:
  434. freqs_psd_rip_for_plot = np.linspace(0, fs_for_plot/2, len(avg_ripple_psd_zscore))
  435. psd_plot_freq_mask = (freqs_psd_rip_for_plot >= ripple_band_for_plot[0]-20) & (freqs_psd_rip_for_plot <= ripple_band_for_plot[1]+20)
  436. axes_rip[1].plot(freqs_psd_rip_for_plot[psd_plot_freq_mask], avg_ripple_psd_zscore[psd_plot_freq_mask])
  437. axes_rip[1].set_xlabel('Frequency (Hz)')
  438. axes_rip[1].set_ylabel('Z-scored Avg Power (dB/Hz)')
  439. axes_rip[1].set_title('Average Ripple PSD (Z-scored)')
  440. axes_rip[1].grid(True, which="both", ls="-", alpha=0.5)
  441. plt.tight_layout()
  442. plot_filename = Path(output_dir_plot) / f"{session_name_plot}_avg_ripple_plots_manual.png"
  443. try:
  444. plt.savefig(plot_filename)
  445. print(f"Saved average ripple plots to {plot_filename}")
  446. except Exception as e_save_plot:
  447. print(f"Error saving ripple plot: {e_save_plot}")
  448. plt.close(fig_rip)
  449. # ------------------------------------------------------------------------------
  450. # Main Execution
  451. # ------------------------------------------------------------------------------
  452. if __name__ == "__main__":
  453. output_dir_specific = Path(OUTPUT_DIR_BASE) / "ripple_analysis_output_py_manual_ds"
  454. output_dir_specific.mkdir(parents=True, exist_ok=True)
  455. session_name_for_files = f"{path_part_for_filename}_{EXPERIMENT_NAME}_{RECORDING_NAME}"
  456. print(f"Starting analysis for: {session_dir}")
  457. print(f"Base for output filenames: {session_name_for_files}")
  458. electrode_map_df = load_electrode_mapping(MAT_FILE_PATH)
  459. if electrode_map_df is None:
  460. sys.exit()
  461. logical_ca1_channels_1based = []
  462. if isinstance(CA1_REGION_IDS, list):
  463. for region_id in CA1_REGION_IDS:
  464. channels_for_region = electrode_map_df[electrode_map_df['RegionID'] == region_id]['Channel'].astype(int).tolist()
  465. logical_ca1_channels_1based.extend(channels_for_region)
  466. elif isinstance(CA1_REGION_IDS, int):
  467. logical_ca1_channels_1based = electrode_map_df[electrode_map_df['RegionID'] == CA1_REGION_IDS]['Channel'].astype(int).tolist()
  468. else:
  469. print(f"Error: CA1_REGION_IDS type. Value: {CA1_REGION_IDS}");
  470. sys.exit()
  471. logical_ca1_channels_1based = sorted(list(set(logical_ca1_channels_1based)))
  472. if not logical_ca1_channels_1based:
  473. print(f"Error: No LOGICAL CA1 channels for RegionIDs {CA1_REGION_IDS}.");
  474. sys.exit()
  475. print(f"Identified LOGICAL CA1 channels (1-16 mapping): {logical_ca1_channels_1based}")
  476. logical_dg_channel_1based_list = []
  477. if DG_REGION_ID is not None:
  478. dg_channels_df = electrode_map_df[electrode_map_df['RegionID'] == DG_REGION_ID]
  479. if not dg_channels_df.empty:
  480. logical_dg_channel_1based_list = [dg_channels_df['Channel'].astype(int).iloc[0]]
  481. print(f"Identified LOGICAL DG noise channel (1-16 mapping): {logical_dg_channel_1based_list[0]}")
  482. else: print(f"Warning: No LOGICAL DG channels for RegionID {DG_REGION_ID}.")
  483. else: print("DG_REGION_ID not set for noise channel.")
  484. physical_ca1_channels_1based = [lc + LOGICAL_TO_PHYSICAL_OFFSET for lc in logical_ca1_channels_1based]
  485. physical_dg_channel_1based_list = [ldc + LOGICAL_TO_PHYSICAL_OFFSET for ldc in logical_dg_channel_1based_list] if logical_dg_channel_1based_list else []
  486. print(f"Translated to PHYSICAL CA1 channels (for CH names in oebin): {physical_ca1_channels_1based}")
  487. if physical_dg_channel_1based_list:
  488. print(f"Translated to PHYSICAL DG noise channel: {physical_dg_channel_1based_list[0]}")
  489. print("\n--- Starting Manual Data Loading via structure.oebin ---")
  490. lfp_data_processed_scaled = None
  491. original_fs_from_oebin = None
  492. lfp_timestamps_loaded = None
  493. loaded_channel_physical_ids_in_order = []
  494. structure_file_path = session_dir / "structure.oebin"
  495. metadata_oebin = None
  496. if not structure_file_path.exists():
  497. print(f"ERROR: structure.oebin not found at {structure_file_path}");
  498. sys.exit()
  499. try:
  500. with open(structure_file_path, 'r') as f: metadata_oebin = json.load(f)
  501. print("Successfully parsed structure.oebin")
  502. except Exception as e:
  503. print(f"ERROR: Could not parse structure.oebin: {e}");
  504. sys.exit()
  505. if metadata_oebin and metadata_oebin.get('continuous') and len(metadata_oebin['continuous']) > 0:
  506. continuous_stream_info = metadata_oebin['continuous'][0]
  507. stream_folder_name = continuous_stream_info.get('folder_name')
  508. original_fs_from_oebin = float(continuous_stream_info.get('sample_rate', 0))
  509. num_channels_total_in_stream = int(continuous_stream_info.get('num_channels', 0))
  510. channels_metadata_list_oebin = continuous_stream_info.get('channels', [])
  511. print(f" Stream Folder: {stream_folder_name}, Original SR: {original_fs_from_oebin} Hz, Total Stream Ch: {num_channels_total_in_stream}")
  512. if not all([stream_folder_name, original_fs_from_oebin > 0, num_channels_total_in_stream > 0]):
  513. print("ERROR: Essential stream info missing from structure.oebin."); sys.exit()
  514. continuous_dat_path = session_dir / "continuous" / stream_folder_name / "continuous.dat"
  515. timestamps_npy_path = session_dir / "continuous" / stream_folder_name / "timestamps.npy"
  516. print(f" Expected continuous.dat: {continuous_dat_path}")
  517. if not continuous_dat_path.exists():
  518. print("ERROR: continuous.dat not found.");
  519. sys.exit()
  520. if not timestamps_npy_path.exists():
  521. print("ERROR: timestamps.npy not found.");
  522. sys.exit()
  523. target_physical_channels_to_extract = sorted(list(set(physical_ca1_channels_1based + (physical_dg_channel_1based_list if physical_dg_channel_1based_list else []))))
  524. print(f" Attempting to extract PHYSICAL 1-based channels: {target_physical_channels_to_extract}")
  525. stream_indices_to_load_0based = []
  526. bit_volts_for_selected_channels = []
  527. num_amplifier_channels_in_stream = sum(1 for ch_meta in channels_metadata_list_oebin if ch_meta.get('channel_name','').upper().startswith("CH"))
  528. for target_ch_physical_num_1based in target_physical_channels_to_extract:
  529. found_in_oebin = False
  530. for oebin_idx_0based, oebin_ch_meta in enumerate(channels_metadata_list_oebin):
  531. oebin_ch_name = oebin_ch_meta.get('channel_name', '').upper()
  532. num_part_str = ''.join(filter(str.isdigit, oebin_ch_name))
  533. if not num_part_str: continue
  534. oebin_ch_num_part = int(num_part_str)
  535. current_ch_matches_target = False
  536. if oebin_ch_name.startswith("CH") and oebin_ch_num_part == target_ch_physical_num_1based:
  537. current_ch_matches_target = True
  538. elif oebin_ch_name.startswith("ADC"):
  539. if (num_amplifier_channels_in_stream + oebin_ch_num_part) == target_ch_physical_num_1based:
  540. current_ch_matches_target = True
  541. if current_ch_matches_target:
  542. stream_indices_to_load_0based.append(oebin_idx_0based)
  543. bit_v = float(oebin_ch_meta.get('bit_volts'))
  544. units = oebin_ch_meta.get('units', '').upper()
  545. if "V" in units and "UV" not in units and abs(bit_v) < 1:
  546. bit_v *= 1e6
  547. bit_volts_for_selected_channels.append(bit_v)
  548. loaded_channel_physical_ids_in_order.append(target_ch_physical_num_1based)
  549. found_in_oebin = True
  550. break
  551. if not found_in_oebin:
  552. print(f"Warning: Target PHYSICAL channel {target_ch_physical_num_1based} not matched in structure.oebin.")
  553. if not stream_indices_to_load_0based:
  554. print("ERROR: No channels mapped for loading.");
  555. sys.exit()
  556. print(f" Mapped 0-based stream indices: {stream_indices_to_load_0based}")
  557. print(f" Corresponding PHYSICAL 1-based IDs loaded: {loaded_channel_physical_ids_in_order}")
  558. try:
  559. raw_data_memmap = np.memmap(continuous_dat_path, dtype='int16', mode='r')
  560. num_total_samples_in_file = len(raw_data_memmap) // num_channels_total_in_stream
  561. valid_length = num_total_samples_in_file * num_channels_total_in_stream
  562. all_channels_data_reshaped = raw_data_memmap[:valid_length].reshape((num_total_samples_in_file,
  563. num_channels_total_in_stream))
  564. lfp_data_processed_scaled = np.zeros((num_total_samples_in_file,
  565. len(stream_indices_to_load_0based)), dtype=np.float32)
  566. for i, (stream_idx, bit_v) in enumerate(zip(stream_indices_to_load_0based, bit_volts_for_selected_channels)):
  567. lfp_data_processed_scaled[:, i] = all_channels_data_reshaped[:, stream_idx].astype(np.float32) * bit_v
  568. del raw_data_memmap, all_channels_data_reshaped
  569. except Exception as e:
  570. print(f"Error reading/processing continuous.dat: {e}");
  571. sys.exit()
  572. try:
  573. lfp_timestamps_loaded = np.load(timestamps_npy_path)
  574. if len(lfp_timestamps_loaded) == 1:
  575. print("Timestamps.npy has one entry; assuming start time and reconstructing.")
  576. start_time_abs = lfp_timestamps_loaded[0]
  577. lfp_timestamps_loaded = start_time_abs + np.arange(num_total_samples_in_file) / original_fs_from_oebin
  578. elif len(lfp_timestamps_loaded) != num_total_samples_in_file:
  579. print(f"Warning: Timestamps ({len(lfp_timestamps_loaded)}) != samples ({num_total_samples_in_file}). Adjusting.")
  580. min_len_ts_data = min(len(lfp_timestamps_loaded), num_total_samples_in_file)
  581. lfp_timestamps_loaded = lfp_timestamps_loaded[:min_len_ts_data]
  582. lfp_data_processed_scaled = lfp_data_processed_scaled[:min_len_ts_data, :]
  583. num_total_samples_in_file = min_len_ts_data
  584. print(f" Adjusted data/timestamps to min length: {min_len_ts_data}")
  585. except Exception as e:
  586. print(f"Error loading timestamps.npy: {e}");
  587. sys.exit()
  588. print("Manual data loading complete.")
  589. else:
  590. print("ERROR: No 'continuous' stream info in structure.oebin.");
  591. sys.exit()
  592. if lfp_data_processed_scaled is None or original_fs_from_oebin is None or lfp_timestamps_loaded is None:
  593. print("Exiting: Manual LFP data loading failed.");
  594. sys.exit()
  595. print(f"LFP data (manual raw). Shape: {lfp_data_processed_scaled.shape}, Original SR: {original_fs_from_oebin} Hz, Timestamps: {len(lfp_timestamps_loaded)}")
  596. ca1_indices_in_loaded = [i for i, ph_id in
  597. enumerate(loaded_channel_physical_ids_in_order)
  598. if ph_id in physical_ca1_channels_1based]
  599. if not ca1_indices_in_loaded:
  600. print("ERROR: CA1 channels not found in manually loaded data array.");
  601. sys.exit()
  602. lfp_ca1_all_raw_sr = lfp_data_processed_scaled[:, ca1_indices_in_loaded]
  603. lfp_ca1_avg_raw_sr = np.mean(lfp_ca1_all_raw_sr, axis=1)
  604. lfp_dg_noise_raw_sr = None
  605. if physical_dg_channel_1based_list:
  606. dg_idx_in_loaded = [i for i, ph_id in
  607. enumerate(loaded_channel_physical_ids_in_order)
  608. if ph_id == physical_dg_channel_1based_list[0]]
  609. if dg_idx_in_loaded: lfp_dg_noise_raw_sr = lfp_data_processed_scaled[:, dg_idx_in_loaded[0]].squeeze()
  610. print(f"Averaged CA1 LFP (raw SR). Shape: {lfp_ca1_avg_raw_sr.shape}")
  611. if lfp_dg_noise_raw_sr is not None:
  612. print(f"DG Noise LFP (raw SR). Shape: {lfp_dg_noise_raw_sr.shape}")
  613. fs_current = original_fs_from_oebin
  614. if fs_current <= TARGET_FS_DOWNSAMPLED:
  615. print(f"Original SR ({fs_current} Hz) is at/below target ({TARGET_FS_DOWNSAMPLED} Hz). No decimation.")
  616. lfp_ca1_avg_final = lfp_ca1_avg_raw_sr.copy()
  617. lfp_dg_noise_final = lfp_dg_noise_raw_sr.copy() if lfp_dg_noise_raw_sr is not None else None
  618. lfp_timestamps_final = lfp_timestamps_loaded.copy()
  619. else:
  620. decimation_factor = int(round(fs_current / TARGET_FS_DOWNSAMPLED))
  621. if decimation_factor < 1: decimation_factor = 1
  622. print(f"\n--- Downsampling LFP from {fs_current} Hz to ~{TARGET_FS_DOWNSAMPLED} Hz (factor: {decimation_factor}) ---")
  623. lfp_ca1_avg_final = scipy.signal.decimate(lfp_ca1_avg_raw_sr, decimation_factor, ftype='fir', zero_phase=True)
  624. print(f"CA1 LFP downsampled. New shape: {lfp_ca1_avg_final.shape}")
  625. if lfp_dg_noise_raw_sr is not None:
  626. lfp_dg_noise_final = scipy.signal.decimate(lfp_dg_noise_raw_sr, decimation_factor, ftype='fir', zero_phase=True)
  627. print(f"DG LFP downsampled. New shape: {lfp_dg_noise_final.shape}")
  628. else: lfp_dg_noise_final = None
  629. lfp_timestamps_final = lfp_timestamps_loaded[::decimation_factor]
  630. fs_current = fs_current / decimation_factor
  631. print(f"Timestamps downsampled. New length: {len(lfp_timestamps_final)}")
  632. print(f"New effective sampling rate (fs_current): {fs_current:.2f} Hz")
  633. print("\n--- Performing Sleep State Scoring on downsampled data ---")
  634. full_spec_sxx, full_spec_freqs, spec_times_centered = compute_spectrogram_custom(
  635. lfp_ca1_avg_final, fs_current, SPECTROGRAM_WINDOW_SEC, SPECTROGRAM_STEP_SEC
  636. )
  637. if full_spec_sxx is None:
  638. print("Spectrogram failed. Exiting.");
  639. sys.exit()
  640. # --- Calculate Sleep States ---
  641. # 1. Existing PCA/Theta Method
  642. freq_mask_for_pca = (full_spec_freqs >= PCA_SPECTROGRAM_FREQ_RANGE[0]) & (full_spec_freqs <= PCA_SPECTROGRAM_FREQ_RANGE[1])
  643. if not np.any(freq_mask_for_pca):
  644. print(f"Error: No freqs in {PCA_SPECTROGRAM_FREQ_RANGE} Hz for PCA. Using full spectrum.");
  645. spec_sxx_for_pca, freqs_for_pca = full_spec_sxx, full_spec_freqs
  646. else:
  647. spec_sxx_for_pca, freqs_for_pca = full_spec_sxx[freq_mask_for_pca, :], full_spec_freqs[freq_mask_for_pca]
  648. spec_abs_times = spec_times_centered + (lfp_timestamps_final[0] if len(lfp_timestamps_final)>0 else 0)
  649. pc1 = compute_pca_custom(spec_sxx_for_pca)
  650. theta_dominance = compute_theta_dominance_custom(full_spec_sxx, full_spec_freqs,
  651. THETA_DOMINANCE_THETA_BAND, THETA_DOMINANCE_TOTAL_BAND)
  652. min_len_metrics = min(len(pc1) if pc1 is not None else 0,
  653. len(theta_dominance) if theta_dominance is not None else 0,
  654. len(spec_abs_times))
  655. # Align arrays
  656. if pc1 is not None: pc1 = pc1[:min_len_metrics]
  657. if theta_dominance is not None: theta_dominance = theta_dominance[:min_len_metrics]
  658. spec_abs_times_aligned = spec_abs_times[:min_len_metrics]
  659. sleep_states, sleep_thresholds = score_sleep_states_custom(pc1, theta_dominance, spec_abs_times_aligned, SPECTROGRAM_STEP_SEC, SCORING_BUFFER_SEC)
  660. # 2. Logic for NREM Detection Selection
  661. is_nrem_state = None
  662. if USE_DT_RATIO_FOR_NREM:
  663. print(f"\n--- [OPTION ENABLED] Estimating NREM using Delta/Theta Ratio > {DT_RATIO_THRESHOLD} ---")
  664. # Calculate Delta Power (Mean power in Delta band)
  665. delta_mask = (full_spec_freqs >= DT_DELTA_BAND[0]) & (full_spec_freqs <= DT_DELTA_BAND[1])
  666. theta_mask = (full_spec_freqs >= DT_THETA_BAND[0]) & (full_spec_freqs <= DT_THETA_BAND[1])
  667. if np.any(delta_mask) and np.any(theta_mask):
  668. dt_delta_power = np.nanmean(full_spec_sxx[delta_mask, :], axis=0)[:min_len_metrics]
  669. dt_theta_power = np.nanmean(full_spec_sxx[theta_mask, :], axis=0)[:min_len_metrics]
  670. # Avoid division by zero
  671. dt_ratio = np.zeros_like(dt_delta_power)
  672. valid_ratio_mask = dt_theta_power > 1e-9
  673. dt_ratio[valid_ratio_mask] = dt_delta_power[valid_ratio_mask] / dt_theta_power[valid_ratio_mask]
  674. is_nrem_state = dt_ratio > DT_RATIO_THRESHOLD
  675. print(f" Identified {np.sum(is_nrem_state)} spectrogram bins as NREM based on Delta/Theta ratio.")
  676. else:
  677. print("Error: Could not calculate Delta or Theta power for ratio. Fallback to scoring.")
  678. if sleep_states is not None: is_nrem_state = sleep_states == 1
  679. else:
  680. # Standard scoring
  681. if sleep_states is not None: is_nrem_state = sleep_states == 1
  682. # Fallback if detection failed completely
  683. if is_nrem_state is None:
  684. print("Warning: NREM state detection failed. No NREM epochs will be processed.")
  685. is_nrem_state = np.zeros(min_len_metrics, dtype=bool)
  686. nrem_diff = np.diff(is_nrem_state.astype(int))
  687. nrem_start_indices_spec = np.where(nrem_diff == 1)[0] + 1
  688. nrem_end_indices_spec = np.where(nrem_diff == -1)[0] + 1
  689. if len(is_nrem_state)>0:
  690. if is_nrem_state[0]: nrem_start_indices_spec = np.insert(nrem_start_indices_spec, 0, 0)
  691. if is_nrem_state[-1]: nrem_end_indices_spec = np.append(nrem_end_indices_spec, len(is_nrem_state))
  692. final_nrem_starts, final_nrem_ends = [], []
  693. if len(nrem_start_indices_spec) > 0 and len(nrem_end_indices_spec) > 0:
  694. # Ensure not empty before potential indexing
  695. # Ensure start_indices are less than end_indices and arrays are of same length for zipping
  696. min_len_nrem_edges = min(len(nrem_start_indices_spec), len(nrem_end_indices_spec))
  697. nrem_start_indices_spec = nrem_start_indices_spec[:min_len_nrem_edges]
  698. nrem_end_indices_spec = nrem_end_indices_spec[:min_len_nrem_edges]
  699. for s, e_idx in zip(nrem_start_indices_spec, nrem_end_indices_spec):
  700. if e_idx > s: final_nrem_starts.append(s); final_nrem_ends.append(e_idx)
  701. nrem_start_indices_spec, nrem_end_indices_spec = np.array(final_nrem_starts), np.array(final_nrem_ends)
  702. nrem_periods_sec = []
  703. lfp_start_time_abs_final = lfp_timestamps_final[0] if len(lfp_timestamps_final) > 0 else 0
  704. lfp_end_time_abs_final = lfp_timestamps_final[-1] if len(lfp_timestamps_final) > 0 else ((len(lfp_ca1_avg_final) / fs_current + lfp_start_time_abs_final) if 'lfp_ca1_avg_final' in locals() and lfp_ca1_avg_final is not None else lfp_start_time_abs_final)
  705. for s_idx, e_idx in zip(nrem_start_indices_spec, nrem_end_indices_spec):
  706. if e_idx > s_idx and s_idx < len(spec_abs_times_aligned) and (e_idx -1) < len(spec_abs_times_aligned): # e_idx-1 for indexing spec_abs_times
  707. start_t = spec_abs_times_aligned[s_idx] - SPECTROGRAM_STEP_SEC / 2
  708. end_t = spec_abs_times_aligned[e_idx - 1] + SPECTROGRAM_STEP_SEC / 2
  709. start_t,end_t = max(start_t,lfp_start_time_abs_final),min(end_t,lfp_end_time_abs_final)
  710. if end_t > start_t: nrem_periods_sec.append((start_t, end_t))
  711. total_nrem_duration_s = sum(e - s for s, e in nrem_periods_sec)
  712. print(f"Total NREM duration: {total_nrem_duration_s:.2f}s from {len(nrem_periods_sec)} episodes.")
  713. all_detected_ripples_df = pd.DataFrame()
  714. if total_nrem_duration_s > 0 and nrem_periods_sec:
  715. print(f"\n--- Detecting ripples in {len(nrem_periods_sec)} NREM epochs ---")
  716. for i, (nrem_s_time, nrem_e_time) in enumerate(nrem_periods_sec):
  717. nrem_s_sample = np.searchsorted(lfp_timestamps_final, nrem_s_time, side='left')
  718. nrem_e_sample = np.searchsorted(lfp_timestamps_final, nrem_e_time, side='right')
  719. if nrem_e_sample <= nrem_s_sample: continue
  720. seg_ca1, seg_ts = lfp_ca1_avg_final[nrem_s_sample:nrem_e_sample], lfp_timestamps_final[nrem_s_sample:nrem_e_sample]
  721. seg_dg = lfp_dg_noise_final[nrem_s_sample:nrem_e_sample] if lfp_dg_noise_final is not None else None
  722. min_len_for_ripple = int(RIPPLE_DURATIONS[2] / 1000 * fs_current * 1.1)
  723. if len(seg_ca1) < min_len_for_ripple: continue
  724. # print(f" Processing NREM epoch {i+1}, duration {len(seg_ca1)/fs_current:.2f}s")
  725. df_rip = find_swr_custom(seg_ca1, seg_ts, fs_current, RIPPLE_THRESHOLDS, RIPPLE_DURATIONS,
  726. RIPPLE_FREQ_RANGE, RIPPLE_ENVELOPE_FILTER_HZ, seg_dg)
  727. if not df_rip.empty:
  728. for col_sample in ['start_sample', 'end_sample', 'peak_sample_power', 'peak_sample_lfp']:
  729. if col_sample in df_rip and pd.api.types.is_numeric_dtype(df_rip[col_sample]):
  730. df_rip[col_sample] = df_rip[col_sample].add(nrem_s_sample, fill_value=0).astype('Int64')
  731. all_detected_ripples_df = pd.concat([all_detected_ripples_df, df_rip], ignore_index=True)
  732. if not all_detected_ripples_df.empty:
  733. all_detected_ripples_df['duration_ms'] = (all_detected_ripples_df['end_time'] - all_detected_ripples_df['start_time']) * 1000.0
  734. all_detected_ripples_df = all_detected_ripples_df.sort_values(by='start_time').reset_index(drop=True)
  735. if len(all_detected_ripples_df) > 0: all_detected_ripples_df['IRI_s'] = np.nan
  736. if len(all_detected_ripples_df) > 1:
  737. iri_values = all_detected_ripples_df['start_time'].iloc[1:].values - all_detected_ripples_df['end_time'].iloc[:-1].values
  738. all_detected_ripples_df.loc[1:, 'IRI_s'] = iri_values
  739. all_detected_ripples_df['num_cycles_approx'] = all_detected_ripples_df.apply(
  740. lambda r: (r['duration_ms']/1000.0)*np.mean(RIPPLE_FREQ_RANGE) if pd.notna(r['duration_ms']) else np.nan, axis=1)
  741. else: print("No NREM / NREM duration zero. Skipping ripple detection.")
  742. if not all_detected_ripples_df.empty:
  743. print(f"\n--- Detected {len(all_detected_ripples_df)} ripples (manual load, downsampled) ---")
  744. ripple_band_filtered_lfp_ca1_avg_final = butter_bandpass_filter(lfp_ca1_avg_final, RIPPLE_FREQ_RANGE[0], RIPPLE_FREQ_RANGE[1], fs_current, order=4)
  745. if len(ripple_band_filtered_lfp_ca1_avg_final) > 0 and not np.all(np.isnan(ripple_band_filtered_lfp_ca1_avg_final)):
  746. z_ripple_band_filtered_lfp_ca1_avg_final = zscore(ripple_band_filtered_lfp_ca1_avg_final, nan_policy='omit')
  747. z_ripple_band_filtered_lfp_ca1_avg_final = np.nan_to_num(z_ripple_band_filtered_lfp_ca1_avg_final)
  748. new_peak_lfp_amplitude_zscore = []
  749. for idx, ripple in all_detected_ripples_df.iterrows():
  750. s_abs_val, e_abs_val = ripple['start_sample'], ripple['end_sample']
  751. if pd.isna(s_abs_val) or pd.isna(e_abs_val): new_peak_lfp_amplitude_zscore.append(np.nan); continue
  752. s_abs, e_abs = int(s_abs_val), int(e_abs_val)
  753. if s_abs>=e_abs or e_abs > len(z_ripple_band_filtered_lfp_ca1_avg_final) or s_abs < 0 :
  754. new_peak_lfp_amplitude_zscore.append(np.nan); continue
  755. segment_z_lfp_abs = z_ripple_band_filtered_lfp_ca1_avg_final[s_abs:e_abs]
  756. if len(segment_z_lfp_abs)>0:
  757. abs_max_idx_in_segment=np.argmax(np.abs(segment_z_lfp_abs))
  758. new_peak_lfp_amplitude_zscore.append(segment_z_lfp_abs[abs_max_idx_in_segment])
  759. else: new_peak_lfp_amplitude_zscore.append(np.nan)
  760. all_detected_ripples_df['peak_lfp_amplitude_zscore'] = new_peak_lfp_amplitude_zscore
  761. else:
  762. all_detected_ripples_df['peak_lfp_amplitude_zscore'] = np.nan
  763. print("Warning: Full CA1 LFP avg (downsampled) problematic for LFP Z-score calculation.")
  764. plot_ripple_details(lfp_ca1_avg_final, fs_current, all_detected_ripples_df,
  765. RIPPLE_PLOT_WINDOW_MS, RIPPLE_FREQ_RANGE,
  766. output_dir_specific, session_name_for_files)
  767. csv_events_path = output_dir_specific / f"{session_name_for_files}_ripple_events_detailed_manual_ds.csv"
  768. all_detected_ripples_df.to_csv(csv_events_path, index=False, float_format='%.4f')
  769. print(f"Saved detailed ripple events to {csv_events_path}")
  770. ripple_rate_hz = len(all_detected_ripples_df)/total_nrem_duration_s if total_nrem_duration_s > 0 else 0
  771. summary_metrics = {
  772. 'recording_name': session_name_for_files, 'total_nrem_duration_s': total_nrem_duration_s,
  773. 'total_ripples_detected': len(all_detected_ripples_df), 'ripple_rate_nrem_hz': ripple_rate_hz,
  774. 'mean_duration_ms': all_detected_ripples_df['duration_ms'].mean(),
  775. 'mean_peak_power_zscore': all_detected_ripples_df['peak_power_zscore'].mean(),
  776. 'mean_peak_lfp_amplitude_zscore': all_detected_ripples_df['peak_lfp_amplitude_zscore'].mean(),
  777. 'mean_IRI_s': all_detected_ripples_df['IRI_s'].mean(skipna=True),
  778. 'mean_num_cycles_approx': all_detected_ripples_df['num_cycles_approx'].mean()
  779. }
  780. summary_df = pd.DataFrame([summary_metrics])
  781. csv_summary_path = output_dir_specific / f"{session_name_for_files}_ripple_summary_stats_manual_ds.csv"
  782. summary_df.to_csv(csv_summary_path, index=False, float_format='%.4f')
  783. print(f"Saved ripple summary statistics to {csv_summary_path}")
  784. else: print("No ripples detected. No CSV/plots for ripples.")
  785. if 'root_tk' in locals() and root_tk is not None:
  786. try: root_tk.destroy()
  787. except tk.TclError: pass
  788. print(f"\nAnalysis complete for {session_dir} (using manual data loading and downsampling)")

Ripples_Z-scored_Single Trial Script.py, under CC-BY-4.0 · at the source

Overview

Authors: Yu-Tzu Shih1,2,3,4,5, Jason Bondoc Alipio1,2,3,4,5, Zin-Juan Klaft6, Nathaniel Green1,2,3,4, Alok Nath Mohapatra1,2,3,4, Travis D Goode1,2,3,4, Muthu Panchanatham1,2,3,4, Devesh Pathak1,2,3, Lai Ping Wong7, Ruslan Sadreyev7, Jung Ho Hyun8, Omar Ahmed9,10,11, Chris Dulla6, Amar Sahay1,2,3,4
  1. Center for Regenerative Medicine, Massachusetts General Hospital, Boston, MA, USA
  2. Harvard Stem Cell Institute, Cambridge, MA, USA
  3. Department of Psychiatry, Massachusetts General Hospital, Harvard Medical School, Boston, MA, USA
  4. BROAD Institute of MIT and Harvard, Cambridge, MA, USA
  5. These authors contributed equally: Yu-Tzu Shih, Jason Bondoc Alipio
  6. Department of Neuroscience, Tufts University School of Medicine, Boston, MA, USA
  7. Department of Molecular Biology, Massachusetts General Hospital, Harvard Medical School, Boston, MA, USA
  8. Department of Brain Sciences, Daegu Gyeongbuk Institute of Science and Technology, Daegu, South Korea
  9. Department of Psychology, University of Michigan, Ann Arbor, MI, USA
  10. Neuroscience Graduate Program, University of Michigan, Ann Arbor, MI, USA
  11. Department of Biomedical Engineering, University of Michigan, Ann Arbor, MI, USA
Institutions: Broad Institute (United States); Harvard University (United States); Massachusetts General Hospital (United States); Harvard Stem Cell Institute (United States); Tufts University (United States); Daegu Gyeongbuk Institute of Science and Technology (South Korea); University of Michigan (United States)
Journal: Nature, pages 10.1038/s41586-026-10907-8
Dates: published online 12 August 2026; in print August 2026
Type: Research article · Language: English
License: CC BY-NC-ND
Identifiers: DOI 10.1038/s41586-026-10907-8 · PMID 42587157 · PMCID PMC13531005 · OpenAlex W7202253421
Open access: hybrid, a free copy (OpenAlex)
Status: code verified
Categories: other condition (population), epilepsy (population)
Methods: Spectral & time-frequency, Connectivity, Statistics, Smoothing, state filtering, decompositions, Preprocessing, Evoked potentials
Topic: Neurogenesis and neuroplasticity mechanisms (Developmental Neuroscience, Neuroscience), according to OpenAlex
Funding: NIMH NIH HHS (R01 MH111729, R01 MH131652); NINDS NIH HHS (R01 NS139468, R01 NS113499); NIA NIH HHS (R01 AG076612)
Citations: not cited yet (Europe PMC); 79 references in the paper

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.

Repository

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

Zenodo 18496478

License: CC-BY-4.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Languages: Python (1)
Size: 4 files, 1 script
Software Heritage: not checked
Found in: “Code availability”
Holds: README
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: Matplotlib (1 file), NumPy (1 file), pandas (1 file), scikit-learn (1 file), SciPy (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
  • 27 September 2026: the link answers (HTTP 200)
1 file

Code availability statement

The paper has a code availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

Read it in the paper: doi.org/10.1038/s41586-026-10907-8.

Tracing map

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

What the map holds:

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

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

Data

Datasets cited

Data availability statement

The paper has a data availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

Read it in the paper: doi.org/10.1038/s41586-026-10907-8.

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

  • Publisher: n/a → Nature Portfolio

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, pages, dates, 14 authors, 3 funders, 78 references, 16 RRIDs.

Cite

This paper

Shih, Y.-T., Alipio, J. B., Klaft, Z.-J., Green, N., Mohapatra, A. N., Goode, T. D., Panchanatham, M., Pathak, D., Wong, L. P., Sadreyev, R., Hyun, J. H., Ahmed, O., Dulla, C., & Sahay, A. (2026). Procognitive restoration of PV neuron plasticity in neurodevelopmental disorders. Nature, 10.1038/s41586-026-10907-8. https://doi.org/10.1038/s41586-026-10907-8

BibTeX

@article{shih2026procognitive,
author = {Shih, Yu-Tzu and Alipio, Jason Bondoc and Klaft, Zin-Juan and Green, Nathaniel and Mohapatra, Alok Nath and Goode, Travis D and Panchanatham, Muthu and Pathak, Devesh and Wong, Lai Ping and Sadreyev, Ruslan and Hyun, Jung Ho and Ahmed, Omar and Dulla, Chris and Sahay, Amar},
title = {{Procognitive restoration of PV neuron plasticity in neurodevelopmental disorders}},
journal = {Nature},
year = {2026},
month = aug,
pages = {10.1038/s41586--026--10907--8},
publisher = {Nature Portfolio},
issn = {0028-0836},
doi = {10.1038/s41586-026-10907-8},
url = {https://doi.org/10.1038/s41586-026-10907-8},
pmid = {42587157},
pmcid = {PMC13531005}
}

RIS

TY - JOUR
AU - Shih, Yu-Tzu
AU - Alipio, Jason Bondoc
AU - Klaft, Zin-Juan
AU - Green, Nathaniel
AU - Mohapatra, Alok Nath
AU - Goode, Travis D
AU - Panchanatham, Muthu
AU - Pathak, Devesh
AU - Wong, Lai Ping
AU - Sadreyev, Ruslan
AU - Hyun, Jung Ho
AU - Ahmed, Omar
AU - Dulla, Chris
AU - Sahay, Amar
TI - Procognitive restoration of PV neuron plasticity in neurodevelopmental disorders
T2 - Nature
J2 - Nature
PY - 2026
DA - 2026/08/12
SP - 10.1038/s41586
EP - 026-10907-8
SN - 0028-0836
PB - Nature Portfolio
DO - 10.1038/s41586-026-10907-8
UR - https://doi.org/10.1038/s41586-026-10907-8
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41586-026-10907-8",
"type": "article-journal",
"title": "Procognitive restoration of PV neuron plasticity in neurodevelopmental disorders",
"container-title": "Nature",
"author": [
{
"family": "Shih",
"given": "Yu-Tzu"
},
{
"family": "Alipio",
"given": "Jason Bondoc"
},
{
"family": "Klaft",
"given": "Zin-Juan"
},
{
"family": "Green",
"given": "Nathaniel"
},
{
"family": "Mohapatra",
"given": "Alok Nath"
},
{
"family": "Goode",
"given": "Travis D"
},
{
"family": "Panchanatham",
"given": "Muthu"
},
{
"family": "Pathak",
"given": "Devesh"
},
{
"family": "Wong",
"given": "Lai Ping"
},
{
"family": "Sadreyev",
"given": "Ruslan"
},
{
"family": "Hyun",
"given": "Jung Ho"
},
{
"family": "Ahmed",
"given": "Omar"
},
{
"family": "Dulla",
"given": "Chris"
},
{
"family": "Sahay",
"given": "Amar"
}
],
"container-title-short": "Nature",
"page": "10.1038/s41586-026-10907-8",
"DOI": "10.1038/s41586-026-10907-8",
"PMID": "42587157",
"PMCID": "PMC13531005",
"ISSN": "0028-0836",
"publisher": "Nature Portfolio",
"URL": "https://doi.org/10.1038/s41586-026-10907-8",
"language": "en",
"issued": {
"date-parts": [
[
2026,
8,
12
]
]
}
}

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/s41593-026-02388-9 [code]
Hippocampal CA3 connectomics reveals a gradient of mossy fiber inputs and selective feedforward inhibition onto pyramidal cells.
Journal: Nature neuroscience
In common: scikit-learn, pandas, SciPy, 2 other tools, 5 references
[2] doi:10.1016/j.neuron.2026.05.004 [code]
A learning-evoked slow-oscillatory architecture paces population activity for offline reactivation across the human medial temporal lobe.
Journal: Neuron
In common: SciPy, Matplotlib, NumPy, 6 references
[3] doi:10.1093/sleep/zsag168 [code]
Deltas' and spindles' cross-area synchronization and ripple subtypes.
Journal: Sleep
In common: scikit-learn, SciPy, Matplotlib, 1 other tool, 5 references
[4] doi:10.1038/s41592-026-03043-8 [code]
Designer indicators for two-photon recording of subthreshold voltage dynamics.
Journal: Nature methods
In common: pandas, SciPy, Matplotlib, 1 other tool, 4 references
[5] doi:10.1016/j.neuron.2026.03.034 [code]
Dentate gyrus interneurons modulate winner-take-all network dynamics in freely behaving mice.
Journal: Neuron
In common: scikit-learn, pandas, SciPy, 2 other tools, 3 references
[6] doi:10.1038/s43856-026-01846-6
High-frequency visual stimulation can increase medial temporal lobe ripple oscillation density.
Journal: Communications medicine
In common: 6 references
[7] doi:10.1038/s41593-026-02357-2 [code]
Experience reorganizes content-specific memory traces in macaques.
Journal: Nature neuroscience
In common: scikit-learn, pandas, SciPy, 2 other tools, 3 references
[8] doi:10.1038/s41586-026-10515-6 [code]
An X-linked long non-coding RNA, PTCHD1-AS, and the core features of autism.
Journal: Nature
In common: pandas, SciPy, Matplotlib, 1 other tool, 5 references
[9] doi:10.1038/s41586-026-10679-1 [code]
Cortical development dynamics across autism spectrum disorder mouse models.
Journal: Nature
In common: scikit-learn, pandas, SciPy, 2 other tools, 4 references
[10] doi:10.1038/s41593-026-02362-5 [code]
Replay of procedural memory is independent of the hippocampus.
Journal: Nature neuroscience
In common: scikit-learn, pandas, SciPy, 2 other tools, 3 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.