OSCR

DBSsync: combining intracranial and multimodal data to investigate new biomarkers in Parkinson's disease.

Code ↔ Paper

15 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 15 matches
  1. [1] § Methods › Cardiac artifact removal from Percept-LFP data ↔ functions/plotting.py, lines 225–303 · score 0.83 · band pass filtered, 0.5–60 Hz, manually override, external ECG channel, peak detection, 0.5 Hz
  2. [2] § Methods › Cardiac artifact removal from Percept-LFP data ↔ functions/ecg_cleaning.py, lines 264–323 · score 0.83 · band pass filtered, 0.5–60 Hz, peak detection, ECG channel, algorithm, override
  3. [3] § Methods › Synchronization of intracranial Percept-LFP and external recordings ↔ functions/find_artifacts.py, lines 51–100 · score 0.82 · threshold window, bipolar electrode, external channel, sharp, external recordings, crossing
  4. [4] § Methods › Cardiac artifact removal from Percept-LFP data ↔ DBSsync_main.py, lines 1009–1122 · score 0.79 · cardiac artifact removal, power spectrum, cleaned channel, ECG artifact, reconstruction, epochs
  5. [5] § Methods › Cardiac artifact removal from Percept-LFP data ↔ functions/ecg_cleaning.py, lines 1–44 · score 0.73 · QRS complex, template subtraction, fitted, Stam, singular, components
  6. [6] § Methods › Cardiac artifact removal from Percept-LFP data ↔ DBSsync_main.py, lines 1009–1122 · score 0.69 · cardiac artifact removal, ECG artifacts, fitted, QRS, subtraction, reconstructed
  7. [7] § Methods › Synchronization of intracranial Percept-LFP and external recordings ↔ functions/interactive.py, lines 129–207 · score 0.68 · pass filter, external ECG channel, cardiac artifacts, detrend, doesn, threshold
  8. [8] § Methods › Validation of cardiac artifact removal ↔ functions/ecg_cleaning.py, lines 264–323 · score 0.68 · externally recorded ECG, LFP signal, pass filtered, validate, window, artifact
  9. [9] § Methods › Main interface and compatible file formats ↔ functions/io.py, lines 1–50 · score 0.67 · MNE python, compatibility, POLY5, FIF, interface, GUI
  10. [10] § Methods › Validation of cardiac artifact removal ↔ functions/ecg_cleaning.py, lines 61–183 · score 0.67 · avoid stimulation pulses, manual override, peak detection, polarity, ECG, artifacts
  11. [11] § Methods › Cardiac artifact removal from Percept-LFP data ↔ functions/ecg_cleaning.py, lines 985–1116 · score 0.62 · power spectrum, cleaned channel, ECG artifact, epochs, overlap, template
  12. [12] § Methods › Cardiac artifact removal from Percept-LFP data ↔ functions/ecg_cleaning.py, lines 590–633 · score 0.61 · pass detection, refined, segment, epochs, correlation, threshold
  13. [13] § Results › Stimulation artifacts are reproducible across patients ↔ functions/find_artifacts.py, lines 1–26 · score 0.59 · identifiable artifacts, bipolar electrode, intracranial recordings, external recordings, stimulation, DBS
  14. [14] § Methods › Induction of DBS synchronization artifacts ↔ functions/find_artifacts.py, lines 51–100 · score 0.56 · short pulses, 0–1, ramp, external recordings, amplitude, stimulation
  15. [15] § Methods › Validation of cardiac artifact removal › Beta/theta power preservation (BPP/TPP) ↔ functions/ecg_cleaning.py, lines 985–1116 · score 0.52 · Power spectral density, Welch, PSD, overlap, window, signals

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,210 lines · 48 KB · MIT · 7 matches

  1. """
  2. This module contains functions for cleaning ECG data using various methods as
  3. described in Stam et al., 2023.
  4. There are three main methods implemented:
  5. 1. Interpolation Method: This method uses simple linear interpolation to remove artifacts
  6. from the ECG signal, i.e. linearly interpolated over the R-peaks found.
  7. 2. Template Subtraction Method: This method creates a QRS template based on the
  8. R-peaks found and subtracts it from the ECG signal, using a linear fit to adjust
  9. the template to the raw data.
  10. 3. Singular Value Decomposition (SVD) Method: This method uses SVD to decompose
  11. the raw contaminated signal into components, allowing for the removal of
  12. artifacts by selecting the most significant components.
  13. The detection of R-peaks is done using either only the LFP channel or an
  14. additional external ECG channel synchronized with the LFP channel.
  15. The R-peaks are detected with the function scipy.signal.find_peaks, with
  16. a threshold-based at 95th percentile of the signal amplitude, and a minimum
  17. distance of 500 ms between peaks. The polarity of the R-peaks is determined
  18. by comparing the mean amplitude of the detected peaks in both orientations
  19. (positive and negative). The orientation with the higher mean absolute amplitude
  20. is chosen as the orientation of the QRS complexes.
  21. There is the possibility to manually override some parameters for R-peak detection,
  22. such as the polarity, start and end times for cleaning, periods to exclude peaks from,
  23. and the detection threshold.
  24. Reference paper:
  25. Stam M.J., van Wijk B.C.M., Sharma P., Beudel M., Piña-Fuentes D.A.,
  26. de Bie R.M.A., Schuurman P.R., Neumann W.J., Buijink A.W.G. (2023)
  27. A comparison of methods to suppress electrocardiographic artifacts in local
  28. field potential recordings. Clin Neurophysiol. doi: 10.1016/j.clinph.2022.11.011
  29. This module contains the following functions:
  30. - `find_r_peaks`: Finds R-peaks in the LFP channel using either only the LFP channel itself,
  31. or an additional external ECG channel synchronized with the LFP channel.
  32. - `find_r_peaks_based_on_ext_ecg`: Finds R-peaks in the LFP channel based on an external ECG channel.
  33. - `find_r_peaks_in_lfp_channel`: Finds R-peaks in the LFP channel using the LFP channel itself.
  34. - `start_ecg_cleaning_interpolation`: Starts the ECG cleaning process using the interpolation method.
  35. - `clean_ecg_interpolation`: Cleans the ECG signal using the interpolation method.
  36. - `start_ecg_cleaning_template_sub`: Starts the ECG cleaning process using the template subtraction method.
  37. - `clean_ecg_template_sub`: Cleans the ECG signal using the template subtraction method.
  38. - `start_ecg_cleaning_svd`: Starts the ECG cleaning process using the SVD method.
  39. - `clean_ecg_svd`: Cleans the ECG signal using the SVD method.
  40. """
  41. #######################################################################
  42. # ECG CLEANING FUNCTIONS #
  43. #######################################################################
  44. from PyQt5.QtWidgets import QMessageBox, QDialog, QVBoxLayout, QLabel, QLineEdit, QComboBox, QHBoxLayout, QPushButton
  45. import numpy as np
  46. import scipy
  47. import scipy.signal
  48. import mne
  49. import ast
  50. from functions.utils import get_start_end_times, find_similar_sample
  51. from functions.classes import PlotWindow
  52. def manual_override(self):
  53. # Create dialog box for manual parameter input
  54. dialog = QDialog()
  55. dialog.setWindowTitle("Set R-peak Detection Parameters")
  56. layout = QVBoxLayout()
  57. # Create combo box for polarity selection
  58. polarity_layout = QHBoxLayout()
  59. polarity_label = QLabel("R-peak polarity (LFP):")
  60. combo_polarity = QComboBox()
  61. combo_polarity.addItems(["None", "Down", "Up"])
  62. # combo_polarity.setCurrentText("None")
  63. combo_polarity.setCurrentText(self.r_peak_polarity_lfp)
  64. polarity_layout.addWidget(polarity_label)
  65. polarity_layout.addWidget(combo_polarity)
  66. layout.addLayout(polarity_layout)
  67. # Line edits to adapt start/end cleaning times (avoid stimulation pulses)
  68. start_layout = QHBoxLayout()
  69. start_label = QLabel("Start cleaning time (s):")
  70. start_edit = QLineEdit()
  71. # start_edit.setPlaceholderText("None")
  72. start_edit.setText(str(self.start_cleaning_time))
  73. start_layout.addWidget(start_label)
  74. start_layout.addWidget(start_edit)
  75. layout.addLayout(start_layout)
  76. end_layout = QHBoxLayout()
  77. end_label = QLabel("End cleaning time (s):")
  78. end_edit = QLineEdit()
  79. # end_edit.setPlaceholderText("None")
  80. end_edit.setText(str(self.end_cleaning_time))
  81. end_layout.addWidget(end_label)
  82. end_layout.addWidget(end_edit)
  83. layout.addLayout(end_layout)
  84. # Line edit for periods to exclude peaks from (avoid strong artifacts)
  85. exclusion_layout = QHBoxLayout()
  86. exclusion_label = QLabel("Exclusion period (s) as tuples:")
  87. exclusion_edit = QLineEdit()
  88. # exclusion_edit.setPlaceholderText("None")
  89. exclusion_edit.setText(str(self.exclusion_periods))
  90. exclusion_layout.addWidget(exclusion_label)
  91. exclusion_layout.addWidget(exclusion_edit)
  92. layout.addLayout(exclusion_layout)
  93. # Combo box for R-peak detection threshold. Default to 95%
  94. threshold_layout = QHBoxLayout()
  95. threshold_label = QLabel("R-peak detection threshold (%):")
  96. combo_r_peak_threshold = QComboBox()
  97. combo_r_peak_threshold.addItems(["95", "96", "97", "98", "99"])
  98. combo_r_peak_threshold.setCurrentText(str(self.detection_threshold))
  99. threshold_layout.addWidget(threshold_label)
  100. threshold_layout.addWidget(combo_r_peak_threshold)
  101. layout.addLayout(threshold_layout)
  102. # OK / Cancel buttons
  103. button_layout = QHBoxLayout()
  104. ok_button = QPushButton("OK")
  105. cancel_button = QPushButton("Cancel")
  106. button_layout.addWidget(ok_button)
  107. button_layout.addWidget(cancel_button)
  108. layout.addLayout(button_layout)
  109. dialog.setLayout(layout)
  110. # Button connections
  111. def on_ok():
  112. try:
  113. r_peak_polarity_lfp = combo_polarity.currentText()
  114. if r_peak_polarity_lfp == "None":
  115. r_peak_polarity_lfp = None
  116. start_text = start_edit.text().strip()
  117. if start_text == "None":
  118. start_text = None
  119. start_cleaning_time = (float(start_text) if start_text is not None else None)
  120. end_text = end_edit.text().strip()
  121. if end_text == "None":
  122. end_text = None
  123. end_cleaning_time = (float(end_text) if end_text is not None else None)
  124. exclusion_text = exclusion_edit.text().strip()
  125. if exclusion_text == "None":
  126. exclusion_text = None
  127. if exclusion_text:
  128. try:
  129. exclusion_periods = ast.literal_eval(exclusion_text)
  130. # Check it's a list of tuples of floats
  131. if (isinstance(exclusion_periods, list) and
  132. all(isinstance(t, tuple) and len(t) == 2 for t in exclusion_periods)):
  133. exclusion_periods = [(float(a), float(b)) for a, b in exclusion_periods]
  134. else:
  135. raise ValueError
  136. except Exception:
  137. QMessageBox.warning(dialog, "Invalid Input",
  138. "Please enter exclusion periods in the format [(6.2, 8.8), (101.45, 123.65)].")
  139. return
  140. else:
  141. exclusion_periods = None
  142. # store as instance attributes for later use
  143. self.r_peak_polarity_lfp = r_peak_polarity_lfp
  144. self.start_cleaning_time = start_cleaning_time
  145. self.end_cleaning_time = end_cleaning_time
  146. self.exclusion_periods = exclusion_periods
  147. self.detection_threshold = int(combo_r_peak_threshold.currentText() or 95)
  148. dialog.accept()
  149. except ValueError:
  150. QMessageBox.warning(dialog, "Invalid Input", "Please enter valid numbers for times.")
  151. ok_button.clicked.connect(on_ok)
  152. cancel_button.clicked.connect(dialog.reject)
  153. # Show dialog
  154. if dialog.exec_() == QDialog.Accepted:
  155. print(f"Polarity: {self.r_peak_polarity_lfp}, "
  156. f"Start: {self.start_cleaning_time}, End: {self.end_cleaning_time}, "
  157. f"Ignoring: {self.exclusion_periods}, Threshold: {self.detection_threshold}%")
  158. else:
  159. return # User canceled
  160. ###############################################################################
  161. #######################################################################
  162. ######### FINDING R-PEAKS #########
  163. #######################################################################
  164. def find_r_peaks(self):
  165. """
  166. Find R-peaks in the LFP channel using either only the LFP channel itself,
  167. or an additional external ECG channel synchronized with the LFP channel.
  168. This function will be called when the user clicks the "Find R-peaks" button.
  169. """
  170. if self.config['NoSync'] == True:
  171. full_data = self.dataset_intra.raw_data.get_data()[
  172. self.dataset_intra.selected_channel_index_ecg
  173. ]
  174. else:
  175. full_data = self.dataset_intra.synced_data.get_data()[
  176. self.dataset_intra.selected_channel_index_ecg
  177. ]
  178. nb_points = len(full_data)
  179. times = np.arange(nb_points) * (1.0 / self.dataset_intra.sf)
  180. if self.dataset_extra.selected_channel_name_ecg is not None:
  181. # an external ECG channel was selected and will therefore be used for R-peak detection
  182. # Use external ECG channel to find R-peaks
  183. final_peaks, polarity, mean_epoch = find_r_peaks_based_on_ext_ecg(
  184. self, full_data, times, self.detection_threshold,
  185. window_artifact = [-0.5, 0.5]
  186. )
  187. # Add a message window to inform the user about the result of the peak detection:
  188. QMessageBox.information(
  189. self,
  190. "R-Peak Detection",
  191. f"{len(final_peaks)} R-peaks have been detected in the LFP channel using the ECG channel provided.",
  192. QMessageBox.Ok
  193. )
  194. else:
  195. # Use only LFP channel to find R-peaks
  196. final_peaks, polarity, mean_epoch = find_r_peaks_in_lfp_channel(
  197. self, full_data, times, self.detection_threshold,
  198. window = [-0.5, 0.5]
  199. )
  200. # Add a message window to inform the user about the result of the peak detection:
  201. QMessageBox.information(
  202. self,
  203. "R-Peak Detection",
  204. f"{len(final_peaks)} R-peaks have been detected in the LFP channel alone, using a threshold of {self.detection_threshold}%.",
  205. QMessageBox.Ok
  206. )
  207. # Check if peak detection was successful
  208. if len(final_peaks) == 0:
  209. # Disable cleaning buttons since we don't have valid peaks
  210. self.btn_start_ecg_cleaning_interpolation.setEnabled(False)
  211. self.btn_start_ecg_cleaning_template_sub.setEnabled(False)
  212. self.btn_start_ecg_cleaning_svd.setEnabled(False)
  213. QMessageBox.warning(
  214. self,
  215. "R-Peak Detection",
  216. f"R-peak detection failed, no peaks were detected",
  217. QMessageBox.Ok
  218. )
  219. return
  220. else:
  221. self.final_peaks = final_peaks
  222. self.polarity = polarity
  223. self.mean_epoch = mean_epoch
  224. self.btn_start_ecg_cleaning_interpolation.setEnabled(True)
  225. self.btn_start_ecg_cleaning_template_sub.setEnabled(True)
  226. self.btn_start_ecg_cleaning_svd.setEnabled(True)
  227. def find_r_peaks_based_on_ext_ecg(
  228. self,
  229. full_data: np.ndarray,
  230. times: np.ndarray,
  231. detection_threshold: int,
  232. window_artifact: list = [-0.5, 0.5]
  233. ):
  234. #### PREDETERMINE R-PEAKS TIMESTAMPS USING ECG CHANNEL ####
  235. """
  236. The externally recorded ECG signal is used to predetermine
  237. the timestamps of the R-peaks. The ECG signal is z-scored ((x-l)/r)
  238. over the entire recording and the function findpeaks was used to search
  239. for R-peaks with a specific height (95th percentile) and at a
  240. specific inter-peak distance (minimally 500 ms).
  241. The algorithm accounts for negative QRS complexes
  242. by repeating this procedure after multiplying the signal with
  243. -1. For both orientations of the LFP signal, the values of the
  244. peaks were averaged and the peaks with the highest mean
  245. determined the orientation of the QRS complexes.
  246. """
  247. # searches for stimulation pulses to avoid them during R-peak detection
  248. last_peak_start, first_peak_end = get_start_end_times(full_data, times)
  249. # Override with user-defined times if provided
  250. if self.start_cleaning_time is not None:
  251. last_peak_start = self.start_cleaning_time
  252. if self.end_cleaning_time is not None:
  253. first_peak_end = self.end_cleaning_time
  254. # Validate crop range and correct to default if invalid
  255. if last_peak_start >= first_peak_end:
  256. # Use a reasonable portion of the signal (skip first and last 10 seconds)
  257. last_peak_start = max(10.0, times[0] + 10.0)
  258. first_peak_end = min(times[-1] - 10.0, times[-1] - 10.0)
  259. data_extra = self.dataset_extra.synced_data.get_data()[
  260. self.dataset_extra.selected_channel_index_ecg
  261. ]
  262. # Apply 0.5 Hz-60Hz band-pass filter to ECG data. The low-pass can be changed in the config file.
  263. # b, a = scipy.signal.butter(1, 0.05, "highpass")
  264. b, a = scipy.signal.butter(1, 0.5, "highpass", fs = self.dataset_extra.sf)
  265. detrended_data = scipy.signal.filtfilt(b, a, data_extra)
  266. b2, a2 = scipy.signal.butter(
  267. N=4, # Filter order
  268. Wn=self.config["EcgLowpassFilter"],
  269. btype="lowpass",
  270. fs=self.dataset_extra.sf
  271. )
  272. ecg_data = scipy.signal.filtfilt(b2, a2, detrended_data)
  273. nb_points = len(ecg_data)
  274. timescale_extra = np.arange(nb_points) * (1.0 / self.dataset_extra.sf)
  275. # timescale_extra = np.linspace(
  276. # 0,
  277. # self.dataset_extra.synced_data.get_data().shape[1]/self.dataset_extra.sf,
  278. # self.dataset_extra.synced_data.get_data().shape[1]
  279. # )
  280. # end_time_extra = self.dataset_extra.synced_data.get_data().shape[1]/self.dataset_extra.sf
  281. # timescale_extra = np.arange(0, end_time_extra, 1/self.dataset_extra.sf)
  282. # Z-score the ECG signal
  283. ecg_z = (ecg_data - np.mean(ecg_data)) / np.std(ecg_data)
  284. # Define peak detection params in the ECG channel
  285. threshold = np.percentile(ecg_z, detection_threshold) # Convert to percentile for robustness
  286. min_distance_samples = int(0.5 * self.dataset_extra.sf) # 500 ms in samples
  287. # Detect peaks in original signal
  288. peaks_pos, _ = scipy.signal.find_peaks(
  289. ecg_z,
  290. height=threshold,
  291. distance=min_distance_samples
  292. )
  293. # Detect peaks in inverted signal
  294. peaks_neg, __ = scipy.signal.find_peaks(
  295. -ecg_z,
  296. height=threshold,
  297. distance=min_distance_samples
  298. )
  299. # Select better polarity based on the number of peaks detected
  300. if len(peaks_pos) >= len(peaks_neg):
  301. chosen_peaks = peaks_pos
  302. polarity_ecg = 'Positive'
  303. else:
  304. chosen_peaks = peaks_neg
  305. polarity_ecg = 'Negative'
  306. # Check if any peaks were detected in the external ECG
  307. if len(chosen_peaks) == 0:
  308. # Try with lower thresholds
  309. for fallback_threshold in [90, 85, 80, 75, 70]:
  310. threshold_fallback = np.percentile(ecg_z, fallback_threshold)
  311. peaks_pos_fb, _ = scipy.signal.find_peaks(
  312. ecg_z, height=threshold_fallback, distance=min_distance_samples
  313. )
  314. peaks_neg_fb, _ = scipy.signal.find_peaks(
  315. -ecg_z, height=threshold_fallback, distance=min_distance_samples
  316. )
  317. if len(peaks_pos_fb) > 0 or len(peaks_neg_fb) > 0:
  318. chosen_peaks = peaks_pos_fb if len(peaks_pos_fb) >= len(peaks_neg_fb) else peaks_neg_fb
  319. polarity_ecg = 'Positive' if len(peaks_pos_fb) >= len(peaks_neg_fb) else 'Negative'
  320. break
  321. # If still no peaks found
  322. if len(chosen_peaks) == 0:
  323. print("No peaks detected in external ECG with any threshold!")
  324. QMessageBox.warning(
  325. self,
  326. "External ECG Peak Detection Failed",
  327. "No R-peaks could be detected in the external ECG channel. Please check if:\n"
  328. "1. The correct ECG channel is selected\n"
  329. "2. The ECG signal quality is sufficient\n"
  330. "3. The synchronization between recordings is correct",
  331. QMessageBox.Ok
  332. )
  333. return np.array([]), 'Unknown', np.array([])
  334. # Convert R-peaks from ECG samples to seconds
  335. r_peak_times_sec = chosen_peaks / self.dataset_extra.sf
  336. # Convert times to LFP sample indices
  337. r_peaks_lfp_idx = np.round(r_peak_times_sec * self.dataset_intra.sf).astype(int)
  338. # Look for R-peaks in LFP channel based on predetermined timestamps:
  339. window_around_peaks = 20 # ±20 LFP samples = 80ms at 250 Hz
  340. max_peaks = []
  341. min_peaks = []
  342. for idx in r_peaks_lfp_idx:
  343. start = max(idx - window_around_peaks, 0)
  344. end = min(idx + window_around_peaks + 1, len(full_data))
  345. segment = full_data[start:end]
  346. if len(segment) > 0:
  347. max_peaks.append(np.max(segment))
  348. min_peaks.append(np.min(segment))
  349. # Calculate mean absolute values
  350. mean_abs_max = np.nanmean(np.abs(max_peaks)) if max_peaks else 0
  351. mean_abs_min = np.nanmean(np.abs(min_peaks)) if min_peaks else 0
  352. # Override polarity if user specified
  353. if self.r_peak_polarity_lfp is not None:
  354. polarity = self.r_peak_polarity_lfp
  355. if polarity == 'Up':
  356. mean_abs_max = 2
  357. mean_abs_min = 1
  358. else:
  359. mean_abs_max = 1
  360. mean_abs_min = 2
  361. # Choose the orientation with the higher mean absolute amplitude in the LFP channel
  362. lfp_peak_indices = []
  363. polarity = None
  364. if mean_abs_max >= mean_abs_min:
  365. polarity = 'Up'
  366. for idx in r_peaks_lfp_idx:
  367. start = idx - window_around_peaks
  368. end = idx + window_around_peaks + 1
  369. # Check signal boundaries
  370. if start < 0 or end > len(full_data):
  371. continue
  372. segment = full_data[start:end]
  373. if np.isnan(segment).any():
  374. continue
  375. local_max_idx = np.argmax(segment)
  376. peak_global_idx = start + local_max_idx
  377. lfp_peak_indices.append(peak_global_idx)
  378. else:
  379. polarity = 'Down'
  380. for idx in r_peaks_lfp_idx:
  381. start = idx - window_around_peaks
  382. end = idx + window_around_peaks + 1
  383. # Check signal boundaries
  384. if start < 0 or end > len(full_data):
  385. continue
  386. segment = full_data[start:end]
  387. if np.isnan(segment).any():
  388. continue
  389. local_min_idx = np.argmin(segment)
  390. peak_global_idx = start + local_min_idx
  391. lfp_peak_indices.append(peak_global_idx)
  392. # Remove peaks that are before last_peak_start and after first_peak_end:
  393. initial_lfp_count = len(lfp_peak_indices)
  394. lfp_peak_indices = [
  395. p for p in lfp_peak_indices if (
  396. p >= int(last_peak_start * self.dataset_intra.sf) and
  397. p <= int(first_peak_end * self.dataset_intra.sf)
  398. )]
  399. # Also remove peaks in exclusion periods if provided
  400. if self.exclusion_periods is not None:
  401. lfp_peak_indices = [
  402. p for p in lfp_peak_indices
  403. if not any(start <= (p / self.dataset_intra.sf) <= end for start, end in self.exclusion_periods)
  404. ]
  405. # Check if we have sufficient LFP peaks
  406. if len(lfp_peak_indices) == 0:
  407. print("No LFP peaks remain after time filtering!")
  408. QMessageBox.warning(
  409. self,
  410. "LFP Peak Detection Failed",
  411. "No valid R-peaks found in the LFP channel after applying time constraints. "
  412. "This might be due to timing issues between the external ECG and LFP recordings.",
  413. QMessageBox.Ok
  414. )
  415. return np.array([]), polarity, np.array([])
  416. # Plot detected R-peaks in external
  417. # first scale the ECG data to match the amplitude of the LFP channel for better visualization
  418. ptp_lfp = np.ptp(full_data) / 2
  419. ptp_ecg = np.ptp(ecg_data)
  420. factor = ptp_lfp/ptp_ecg
  421. ecg_data_scaled = ecg_data * factor
  422. self.canvas_detected_peaks.setEnabled(True)
  423. self.toolbar_detected_peaks.setEnabled(True)
  424. self.ax_detected_peaks.clear()
  425. self.ax_detected_peaks.set_title('Detected Peaks')
  426. self.ax_detected_peaks.plot(timescale_extra, ecg_data_scaled, label='Raw ECG', alpha=0.1)
  427. self.ax_detected_peaks.plot(timescale_extra[chosen_peaks], ecg_data_scaled[chosen_peaks], 'ro', label='Detected Peaks', alpha=0.1)
  428. self.canvas_detected_peaks.draw()
  429. # Plot detected R-peaks in intracranial
  430. self.ax_detected_peaks.plot(times, full_data, label='Raw LFP', color='black')
  431. self.ax_detected_peaks.plot(
  432. np.array(times)[lfp_peak_indices],
  433. np.array(full_data)[lfp_peak_indices],
  434. 'ro', label='LFP Peaks'
  435. )
  436. self.ax_detected_peaks.legend()
  437. self.canvas_detected_peaks.draw()
  438. # Estimate HR and display it in label
  439. peak_intervals = np.diff(lfp_peak_indices) / self.dataset_intra.sf # Convert to seconds
  440. hr = 60 / np.mean(peak_intervals) if len(peak_intervals) > 0 else 0
  441. self.label_heart_rate_lfp.setText(f'Heart rate: {hr:.1f} bpm')
  442. # Define epoch window
  443. sf_lfp = self.dataset_intra.sf
  444. pre_samples = int(abs(window_artifact[0]) * sf_lfp)
  445. post_samples = int(window_artifact[1] * sf_lfp)
  446. epoch_length = pre_samples + post_samples # Total length of each epoch
  447. # time = np.linspace(window_artifact[0], window_artifact[1], epoch_length) # Time in seconds
  448. time_epoch = np.arange(-pre_samples, post_samples) / sf_lfp # Time in seconds
  449. epochs = [] # Store extracted heartbeats
  450. for peak in lfp_peak_indices:
  451. start = peak - pre_samples
  452. end = peak + post_samples
  453. if np.isnan(full_data[start:end]).any():
  454. print(f"Skipping peak at {peak} due to NaNs in the epoch")
  455. continue
  456. if (
  457. start >= last_peak_start*self.dataset_intra.sf
  458. ) and (
  459. end < first_peak_end*self.dataset_intra.sf
  460. ): # Ensure we don't take the peaks that are in the stimulation pulses
  461. epochs.append(full_data[start:end])
  462. epochs = np.array(epochs)
  463. # Check if we have valid epochs
  464. if len(epochs) == 0:
  465. print("No valid epochs extracted from LFP peaks!")
  466. QMessageBox.warning(
  467. self,
  468. "LFP Epoch Extraction Failed",
  469. "No valid epochs could be extracted from the detected LFP peaks. "
  470. "This might be due to peaks being too close to signal boundaries.",
  471. QMessageBox.Ok
  472. )
  473. return np.array([]), polarity, np.array([])
  474. # Compute average heartbeat template
  475. mean_epoch = np.nanmean(epochs, axis=0)
  476. # Additional check for mean_epoch validity
  477. if np.isnan(mean_epoch).all() or len(mean_epoch) == 0:
  478. print("LFP mean epoch is invalid (all NaN or empty)!")
  479. QMessageBox.warning(
  480. self,
  481. "LFP Template Creation Failed",
  482. "Could not create a valid ECG template from the detected LFP peaks.",
  483. QMessageBox.Ok
  484. )
  485. return np.array([]), polarity, np.array([])
  486. # Plot the detected ECG epochs
  487. self.canvas_ecg_artifact.setEnabled(True)
  488. self.toolbar_ecg_artifact.setEnabled(True)
  489. self.ax_ecg_artifact.clear()
  490. self.ax_ecg_artifact.set_title("Detected ECG epochs")
  491. for epoch in epochs:
  492. self.ax_ecg_artifact.plot(time_epoch, epoch, color='gray', alpha=0.3)
  493. self.ax_ecg_artifact.plot(
  494. time_epoch,
  495. mean_epoch,
  496. color='black',
  497. linewidth=2,
  498. label='Average ECG Template'
  499. )
  500. self.ax_ecg_artifact.set_xlabel("Time (s)")
  501. self.ax_ecg_artifact.set_ylabel("Amplitude")
  502. self.ax_ecg_artifact.legend()
  503. self.canvas_ecg_artifact.draw()
  504. ## store start and end times of cleaning
  505. self.after_first_stim_pulses = last_peak_start
  506. self.before_last_stim_pulses = first_peak_end
  507. return lfp_peak_indices, polarity, mean_epoch
  508. def find_r_peaks_in_lfp_channel(
  509. self,
  510. full_data: np.ndarray,
  511. times: np.ndarray,
  512. detection_threshold: int,
  513. window = [-0.5, 0.5]
  514. ):
  515. """
  516. The LFP signal itself is used to find the R-peaks. It is cropped in 1s
  517. segments and the function findpeaks was used to search for R-peaks in each segment.
  518. This is repeated with signal multiplied by -1 to account for negative QRS complexes.
  519. The polarity for which more peaks were detected is chosen as the orientation
  520. of the QRS complexes in LFP channel and a second-pass detection is performed:
  521. epochs are created around each detected R-peak (-0.5 - +0.5s around) and averaged
  522. to generate a first QRS template.
  523. Template correlation is used to refine the R-peak locations by searching
  524. for the local maxima within each epoch that has the highest correlation
  525. with the mean QRS template. These local maxima are used to create a better
  526. QRS template by averaging epochs around these new R-peak locations.
  527. Template correlation is applied a second time using this improved QRS template
  528. to finalize R-peak locations.
  529. """
  530. sf_lfp = round(self.dataset_intra.sf)
  531. # Define period to look for peaks (avoid stimulation pulses if present):
  532. if self.config['NoSync'] == True:
  533. start_idx = 0
  534. end_idx = len(full_data)
  535. # Override with user-defined times if provided
  536. if self.start_cleaning_time is not None:
  537. start_idx = int(self.start_cleaning_time * sf_lfp)
  538. if self.end_cleaning_time is not None:
  539. end_idx = int(self.end_cleaning_time * sf_lfp)
  540. last_peak_start = start_idx / sf_lfp
  541. first_peak_end = end_idx / sf_lfp
  542. else:
  543. last_peak_start, first_peak_end = get_start_end_times(full_data, times)
  544. # Override with user-defined times if provided
  545. if self.start_cleaning_time is not None:
  546. last_peak_start = self.start_cleaning_time
  547. if self.end_cleaning_time is not None:
  548. first_peak_end = self.end_cleaning_time
  549. # Validate crop range
  550. if last_peak_start >= first_peak_end:
  551. # Use a reasonable portion of the signal (skip first and last 10 seconds)
  552. last_peak_start = max(10.0, times[0] + 10.0)
  553. first_peak_end = min(times[-1] - 10.0, times[-1] - 10.0)
  554. # Calculate crop indices and validate
  555. start_idx = int(last_peak_start * sf_lfp)
  556. end_idx = int(first_peak_end * sf_lfp)
  557. # Ensure indices are within bounds
  558. start_idx = max(0, min(start_idx, len(full_data) - 1))
  559. end_idx = max(start_idx + 1, min(end_idx, len(full_data)))
  560. cropped_data = full_data[start_idx:end_idx]
  561. # Check if cropped data is valid
  562. if len(cropped_data) == 0:
  563. cropped_data = full_data
  564. last_peak_start = times[0]
  565. first_peak_end = times[-1]
  566. #ecg = {'proc': {}}
  567. ns = len(cropped_data) # Number of samples in the cropped data
  568. # Segment the signal into overlapping windows
  569. dwindow = int(round(sf_lfp)) # 1s window
  570. dmove = sf_lfp # 1s step
  571. n_segments = (ns - dwindow) // dmove + 1
  572. detected_peaks_positive = [] # Store peak indices in the original timescale of the cropped_data
  573. x = np.array(
  574. [
  575. cropped_data[
  576. i * dmove: i * dmove + dwindow
  577. ] for i in range(n_segments) if i * dmove + dwindow <= ns
  578. ])
  579. # Loop through each segment and find peaks
  580. for i in range(n_segments):
  581. segment = x[i]
  582. # Skip segment if it contains any NaNs
  583. if np.isnan(segment).any():
  584. continue
  585. for threshold_pct in [80, 70, 60, 50]:
  586. peaks, _ = scipy.signal.find_peaks(
  587. segment, height=np.percentile(segment, threshold_pct), distance=sf_lfp//3
  588. )
  589. if len(peaks) > 0:
  590. break
  591. real_peaks = peaks + (i * dmove) # Convert to original timescale
  592. detected_peaks_positive.extend(real_peaks)
  593. # Repeat with reverted signal to find negative peaks
  594. detected_peaks_negative = [] # Store peak indices in the original timescale of the cropped_data
  595. x_neg = np.array(
  596. [
  597. -cropped_data[
  598. i * dmove: i * dmove + dwindow
  599. ] for i in range(n_segments) if i * dmove + dwindow <= ns
  600. ])
  601. for i in range(n_segments):
  602. segment = x_neg[i]
  603. # 6. Skip segment if it contains any NaNs
  604. if np.isnan(segment).any():
  605. continue
  606. # Try different thresholds if 90th percentile fails
  607. for threshold_pct in [80, 70, 60, 50]:
  608. peaks, _ = scipy.signal.find_peaks(
  609. segment, height=np.percentile(segment, threshold_pct), distance=sf_lfp//3
  610. )
  611. if len(peaks) > 0:
  612. break
  613. real_peaks = peaks + (i * dmove) # Convert to original timescale
  614. detected_peaks_negative.extend(real_peaks)
  615. # Find which set of peaks has more elements
  616. if len(detected_peaks_positive) >= len(detected_peaks_negative):
  617. detected_peaks = detected_peaks_positive
  618. polarity = 'Up'
  619. else:
  620. detected_peaks = detected_peaks_negative
  621. polarity = 'Down'
  622. # Override polarity if user specified
  623. if self.r_peak_polarity_lfp is not None:
  624. polarity = self.r_peak_polarity_lfp
  625. print(f"Overriding detected polarity to user-specified: {polarity}")
  626. if polarity == 'Up':
  627. detected_peaks = detected_peaks_positive
  628. else:
  629. detected_peaks = detected_peaks_negative
  630. detected_peaks = np.array(detected_peaks)
  631. # Check if any peaks were detected
  632. if len(detected_peaks) == 0:
  633. # Try the simpler fallback method
  634. detected_peaks, polarity = simple_peak_detection_fallback(cropped_data, sf_lfp)
  635. if len(detected_peaks) == 0:
  636. print("No peaks detected with any method!")
  637. QMessageBox.warning(
  638. self,
  639. "Peak Detection Failed",
  640. "No R-peaks could be detected in the signal with any method. Please check if:\n"
  641. "1. The signal contains visible ECG artifacts\n"
  642. "2. The channel selection is correct\n"
  643. "3. The signal amplitude is sufficient\n"
  644. "4. Try adjusting the threshold percentage",
  645. QMessageBox.Ok
  646. )
  647. # Return empty arrays to prevent crashes
  648. return np.array([]), 'Unknown', np.array([])
  649. # Define epoch window
  650. pre_samples = int(abs(window[0]) * sf_lfp)
  651. post_samples = int(window[1] * sf_lfp)
  652. #epoch_length = pre_samples + post_samples # Total length of each epoch
  653. epochs = [] # Store extracted heartbeats
  654. for peak in detected_peaks:
  655. start = peak - pre_samples
  656. end = peak + post_samples
  657. if start >= 0 and end < ns: # Ensure we don't go out of bounds
  658. epoch = cropped_data[start:end]
  659. if np.isnan(epoch).any():
  660. continue
  661. else:
  662. epochs.append(epoch)
  663. epochs = np.array(epochs)
  664. # Check if we have valid epochs
  665. if len(epochs) == 0:
  666. print("No valid epochs extracted from detected peaks!")
  667. QMessageBox.warning(
  668. self,
  669. "Epoch Extraction Failed",
  670. "No valid epochs could be extracted from the detected peaks. This might be due to peaks being too close to signal boundaries.",
  671. QMessageBox.Ok
  672. )
  673. # Return empty arrays to prevent crashes
  674. return np.array([]), polarity, np.array([])
  675. # Compute average heartbeat template
  676. mean_epoch = np.nanmean(epochs, axis=0)
  677. # Additional check for mean_epoch validity
  678. if np.isnan(mean_epoch).all() or len(mean_epoch) == 0:
  679. print("Mean epoch is invalid (all NaN or empty)!")
  680. QMessageBox.warning(
  681. self,
  682. "Template Creation Failed",
  683. "Could not create a valid ECG template from the detected peaks.",
  684. QMessageBox.Ok
  685. )
  686. return np.array([]), polarity, np.array([])
  687. # Temporal correlation for ECG detection
  688. # adapt in case NaNs are present:
  689. if np.isnan(cropped_data).any():
  690. cropped_data_clean = np.nan_to_num(cropped_data, nan=0.0)
  691. r = np.correlate(cropped_data_clean, mean_epoch, mode='same')
  692. else:
  693. r = np.correlate(cropped_data, mean_epoch, mode='same')
  694. threshold = np.percentile(r, 95)
  695. detected_peaks, _ = scipy.signal.find_peaks(
  696. r, height=threshold, distance=sf_lfp//2
  697. )
  698. # Second pass for refining detection
  699. refined_template = np.nanmean(
  700. [cropped_data[
  701. p - dwindow//2 : p + dwindow//2
  702. ] for p in detected_peaks if p - dwindow//2 > 0 and p + dwindow//2 < ns
  703. ], axis=0
  704. )
  705. if np.isnan(cropped_data).any():
  706. cropped_data_clean = np.nan_to_num(cropped_data, nan=0.0)
  707. r2 = np.correlate(cropped_data_clean, refined_template, mode='same')
  708. else:
  709. r2 = np.correlate(cropped_data, refined_template, mode='same')
  710. threshold2 = np.percentile(r2, detection_threshold)
  711. final_peaks, _ = scipy.signal.find_peaks(
  712. r2, height=threshold2, distance=sf_lfp//2
  713. )
  714. # Adjust the final peaks to the original data scale
  715. final_peaks = final_peaks + start_idx
  716. # Remove peaks in exclusion periods if provided
  717. if self.exclusion_periods is not None:
  718. final_peaks = [
  719. p for p in final_peaks
  720. if not any(start <= (p / self.dataset_intra.sf) <= end for start, end in self.exclusion_periods)
  721. ]
  722. # check that no peak is close to NaN values, if yes, remove them
  723. peaks_to_remove = []
  724. for peak in final_peaks:
  725. start = peak - pre_samples
  726. end = peak + post_samples
  727. if start < 0 or end >= len(full_data):
  728. continue
  729. if np.isnan(full_data[start:end]).any():
  730. peaks_to_remove.append(peak)
  731. final_peaks = [p for p in final_peaks if p not in peaks_to_remove]
  732. # plot the detected peaks
  733. self.canvas_detected_peaks.setEnabled(True)
  734. self.toolbar_detected_peaks.setEnabled(True)
  735. self.ax_detected_peaks.clear()
  736. self.ax_detected_peaks.set_title('Detected Peaks')
  737. self.ax_detected_peaks.plot(times, full_data, label='Raw LFP', color='black')
  738. self.ax_detected_peaks.plot(
  739. np.array(times)[final_peaks],
  740. np.array(full_data)[final_peaks],
  741. 'ro', label='Detected Peaks'
  742. )
  743. self.canvas_detected_peaks.draw()
  744. # Estimate HR
  745. peak_intervals = np.diff(final_peaks) / sf_lfp # Convert to seconds
  746. hr = 60 / np.mean(peak_intervals) if len(peak_intervals) > 0 else 0
  747. self.label_heart_rate_lfp.setText(f'Heart rate: {hr} bpm')
  748. ## store start and end times of cleaning
  749. self.after_first_stim_pulses = last_peak_start
  750. self.before_last_stim_pulses = first_peak_end
  751. return final_peaks, polarity, mean_epoch
  752. #######################################################################
  753. ######### INTERPOLATION METHOD #########
  754. #######################################################################
  755. def start_ecg_cleaning_interpolation(self):
  756. self.ax_ecg_clean.clear()
  757. self.ax_ecg_artifact.clear()
  758. self.ax_psd.clear()
  759. """Start the ECG cleaning process using the interpolation method from Perceive toolbox."""
  760. try:
  761. clean_ecg_interpolation(self)
  762. except Exception as e:
  763. QMessageBox.critical(self, "Error", f"Failed to clean ECG: {e}")
  764. def clean_ecg_interpolation(self):
  765. if self.config['NoSync'] == True:
  766. full_data = self.dataset_intra.raw_data.get_data()[
  767. self.dataset_intra.selected_channel_index_ecg
  768. ]
  769. else:
  770. full_data = self.dataset_intra.synced_data.get_data()[
  771. self.dataset_intra.selected_channel_index_ecg
  772. ]
  773. nb_points = len(full_data)
  774. times = np.arange(nb_points) * (1.0 / self.dataset_intra.sf)
  775. ############################################################################
  776. # prepare a copy of the full data to store the cleaned data
  777. clean_data = np.copy(full_data)
  778. ns = len(full_data)
  779. #### INTERPOLATE DATA AT EACH R-PEAK FOUND ####
  780. # Remove artifacts (simple interpolation)
  781. for p in self.final_peaks:
  782. clean_data[max(0, p - 5): min(ns, p + 5)] = np.nan # NaN out artifacts
  783. clean_data = np.interp(
  784. np.arange(ns), np.arange(ns)[~np.isnan(clean_data)],
  785. clean_data[~np.isnan(clean_data)]
  786. )
  787. if self.dataset_intra.selected_channel_index_ecg == 0:
  788. self.dataset_intra.cleaned_ecg_left = clean_data
  789. elif self.dataset_intra.selected_channel_index_ecg == 1:
  790. self.dataset_intra.cleaned_ecg_right = clean_data
  791. # plot an overlap of the raw and cleaned data
  792. self.canvas_ecg_clean.setEnabled(True)
  793. self.toolbar_ecg_clean.setEnabled(True)
  794. self.ax_ecg_clean.clear()
  795. self.ax_ecg_clean.set_title("Cleaned ECG Signal")
  796. self.ax_ecg_clean.plot(times,full_data, label='Raw data')
  797. self.ax_ecg_clean.plot(times,clean_data, label='Cleaned data')
  798. self.ax_ecg_clean.set_xlabel("Time (s)")
  799. self.ax_ecg_clean.set_ylabel("Amplitude")
  800. self.ax_ecg_clean.legend()
  801. self.canvas_ecg_clean.draw()
  802. # Plot an overlap of the power spectrum using welch's method:
  803. n_fft = int(round(self.dataset_intra.sf))
  804. n_overlap=int(round(self.dataset_intra.sf)/2)
  805. # make sure that stimulation pulses are not included in the PSD calculation
  806. start_index = int(self.after_first_stim_pulses * self.dataset_intra.sf)
  807. end_index = int(self.before_last_stim_pulses * self.dataset_intra.sf)
  808. psd_raw, freqs_raw = mne.time_frequency.psd_array_welch(
  809. full_data[start_index: end_index],self.dataset_intra.sf,fmin=0,
  810. fmax=125,n_fft=n_fft,
  811. n_overlap=n_overlap)
  812. psd_clean, freqs_clean = mne.time_frequency.psd_array_welch(
  813. clean_data[start_index: end_index],self.dataset_intra.sf,fmin=0,
  814. fmax=125,n_fft=n_fft,
  815. n_overlap=n_overlap)
  816. self.canvas_psd.setEnabled(True)
  817. self.toolbar_psd.setEnabled(True)
  818. self.ax_psd.clear()
  819. self.ax_psd.plot(
  820. freqs_raw, np.log(psd_raw), color='blue', label='PSD raw channel'
  821. )
  822. self.ax_psd.plot(
  823. freqs_clean, np.log(psd_clean), color = 'orange',
  824. label='PSD cleaned channel'
  825. )
  826. self.ax_psd.legend()
  827. self.canvas_psd.draw()
  828. self.btn_confirm_cleaning.setEnabled(True) # Enable the button after cleaning
  829. #######################################################################
  830. ######### TEMPLATE SUBSTRACTION METHOD #########
  831. #######################################################################
  832. def start_ecg_cleaning_template_sub(self):
  833. self.ax_ecg_clean.clear()
  834. self.ax_ecg_artifact.clear()
  835. self.ax_psd.clear()
  836. """Start the ECG cleaning process using the template substraction method."""
  837. try:
  838. clean_ecg_template_sub(self)
  839. except Exception as e:
  840. QMessageBox.critical(self, "Error", f"Failed to clean ECG: {e}")
  841. def clean_ecg_template_sub(self):
  842. if self.config['NoSync'] == True:
  843. full_data = self.dataset_intra.raw_data.get_data()[
  844. self.dataset_intra.selected_channel_index_ecg
  845. ]
  846. else:
  847. full_data = self.dataset_intra.synced_data.get_data()[
  848. self.dataset_intra.selected_channel_index_ecg
  849. ]
  850. nb_points = len(full_data)
  851. times = np.arange(nb_points) * (1.0 / self.dataset_intra.sf)
  852. window = [-0.2, 0.2] # QRS complex window
  853. ############################################################################
  854. clean_data = np.copy(full_data)
  855. ns = len(full_data)
  856. # Create a QRS template #
  857. pre_samples = int(abs(window[0]) * self.dataset_intra.sf)
  858. post_samples = int(window[1] * self.dataset_intra.sf)
  859. epoch_length = pre_samples + post_samples # Total length of each epoch
  860. # timescale_epoch = np.linspace(
  861. # window[0], window[1], epoch_length
  862. # ) # Time in seconds
  863. timescale_epoch = np.arange(-pre_samples, post_samples) / self.dataset_intra.sf # Time in seconds
  864. epochs = [] # Store extracted heartbeats
  865. for peak in self.final_peaks:
  866. start = peak - pre_samples
  867. end = peak + post_samples
  868. epochs.append(full_data[start:end])
  869. epochs = np.array(epochs)
  870. # Compute average QRS template
  871. mean_epoch = np.nanmean(epochs, axis=0)
  872. self.canvas_ecg_artifact.setEnabled(True)
  873. self.toolbar_ecg_artifact.setEnabled(True)
  874. self.ax_ecg_artifact.clear()
  875. self.ax_ecg_artifact.set_title("Detected QRS epochs")
  876. for epoch in epochs:
  877. self.ax_ecg_artifact.plot(
  878. timescale_epoch, epoch, color='gray', alpha=0.3
  879. )
  880. self.ax_ecg_artifact.plot(
  881. timescale_epoch, mean_epoch, color='black', linewidth=2,
  882. label='Average QRS Template'
  883. )
  884. self.ax_ecg_artifact.set_xlabel("Time (s)")
  885. self.ax_ecg_artifact.set_ylabel("Amplitude")
  886. self.ax_ecg_artifact.legend()
  887. self.canvas_ecg_artifact.draw()
  888. ####################################################################
  889. pre_samples = int(abs(window[0]) * self.dataset_intra.sf)
  890. post_samples = int(window[1] * self.dataset_intra.sf)
  891. for _, peak in enumerate(self.final_peaks):
  892. raw_epoch = full_data[(peak - pre_samples):(peak + post_samples)]
  893. # Prepare design matrix for linear fit (scale + offset)
  894. X_template = np.vstack([mean_epoch, np.ones_like(mean_epoch)]).T
  895. # Solve for optimal scale (a) and offset (b) using least squares
  896. coeffs, _, _, _ = np.linalg.lstsq(X_template, raw_epoch, rcond=None)
  897. a, b = coeffs
  898. # Build fitted template
  899. fitted_template = a * mean_epoch + b
  900. # Equalize tails
  901. complex_qrs_template, start_idx, end_idx = find_similar_sample(
  902. fitted_template, tails=30
  903. )
  904. start = (peak - pre_samples) + start_idx
  905. end = (peak - pre_samples) + end_idx
  906. raw_epoch = full_data[start:end]
  907. assert len(raw_epoch) == len(complex_qrs_template), "Raw epoch length does not match complex QRS template length"
  908. clean_data[start:end] -= complex_qrs_template
  909. if self.dataset_intra.selected_channel_index_ecg == 0:
  910. self.dataset_intra.cleaned_ecg_left = clean_data
  911. elif self.dataset_intra.selected_channel_index_ecg == 1:
  912. self.dataset_intra.cleaned_ecg_right = clean_data
  913. # plot an overlap of the raw and cleaned data
  914. self.canvas_ecg_clean.setEnabled(True)
  915. self.toolbar_ecg_clean.setEnabled(True)
  916. self.ax_ecg_clean.clear()
  917. self.ax_ecg_clean.set_title("Cleaned ECG Signal")
  918. self.ax_ecg_clean.plot(times, full_data, label='Raw data')
  919. self.ax_ecg_clean.plot(times, clean_data, label='Cleaned data')
  920. self.ax_ecg_clean.set_xlabel("Time (s)")
  921. self.ax_ecg_clean.set_ylabel("Amplitude")
  922. self.ax_ecg_clean.legend()
  923. self.canvas_ecg_clean.draw()
  924. # Plot an overlap of the power spectrum using welch's method:
  925. n_fft = int(round(self.dataset_intra.sf))
  926. n_overlap=int(round(self.dataset_intra.sf)/2)
  927. # make sure that stimulation pulses are not included in the PSD calculation
  928. start_index = int(self.after_first_stim_pulses * self.dataset_intra.sf)
  929. end_index = int(self.before_last_stim_pulses * self.dataset_intra.sf)
  930. psd_raw, freqs_raw = mne.time_frequency.psd_array_welch(
  931. full_data[start_index: end_index],self.dataset_intra.sf,fmin=0,
  932. fmax=125,n_fft=n_fft,
  933. n_overlap=n_overlap)
  934. psd_clean, freqs_clean = mne.time_frequency.psd_array_welch(
  935. clean_data[start_index: end_index],self.dataset_intra.sf,fmin=0,
  936. fmax=125,n_fft=n_fft,
  937. n_overlap=n_overlap)
  938. self.canvas_psd.setEnabled(True)
  939. self.toolbar_psd.setEnabled(True)
  940. self.ax_psd.clear()
  941. self.ax_psd.plot(
  942. freqs_raw, np.log(psd_raw), color='blue', label='PSD raw channel'
  943. )
  944. self.ax_psd.plot(
  945. freqs_clean, np.log(psd_clean), color = 'orange',
  946. label='PSD cleaned channel'
  947. )
  948. self.ax_psd.legend()
  949. self.canvas_psd.draw()
  950. self.btn_confirm_cleaning.setEnabled(True) # Enable the button after cleaning
  951. #######################################################################
  952. ######### SINGULAR VALUE DECOMPOSITION METHOD #########
  953. #######################################################################
  954. def start_ecg_cleaning_svd(self):
  955. """Start the ECG cleaning process using Singular Value Decomposition method."""
  956. self.ax_ecg_clean.clear()
  957. self.ax_ecg_artifact.clear()
  958. self.ax_psd.clear()
  959. try:
  960. clean_ecg_svd(self)
  961. except Exception as e:
  962. QMessageBox.critical(self, "Error", f"Failed to clean ECG: {e}")
  963. def clean_ecg_svd(self):
  964. """
  965. This function cleans the ECG signal using Singular Value Decomposition (SVD).
  966. It extracts QRS templates around the R-peaks, performs SVD on these epochs,
  967. and then reconstructs the signal using the first few singular values.
  968. The function opens a secondary plotting window to visualize the SVD results,
  969. so that the user can choose which components to keep to reconstruct the signal.
  970. """
  971. if self.config['NoSync'] == True:
  972. self.full_data = self.dataset_intra.raw_data.get_data()[
  973. self.dataset_intra.selected_channel_index_ecg
  974. ]
  975. else:
  976. self.full_data = self.dataset_intra.synced_data.get_data()[
  977. self.dataset_intra.selected_channel_index_ecg
  978. ]
  979. self.window = [-0.2, 0.2] # add an option to choose QRS or PQRST window??
  980. # Create a QRS template
  981. pre_samples = int(abs(self.window[0]) * self.dataset_intra.sf)
  982. post_samples = int(self.window[1] * self.dataset_intra.sf)
  983. self.epoch_length = pre_samples + post_samples # Total length of each epoch
  984. epochs = [] # Store extracted heartbeats
  985. for peak in self.final_peaks:
  986. start = peak - pre_samples
  987. end = peak + post_samples
  988. epochs.append(self.full_data[start:end])
  989. epochs = np.array(epochs) # shape: (n timepoints, n epochs)
  990. ######### SINGULAR VALUE DECOMPOSITION ################
  991. X = epochs.T # shape: (n epochs, n timepoints)
  992. self.U, self.S, self.Vh = np.linalg.svd(X, full_matrices=False)
  993. # Open the secondary plotting window for the SVD template
  994. self.plot_window = PlotWindow(
  995. self.process_value_from_plot, self.U, self.S, self.window,
  996. self.epoch_length
  997. )
  998. self.plot_window.show()
  999. def simple_peak_detection_fallback(cropped_data, sf_lfp):
  1000. """
  1001. Fallback method for peak detection using a simpler approach in case detection
  1002. with usual thresholds fails.
  1003. This method applies peak detection directly to the entire signal.
  1004. """
  1005. # Try peak detection on the entire signal with different parameters
  1006. for height_pct in [90, 80, 70, 60, 50, 40]:
  1007. for distance_factor in [2, 3, 4, 5]: # Different distance constraints
  1008. distance = sf_lfp // distance_factor
  1009. # Try positive peaks
  1010. height_pos = np.percentile(cropped_data, height_pct)
  1011. peaks_pos, _ = scipy.signal.find_peaks(
  1012. cropped_data, height=height_pos, distance=distance
  1013. )
  1014. # Try negative peaks
  1015. height_neg = np.percentile(-cropped_data, height_pct)
  1016. peaks_neg, _ = scipy.signal.find_peaks(
  1017. -cropped_data, height=height_neg, distance=distance
  1018. )
  1019. # Return the first successful detection with reasonable number of peaks
  1020. if len(peaks_pos) >= 3: # At least 3 peaks for meaningful analysis
  1021. return peaks_pos, 'Up'
  1022. elif len(peaks_neg) >= 3:
  1023. return peaks_neg, 'Down'
  1024. return np.array([]), 'Unknown'

ecg_cleaning.py at commit d91e8d1, under MIT · at the source

Overview

Authors: Juliette Vivien1,2, Charlotte E Stensholt1,2, Lucie Hortmann1, Merle Hendel1, Arian Memarpouri1, Roxanne Lofredi1,3, Jeroen G V Habets1,3, Alessia Cavallo1, Lucia K Feldmann1, Andrea A Kühn1,2,4,5,6
  1. Department of Neurology, Charité-Universitätsmedizin Berlin, Berlin, Germany
  2. Humboldt-Universität zu Berlin, Berlin School of Mind and Brain, Berlin, Germany
  3. Berlin Institute of Health (BIH), Berlin, Germany
  4. Bernstein Center for Computational Neuroscience, Humboldt-Universität, Berlin, Germany
  5. NeuroCure, Exzellenzcluster, Charité-Universitätsmedizin Berlin, Berlin, Germany
  6. DZNE, German Center for Neurodegenerative Diseases, Berlin, Germany
Journal: NPJ Parkinson's disease, volume 12, issue 1, article 151
Dates: received 28 November 2025; accepted 10 June 2026; published online 19 June 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1038/s41531-026-01439-z · PMID 42315529 · PMCID PMC13279824 · OpenAlex W4417115847
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: Parkinson's (population), clinical / translational (subfield)
Methods: Spectral & time-frequency, Statistics, Machine learning, Preprocessing, Connectivity, Evoked potentials
Keywords: Biological techniques, Biomarkers, Engineering, Neurology, Neuroscience
Topic: Neurological disorders and treatments (Neurology, Medicine), according to OpenAlex
Funding: Deutscher Akademischer Austauschdienst; Deutsche Forschungsgemeinschaft (Project-ID 424778381 - TRR 295, Project-ID 424778381 – TRR 295); Berlin Institute of Health; Lundbeck Foundation (Grant Nr. R336-2020-1035)
Citations: not cited yet (Europe PMC); 17 references in the paper

Abstract

Implanted deep brain stimulation devices are now capable of chronically recording activity from intracranial brain areas during stimulation. This new type of data has the potential to increase our understanding of disease-related brain activity and its modulation in response to therapy or other types of stimuli. With the innovative approach of adaptive deep brain stimulation now clinically available, multimodal characterization of neural biomarkers becomes of utmost importance to define optimal feedback signals for adaptive brain stimulation and allow for better fine-tuning of stimulation parameters. To investigate these biomarkers, we developed DBSsync, a paradigm and an open-source Python toolbox with its graphical user interface for temporally precise synchronization of intracranial recordings with external data, allowing for multimodal research protocols. DBSsync achieves a temporal precision of 8 ms and incorporates cardiac artifact removal methods to facilitate intracranial data preprocessing, thus enabling the integration and precise synchronization of multiple brain signals with external sensors and various behavioral timeline data.

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 15 matches between paragraphs and lines of code.

juliettevivien/DBSsync

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: d91e8d186a5eda8a5f57897105f2487a543d7af0, 11 June 2026
Languages: Python (23)
Size: 51 files, 23 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file, environment (environment.yml, environment_linux.yml, requirements.txt)
Not found: CITATION.cff, tests, continuous integration, documentation
Tools: NumPy (11 files), MNE-Python (8 files), SciPy (8 files), Matplotlib (6 files), pandas (2 files)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
25 files

neuromodulation/perceive

License: Apache-2.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 23b12f2da3093f623cb192157e4dfc65b19a0c70, 10 September 2026
Languages: MATLAB (83), Shell (2)
Size: 150 files, 85 scripts
Software Heritage: not archived
Found in: the text, “Main interface and compatible file formats”
Holds: README, license file, tests, documentation
Not found: CITATION.cff, environment file, continuous integration
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
87 files

Code availability

The code of the toolbox is publicly available in a Github repository and can be accessed via this link: https://github.com/juliettevivien/DBSsync.

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;
  • 108 scripts, each with its path and the digest of its content;
  • 15 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

Data are available conditionally through data-sharing agreements in accordance with data privacy statements signed by the patients within the legal framework of the General Data Protection Regulation of the European Union, within a time frame of 6 months. Requests should be directed to AAK or the Open Data officer.

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, 10 authors, 5 keywords, 4 funders, 16 references.

Cite

This paper

Vivien, J., Stensholt, C. E., Hortmann, L., Hendel, M., Memarpouri, A., Lofredi, R., Habets, J. G. V., Cavallo, A., Feldmann, L. K., & Kühn, A. A. (2026). DBSsync: combining intracranial and multimodal data to investigate new biomarkers in Parkinson's disease. NPJ Parkinson's disease, 12(1), 151. https://doi.org/10.1038/s41531-026-01439-z

BibTeX

@article{vivien2026dbssync,
author = {Vivien, Juliette and Stensholt, Charlotte E and Hortmann, Lucie and Hendel, Merle and Memarpouri, Arian and Lofredi, Roxanne and Habets, Jeroen G V and Cavallo, Alessia and Feldmann, Lucia K and Kühn, Andrea A},
title = {{DBSsync: combining intracranial and multimodal data to investigate new biomarkers in Parkinson's disease}},
journal = {NPJ Parkinson's disease},
year = {2026},
month = jun,
volume = {12},
number = {1},
pages = {151},
publisher = {Nature Publishing Group},
issn = {2373-8057},
doi = {10.1038/s41531-026-01439-z},
url = {https://doi.org/10.1038/s41531-026-01439-z},
pmid = {42315529},
pmcid = {PMC13279824}
}

RIS

TY - JOUR
AU - Vivien, Juliette
AU - Stensholt, Charlotte E
AU - Hortmann, Lucie
AU - Hendel, Merle
AU - Memarpouri, Arian
AU - Lofredi, Roxanne
AU - Habets, Jeroen G V
AU - Cavallo, Alessia
AU - Feldmann, Lucia K
AU - Kühn, Andrea A
TI - DBSsync: combining intracranial and multimodal data to investigate new biomarkers in Parkinson's disease
T2 - NPJ Parkinson's disease
J2 - NPJ Parkinsons Dis
PY - 2026
DA - 2026/06/19
VL - 12
IS - 1
SP - 151
SN - 2373-8057
PB - Nature Publishing Group
DO - 10.1038/s41531-026-01439-z
UR - https://doi.org/10.1038/s41531-026-01439-z
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41531-026-01439-z",
"type": "article-journal",
"title": "DBSsync: combining intracranial and multimodal data to investigate new biomarkers in Parkinson's disease",
"container-title": "NPJ Parkinson's disease",
"author": [
{
"family": "Vivien",
"given": "Juliette"
},
{
"family": "Stensholt",
"given": "Charlotte E"
},
{
"family": "Hortmann",
"given": "Lucie"
},
{
"family": "Hendel",
"given": "Merle"
},
{
"family": "Memarpouri",
"given": "Arian"
},
{
"family": "Lofredi",
"given": "Roxanne"
},
{
"family": "Habets",
"given": "Jeroen G V"
},
{
"family": "Cavallo",
"given": "Alessia"
},
{
"family": "Feldmann",
"given": "Lucia K"
},
{
"family": "Kühn",
"given": "Andrea A"
}
],
"container-title-short": "NPJ Parkinsons Dis",
"volume": "12",
"issue": "1",
"page": "151",
"DOI": "10.1038/s41531-026-01439-z",
"PMID": "42315529",
"PMCID": "PMC13279824",
"ISSN": "2373-8057",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41531-026-01439-z",
"language": "en",
"issued": {
"date-parts": [
[
2026,
6,
19
]
]
}
}

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/s41531-026-01380-1 [code]
Identifying maximal beta power from directional subthalamic local field potentials in Parkinson's disease.
Journal: NPJ Parkinson's disease
In common: MNE-Python, pandas, SciPy, 2 other tools, Parkinson's, clinical / translational, 2 authors
[2] doi:10.1038/s41591-026-04434-2 [code]
Adaptive deep brain stimulation for dynamic gait control in Parkinson's disease: a randomized feasibility trial.
Journal: Nature medicine
In common: Signal Processing Toolbox, Statistics and Machine Learning Toolbox, pandas, 3 other tools, Parkinson's, clinical / translational, 4 references
[3] doi:10.1038/s41591-026-04432-4 [code]
Activity-dependent adaptive deep brain stimulation improves gait in Parkinson's disease.
Journal: Nature medicine
In common: MNE-Python, SciPy, Matplotlib, 1 other tool, Parkinson's, clinical / translational, 4 references
[4] doi:10.1002/ana.78206 [code]
Multimodal Image Guidance in Subthalamic Deep Brain Stimulation for Parkinson's Disease.
Journal: Annals of neurology
In common: Signal Processing Toolbox, Statistics and Machine Learning Toolbox, pandas, 3 other tools, Parkinson's, clinical / translational, author Andrea A Kühn
[5] doi:10.1111/ene.70678 [code]
Who Falls After a Stroke? Evidence From a Prospective Stroke Cohort.
Journal: European journal of neurology
In common: Signal Processing Toolbox, Statistics and Machine Learning Toolbox, pandas, 3 other tools, clinical / translational, author Andrea A Kühn
[6] doi:10.1016/j.celrep.2026.117404 [code]
Action and rest tremor map to distinct networks within the primary motor cortex.
Journal: Cell reports
In common: Signal Processing Toolbox, Statistics and Machine Learning Toolbox, pandas, 3 other tools, author Andrea A Kühn
[7] doi:10.1126/sciadv.aea3919 [code]
Hierarchical brain dynamics supporting visual perceptual transitions.
Journal: Science advances
In common: MNE-Python, Signal Processing Toolbox, Statistics and Machine Learning Toolbox, 4 other tools, 1 reference
[8] doi:10.64898/2026.03.06.710026 [code]
Distinct beta burst motifs exhibit opposing error relationships during motor adaptation
Journal: bioRxiv (preprint)
In common: MNE-Python, Signal Processing Toolbox, Statistics and Machine Learning Toolbox, 4 other tools, 1 reference
[9] doi:10.1038/s41597-025-06397-4 [code]
MEG-SCANS - A comprehensive magnetoencephalography speech dataset with Stories, Chirps and Noisy Sentences
Journal: n/a
In common: MNE-Python, Signal Processing Toolbox, Statistics and Machine Learning Toolbox, 4 other tools, 1 reference
[10] doi:10.1162/imag.a.1040 [code]
Novel 4 He-OPMs support waveform-specific beta burst analysis comparable to SQUID-MEG
Journal: n/a
In common: MNE-Python, Signal Processing Toolbox, Statistics and Machine Learning Toolbox, 4 other tools, 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.