def test_constructor_with_high_pass(test_variables, high_pass_cutoff): time_list = test_variables["time"] signal_line = test_variables["line"] _ = FilteredSignal(time_list, signal_line, high_pass_cutoff=high_pass_cutoff) assert True
def test_constructor_with_bad_low_pass(test_variables, low_pass_cutoff): time_list = test_variables["time"] signal_line = test_variables["line"] with pytest.raises(TypeError): _ = FilteredSignal(time_list, signal_line, low_pass_cutoff=low_pass_cutoff)
def test_constructor_with_bad_high_low_pass(test_variables, high_pass_cutoff, low_pass_cutoff): with pytest.raises(ValueError): time_list = test_variables["time"] signal_line = test_variables["line"] _ = FilteredSignal(time_list, signal_line, high_pass_cutoff=high_pass_cutoff, low_pass_cutoff=low_pass_cutoff)
def __init__(self, time, signal, **kwargs): # confirms they are good inputs super().__init__(time, signal, **kwargs) self.high_cutoff = kwargs.get('high_pass_cutoff', 1) if type(self.high_cutoff) != int: raise TypeError("high_cutoff must be type int.") self.low_cutoff = kwargs.get('low_pass_cutoff', 30) if type(self.low_cutoff) != int: raise TypeError("low_cutoff must be type int.") self.threshold_frac = kwargs.get('threshold_frac', 1) if type(self.threshold_frac) != float and type(self.threshold_frac) != int: raise TypeError("threshold_frac must be type int.") elif self.threshold_frac > 1 or self.threshold_frac < 0: raise ValueError("threshold_frac must be between [0,1].") # this does not need to be here for this class... will iron this out later try: self.filtered_signal_obj = FilteredSignal( time=self.time, signal=self.raw_signal, high_pass_cutoff=self.high_cutoff, low_pass_cutoff=self.low_cutoff) self.filtered_signal = self.filtered_signal_obj.filtered_signal self.background = self.filtered_signal_obj.bg_sub_signal self.fs = self.filtered_signal_obj.fs except ValueError or TypeError as e: logging.exception(e) self.filtered_signal_obj = None self.filtered_signal = self.raw_signal self.background = np.zeros(len(self.raw_signal)) self.fs = 0 # print(self.filtered_signal_obj.get_properties()) # processing parameters self.threshold = None self.binary_signal = None self.binary_centers = None self.rising_edges = None self.falling_edges = None self.signal_period = None
def filtered_signal_obj(file_num): filename = "tests/test_data/test_data{}.csv".format(file_num) file = FileHandler(filename) filtered_signal_sp = FilteredSignal(file.time, file.signal) return filtered_signal_sp
def test_constructor(test_variables): time_list = test_variables["time"] signal_line = test_variables["line"] _ = FilteredSignal(time_list, signal_line) assert True
def test_constructor_values(test_variables, time_list, error): with pytest.raises(error): signal_line = test_variables["line"] _ = FilteredSignal(time_list, signal_line)
class Threshold(ECGDetectionAlgorithm): def __init__(self, time, signal, **kwargs): # confirms they are good inputs super().__init__(time, signal, **kwargs) self.high_cutoff = kwargs.get('high_pass_cutoff', 1) if type(self.high_cutoff) != int: raise TypeError("high_cutoff must be type int.") self.low_cutoff = kwargs.get('low_pass_cutoff', 30) if type(self.low_cutoff) != int: raise TypeError("low_cutoff must be type int.") self.threshold_frac = kwargs.get('threshold_frac', 1) if type(self.threshold_frac) != float and type(self.threshold_frac) != int: raise TypeError("threshold_frac must be type int.") elif self.threshold_frac > 1 or self.threshold_frac < 0: raise ValueError("threshold_frac must be between [0,1].") # this does not need to be here for this class... will iron this out later try: self.filtered_signal_obj = FilteredSignal( time=self.time, signal=self.raw_signal, high_pass_cutoff=self.high_cutoff, low_pass_cutoff=self.low_cutoff) self.filtered_signal = self.filtered_signal_obj.filtered_signal self.background = self.filtered_signal_obj.bg_sub_signal self.fs = self.filtered_signal_obj.fs except ValueError or TypeError as e: logging.exception(e) self.filtered_signal_obj = None self.filtered_signal = self.raw_signal self.background = np.zeros(len(self.raw_signal)) self.fs = 0 # print(self.filtered_signal_obj.get_properties()) # processing parameters self.threshold = None self.binary_signal = None self.binary_centers = None self.rising_edges = None self.falling_edges = None self.signal_period = None def find_beats(self) -> np.ndarray: """ Finds the beats from the signal. Returns: numpy.ndarray: Times at which the beats occur. """ self.binary_signal = self.apply_threshold(self.filtered_signal, self.background) self.binary_centers = self._find_binary_centers(self.binary_signal) # find the indices where it equals 1 beat_ind = self._find_indices(self.binary_centers, lambda x: x == 1) if not self.duration: self.duration = self.find_duration() test_bpm = len(beat_ind) / (self.duration / 60) if test_bpm < 40: # reasonable, but still abnormal bpm binary_signal_rev = self.apply_threshold( self.filtered_signal, self.background, reverse_threshold=True) binary_centers_rev = self._find_binary_centers(binary_signal_rev) beat_ind_rev = self._find_indices(binary_centers_rev, lambda x: x == 1) test_bpm_rev = len(beat_ind_rev) / (self.duration / 60) if test_bpm_rev >= 40: self.binary_signal = binary_signal_rev self.binary_centers = binary_centers_rev beat_ind = beat_ind_rev beat_time_list = np.take(self.time, tuple(beat_ind)) return np.array(beat_time_list) def _find_indices(self, values, func) -> list: """ Finds indices of an array given parameters. Args: values (numpy.ndarray): list of values func: lambda function Returns: list: list of indices that fit the lambda function. """ return [i for (i, val) in enumerate(values) if func(val)] def apply_threshold(self, signal=None, background=None, abs_signal=False, reverse_threshold=False) -> np.ndarray: """ Applies a threshold of a certain percentage. Args: reverse_threshold (bool): Reverse threshold from what it should be. background (numpy.ndarray): Supply a background signal to consider. abs_signal (bool): Whether or not to threshold with absolute values. signal (numpy.ndarray): Filtered signal in numpy array. Returns: numpy.ndarray: list of binary values based on threshold. """ logging.info("THRESHOLD apply_threshold called") if signal is None: signal = self.raw_signal if type(signal) != np.ndarray: raise TypeError("signal must be type numpy.ndarray") if type(background) != np.ndarray and background is not None: raise TypeError("background must be type numpy.ndarray") if type(abs_signal) != bool: raise TypeError("abs_signal must be type bool") if type(reverse_threshold) != bool: raise TypeError("reverse_threshold must be type bool") self.threshold, is_negative = self._find_threshold(signal, background, reverse_threshold=reverse_threshold) if abs_signal: signal = abs(signal) if is_negative: bin_sig = signal <= self.threshold else: bin_sig = signal >= self.threshold return bin_sig def _find_threshold(self, signal, background=None, filter_bg: bool = True, reverse_threshold=False) -> tuple: """ Determines threshold based on a absolute-value-filtered/zeroed signal and proportion. Threshold is padded by one period. Note: abs value isn't used because of double/triple counting. Args: reverse_threshold (bool): Reverse threshold of what it should be in terms of positive or negative. filter_bg (bool): Whether or not to filter the background. background (numpy.ndarray): background for the signal. signal (numpy.ndarray): Heart beat signal. Returns: tuple: First is a numpy.ndarray threshold array and second is bool if threshold is negative. """ if filter_bg and background is not None: background = self.filtered_signal_obj.apply_noise_reduction( background, self.low_cutoff + 10, max(0, self.high_cutoff - 5)) try: padding = self.filtered_signal_obj.period if padding: start_ind = padding end_ind = len(self.filtered_signal) - padding padded_signal = signal[start_ind:end_ind] min_v, max_v = self._find_voltage_extremes(padded_signal) else: min_v, max_v = self._find_voltage_extremes(signal) except ValueError as e: logging.exception(e) min_v, max_v = self._find_voltage_extremes(signal) if reverse_threshold: if abs(min_v) < abs(max_v): is_negative = True threshold_value = min_v * self.threshold_frac else: is_negative = False threshold_value = max_v * self.threshold_frac else: if abs(min_v) > abs(max_v): is_negative = True threshold_value = min_v * self.threshold_frac else: is_negative = False threshold_value = max_v * self.threshold_frac # determine if spikes tend to be positive or negative threshold_array = [] if background is None: threshold_array = np.ones(len(self.filtered_signal)) * threshold_value else: for bg_val in background: threshold_array.append(threshold_value - bg_val) return threshold_array, is_negative def _find_num_pm(self, signal) -> tuple: """ Finds the number of values above and below axis. Args: signal: Signal in question. Returns: tuple: First is number of positive, second is number of negative elements. """ signal = np.array(signal) # strictly above or below 0 pos = signal[np.where(signal > 0)] neg = signal[np.where(signal < 0)] return len(pos), len(neg) def find_mean_hr_bpm(self, time_interval=None) -> float: """ Finds the mean heart rate beats per minute for signal. Args: time_interval (tuple): Interval in minutes of the signal to find mean hr bpm. Returns: float: mean heart rate bpm within the designated time interval. """ logging.info("find_mean_hr_bpm called") # get necessary information if not already calculated if self.duration is None: self.duration = self.find_duration() if self.beats is None: self.beats = self.find_beats() if time_interval is None: time_interval = (min(self.time) / 60, max(self.time) / 60) elif type(time_interval) != tuple: raise TypeError("interval must be type tuple.") elif len(time_interval) != 2: raise ValueError("interval tuple must have two elements.") elif (type(time_interval[0]) != float and type(time_interval[0]) != int) or \ (type(time_interval[1]) != float and type(time_interval[1]) != int): raise TypeError("tuple elements must be type float or int.") elif time_interval[0] * 60 < min(self.time) or \ time_interval[1] * 60 > max(self.time): raise ValueError("interval tuple must have proper range.") elif time_interval[0] >= time_interval[1]: raise ValueError("interval tuple must have proper range.") elif (time_interval[1] - time_interval[0]) * 60 > self.duration: # check if they are within range raise ValueError("interval must be less than signal duration.") # find proper signal and time intervals duration_oi_sec = (time_interval[0] * 60, time_interval[1] * 60) duration_indices = (self._find_nearest_index(self.time, duration_oi_sec[0]), self._find_nearest_index(self.time, duration_oi_sec[1])) duration_indices = np.array(duration_indices) if self.binary_centers is None: self.find_beats() bin_center_oi = self.binary_centers[duration_indices[0]: duration_indices[1]] num_beats_oi = self._find_indices(bin_center_oi, lambda x: x == 1) duration_oi = time_interval[1] - time_interval[0] # minutes bpm = float(len(num_beats_oi) / float(duration_oi)) return bpm def plot_graph(self, file_path: str = None): """ Plots a graph of thresholding and frequency information for the threshold algorithm. Args: file_path: The path of the file to output. """ logging.info("THRESHOLD plot_graph called") fig = plt.figure(figsize=(10, 6)) plt.title("{}".format(self.name)) plt.rcParams['text.antialiased'] = True plt.style.use('ggplot') ax1 = fig.add_subplot(211) ax1.grid(True) ax1.plot(self.time, self.raw_signal, label='Raw Signal', linewidth=1, antialiased=True) ax1.plot(self.time, self.filtered_signal, label='Filtered Signal', linewidth=1, antialiased=True) ax1.plot(self.time, np.ones(len(self.time)) * self.threshold, label='Threshold', linewidth=1, antialiased=True) # scale the signals _, max_val = self._find_voltage_extremes(self.filtered_signal) ax1.plot(self.time, self.binary_signal * max_val, label='Binary Signal', linewidth=5, antialiased=True) ax1.plot(self.time, self.binary_centers * max_val, label='Binary Centers', linewidth=5, antialiased=True) ax1.legend(loc='best') ax2 = fig.add_subplot(212) freq_raw, fft_out_raw = self.filtered_signal_obj.get_fft(is_filtered=False) ax2.plot(freq_raw, abs(fft_out_raw), label='Raw Signal', linewidth=1) # plotting the spectrum freq_filtered, fft_out_filtered = self.filtered_signal_obj.get_fft(is_filtered=True) ax2.plot(freq_filtered, abs(fft_out_filtered), label='Filtered Signal', linewidth=1) # plotting the spectrum ax2.set_xlabel('Freq (Hz)') ax2.set_ylabel('|Y(freq)|') ax2.legend(loc='best') fig.tight_layout() if file_path: fig.savefig(file_path) plt.show() plt.close() def _find_binary_centers(self, bin_signal, min_width: int = 1) -> np.ndarray: # first make sure that this is a binary signal """ Finds the centers of the thresholded binary signal. Args: min_width (int): Minimum width for binary signal. bin_signal (numpy.ndarray): binary signal Returns: numpy.ndarray: List of binary values representing the centers of the binary steps. """ if min_width < 1: raise ValueError("min_width must be int greater than 0.") try: self.rising_edges = self._find_rising_edges(bin_signal) self.falling_edges = self._find_falling_edges(bin_signal) except ValueError as e: logging.exception(e) return np.zeros(len(bin_signal)) # puts falling edge at end if there's a incomplete peak at end (test_data1) if len(self.rising_edges) > len(self.falling_edges): temp_falling_edges = self.falling_edges.tolist() temp_falling_edges.append(len(bin_signal)) self.falling_edges = np.array(temp_falling_edges) max_len = min(len(self.rising_edges), len(self.falling_edges)) centers = [] # gets the centers only for i in range(max_len): if (self.falling_edges[i] - self.rising_edges[i]) >= min_width: centers.append(round((self.rising_edges[i] + self.falling_edges[i]) / 2)) # generate actual binary for centers ecg_center_peaks = [] for i in range(len(bin_signal)): if i in centers: ecg_center_peaks.append(1) else: ecg_center_peaks.append(0) return np.array(ecg_center_peaks) def _find_rising_edges(self, bin_signal) -> np.ndarray: """ Finds the rising edge of a binary signal. Args: bin_signal (numpy.ndarray): binary signal Returns: numpy.array: Indices at which a rising edge occurs. """ is_binary = self._confirm_binary(bin_signal) if not is_binary: raise ValueError("Signal is not binary") rising_edges = [] previous_val = 0 for i, val in enumerate(bin_signal): if i == 0: if val == 0: previous_val = 0 elif val == 1: previous_val = 1 elif previous_val == 1 and val == 1: previous_val = 1 elif previous_val == 0 and val == 1: previous_val = 1 rising_edges.append(i) elif val == 0: previous_val = 0 return np.array(rising_edges) def _find_falling_edges(self, bin_signal) -> np.ndarray: """ Finds the falling edge of a binary signal. Args: bin_signal (numpy.ndarray): binary signal Returns: numpy.array: Indices at which a falling edge occurs. """ is_binary = self._confirm_binary(bin_signal) if not is_binary: raise ValueError("Signal is not binary") falling_edges = [] previous_val = 0 for i, val in enumerate(bin_signal): if i == 0: if val == 0: previous_val = 0 elif val == 1: previous_val = 1 elif previous_val == 1 and val == 0: previous_val = 0 falling_edges.append(i - 1) elif val == 1: previous_val = 1 return np.array(falling_edges) def _confirm_binary(self, signal) -> bool: """ Tests of the signal is a binary signal of 0s and 1s Args: signal (numpy.ndarray): signal to test Returns: bool: Whether or not signal is a binary signal """ signal = np.array(signal) return np.array_equal(signal, signal.astype(bool))