DBSsync: combining intracranial and multimodal data to investigate new biomarkers in Parkinson's disease.
The 15 matches
- [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] § 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] § 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] § 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] § 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] § 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] § 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] § 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] § Methods › Main interface and compatible file formats ↔ functions/io.py, lines 1–50 · score 0.67 · MNE python, compatibility, POLY5, FIF, interface, GUI
- [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] § 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] § 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] § 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] § 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] § 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
- """
- This module contains functions for cleaning ECG data using various methods as
- described in Stam et al., 2023.
- There are three main methods implemented:
- 1. Interpolation Method: This method uses simple linear interpolation to remove artifacts
- from the ECG signal, i.e. linearly interpolated over the R-peaks found.
- 2. Template Subtraction Method: This method creates a QRS template based on the
- R-peaks found and subtracts it from the ECG signal, using a linear fit to adjust
- the template to the raw data.
- 3. Singular Value Decomposition (SVD) Method: This method uses SVD to decompose
- the raw contaminated signal into components, allowing for the removal of
- artifacts by selecting the most significant components.
- The detection of R-peaks is done using either only the LFP channel or an
- additional external ECG channel synchronized with the LFP channel.
- The R-peaks are detected with the function scipy.signal.find_peaks, with
- a threshold-based at 95th percentile of the signal amplitude, and a minimum
- distance of 500 ms between peaks. The polarity of the R-peaks is determined
- by comparing the mean amplitude of the detected peaks in both orientations
- (positive and negative). The orientation with the higher mean absolute amplitude
- is chosen as the orientation of the QRS complexes.
- There is the possibility to manually override some parameters for R-peak detection,
- such as the polarity, start and end times for cleaning, periods to exclude peaks from,
- and the detection threshold.
- Reference paper:
- Stam M.J., van Wijk B.C.M., Sharma P., Beudel M., Piña-Fuentes D.A.,
- de Bie R.M.A., Schuurman P.R., Neumann W.J., Buijink A.W.G. (2023)
- A comparison of methods to suppress electrocardiographic artifacts in local
- field potential recordings. Clin Neurophysiol. doi: 10.1016/j.clinph.2022.11.011
- This module contains the following functions:
- - `find_r_peaks`: Finds R-peaks in the LFP channel using either only the LFP channel itself,
- or an additional external ECG channel synchronized with the LFP channel.
- - `find_r_peaks_based_on_ext_ecg`: Finds R-peaks in the LFP channel based on an external ECG channel.
- - `find_r_peaks_in_lfp_channel`: Finds R-peaks in the LFP channel using the LFP channel itself.
- - `start_ecg_cleaning_interpolation`: Starts the ECG cleaning process using the interpolation method.
- - `clean_ecg_interpolation`: Cleans the ECG signal using the interpolation method.
- - `start_ecg_cleaning_template_sub`: Starts the ECG cleaning process using the template subtraction method.
- - `clean_ecg_template_sub`: Cleans the ECG signal using the template subtraction method.
- - `start_ecg_cleaning_svd`: Starts the ECG cleaning process using the SVD method.
- - `clean_ecg_svd`: Cleans the ECG signal using the SVD method.
- """
- #######################################################################
- # ECG CLEANING FUNCTIONS #
- #######################################################################
- from PyQt5.QtWidgets import QMessageBox, QDialog, QVBoxLayout, QLabel, QLineEdit, QComboBox, QHBoxLayout, QPushButton
- import numpy as np
- import scipy
- import scipy.signal
- import mne
- import ast
- from functions.utils import get_start_end_times, find_similar_sample
- from functions.classes import PlotWindow
- def manual_override(self):
- # Create dialog box for manual parameter input
- dialog = QDialog()
- dialog.setWindowTitle("Set R-peak Detection Parameters")
- layout = QVBoxLayout()
- # Create combo box for polarity selection
- polarity_layout = QHBoxLayout()
- polarity_label = QLabel("R-peak polarity (LFP):")
- combo_polarity = QComboBox()
- combo_polarity.addItems(["None", "Down", "Up"])
- # combo_polarity.setCurrentText("None")
- combo_polarity.setCurrentText(self.r_peak_polarity_lfp)
- polarity_layout.addWidget(polarity_label)
- polarity_layout.addWidget(combo_polarity)
- layout.addLayout(polarity_layout)
- # Line edits to adapt start/end cleaning times (avoid stimulation pulses)
- start_layout = QHBoxLayout()
- start_label = QLabel("Start cleaning time (s):")
- start_edit = QLineEdit()
- # start_edit.setPlaceholderText("None")
- start_edit.setText(str(self.start_cleaning_time))
- start_layout.addWidget(start_label)
- start_layout.addWidget(start_edit)
- layout.addLayout(start_layout)
- end_layout = QHBoxLayout()
- end_label = QLabel("End cleaning time (s):")
- end_edit = QLineEdit()
- # end_edit.setPlaceholderText("None")
- end_edit.setText(str(self.end_cleaning_time))
- end_layout.addWidget(end_label)
- end_layout.addWidget(end_edit)
- layout.addLayout(end_layout)
- # Line edit for periods to exclude peaks from (avoid strong artifacts)
- exclusion_layout = QHBoxLayout()
- exclusion_label = QLabel("Exclusion period (s) as tuples:")
- exclusion_edit = QLineEdit()
- # exclusion_edit.setPlaceholderText("None")
- exclusion_edit.setText(str(self.exclusion_periods))
- exclusion_layout.addWidget(exclusion_label)
- exclusion_layout.addWidget(exclusion_edit)
- layout.addLayout(exclusion_layout)
- # Combo box for R-peak detection threshold. Default to 95%
- threshold_layout = QHBoxLayout()
- threshold_label = QLabel("R-peak detection threshold (%):")
- combo_r_peak_threshold = QComboBox()
- combo_r_peak_threshold.addItems(["95", "96", "97", "98", "99"])
- combo_r_peak_threshold.setCurrentText(str(self.detection_threshold))
- threshold_layout.addWidget(threshold_label)
- threshold_layout.addWidget(combo_r_peak_threshold)
- layout.addLayout(threshold_layout)
- # OK / Cancel buttons
- button_layout = QHBoxLayout()
- ok_button = QPushButton("OK")
- cancel_button = QPushButton("Cancel")
- button_layout.addWidget(ok_button)
- button_layout.addWidget(cancel_button)
- layout.addLayout(button_layout)
- dialog.setLayout(layout)
- # Button connections
- def on_ok():
- try:
- r_peak_polarity_lfp = combo_polarity.currentText()
- if r_peak_polarity_lfp == "None":
- r_peak_polarity_lfp = None
- start_text = start_edit.text().strip()
- if start_text == "None":
- start_text = None
- start_cleaning_time = (float(start_text) if start_text is not None else None)
- end_text = end_edit.text().strip()
- if end_text == "None":
- end_text = None
- end_cleaning_time = (float(end_text) if end_text is not None else None)
- exclusion_text = exclusion_edit.text().strip()
- if exclusion_text == "None":
- exclusion_text = None
- if exclusion_text:
- try:
- exclusion_periods = ast.literal_eval(exclusion_text)
- # Check it's a list of tuples of floats
- if (isinstance(exclusion_periods, list) and
- all(isinstance(t, tuple) and len(t) == 2 for t in exclusion_periods)):
- exclusion_periods = [(float(a), float(b)) for a, b in exclusion_periods]
- else:
- raise ValueError
- except Exception:
- QMessageBox.warning(dialog, "Invalid Input",
- "Please enter exclusion periods in the format [(6.2, 8.8), (101.45, 123.65)].")
- return
- else:
- exclusion_periods = None
- # store as instance attributes for later use
- self.r_peak_polarity_lfp = r_peak_polarity_lfp
- self.start_cleaning_time = start_cleaning_time
- self.end_cleaning_time = end_cleaning_time
- self.exclusion_periods = exclusion_periods
- self.detection_threshold = int(combo_r_peak_threshold.currentText() or 95)
- dialog.accept()
- except ValueError:
- QMessageBox.warning(dialog, "Invalid Input", "Please enter valid numbers for times.")
- ok_button.clicked.connect(on_ok)
- cancel_button.clicked.connect(dialog.reject)
- # Show dialog
- if dialog.exec_() == QDialog.Accepted:
- print(f"Polarity: {self.r_peak_polarity_lfp}, "
- f"Start: {self.start_cleaning_time}, End: {self.end_cleaning_time}, "
- f"Ignoring: {self.exclusion_periods}, Threshold: {self.detection_threshold}%")
- else:
- return # User canceled
- ###############################################################################
- #######################################################################
- ######### FINDING R-PEAKS #########
- #######################################################################
- def find_r_peaks(self):
- """
- Find R-peaks in the LFP channel using either only the LFP channel itself,
- or an additional external ECG channel synchronized with the LFP channel.
- This function will be called when the user clicks the "Find R-peaks" button.
- """
- if self.config['NoSync'] == True:
- full_data = self.dataset_intra.raw_data.get_data()[
- self.dataset_intra.selected_channel_index_ecg
- ]
- else:
- full_data = self.dataset_intra.synced_data.get_data()[
- self.dataset_intra.selected_channel_index_ecg
- ]
- nb_points = len(full_data)
- times = np.arange(nb_points) * (1.0 / self.dataset_intra.sf)
- if self.dataset_extra.selected_channel_name_ecg is not None:
- # an external ECG channel was selected and will therefore be used for R-peak detection
- # Use external ECG channel to find R-peaks
- final_peaks, polarity, mean_epoch = find_r_peaks_based_on_ext_ecg(
- self, full_data, times, self.detection_threshold,
- window_artifact = [-0.5, 0.5]
- )
- # Add a message window to inform the user about the result of the peak detection:
- QMessageBox.information(
- self,
- "R-Peak Detection",
- f"{len(final_peaks)} R-peaks have been detected in the LFP channel using the ECG channel provided.",
- QMessageBox.Ok
- )
- else:
- # Use only LFP channel to find R-peaks
- final_peaks, polarity, mean_epoch = find_r_peaks_in_lfp_channel(
- self, full_data, times, self.detection_threshold,
- window = [-0.5, 0.5]
- )
- # Add a message window to inform the user about the result of the peak detection:
- QMessageBox.information(
- self,
- "R-Peak Detection",
- f"{len(final_peaks)} R-peaks have been detected in the LFP channel alone, using a threshold of {self.detection_threshold}%.",
- QMessageBox.Ok
- )
- # Check if peak detection was successful
- if len(final_peaks) == 0:
- # Disable cleaning buttons since we don't have valid peaks
- self.btn_start_ecg_cleaning_interpolation.setEnabled(False)
- self.btn_start_ecg_cleaning_template_sub.setEnabled(False)
- self.btn_start_ecg_cleaning_svd.setEnabled(False)
- QMessageBox.warning(
- self,
- "R-Peak Detection",
- f"R-peak detection failed, no peaks were detected",
- QMessageBox.Ok
- )
- return
- else:
- self.final_peaks = final_peaks
- self.polarity = polarity
- self.mean_epoch = mean_epoch
- self.btn_start_ecg_cleaning_interpolation.setEnabled(True)
- self.btn_start_ecg_cleaning_template_sub.setEnabled(True)
- self.btn_start_ecg_cleaning_svd.setEnabled(True)
- def find_r_peaks_based_on_ext_ecg(
- self,
- full_data: np.ndarray,
- times: np.ndarray,
- detection_threshold: int,
- window_artifact: list = [-0.5, 0.5]
- ):
- #### PREDETERMINE R-PEAKS TIMESTAMPS USING ECG CHANNEL ####
- """
- The externally recorded ECG signal is used to predetermine
- the timestamps of the R-peaks. The ECG signal is z-scored ((x-l)/r)
- over the entire recording and the function findpeaks was used to search
- for R-peaks with a specific height (95th percentile) and at a
- specific inter-peak distance (minimally 500 ms).
- The algorithm accounts for negative QRS complexes
- by repeating this procedure after multiplying the signal with
- -1. For both orientations of the LFP signal, the values of the
- peaks were averaged and the peaks with the highest mean
- determined the orientation of the QRS complexes.
- """
- # searches for stimulation pulses to avoid them during R-peak detection
- last_peak_start, first_peak_end = get_start_end_times(full_data, times)
- # Override with user-defined times if provided
- if self.start_cleaning_time is not None:
- last_peak_start = self.start_cleaning_time
- if self.end_cleaning_time is not None:
- first_peak_end = self.end_cleaning_time
- # Validate crop range and correct to default if invalid
- if last_peak_start >= first_peak_end:
- # Use a reasonable portion of the signal (skip first and last 10 seconds)
- last_peak_start = max(10.0, times[0] + 10.0)
- first_peak_end = min(times[-1] - 10.0, times[-1] - 10.0)
- data_extra = self.dataset_extra.synced_data.get_data()[
- self.dataset_extra.selected_channel_index_ecg
- ]
- # Apply 0.5 Hz-60Hz band-pass filter to ECG data. The low-pass can be changed in the config file.
- # b, a = scipy.signal.butter(1, 0.05, "highpass")
- b, a = scipy.signal.butter(1, 0.5, "highpass", fs = self.dataset_extra.sf)
- detrended_data = scipy.signal.filtfilt(b, a, data_extra)
- b2, a2 = scipy.signal.butter(
- N=4, # Filter order
- Wn=self.config["EcgLowpassFilter"],
- btype="lowpass",
- fs=self.dataset_extra.sf
- )
- ecg_data = scipy.signal.filtfilt(b2, a2, detrended_data)
- nb_points = len(ecg_data)
- timescale_extra = np.arange(nb_points) * (1.0 / self.dataset_extra.sf)
- # timescale_extra = np.linspace(
- # 0,
- # self.dataset_extra.synced_data.get_data().shape[1]/self.dataset_extra.sf,
- # self.dataset_extra.synced_data.get_data().shape[1]
- # )
- # end_time_extra = self.dataset_extra.synced_data.get_data().shape[1]/self.dataset_extra.sf
- # timescale_extra = np.arange(0, end_time_extra, 1/self.dataset_extra.sf)
- # Z-score the ECG signal
- ecg_z = (ecg_data - np.mean(ecg_data)) / np.std(ecg_data)
- # Define peak detection params in the ECG channel
- threshold = np.percentile(ecg_z, detection_threshold) # Convert to percentile for robustness
- min_distance_samples = int(0.5 * self.dataset_extra.sf) # 500 ms in samples
- # Detect peaks in original signal
- peaks_pos, _ = scipy.signal.find_peaks(
- ecg_z,
- height=threshold,
- distance=min_distance_samples
- )
- # Detect peaks in inverted signal
- peaks_neg, __ = scipy.signal.find_peaks(
- -ecg_z,
- height=threshold,
- distance=min_distance_samples
- )
- # Select better polarity based on the number of peaks detected
- if len(peaks_pos) >= len(peaks_neg):
- chosen_peaks = peaks_pos
- polarity_ecg = 'Positive'
- else:
- chosen_peaks = peaks_neg
- polarity_ecg = 'Negative'
- # Check if any peaks were detected in the external ECG
- if len(chosen_peaks) == 0:
- # Try with lower thresholds
- for fallback_threshold in [90, 85, 80, 75, 70]:
- threshold_fallback = np.percentile(ecg_z, fallback_threshold)
- peaks_pos_fb, _ = scipy.signal.find_peaks(
- ecg_z, height=threshold_fallback, distance=min_distance_samples
- )
- peaks_neg_fb, _ = scipy.signal.find_peaks(
- -ecg_z, height=threshold_fallback, distance=min_distance_samples
- )
- if len(peaks_pos_fb) > 0 or len(peaks_neg_fb) > 0:
- chosen_peaks = peaks_pos_fb if len(peaks_pos_fb) >= len(peaks_neg_fb) else peaks_neg_fb
- polarity_ecg = 'Positive' if len(peaks_pos_fb) >= len(peaks_neg_fb) else 'Negative'
- break
- # If still no peaks found
- if len(chosen_peaks) == 0:
- print("No peaks detected in external ECG with any threshold!")
- QMessageBox.warning(
- self,
- "External ECG Peak Detection Failed",
- "No R-peaks could be detected in the external ECG channel. Please check if:\n"
- "1. The correct ECG channel is selected\n"
- "2. The ECG signal quality is sufficient\n"
- "3. The synchronization between recordings is correct",
- QMessageBox.Ok
- )
- return np.array([]), 'Unknown', np.array([])
- # Convert R-peaks from ECG samples to seconds
- r_peak_times_sec = chosen_peaks / self.dataset_extra.sf
- # Convert times to LFP sample indices
- r_peaks_lfp_idx = np.round(r_peak_times_sec * self.dataset_intra.sf).astype(int)
- # Look for R-peaks in LFP channel based on predetermined timestamps:
- window_around_peaks = 20 # ±20 LFP samples = 80ms at 250 Hz
- max_peaks = []
- min_peaks = []
- for idx in r_peaks_lfp_idx:
- start = max(idx - window_around_peaks, 0)
- end = min(idx + window_around_peaks + 1, len(full_data))
- segment = full_data[start:end]
- if len(segment) > 0:
- max_peaks.append(np.max(segment))
- min_peaks.append(np.min(segment))
- # Calculate mean absolute values
- mean_abs_max = np.nanmean(np.abs(max_peaks)) if max_peaks else 0
- mean_abs_min = np.nanmean(np.abs(min_peaks)) if min_peaks else 0
- # Override polarity if user specified
- if self.r_peak_polarity_lfp is not None:
- polarity = self.r_peak_polarity_lfp
- if polarity == 'Up':
- mean_abs_max = 2
- mean_abs_min = 1
- else:
- mean_abs_max = 1
- mean_abs_min = 2
- # Choose the orientation with the higher mean absolute amplitude in the LFP channel
- lfp_peak_indices = []
- polarity = None
- if mean_abs_max >= mean_abs_min:
- polarity = 'Up'
- for idx in r_peaks_lfp_idx:
- start = idx - window_around_peaks
- end = idx + window_around_peaks + 1
- # Check signal boundaries
- if start < 0 or end > len(full_data):
- continue
- segment = full_data[start:end]
- if np.isnan(segment).any():
- continue
- local_max_idx = np.argmax(segment)
- peak_global_idx = start + local_max_idx
- lfp_peak_indices.append(peak_global_idx)
- else:
- polarity = 'Down'
- for idx in r_peaks_lfp_idx:
- start = idx - window_around_peaks
- end = idx + window_around_peaks + 1
- # Check signal boundaries
- if start < 0 or end > len(full_data):
- continue
- segment = full_data[start:end]
- if np.isnan(segment).any():
- continue
- local_min_idx = np.argmin(segment)
- peak_global_idx = start + local_min_idx
- lfp_peak_indices.append(peak_global_idx)
- # Remove peaks that are before last_peak_start and after first_peak_end:
- initial_lfp_count = len(lfp_peak_indices)
- lfp_peak_indices = [
- p for p in lfp_peak_indices if (
- p >= int(last_peak_start * self.dataset_intra.sf) and
- p <= int(first_peak_end * self.dataset_intra.sf)
- )]
- # Also remove peaks in exclusion periods if provided
- if self.exclusion_periods is not None:
- lfp_peak_indices = [
- p for p in lfp_peak_indices
- if not any(start <= (p / self.dataset_intra.sf) <= end for start, end in self.exclusion_periods)
- ]
- # Check if we have sufficient LFP peaks
- if len(lfp_peak_indices) == 0:
- print("No LFP peaks remain after time filtering!")
- QMessageBox.warning(
- self,
- "LFP Peak Detection Failed",
- "No valid R-peaks found in the LFP channel after applying time constraints. "
- "This might be due to timing issues between the external ECG and LFP recordings.",
- QMessageBox.Ok
- )
- return np.array([]), polarity, np.array([])
- # Plot detected R-peaks in external
- # first scale the ECG data to match the amplitude of the LFP channel for better visualization
- ptp_lfp = np.ptp(full_data) / 2
- ptp_ecg = np.ptp(ecg_data)
- factor = ptp_lfp/ptp_ecg
- ecg_data_scaled = ecg_data * factor
- self.canvas_detected_peaks.setEnabled(True)
- self.toolbar_detected_peaks.setEnabled(True)
- self.ax_detected_peaks.clear()
- self.ax_detected_peaks.set_title('Detected Peaks')
- self.ax_detected_peaks.plot(timescale_extra, ecg_data_scaled, label='Raw ECG', alpha=0.1)
- self.ax_detected_peaks.plot(timescale_extra[chosen_peaks], ecg_data_scaled[chosen_peaks], 'ro', label='Detected Peaks', alpha=0.1)
- self.canvas_detected_peaks.draw()
- # Plot detected R-peaks in intracranial
- self.ax_detected_peaks.plot(times, full_data, label='Raw LFP', color='black')
- self.ax_detected_peaks.plot(
- np.array(times)[lfp_peak_indices],
- np.array(full_data)[lfp_peak_indices],
- 'ro', label='LFP Peaks'
- )
- self.ax_detected_peaks.legend()
- self.canvas_detected_peaks.draw()
- # Estimate HR and display it in label
- peak_intervals = np.diff(lfp_peak_indices) / self.dataset_intra.sf # Convert to seconds
- hr = 60 / np.mean(peak_intervals) if len(peak_intervals) > 0 else 0
- self.label_heart_rate_lfp.setText(f'Heart rate: {hr:.1f} bpm')
- # Define epoch window
- sf_lfp = self.dataset_intra.sf
- pre_samples = int(abs(window_artifact[0]) * sf_lfp)
- post_samples = int(window_artifact[1] * sf_lfp)
- epoch_length = pre_samples + post_samples # Total length of each epoch
- # time = np.linspace(window_artifact[0], window_artifact[1], epoch_length) # Time in seconds
- time_epoch = np.arange(-pre_samples, post_samples) / sf_lfp # Time in seconds
- epochs = [] # Store extracted heartbeats
- for peak in lfp_peak_indices:
- start = peak - pre_samples
- end = peak + post_samples
- if np.isnan(full_data[start:end]).any():
- print(f"Skipping peak at {peak} due to NaNs in the epoch")
- continue
- if (
- start >= last_peak_start*self.dataset_intra.sf
- ) and (
- end < first_peak_end*self.dataset_intra.sf
- ): # Ensure we don't take the peaks that are in the stimulation pulses
- epochs.append(full_data[start:end])
- epochs = np.array(epochs)
- # Check if we have valid epochs
- if len(epochs) == 0:
- print("No valid epochs extracted from LFP peaks!")
- QMessageBox.warning(
- self,
- "LFP Epoch Extraction Failed",
- "No valid epochs could be extracted from the detected LFP peaks. "
- "This might be due to peaks being too close to signal boundaries.",
- QMessageBox.Ok
- )
- return np.array([]), polarity, np.array([])
- # Compute average heartbeat template
- mean_epoch = np.nanmean(epochs, axis=0)
- # Additional check for mean_epoch validity
- if np.isnan(mean_epoch).all() or len(mean_epoch) == 0:
- print("LFP mean epoch is invalid (all NaN or empty)!")
- QMessageBox.warning(
- self,
- "LFP Template Creation Failed",
- "Could not create a valid ECG template from the detected LFP peaks.",
- QMessageBox.Ok
- )
- return np.array([]), polarity, np.array([])
- # Plot the detected ECG epochs
- self.canvas_ecg_artifact.setEnabled(True)
- self.toolbar_ecg_artifact.setEnabled(True)
- self.ax_ecg_artifact.clear()
- self.ax_ecg_artifact.set_title("Detected ECG epochs")
- for epoch in epochs:
- self.ax_ecg_artifact.plot(time_epoch, epoch, color='gray', alpha=0.3)
- self.ax_ecg_artifact.plot(
- time_epoch,
- mean_epoch,
- color='black',
- linewidth=2,
- label='Average ECG Template'
- )
- self.ax_ecg_artifact.set_xlabel("Time (s)")
- self.ax_ecg_artifact.set_ylabel("Amplitude")
- self.ax_ecg_artifact.legend()
- self.canvas_ecg_artifact.draw()
- ## store start and end times of cleaning
- self.after_first_stim_pulses = last_peak_start
- self.before_last_stim_pulses = first_peak_end
- return lfp_peak_indices, polarity, mean_epoch
- def find_r_peaks_in_lfp_channel(
- self,
- full_data: np.ndarray,
- times: np.ndarray,
- detection_threshold: int,
- window = [-0.5, 0.5]
- ):
- """
- The LFP signal itself is used to find the R-peaks. It is cropped in 1s
- segments and the function findpeaks was used to search for R-peaks in each segment.
- This is repeated with signal multiplied by -1 to account for negative QRS complexes.
- The polarity for which more peaks were detected is chosen as the orientation
- of the QRS complexes in LFP channel and a second-pass detection is performed:
- epochs are created around each detected R-peak (-0.5 - +0.5s around) and averaged
- to generate a first QRS template.
- Template correlation is used to refine the R-peak locations by searching
- for the local maxima within each epoch that has the highest correlation
- with the mean QRS template. These local maxima are used to create a better
- QRS template by averaging epochs around these new R-peak locations.
- Template correlation is applied a second time using this improved QRS template
- to finalize R-peak locations.
- """
- sf_lfp = round(self.dataset_intra.sf)
- # Define period to look for peaks (avoid stimulation pulses if present):
- if self.config['NoSync'] == True:
- start_idx = 0
- end_idx = len(full_data)
- # Override with user-defined times if provided
- if self.start_cleaning_time is not None:
- start_idx = int(self.start_cleaning_time * sf_lfp)
- if self.end_cleaning_time is not None:
- end_idx = int(self.end_cleaning_time * sf_lfp)
- last_peak_start = start_idx / sf_lfp
- first_peak_end = end_idx / sf_lfp
- else:
- last_peak_start, first_peak_end = get_start_end_times(full_data, times)
- # Override with user-defined times if provided
- if self.start_cleaning_time is not None:
- last_peak_start = self.start_cleaning_time
- if self.end_cleaning_time is not None:
- first_peak_end = self.end_cleaning_time
- # Validate crop range
- if last_peak_start >= first_peak_end:
- # Use a reasonable portion of the signal (skip first and last 10 seconds)
- last_peak_start = max(10.0, times[0] + 10.0)
- first_peak_end = min(times[-1] - 10.0, times[-1] - 10.0)
- # Calculate crop indices and validate
- start_idx = int(last_peak_start * sf_lfp)
- end_idx = int(first_peak_end * sf_lfp)
- # Ensure indices are within bounds
- start_idx = max(0, min(start_idx, len(full_data) - 1))
- end_idx = max(start_idx + 1, min(end_idx, len(full_data)))
- cropped_data = full_data[start_idx:end_idx]
- # Check if cropped data is valid
- if len(cropped_data) == 0:
- cropped_data = full_data
- last_peak_start = times[0]
- first_peak_end = times[-1]
- #ecg = {'proc': {}}
- ns = len(cropped_data) # Number of samples in the cropped data
- # Segment the signal into overlapping windows
- dwindow = int(round(sf_lfp)) # 1s window
- dmove = sf_lfp # 1s step
- n_segments = (ns - dwindow) // dmove + 1
- detected_peaks_positive = [] # Store peak indices in the original timescale of the cropped_data
- x = np.array(
- [
- cropped_data[
- i * dmove: i * dmove + dwindow
- ] for i in range(n_segments) if i * dmove + dwindow <= ns
- ])
- # Loop through each segment and find peaks
- for i in range(n_segments):
- segment = x[i]
- # Skip segment if it contains any NaNs
- if np.isnan(segment).any():
- continue
- for threshold_pct in [80, 70, 60, 50]:
- peaks, _ = scipy.signal.find_peaks(
- segment, height=np.percentile(segment, threshold_pct), distance=sf_lfp//3
- )
- if len(peaks) > 0:
- break
- real_peaks = peaks + (i * dmove) # Convert to original timescale
- detected_peaks_positive.extend(real_peaks)
- # Repeat with reverted signal to find negative peaks
- detected_peaks_negative = [] # Store peak indices in the original timescale of the cropped_data
- x_neg = np.array(
- [
- -cropped_data[
- i * dmove: i * dmove + dwindow
- ] for i in range(n_segments) if i * dmove + dwindow <= ns
- ])
- for i in range(n_segments):
- segment = x_neg[i]
- # 6. Skip segment if it contains any NaNs
- if np.isnan(segment).any():
- continue
- # Try different thresholds if 90th percentile fails
- for threshold_pct in [80, 70, 60, 50]:
- peaks, _ = scipy.signal.find_peaks(
- segment, height=np.percentile(segment, threshold_pct), distance=sf_lfp//3
- )
- if len(peaks) > 0:
- break
- real_peaks = peaks + (i * dmove) # Convert to original timescale
- detected_peaks_negative.extend(real_peaks)
- # Find which set of peaks has more elements
- if len(detected_peaks_positive) >= len(detected_peaks_negative):
- detected_peaks = detected_peaks_positive
- polarity = 'Up'
- else:
- detected_peaks = detected_peaks_negative
- polarity = 'Down'
- # Override polarity if user specified
- if self.r_peak_polarity_lfp is not None:
- polarity = self.r_peak_polarity_lfp
- print(f"Overriding detected polarity to user-specified: {polarity}")
- if polarity == 'Up':
- detected_peaks = detected_peaks_positive
- else:
- detected_peaks = detected_peaks_negative
- detected_peaks = np.array(detected_peaks)
- # Check if any peaks were detected
- if len(detected_peaks) == 0:
- # Try the simpler fallback method
- detected_peaks, polarity = simple_peak_detection_fallback(cropped_data, sf_lfp)
- if len(detected_peaks) == 0:
- print("No peaks detected with any method!")
- QMessageBox.warning(
- self,
- "Peak Detection Failed",
- "No R-peaks could be detected in the signal with any method. Please check if:\n"
- "1. The signal contains visible ECG artifacts\n"
- "2. The channel selection is correct\n"
- "3. The signal amplitude is sufficient\n"
- "4. Try adjusting the threshold percentage",
- QMessageBox.Ok
- )
- # Return empty arrays to prevent crashes
- return np.array([]), 'Unknown', np.array([])
- # Define epoch window
- pre_samples = int(abs(window[0]) * sf_lfp)
- post_samples = int(window[1] * sf_lfp)
- #epoch_length = pre_samples + post_samples # Total length of each epoch
- epochs = [] # Store extracted heartbeats
- for peak in detected_peaks:
- start = peak - pre_samples
- end = peak + post_samples
- if start >= 0 and end < ns: # Ensure we don't go out of bounds
- epoch = cropped_data[start:end]
- if np.isnan(epoch).any():
- continue
- else:
- epochs.append(epoch)
- epochs = np.array(epochs)
- # Check if we have valid epochs
- if len(epochs) == 0:
- print("No valid epochs extracted from detected peaks!")
- QMessageBox.warning(
- self,
- "Epoch Extraction Failed",
- "No valid epochs could be extracted from the detected peaks. This might be due to peaks being too close to signal boundaries.",
- QMessageBox.Ok
- )
- # Return empty arrays to prevent crashes
- return np.array([]), polarity, np.array([])
- # Compute average heartbeat template
- mean_epoch = np.nanmean(epochs, axis=0)
- # Additional check for mean_epoch validity
- if np.isnan(mean_epoch).all() or len(mean_epoch) == 0:
- print("Mean epoch is invalid (all NaN or empty)!")
- QMessageBox.warning(
- self,
- "Template Creation Failed",
- "Could not create a valid ECG template from the detected peaks.",
- QMessageBox.Ok
- )
- return np.array([]), polarity, np.array([])
- # Temporal correlation for ECG detection
- # adapt in case NaNs are present:
- if np.isnan(cropped_data).any():
- cropped_data_clean = np.nan_to_num(cropped_data, nan=0.0)
- r = np.correlate(cropped_data_clean, mean_epoch, mode='same')
- else:
- r = np.correlate(cropped_data, mean_epoch, mode='same')
- threshold = np.percentile(r, 95)
- detected_peaks, _ = scipy.signal.find_peaks(
- r, height=threshold, distance=sf_lfp//2
- )
- # Second pass for refining detection
- refined_template = np.nanmean(
- [cropped_data[
- p - dwindow//2 : p + dwindow//2
- ] for p in detected_peaks if p - dwindow//2 > 0 and p + dwindow//2 < ns
- ], axis=0
- )
- if np.isnan(cropped_data).any():
- cropped_data_clean = np.nan_to_num(cropped_data, nan=0.0)
- r2 = np.correlate(cropped_data_clean, refined_template, mode='same')
- else:
- r2 = np.correlate(cropped_data, refined_template, mode='same')
- threshold2 = np.percentile(r2, detection_threshold)
- final_peaks, _ = scipy.signal.find_peaks(
- r2, height=threshold2, distance=sf_lfp//2
- )
- # Adjust the final peaks to the original data scale
- final_peaks = final_peaks + start_idx
- # Remove peaks in exclusion periods if provided
- if self.exclusion_periods is not None:
- final_peaks = [
- p for p in final_peaks
- if not any(start <= (p / self.dataset_intra.sf) <= end for start, end in self.exclusion_periods)
- ]
- # check that no peak is close to NaN values, if yes, remove them
- peaks_to_remove = []
- for peak in final_peaks:
- start = peak - pre_samples
- end = peak + post_samples
- if start < 0 or end >= len(full_data):
- continue
- if np.isnan(full_data[start:end]).any():
- peaks_to_remove.append(peak)
- final_peaks = [p for p in final_peaks if p not in peaks_to_remove]
- # plot the detected peaks
- self.canvas_detected_peaks.setEnabled(True)
- self.toolbar_detected_peaks.setEnabled(True)
- self.ax_detected_peaks.clear()
- self.ax_detected_peaks.set_title('Detected Peaks')
- self.ax_detected_peaks.plot(times, full_data, label='Raw LFP', color='black')
- self.ax_detected_peaks.plot(
- np.array(times)[final_peaks],
- np.array(full_data)[final_peaks],
- 'ro', label='Detected Peaks'
- )
- self.canvas_detected_peaks.draw()
- # Estimate HR
- peak_intervals = np.diff(final_peaks) / sf_lfp # Convert to seconds
- hr = 60 / np.mean(peak_intervals) if len(peak_intervals) > 0 else 0
- self.label_heart_rate_lfp.setText(f'Heart rate: {hr} bpm')
- ## store start and end times of cleaning
- self.after_first_stim_pulses = last_peak_start
- self.before_last_stim_pulses = first_peak_end
- return final_peaks, polarity, mean_epoch
- #######################################################################
- ######### INTERPOLATION METHOD #########
- #######################################################################
- def start_ecg_cleaning_interpolation(self):
- self.ax_ecg_clean.clear()
- self.ax_ecg_artifact.clear()
- self.ax_psd.clear()
- """Start the ECG cleaning process using the interpolation method from Perceive toolbox."""
- try:
- clean_ecg_interpolation(self)
- except Exception as e:
- QMessageBox.critical(self, "Error", f"Failed to clean ECG: {e}")
- def clean_ecg_interpolation(self):
- if self.config['NoSync'] == True:
- full_data = self.dataset_intra.raw_data.get_data()[
- self.dataset_intra.selected_channel_index_ecg
- ]
- else:
- full_data = self.dataset_intra.synced_data.get_data()[
- self.dataset_intra.selected_channel_index_ecg
- ]
- nb_points = len(full_data)
- times = np.arange(nb_points) * (1.0 / self.dataset_intra.sf)
- ############################################################################
- # prepare a copy of the full data to store the cleaned data
- clean_data = np.copy(full_data)
- ns = len(full_data)
- #### INTERPOLATE DATA AT EACH R-PEAK FOUND ####
- # Remove artifacts (simple interpolation)
- for p in self.final_peaks:
- clean_data[max(0, p - 5): min(ns, p + 5)] = np.nan # NaN out artifacts
- clean_data = np.interp(
- np.arange(ns), np.arange(ns)[~np.isnan(clean_data)],
- clean_data[~np.isnan(clean_data)]
- )
- if self.dataset_intra.selected_channel_index_ecg == 0:
- self.dataset_intra.cleaned_ecg_left = clean_data
- elif self.dataset_intra.selected_channel_index_ecg == 1:
- self.dataset_intra.cleaned_ecg_right = clean_data
- # plot an overlap of the raw and cleaned data
- self.canvas_ecg_clean.setEnabled(True)
- self.toolbar_ecg_clean.setEnabled(True)
- self.ax_ecg_clean.clear()
- self.ax_ecg_clean.set_title("Cleaned ECG Signal")
- self.ax_ecg_clean.plot(times,full_data, label='Raw data')
- self.ax_ecg_clean.plot(times,clean_data, label='Cleaned data')
- self.ax_ecg_clean.set_xlabel("Time (s)")
- self.ax_ecg_clean.set_ylabel("Amplitude")
- self.ax_ecg_clean.legend()
- self.canvas_ecg_clean.draw()
- # Plot an overlap of the power spectrum using welch's method:
- n_fft = int(round(self.dataset_intra.sf))
- n_overlap=int(round(self.dataset_intra.sf)/2)
- # make sure that stimulation pulses are not included in the PSD calculation
- start_index = int(self.after_first_stim_pulses * self.dataset_intra.sf)
- end_index = int(self.before_last_stim_pulses * self.dataset_intra.sf)
- psd_raw, freqs_raw = mne.time_frequency.psd_array_welch(
- full_data[start_index: end_index],self.dataset_intra.sf,fmin=0,
- fmax=125,n_fft=n_fft,
- n_overlap=n_overlap)
- psd_clean, freqs_clean = mne.time_frequency.psd_array_welch(
- clean_data[start_index: end_index],self.dataset_intra.sf,fmin=0,
- fmax=125,n_fft=n_fft,
- n_overlap=n_overlap)
- self.canvas_psd.setEnabled(True)
- self.toolbar_psd.setEnabled(True)
- self.ax_psd.clear()
- self.ax_psd.plot(
- freqs_raw, np.log(psd_raw), color='blue', label='PSD raw channel'
- )
- self.ax_psd.plot(
- freqs_clean, np.log(psd_clean), color = 'orange',
- label='PSD cleaned channel'
- )
- self.ax_psd.legend()
- self.canvas_psd.draw()
- self.btn_confirm_cleaning.setEnabled(True) # Enable the button after cleaning
- #######################################################################
- ######### TEMPLATE SUBSTRACTION METHOD #########
- #######################################################################
- def start_ecg_cleaning_template_sub(self):
- self.ax_ecg_clean.clear()
- self.ax_ecg_artifact.clear()
- self.ax_psd.clear()
- """Start the ECG cleaning process using the template substraction method."""
- try:
- clean_ecg_template_sub(self)
- except Exception as e:
- QMessageBox.critical(self, "Error", f"Failed to clean ECG: {e}")
- def clean_ecg_template_sub(self):
- if self.config['NoSync'] == True:
- full_data = self.dataset_intra.raw_data.get_data()[
- self.dataset_intra.selected_channel_index_ecg
- ]
- else:
- full_data = self.dataset_intra.synced_data.get_data()[
- self.dataset_intra.selected_channel_index_ecg
- ]
- nb_points = len(full_data)
- times = np.arange(nb_points) * (1.0 / self.dataset_intra.sf)
- window = [-0.2, 0.2] # QRS complex window
- ############################################################################
- clean_data = np.copy(full_data)
- ns = len(full_data)
- # Create a QRS template #
- pre_samples = int(abs(window[0]) * self.dataset_intra.sf)
- post_samples = int(window[1] * self.dataset_intra.sf)
- epoch_length = pre_samples + post_samples # Total length of each epoch
- # timescale_epoch = np.linspace(
- # window[0], window[1], epoch_length
- # ) # Time in seconds
- timescale_epoch = np.arange(-pre_samples, post_samples) / self.dataset_intra.sf # Time in seconds
- epochs = [] # Store extracted heartbeats
- for peak in self.final_peaks:
- start = peak - pre_samples
- end = peak + post_samples
- epochs.append(full_data[start:end])
- epochs = np.array(epochs)
- # Compute average QRS template
- mean_epoch = np.nanmean(epochs, axis=0)
- self.canvas_ecg_artifact.setEnabled(True)
- self.toolbar_ecg_artifact.setEnabled(True)
- self.ax_ecg_artifact.clear()
- self.ax_ecg_artifact.set_title("Detected QRS epochs")
- for epoch in epochs:
- self.ax_ecg_artifact.plot(
- timescale_epoch, epoch, color='gray', alpha=0.3
- )
- self.ax_ecg_artifact.plot(
- timescale_epoch, mean_epoch, color='black', linewidth=2,
- label='Average QRS Template'
- )
- self.ax_ecg_artifact.set_xlabel("Time (s)")
- self.ax_ecg_artifact.set_ylabel("Amplitude")
- self.ax_ecg_artifact.legend()
- self.canvas_ecg_artifact.draw()
- ####################################################################
- pre_samples = int(abs(window[0]) * self.dataset_intra.sf)
- post_samples = int(window[1] * self.dataset_intra.sf)
- for _, peak in enumerate(self.final_peaks):
- raw_epoch = full_data[(peak - pre_samples):(peak + post_samples)]
- # Prepare design matrix for linear fit (scale + offset)
- X_template = np.vstack([mean_epoch, np.ones_like(mean_epoch)]).T
- # Solve for optimal scale (a) and offset (b) using least squares
- coeffs, _, _, _ = np.linalg.lstsq(X_template, raw_epoch, rcond=None)
- a, b = coeffs
- # Build fitted template
- fitted_template = a * mean_epoch + b
- # Equalize tails
- complex_qrs_template, start_idx, end_idx = find_similar_sample(
- fitted_template, tails=30
- )
- start = (peak - pre_samples) + start_idx
- end = (peak - pre_samples) + end_idx
- raw_epoch = full_data[start:end]
- assert len(raw_epoch) == len(complex_qrs_template), "Raw epoch length does not match complex QRS template length"
- clean_data[start:end] -= complex_qrs_template
- if self.dataset_intra.selected_channel_index_ecg == 0:
- self.dataset_intra.cleaned_ecg_left = clean_data
- elif self.dataset_intra.selected_channel_index_ecg == 1:
- self.dataset_intra.cleaned_ecg_right = clean_data
- # plot an overlap of the raw and cleaned data
- self.canvas_ecg_clean.setEnabled(True)
- self.toolbar_ecg_clean.setEnabled(True)
- self.ax_ecg_clean.clear()
- self.ax_ecg_clean.set_title("Cleaned ECG Signal")
- self.ax_ecg_clean.plot(times, full_data, label='Raw data')
- self.ax_ecg_clean.plot(times, clean_data, label='Cleaned data')
- self.ax_ecg_clean.set_xlabel("Time (s)")
- self.ax_ecg_clean.set_ylabel("Amplitude")
- self.ax_ecg_clean.legend()
- self.canvas_ecg_clean.draw()
- # Plot an overlap of the power spectrum using welch's method:
- n_fft = int(round(self.dataset_intra.sf))
- n_overlap=int(round(self.dataset_intra.sf)/2)
- # make sure that stimulation pulses are not included in the PSD calculation
- start_index = int(self.after_first_stim_pulses * self.dataset_intra.sf)
- end_index = int(self.before_last_stim_pulses * self.dataset_intra.sf)
- psd_raw, freqs_raw = mne.time_frequency.psd_array_welch(
- full_data[start_index: end_index],self.dataset_intra.sf,fmin=0,
- fmax=125,n_fft=n_fft,
- n_overlap=n_overlap)
- psd_clean, freqs_clean = mne.time_frequency.psd_array_welch(
- clean_data[start_index: end_index],self.dataset_intra.sf,fmin=0,
- fmax=125,n_fft=n_fft,
- n_overlap=n_overlap)
- self.canvas_psd.setEnabled(True)
- self.toolbar_psd.setEnabled(True)
- self.ax_psd.clear()
- self.ax_psd.plot(
- freqs_raw, np.log(psd_raw), color='blue', label='PSD raw channel'
- )
- self.ax_psd.plot(
- freqs_clean, np.log(psd_clean), color = 'orange',
- label='PSD cleaned channel'
- )
- self.ax_psd.legend()
- self.canvas_psd.draw()
- self.btn_confirm_cleaning.setEnabled(True) # Enable the button after cleaning
- #######################################################################
- ######### SINGULAR VALUE DECOMPOSITION METHOD #########
- #######################################################################
- def start_ecg_cleaning_svd(self):
- """Start the ECG cleaning process using Singular Value Decomposition method."""
- self.ax_ecg_clean.clear()
- self.ax_ecg_artifact.clear()
- self.ax_psd.clear()
- try:
- clean_ecg_svd(self)
- except Exception as e:
- QMessageBox.critical(self, "Error", f"Failed to clean ECG: {e}")
- def clean_ecg_svd(self):
- """
- This function cleans the ECG signal using Singular Value Decomposition (SVD).
- It extracts QRS templates around the R-peaks, performs SVD on these epochs,
- and then reconstructs the signal using the first few singular values.
- The function opens a secondary plotting window to visualize the SVD results,
- so that the user can choose which components to keep to reconstruct the signal.
- """
- if self.config['NoSync'] == True:
- self.full_data = self.dataset_intra.raw_data.get_data()[
- self.dataset_intra.selected_channel_index_ecg
- ]
- else:
- self.full_data = self.dataset_intra.synced_data.get_data()[
- self.dataset_intra.selected_channel_index_ecg
- ]
- self.window = [-0.2, 0.2] # add an option to choose QRS or PQRST window??
- # Create a QRS template
- pre_samples = int(abs(self.window[0]) * self.dataset_intra.sf)
- post_samples = int(self.window[1] * self.dataset_intra.sf)
- self.epoch_length = pre_samples + post_samples # Total length of each epoch
- epochs = [] # Store extracted heartbeats
- for peak in self.final_peaks:
- start = peak - pre_samples
- end = peak + post_samples
- epochs.append(self.full_data[start:end])
- epochs = np.array(epochs) # shape: (n timepoints, n epochs)
- ######### SINGULAR VALUE DECOMPOSITION ################
- X = epochs.T # shape: (n epochs, n timepoints)
- self.U, self.S, self.Vh = np.linalg.svd(X, full_matrices=False)
- # Open the secondary plotting window for the SVD template
- self.plot_window = PlotWindow(
- self.process_value_from_plot, self.U, self.S, self.window,
- self.epoch_length
- )
- self.plot_window.show()
- def simple_peak_detection_fallback(cropped_data, sf_lfp):
- """
- Fallback method for peak detection using a simpler approach in case detection
- with usual thresholds fails.
- This method applies peak detection directly to the entire signal.
- """
- # Try peak detection on the entire signal with different parameters
- for height_pct in [90, 80, 70, 60, 50, 40]:
- for distance_factor in [2, 3, 4, 5]: # Different distance constraints
- distance = sf_lfp // distance_factor
- # Try positive peaks
- height_pos = np.percentile(cropped_data, height_pct)
- peaks_pos, _ = scipy.signal.find_peaks(
- cropped_data, height=height_pos, distance=distance
- )
- # Try negative peaks
- height_neg = np.percentile(-cropped_data, height_pct)
- peaks_neg, _ = scipy.signal.find_peaks(
- -cropped_data, height=height_neg, distance=distance
- )
- # Return the first successful detection with reasonable number of peaks
- if len(peaks_pos) >= 3: # At least 3 peaks for meaningful analysis
- return peaks_pos, 'Up'
- elif len(peaks_neg) >= 3:
- return peaks_neg, 'Down'
- return np.array([]), 'Unknown'
ecg_cleaning.py at commit d91e8d1, under MIT · at the source
Overview
- Department of Neurology, Charité-Universitätsmedizin Berlin, Berlin, Germany
- Humboldt-Universität zu Berlin, Berlin School of Mind and Brain, Berlin, Germany
- Berlin Institute of Health (BIH), Berlin, Germany
- Bernstein Center for Computational Neuroscience, Humboldt-Universität, Berlin, Germany
- NeuroCure, Exzellenzcluster, Charité-Universitätsmedizin Berlin, Berlin, Germany
- DZNE, German Center for Neurodegenerative Diseases, Berlin, Germany
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
d91e8d186a5eda8a5f57897105f2487a543d7af0, 11 June 2026Availability: 1 check, the latest on 27 September 2026: the link answers
- 27 September 2026: the link answers
25 files
- DBSsync_main.py, Python, 1,193 lines, 2 matches
- functions/
__init__.py , Python, 1 line - functions/
classes.py , Python, 157 lines - functions/
ecg_cleaning.py , Python, 1,210 lines, 7 matches - functions/
find_artifacts.py , Python, 217 lines, 3 matches - functions/
interactive.py , Python, 551 lines, 1 match - functions/
io.py , Python, 1,497 lines, 1 match - functions/
plotting.py , Python, 340 lines, 1 match - functions/
timeshift.py , Python, 322 lines - functions/
tmsi_poly5reader.py , Python, 262 lines - functions/
utils.py , Python, 585 lines - mnelab/
__init__.py , Python, 1 line - mnelab/
io/ , Python, 1 line__init__.py - mnelab/
io/ , Python, 88 linesreaders.py - mnelab/
io/ , Python, 291 linesxdf.py - pyxdftools/
__init__.py , Python, 1 line - pyxdftools/
_init_.py , Python, 1 line - pyxdftools/
antxdfdata.py , Python, 111 lines - pyxdftools/
constants.py , Python, 8 lines - pyxdftools/
errors.py , Python, 22 lines - pyxdftools/
helpers.py , Python, 10 lines - pyxdftools/
rawxdf.py , Python, 196 lines - pyxdftools/
xdfdata.py , Python, 385 lines - LICENSE, License, 21 lines
- README.md, Text, 12 lines
neuromodulation/perceive
23b12f2da3093f623cb192157e4dfc65b19a0c70, 10 September 2026Availability: 1 check, the latest on 27 September 2026: the link answers
- 27 September 2026: the link answers
87 files
- MockData/
generateMOCK.m , MATLAB, 464 lines - MockData/
generateMOCKGroupHistory , MATLAB, 5 lines.m - buildUtilities/
badgesforToolbox.m , MATLAB, 52 lines - buildUtilities/
buildToolbox.m , MATLAB, 32 lines - buildUtilities/
codecheckToolbox.m , MATLAB, 54 lines - buildUtilities/
gendocToolbox.m , MATLAB, 16 lines - buildUtilities/
packageToolbox.m , MATLAB, 120 lines - buildUtilities/
testToolbox.m , MATLAB, 60 lines - buildUtilities/
writeBadgeJSONFile.m , MATLAB, 29 lines - buildfile.m, MATLAB, 71 lines
- install/
perceive_no_license_linu , Shell, 155 linesx.sh - install/
perceive_no_license_macO , Shell, 150 linesS.sh - startup.m, MATLAB, 11 lines
- tests/
testAddNumbers.m , MATLAB, 18 lines - tests/
testPerceiveModularPerce , MATLAB, 119 linesive.m - tests/
testProcessData.m , MATLAB, 97 lines - toolbox/
build_perceive_standalon , MATLAB, 28 linese.m - toolbox/
check_stim.m , MATLAB, 4 lines - toolbox/
ci_build_perceive_standa , MATLAB, 33 lineslone.m - toolbox/
config/ , MATLAB, 91 linesperceive_localsettings_c onstruction.m - toolbox/
config/ , MATLAB, 131 linesperceive_localsettings_c onstruction_mat.m - toolbox/
helper_functions/ , MATLAB, 3 linesaddNumbers.m - toolbox/
helper_functions/ , MATLAB, 60 linescheck_fullname.m - toolbox/
helper_functions/ , MATLAB, 24 linesonAppClose.m - toolbox/
helper_functions/ , MATLAB, 13 linesset_firstsample.m - toolbox/
perceive.m , MATLAB, 845 lines - toolbox/
perceive_GroupHistory.m , MATLAB, 113 lines - toolbox/
perceive_call_ecg_cleani , MATLAB, 12 linesng.m - toolbox/
perceive_check_and_corre , MATLAB, 91 linesct_lfp_missingData_in_js on.m - toolbox/
perceive_check_dataversi , MATLAB, 16 lineson.m - toolbox/
perceive_check_mod_ext.m , MATLAB, 36 lines - toolbox/
perceive_check_stim.m , MATLAB, 79 lines - toolbox/
perceive_ci.m , MATLAB, 46 lines - toolbox/
perceive_ecg.m , MATLAB, 175 lines - toolbox/
perceive_exe_directory_f , MATLAB, 20 linesor_logging.m - toolbox/
perceive_export_bsl_csv. , MATLAB, 25 linesm - toolbox/
perceive_export_signalch , MATLAB, 29 lineseck.m - toolbox/
perceive_extract_brainse , MATLAB, 81 linesnsesurvey.m - toolbox/
perceive_extract_brainse , MATLAB, 389 linesnsesurveystimedomain.m - toolbox/
perceive_extract_bsl.m , MATLAB, 189 lines - toolbox/
perceive_extract_bstd.m , MATLAB, 502 lines - toolbox/
perceive_extract_calibra , MATLAB, 114 linestiontests.m - toolbox/
perceive_extract_diagnos , MATLAB, 118 linestic_lfpsnapshot.m - toolbox/
perceive_extract_diagnos , MATLAB, 162 linestic_lfptrend.m - toolbox/
perceive_extract_hdr.m , MATLAB, 271 lines - toolbox/
perceive_extract_impedan , MATLAB, 45 linesce.m - toolbox/
perceive_extract_indefin , MATLAB, 170 linesitestreaming.m - toolbox/
perceive_extract_lfp_mon , MATLAB, 80 linestage_time_domain.m - toolbox/
perceive_extract_sensech , MATLAB, 105 linesanneltests.m - toolbox/
perceive_extract_signalc , MATLAB, 60 linesheck.m - toolbox/
perceive_ffind.m , MATLAB, 126 lines - toolbox/
perceive_ffind_old.m , MATLAB, 97 lines - toolbox/
perceive_fft.m , MATLAB, 13 lines - toolbox/
perceive_fftlogfitter.m , MATLAB, 46 lines - toolbox/
perceive_impedance.m , MATLAB, 39 lines - toolbox/
perceive_init_logging_if , MATLAB, 23 lines_deployed.m - toolbox/
perceive_jsonview.m , MATLAB, 441 lines - toolbox/
perceive_launch_gui_star , MATLAB, 14 linestup.m - toolbox/
perceive_load_json.m , MATLAB, 36 lines - toolbox/
perceive_localsettings.m , MATLAB, 241 lines - toolbox/
perceive_localsettings_a , MATLAB, 52 linespply_builtin_default.m - toolbox/
perceive_localsettings_m , MATLAB, 39 linesat.m - toolbox/
perceive_mcc_dependency_ , MATLAB, 26 linestouch.m - toolbox/
perceive_metadata_to_tab , MATLAB, 54 linesle.m - toolbox/
perceive_parse_args.m , MATLAB, 143 lines - toolbox/
perceive_plot_brainsense , MATLAB, 128 linesbip.m - toolbox/
perceive_plot_bsl_bipola , MATLAB, 87 linesr.m - toolbox/
perceive_plot_diagnostic , MATLAB, 56 lines_lfpsnapshot.m - toolbox/
perceive_plot_diagnostic , MATLAB, 37 lines_lfptrend.m - toolbox/
perceive_plot_impedance. , MATLAB, 20 linesm - toolbox/
perceive_plot_raw_signal , MATLAB, 69 liness.m - toolbox/
perceive_plot_signalchec , MATLAB, 61 linesk.m - toolbox/
perceive_power_normaliza , MATLAB, 45 linestion.m - toolbox/
perceive_print.m , MATLAB, 46 lines - toolbox/
perceive_raw_tf.m , MATLAB, 6 lines - toolbox/
perceive_sc.m , MATLAB, 10 lines - toolbox/
perceive_set_dependencie , MATLAB, 147 liness.m - toolbox/
perceive_should_open_sta , MATLAB, 38 linesrtup_gui.m - toolbox/
perceive_stitch_interrup , MATLAB, 215 linestion_together.m - toolbox/
perceive_stitch_interrup , MATLAB, 97 linestion_together_TDtime.m - toolbox/
perceive_stitch_single_i , MATLAB, 144 linesnterruption_together.m - toolbox/
perceive_test_BIDS.m , MATLAB, 2,070 lines - toolbox/
perceive_updateAcq.m , MATLAB, 4 lines - toolbox/
prepare_windows_release_ , MATLAB, 25 linesfolder.m - toolbox/
standalone_scripts/ , MATLAB, 74 linesplot_firstpacketdatetime .m - LICENSE, License, 201 lines
- README.md, Text, 226 lines
Code availability
The code of the toolbox is publicly available in a Github repository and can be accessed via this link: https://
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://
BibTeX
@article{vivien2026dbssy
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/
url = {https://
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/
VL - 12
IS - 1
SP - 151
SN - 2373-8057
PB - Nature Publishing Group
DO - 10.1038/
UR - https://
LA - en
ER -
CSL-JSON
{
"id": "10.1038/
"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":
"volume": "12",
"issue": "1",
"page": "151",
"DOI": "10.1038/
"PMID": "42315529",
"PMCID": "PMC13279824",
"ISSN": "2373-8057",
"publisher": "Nature Publishing Group",
"URL": "https://
"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 diseaseIn 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 medicineIn 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 medicineIn 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 neurologyIn 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 neurologyIn 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 reportsIn 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 advancesIn 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 adaptationJournal: 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 SentencesJournal: n/aIn 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-MEGJournal: n/aIn 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.
Claim this paper
Correct its record
Say what each link of this record is, remove the ones that are not the paper's, add the ones that are missing. The correction becomes a new version of the record, in its Versions section.
Validate its tracing map
You validate the map as this page shows it: 2 repositories of the authors' code, each at its verified commit and with its license, 108 scripts, and 15 matches between paragraphs and code (see the Code and Map sections). It then receives a DOI on Zenodo, with you (your ORCID iD) and OSCR as its creators; the code itself is not deposited.
The map's fingerprint: sha256:544c0bb14b9b5c9d…
Add the badge to its README
The badge links the code to this page. Copy one of these into the README of the paper's code: only you decide where it goes, and nothing is changed for you.
Markdown
[, paste the snippet at the top, then “Commit changes…” and, to review it first, “Create a new branch and start a pull request”. You open the pull request; OSCR asks for no permission.
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.
