Source code for pyOMA.core.PreProcessingTools

# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2025  Simon Marwitz, Volkmar Zabel, Andrei Udrea et al.
"""Signal pre-processing: GeometryProcessor, PreProcessSignals, SignalPlot."""
import os
import csv
import datetime

import numpy as np
import scipy.signal
import matplotlib.pyplot as plt
from .Helpers import simplePbar, validate_array, ConfigFile

import logging
logger = logging.getLogger(__name__)
logger.setLevel(level=logging.INFO)


[docs] class GeometryProcessor(object): """Stores structural geometry for mode-shape visualisation. Holds node coordinates, structural connectivity lines, and parent-child (skewed-sensor) relationships. Passed to :class:`~pyOMA.core.PlotMSH.ModeShapePlot` after loading with :meth:`load_geometry`. Parameters ---------- nodes : dict, optional Mapping ``{node_name: (x, y, z)}``. lines : list of (str, str), optional Connectivity list ``[(node_start, node_end), ...]``. parent_childs : list of tuple, optional Skewed-sensor parent-child relations, each entry ``(parent_node, x_amp, y_amp, z_amp, child_node, x_amp, y_amp, z_amp)``. Notes ----- Conventions: * ``chan_dofs = [(chan, node, (x_amplif, y_amplif, z_amplif)), ...]`` * Channels are numbered ``0 ... N-1`` (complete sequence). * Node names are strings; coordinates are ``(x, y, z)`` float tuples. * Lines are unordered pairs ``(node_start, node_end)``. * Parent-child entries are 8-tuples as described in *Parameters*. .. TODO:: * change parent_child assignment to skewed coordinate * change parent_childs to az, elev """
[docs] def __init__(self, nodes=None, lines=None, parent_childs=None): if nodes is None: nodes = {} if lines is None: lines = [] if parent_childs is None: parent_childs = [] super().__init__() self.nodes = {} if not isinstance(nodes, dict): raise TypeError(f"nodes must be dict, got {type(nodes).__name__!r}") self.add_nodes(nodes) self.lines = [] if not isinstance(lines, (list, tuple, np.ndarray)): raise TypeError(f"lines must be list, tuple, or ndarray, got {type(lines).__name__!r}") self.add_lines(lines) self.parent_childs = [] if not isinstance(parent_childs, (list, tuple, np.ndarray)): raise TypeError(f"parent_childs must be list, tuple, or ndarray, got {type(parent_childs).__name__!r}") self.add_parent_childs(parent_childs)
[docs] @staticmethod def nodes_loader(filename): ''' nodes file uses one header line tab-separated file node is treated as a string x,y,z are treated as floats (in scientific format) ''' nodes = {} with open(filename, 'r') as f: f.__next__() for line1 in csv.reader(f, delimiter='\t', skipinitialspace=True): line = [] for val in line1: if not val: continue line += val.split() if not line: continue if line[0].startswith('#'): break node, x, y, z = [float(line[i]) if i >= 1 else line[i].strip( ' ') for i in range(4)] # cut trailing empty columns nodes[node] = [x, y, z] return nodes
[docs] @staticmethod def lines_loader(filename): ''' lines file uses one header line tab-separated file nodenames are treated as strings ''' lines = [] with open(filename, 'r') as f: f.__next__() for line1 in csv.reader(f, delimiter='\t', skipinitialspace=True): line = [] for val in line1: if not val: continue line += val.split() if not line: continue if line[0].startswith('#'): break node_start, node_end = \ [line[i] for i in range(2)] # cut trailing empty columns lines.append((node_start, node_end)) return lines
[docs] @staticmethod def parent_childs_loader(filename): ''' lines file uses one header line tab-separated file nodenames are treated as strings amplification factors are treated as floats ''' parent_childs = [] with open(filename, 'r') as f: f.__next__() reader = csv.reader(f, delimiter='\t', skipinitialspace=True) for line1 in reader: line = [] for val in line1: if not val: continue line += val.split() if not line: continue if line[0].startswith('#'): break i_m, x_m, y_m, z_m, i_sl, x_sl, y_sl, z_sl = [ float(line[i]) if i not in [0, 4] else line[i].strip(' ') for i in range(8)] parent_childs.append( (i_m, x_m, y_m, z_m, i_sl, x_sl, y_sl, z_sl)) return parent_childs
[docs] @staticmethod def nodes_saver(filename, nodes): ''' Write a nodes file readable by :meth:`nodes_loader`: one header line, then tab-separated ``node_name, x, y, z`` rows. Parameters ---------- filename : str Path to write to. nodes : dict Mapping ``{node_name: (x, y, z)}``. ''' with open(filename, 'w', newline='') as f: writer = csv.writer(f, delimiter='\t') writer.writerow(['node_name', 'x', 'y', 'z']) for node_name, (x, y, z) in nodes.items(): writer.writerow([node_name, x, y, z])
[docs] @staticmethod def lines_saver(filename, lines): ''' Write a lines file readable by :meth:`lines_loader`: one header line, then tab-separated ``node_start, node_end`` rows. Parameters ---------- filename : str Path to write to. lines : list of (str, str) Connectivity list ``[(node_start, node_end), ...]``. ''' with open(filename, 'w', newline='') as f: writer = csv.writer(f, delimiter='\t') writer.writerow(['node_name_1', 'node_name_2']) for node_start, node_end in lines: writer.writerow([node_start, node_end])
[docs] @staticmethod def parent_childs_saver(filename, parent_childs): ''' Write a parent-child file readable by :meth:`parent_childs_loader`: one header line, then tab-separated 8-tuple rows. Parameters ---------- filename : str Path to write to. parent_childs : list of tuple Each entry ``(parent_node, x_ampl, y_ampl, z_ampl, child_node, x_ampl, y_ampl, z_ampl)``. ''' with open(filename, 'w', newline='') as f: writer = csv.writer(f, delimiter='\t') writer.writerow([ 'parent_node', 'x_parent', 'y_parent', 'z_parent', 'child_node', 'x_child', 'y_child', 'z_child']) for parent_child in parent_childs: writer.writerow(list(parent_child))
[docs] def save_geometry( self, nodes_file, lines_file=None, parent_childs_file=None): """Save geometry to tab-separated text files. Mirrors :meth:`load_geometry`'s file layout so the result can be loaded back with it. Parameters ---------- nodes_file : str Path to write the nodes file to. lines_file : str, optional Path to write the lines file to. Skipped if *None*. parent_childs_file : str, optional Path to write the parent-child file to. Skipped if *None*. """ self.nodes_saver(nodes_file, self.nodes) if lines_file is not None: self.lines_saver(lines_file, self.lines) if parent_childs_file is not None: self.parent_childs_saver(parent_childs_file, self.parent_childs)
[docs] @classmethod def load_geometry( cls, nodes_file, lines_file=None, parent_childs_file=None): """Load geometry from tab-separated text files. Parameters ---------- nodes_file : str Path to the nodes file (node name + x, y, z coordinates, tab-separated, one header line). lines_file : str, optional Path to the lines file (start_node, end_node pairs, tab-separated). parent_childs_file : str, optional Path to the parent-child file describing skewed-sensor relationships. Returns ------- GeometryProcessor Populated geometry object ready to pass to :class:`~pyOMA.core.PlotMSH.ModeShapePlot`. """ geometry_data = cls() nodes = geometry_data.nodes_loader(nodes_file) geometry_data.add_nodes(nodes) if lines_file is not None and os.path.exists(lines_file): lines = geometry_data.lines_loader(lines_file) geometry_data.add_lines(lines) if parent_childs_file is not None and os.path.exists(parent_childs_file): parent_childs = geometry_data.parent_childs_loader( parent_childs_file) geometry_data.add_parent_childs(parent_childs) return geometry_data
def add_nodes(self, nodes): for item in nodes.items(): try: self.add_node(*item) except BaseException: logger.warning( 'Something was wrong while adding node {}. Continuing!'.format(item)) continue def add_node(self, node_name, coordinate_list): node_name = str(node_name) if node_name in self.nodes.keys(): logger.warning('Node {} is already defined. Overwriting.'.format(node_name)) if not isinstance(coordinate_list, (list, tuple)): raise RuntimeError( 'Coordinates must be provided as (x,y,z) tuples/lists.') if len(coordinate_list) != 3: raise RuntimeError( 'Coordinates must be provided as (x,y,z) tuples/lists.') try: node_name = str(node_name) coordinate_list = list(coordinate_list) for i in range(3): coordinate_list[i] = float(coordinate_list[i]) except ValueError: raise RuntimeError( 'Coordinate {} at position {} could not be converted to float.'.format( coordinate_list[i], i)) except BaseException: raise self.nodes[node_name] = tuple(coordinate_list) def _remove_lines_for_node(self, node_name): """Remove all lines connected to *node_name* from ``self.lines``.""" while True: for j in range(len(self.lines)): if node_name in self.lines[j]: del self.lines[j] break else: break def _remove_parent_childs_for_node(self, node_name): """Remove all parent-child entries that reference *node_name*.""" while True: for j, parent_child in enumerate(self.parent_childs): if node_name == parent_child[0] or node_name == parent_child[4]: del self.parent_childs[j] break else: break def take_node(self, node_name): if node_name not in self.nodes: logger.warning('Node not defined. Exiting') return self._remove_lines_for_node(node_name) self._remove_parent_childs_for_node(node_name) del self.nodes[node_name] logger.info('Node {} removed.'.format(node_name)) def add_lines(self, lines): for line in lines: try: self.add_line(line) except BaseException: logger.warning( 'Something was wrong while adding line {}. Continuing!'.format(line)) continue def add_line(self, line): if not isinstance(line, (list, tuple)): raise RuntimeError( 'Line has to be provided in format (start_node, end_node).') if len(line) != 2: raise RuntimeError( 'Line has to be provided in format (start_node, end_node).') line = [str(line[0]), str(line[1])] if line[0] not in self.nodes or line[1] not in self.nodes: logger.warning('One of the end-nodes of line {} not defined!'.format(line)) else: for line_ in self.lines: if line_[0] == line[0] and line_[1] == line[1]: logger.info('Line {} was defined, already.'.format(line)) self.lines.append(line) def take_line(self, line=None, line_ind=None): if line is not None and line_ind is not None: raise ValueError("At most one of 'line' or 'line_ind' may be specified, not both.") if line is not None: for line_ind in range(len(self.lines)): line_ = self.lines[line_ind] if line[0] == line_[0] and line[1] == line_[1]: break else: logger.warning('Line {} was not found.'.format(line)) return del self.lines[line_ind] logger.info('Line {} at index {} removed.'.format(line, line_ind)) def add_parent_childs(self, parent_childs): for ms in parent_childs: try: self.add_parent_child(ms) except BaseException: logger.warning( 'Something was wrong while adding parent-child-definition {}. Continuing!'.format(ms)) continue def _validate_parent_child_format(self, ms): """Validate format of a parent-child definition tuple.""" if not isinstance(ms, (list, tuple)): raise RuntimeError( 'parent child definition has to be provided in format (start_node, end_node).') if len(ms) != 8: raise RuntimeError( 'parent child definition has to be provided in format (parent_node, x_ampli, y_ampli, z_ampli, child_node, x_ampli, y_ampli, z_ampli).') return ( str(ms[0]), float(ms[1]), float(ms[2]), float(ms[3]), str(ms[4]), float(ms[5]), float(ms[6]), float(ms[7])) def _check_parent_child_duplicate(self, ms): """Log a warning if *ms* is already present in ``self.parent_childs``.""" for ms_ in self.parent_childs: if all(ms_[i] == ms[i] for i in range(8)): logger.info( 'parent child definition {} was defined, already.'.format(ms)) break def add_parent_child(self, ms): ms = self._validate_parent_child_format(ms) if ms[0] not in self.nodes or ms[4] not in self.nodes: logger.warning( 'One of the nodes of parent child definition {} not defined!'.format(ms)) else: self._check_parent_child_duplicate(ms) self.parent_childs.append(ms) def take_parent_child(self, ms=None, ms_ind=None): if ms is not None and ms_ind is not None: raise ValueError("At most one of 'ms' or 'ms_ind' may be specified, not both.") if ms is not None: for ms_ind in range(len(self.parent_childs)): ms_ = self.parent_childs[ms_ind] if all(ms_[i] == ms[i] for i in range(8)): break else: logger.warning('parent child definition {} was not found.'.format(ms)) return del self.parent_childs[ms_ind] logger.info('parent child definition {} at index {} removed.'.format(ms, ms_ind)) def rescale_geometry(self, factor): pass
[docs] class PreProcessSignals(object): """Pre-processor for multi-channel ambient-vibration signals. Provides signal conditioning (filtering, decimation, offset removal, scaling), spectral estimation (Welch, Blackman-Tukey), and book-keeping of channel metadata (reference channels, measurement quantities, channel-DOF assignments). All downstream pyOMA modules (system identification, stabilisation diagram, mode-shape visualisation) consume an instance of this class. .. TODO:: * time-step integration of signals * Multi-block Blackman-Tukey PSD """ def _validate_inputs(self, signals, sampling_rate, F): """Validate constructor arguments for signals, sampling_rate, and F.""" if not isinstance(signals, np.ndarray): raise TypeError(f"signals must be a numpy ndarray, got {type(signals)}") if signals.shape[0] <= signals.shape[1]: raise ValueError( f"signals must have more rows (time steps) than columns (channels); " f"got shape {signals.shape}") if not isinstance(sampling_rate, (int, float)): raise TypeError(f"sampling_rate must be a number, got {type(sampling_rate)}") if F is not None and not isinstance(F, np.ndarray): raise TypeError(f"F must be a numpy ndarray, got {type(F)}") def _resolve_quantity_defaults(self, accel_channels, velo_channels, disp_channels): """Return (accel, velo, disp) channel lists with None replaced by defaults.""" if disp_channels is None: disp_channels = [] if velo_channels is None: velo_channels = [] if accel_channels is None: accel_channels = [c for c in range(self.num_analised_channels) if c not in disp_channels and c not in velo_channels] return accel_channels, velo_channels, disp_channels def _warn_undefined_quantities(self, accel_channels, velo_channels, disp_channels): """Warn if any channel is not assigned to exactly one quantity.""" for chan in range(self.num_analised_channels): n_assigned = ( (chan in accel_channels) + (chan in velo_channels) + (chan in disp_channels) ) if n_assigned != 1: logger.warning(f'Quantity of channel {chan} is not defined.') def _setup_channel_mapping(self, accel_channels, velo_channels, disp_channels): """Initialise the channel-quantity mappings (accel/velo/disp).""" self._accel_channels = [] self._velo_channels = [] self._disp_channels = [] accel_channels, velo_channels, disp_channels = self._resolve_quantity_defaults( accel_channels, velo_channels, disp_channels) self._warn_undefined_quantities(accel_channels, velo_channels, disp_channels) self.accel_channels = accel_channels self.velo_channels = velo_channels self.disp_channels = disp_channels def _setup_metadata(self, setup_name, channel_headers, start_time): """Validate and store setup_name, channel_headers, and start_time.""" if setup_name is None: setup_name = '' if not isinstance(setup_name, str): raise TypeError(f"setup_name must be str, got {type(setup_name).__name__!r}") self.setup_name = setup_name if channel_headers is not None: if len(channel_headers) != self.num_analised_channels: raise ValueError( f"channel_headers must have length {self.num_analised_channels} " f"(num_analised_channels), got {len(channel_headers)}" ) else: channel_headers = list(range(self.num_analised_channels)) self.channel_headers = channel_headers if start_time is not None: if not isinstance(start_time, datetime.datetime): raise TypeError( f"start_time must be datetime.datetime, got {type(start_time).__name__!r}") else: start_time = datetime.datetime.now() self.start_time = start_time def _init_spectral_state(self): """Initialise all spectral estimation result attributes to None.""" self.corr_matrix_wl = None self.corr_matrices_wl = None self.var_corr_wl = None self.psd_matrix_wl = None self.psd_matrices_wl = None self.var_psd_wl = None self.n_lines_wl = None self.m_lags_wl = None self.n_segments_wl = None self.corr_matrix_bt = None self.corr_matrices_bt = None self.var_corr_bt = None self.psd_matrix_bt = None self.psd_matrices_bt = None self.var_psd_bt = None self.n_lines_bt = None self.m_lags_bt = None self.n_segments_bt = None # self.s_vals_cf = None self.s_vals_psd = None
[docs] def __init__(self, signals, sampling_rate, ref_channels=None, accel_channels=None, velo_channels=None, disp_channels=None, **kwargs): """ Parameters ---------- signals : np.ndarray, shape (n_samples, n_channels), or str/os.PathLike Raw measurement time series; must have more rows than columns. A path to a ``.npy`` file is also accepted and loaded via ``np.load``. A ``.npz`` path is rejected with a pointer to :meth:`load_state`, since that format holds a full saved session (multiple fields), not a bare array. sampling_rate : float Sampling frequency in Hz. ref_channels : list of int, optional Column indices of reference (fixed) sensors. Defaults to all channels. accel_channels : list of int, optional Column indices of acceleration channels. Defaults to all channels not in *velo_channels* or *disp_channels*. velo_channels : list of int, optional Column indices of velocity channels. disp_channels : list of int, optional Column indices of displacement channels. Other Parameters ---------------- setup_name : str, optional Label for this measurement setup. channel_headers : list of str, optional Human-readable channel names; defaults to ``[0, 1, 2, ...]``. start_time : datetime.datetime, optional Measurement start timestamp; defaults to ``datetime.datetime.now()``. F : np.ndarray, optional Optional forcing signal array (used only for FRF-based ERA). """ start_time = kwargs.pop('start_time', None) F = kwargs.pop('F', None) setup_name = kwargs.pop('setup_name', None) channel_headers = kwargs.pop('channel_headers', None) super().__init__() if isinstance(signals, (str, os.PathLike)): ext = os.path.splitext(os.fspath(signals))[1].lower() if ext == '.npz': raise ValueError( f"'{signals}' looks like a saved session (.npz); use " "PreProcessSignals.load_state() to load it, not the " "constructor.") signals = np.load(signals) self._validate_inputs(signals, sampling_rate, F) self.signals = np.copy(signals) self.signals_filtered = np.copy(signals) self.sampling_rate = sampling_rate self.F = F self._ref_channels = None if ref_channels is None: ref_channels = list(range(signals.shape[1])) self.ref_channels = ref_channels self._setup_channel_mapping(accel_channels, velo_channels, disp_channels) self._setup_metadata(setup_name, channel_headers, start_time) self.chan_dofs = [] self.channel_factors = [1 for _ in range(self.num_analised_channels)] self.scaling_factors = None self._last_meth = None self._init_spectral_state()
@classmethod def _load_chan_dofs_and_update_headers(cls, chan_dofs_file, headers): """Load chan_dofs from file if given and update headers in-place; return chan_dofs.""" if chan_dofs_file is not None: chan_dofs = cls.load_chan_dofs(chan_dofs_file) else: chan_dofs = None if chan_dofs is not None: cls._apply_chan_dof_headers(chan_dofs, headers) return chan_dofs @classmethod def _apply_delete_if_needed(cls, signals, chan_dofs, delete_channels, ref_channels, motion_channels, headers): """Apply delete_channels if any; return updated tuple.""" accel_channels, velo_channels, disp_channels = motion_channels if delete_channels: return cls._apply_delete_channels( signals, chan_dofs, delete_channels, ref_channels, accel_channels, velo_channels, disp_channels, headers) return (signals, chan_dofs, ref_channels, accel_channels, velo_channels, disp_channels, headers)
[docs] @classmethod def init_from_config( cls, conf_file, meas_file, chan_dofs_file=None, **kwargs): ''' initializes the PreProcessor object with a configuration file to remove channels at loading time use 'usecols' keyword argument if delete_channels are specified, these will be checked against all other channel definitions, which will be adjusted accordingly ''' cfg = ConfigFile(conf_file) name = cfg.str('Setup Name') sampling_rate = cfg.float('Sampling Rate [Hz]') ref_channels = cfg.int_list('Reference Channels') delete_channels = cfg.int_list('Delete Channels') accel_channels = cfg.int_list('Accel. Channels') velo_channels = cfg.int_list('Velo. Channels') disp_channels = cfg.int_list('Disp. Channels') loaded_signals = cls.load_measurement_file(meas_file, **kwargs) signals, headers, start_time = cls._resolve_signals_and_headers( loaded_signals, sampling_rate) chan_dofs = cls._load_chan_dofs_and_update_headers(chan_dofs_file, headers) (signals, chan_dofs, ref_channels, accel_channels, velo_channels, disp_channels, headers) = cls._apply_delete_if_needed( signals, chan_dofs, delete_channels, ref_channels, (accel_channels, velo_channels, disp_channels), headers) num_channels = signals.shape[1] if not accel_channels and not velo_channels and not disp_channels: accel_channels = [i for i in range(num_channels)] prep_signals = cls(signals, sampling_rate, ref_channels, accel_channels, velo_channels, disp_channels, setup_name=name, channel_headers=headers, start_time=start_time, **kwargs) if chan_dofs: prep_signals.add_chan_dofs(chan_dofs) return prep_signals
[docs] def save_config(self, fname, delete_channels=None): """Write a config file readable by :meth:`init_from_config`. Parameters ---------- fname : str delete_channels : list of int, optional Recorded as-is (informational only — channels are already absent from ``self.signals`` by the time this is called). """ data = { 'Setup Name': self.setup_name or '', 'Sampling Rate [Hz]': self.sampling_rate, 'Reference Channels': ' '.join(str(c) for c in self.ref_channels), 'Delete Channels': ' '.join(str(c) for c in (delete_channels or [])), 'Accel. Channels': ' '.join(str(c) for c in self.accel_channels), 'Velo. Channels': ' '.join(str(c) for c in self.velo_channels), 'Disp. Channels': ' '.join(str(c) for c in self.disp_channels), } ConfigFile.write(fname, data)
@staticmethod def _resolve_signals_and_headers(loaded_signals, sampling_rate): """Unpack loaded signals; return (signals, headers, start_time).""" if not isinstance(loaded_signals, np.ndarray): headers, _, start_time, sample_rate, signals = loaded_signals else: signals = loaded_signals start_time = datetime.datetime.now() sample_rate = sampling_rate headers = ['Channel_{}'.format(i) for i in range(signals.shape[1])] if not sample_rate == sampling_rate: logger.warning( 'Sampling Rate from file: {} does not correspond with specified ' 'Sampling Rate from configuration {}'.format(sample_rate, sampling_rate)) return signals, headers, start_time @staticmethod def _apply_chan_dof_headers(chan_dofs, headers): """Update *headers* in-place from channel names stored in *chan_dofs*.""" for chan_dof in chan_dofs: if len(chan_dof) == 5: chan = chan_dof[0] chan_name = chan_dof[4] if len(chan_name) == 0: continue elif headers[chan] == 'Channel_{}'.format(chan): headers[chan] = chan_name elif headers[chan] != chan_name: logger.info( 'Different headers for channel {} in signals file ({}) ' 'and in channel-DOF-assignment ({}).'.format( chan, headers[chan], chan_name)) @staticmethod def _find_chan_dof_entry(chan_dofs, channel): """Find the chan_dof entry for *channel*; return (node, az, elev, cname) or None.""" for chan_dof in chan_dofs: if chan_dof[0] == channel: node, az, elev = chan_dof[1:4] cname = chan_dof[4] if len(chan_dof) == 5 else '' return node, az, elev, cname return None @staticmethod def _apply_delete_channels(signals, chan_dofs, delete_channels, ref_channels, accel_channels, velo_channels, disp_channels, headers): """Remove *delete_channels* from all channel lists and the signal array.""" num_all_channels = signals.shape[1] new_chan_dofs = [] new_ref_channels = [] new_accel_channels = [] new_velo_channels = [] new_disp_channels = [] new_headers = [] new_channel = 0 for channel in range(num_all_channels): if channel in delete_channels: logger.info('Now removing Channel {} (no. {})!'.format( headers[channel], channel)) continue entry = (PreProcessSignals._find_chan_dof_entry(chan_dofs, channel) if chan_dofs else None) if entry is not None: node, az, elev, cname = entry new_chan_dofs.append([new_channel, node, az, elev, cname]) if channel in ref_channels: new_ref_channels.append(new_channel) if channel in accel_channels: new_accel_channels.append(new_channel) if channel in velo_channels: new_velo_channels.append(new_channel) if channel in disp_channels: new_disp_channels.append(new_channel) new_headers.append(headers[channel]) new_channel += 1 signals = np.delete(signals, delete_channels, axis=1) return (signals, new_chan_dofs, new_ref_channels, new_accel_channels, new_velo_channels, new_disp_channels, new_headers)
[docs] @staticmethod def load_chan_dofs(fname): ''' chan_dofs[i] = (chan_num, node_name, az, elev, chan_name) = (int, str, float,float, str) azimuth angle starting from x axis towards y axis elevation defined from x-y plane up x: 0.0, 0.0 y: 90.0, 0.0 z: 0.0, 90.0 channels not present in the file will be removed later nodes do not have to, but should exist, as this information is also used for merging multiple setups, which does not rely on any "real" geometry ''' chan_dofs = [] with open(fname, 'r') as f: f.__next__() for line1 in csv.reader(f, delimiter='\t', skipinitialspace=True): line = [] for val in line1: if not val: continue line += val.split() if not line: continue if line[0].startswith('#'): break entry = PreProcessSignals._parse_chan_dof_line(line) if entry is not None: chan_dofs.append(entry) return chan_dofs
@staticmethod def _parse_chan_dof_line(line): """Parse one tab-split line from a chan_dofs file into a 5-element list.""" while len(line) <= 5: line.append('') chan_num, node, az, elev, chan_name = [line[i].strip(' ') for i in range(5)] chan_num, az, elev = int(float(chan_num)), float(az), float(elev) if node == 'None': node = None return [chan_num, node, az, elev, chan_name]
[docs] def save_chan_dofs(self, fname): """Write ``self.chan_dofs`` to *fname* in the format read by :meth:`load_chan_dofs`.""" with open(fname, 'w') as f: f.write('chan_num\tnode\tazimuth\televation\tchan_name\n') for chan_num, node, az, elev, chan_name in self.chan_dofs: f.write(f'{chan_num}\t{node}\t{az}\t{elev}\t{chan_name}\n')
[docs] @staticmethod def load_measurement_file(fname, **kwargs): ''' A method for loading a signals file Parameters ---------- fname : str The full path of the signals file Returns ------- headers : list of str The names of all channels units : list of str The units of all channels start_time : datetime.datetime The starting time of the measured signal sample_rate : float The sample rate, at wich the signal was acquired signals : ndarray Array of shape (num_timesteps, num_channels) which contains the acquired signal Notes ----- This default implementation only handles a bare ``.npy`` array (returned as-is; :meth:`init_from_config` then synthesizes ``headers``/``units``/``start_time``/``sample_rate``). For any other measurement-file format, assign a custom loader to ``PreProcessSignals.load_measurement_file`` *before* calling :meth:`init_from_config` - see ``scripts/converters/`` for examples. ''' logger.warning( f"PreProcessSignals.load_measurement_file() has not been " f"overridden for this analysis - defaulting to np.load({fname!r}). " "This only supports a bare .npy array; channel headers, units, " "start time, and sample rate are synthesized. Assign a custom " "loader to PreProcessSignals.load_measurement_file before calling " "init_from_config() for other formats - see scripts/converters/ " "for examples.") ext = os.path.splitext(str(fname))[1].lower() if ext == '.npz': raise ValueError( f"'{fname}' is a .npz file; the default load_measurement_file() " "only supports bare .npy arrays. Use PreProcessSignals.load_state() " "to load a saved session, or assign a custom loader for other " "measurement-file formats.") return np.load(fname)
[docs] def add_chan_dofs(self, chan_dofs): ''' chan_dofs = [ (chan_num, node_name, az, elev, chan_name) , ... ] This function is not checking if channels or nodes actually exist the former should be added the latter might only be possible, if the geometry object is known to the class ''' for chan_dof in chan_dofs: chan_dof[0] = int(chan_dof[0]) if chan_dof[1] is not None: chan_dof[1] = str(chan_dof[1]) chan_dof[2] = float(chan_dof[2]) chan_dof[3] = float(chan_dof[3]) if len(chan_dof) == 4: chan_dof.append('') self.chan_dofs.append(chan_dof)
# self.chan_dofs=chan_dofs
[docs] def get_chan_dof(self, channel): """Return the ``(node, az, elev)`` assignment for *channel*, or None. Parameters ---------- channel : int Channel index. """ for chan, node, az, elev, _chan_name in self.chan_dofs: if chan == channel: return node, az, elev return None
[docs] def set_chan_dof(self, channel, node, az, elev): """Assign *channel* to a node and direction (azimuth/elevation). Replaces any existing assignment for this channel - a channel measures at most one DOF. Parameters ---------- channel : int Channel index. node : str Name of the node in ``geometry_data.nodes`` this channel is attached to. az, elev : float Azimuth and elevation (degrees) of the measured direction. """ self.save_undo_snapshot() self.remove_chan_dof(channel) self.chan_dofs.append( [channel, str(node), float(az), float(elev), self.channel_headers[channel]])
[docs] def remove_chan_dof(self, channel): """Remove any existing DOF assignment for *channel*, if present. Parameters ---------- channel : int Channel index. """ self.save_undo_snapshot() self.chan_dofs = [cd for cd in self.chan_dofs if cd[0] != channel]
[docs] def rename_channel(self, channel, new_name): """Rename a channel, keeping any ``chan_dofs`` annotation in sync. Parameters ---------- channel : int Channel index. new_name : str New name for the channel. Must be unique among all channel names, since channels can be looked up by name elsewhere (e.g. :meth:`_str_channel_to_index`). """ self.validate_channels([channel]) new_name = str(new_name) current_name = str(self.channel_headers[channel]) if new_name != current_name and new_name in (str(h) for h in self.channel_headers): raise ValueError(f'Channel name {new_name!r} is already in use.') self.save_undo_snapshot() self.channel_headers[channel] = new_name for chan_dof in self.chan_dofs: if chan_dof[0] == channel: chan_dof[4] = new_name
def save_state(self, fname): # print('fname = ', fname) logger.info('Saving results to {}'.format(fname)) dirname, _ = os.path.split(fname) if not os.path.isdir(dirname): os.makedirs(dirname) out_dict = {} out_dict['self.signals'] = self.signals out_dict['self.sampling_rate'] = self.sampling_rate out_dict['self.ref_channels'] = self._ref_channels out_dict['self.accel_channels'] = self._accel_channels out_dict['self.velo_channels'] = self._velo_channels out_dict['self.disp_channels'] = self._disp_channels out_dict['self.setup_name'] = self.setup_name out_dict['self.channel_headers'] = self.channel_headers out_dict['self.start_time'] = self.start_time out_dict['self.chan_dofs'] = self.chan_dofs out_dict['self.scaling_factors'] = self.scaling_factors out_dict['self.channel_factors'] = self.channel_factors out_dict['self._last_meth'] = self._last_meth out_dict['self.corr_matrix_wl'] = self.corr_matrix_wl out_dict['self.corr_matrices_wl'] = self.corr_matrices_wl out_dict['self.psd_matrix_wl'] = self.psd_matrix_wl out_dict['self.psd_matrices_wl'] = self.psd_matrices_wl out_dict['self.var_corr_wl'] = self.var_corr_wl out_dict['self.var_psd_wl'] = self.var_psd_wl out_dict['self.n_lines_wl'] = self.n_lines_wl out_dict['self.m_lags_wl'] = self.m_lags_wl out_dict['self.n_segments_wl'] = self.n_segments_wl out_dict['self.corr_matrix_bt'] = self.corr_matrix_bt out_dict['self.corr_matrices_bt'] = self.corr_matrices_bt out_dict['self.psd_matrix_bt'] = self.psd_matrix_bt out_dict['self.n_lines_bt'] = self.n_lines_bt out_dict['self.m_lags_bt'] = self.m_lags_bt out_dict['self.n_segments_bt'] = self.n_segments_bt out_dict['self.var_corr_bt'] = self.var_corr_bt np.savez_compressed(fname, **out_dict) @classmethod def load_state(cls, fname): logger.info('Loading results from {}'.format(fname)) in_dict = np.load(fname, allow_pickle=True) signals = validate_array(in_dict['self.signals']) sampling_rate = validate_array(in_dict['self.sampling_rate']) _ref_channels = validate_array(in_dict['self.ref_channels']) _accel_channels = validate_array(in_dict['self.accel_channels']) _velo_channels = validate_array(in_dict['self.velo_channels']) _disp_channels = validate_array(in_dict['self.disp_channels']) channel_headers = validate_array(in_dict['self.channel_headers']) start_time = validate_array(in_dict['self.start_time']) setup_name = validate_array(in_dict['self.setup_name']) preprocessor = cls(signals, sampling_rate, _ref_channels, _accel_channels, _velo_channels, _disp_channels, setup_name=setup_name, channel_headers=channel_headers, start_time=start_time, ) chan_dofs = [[int(float(chan_dof[0])), str(chan_dof[1]), float(chan_dof[2]), float(chan_dof[3]), str( chan_dof[4] if 5 == len(chan_dof) else '')] for chan_dof in in_dict['self.chan_dofs']] preprocessor.add_chan_dofs(chan_dofs) try: preprocessor.scaling_factors = validate_array(in_dict['self.scaling_factors']) preprocessor.channel_factors = validate_array(in_dict['self.channel_factors']) preprocessor._last_meth = validate_array(in_dict['self._last_meth']) preprocessor.corr_matrix_wl = validate_array(in_dict['self.corr_matrix_wl']) preprocessor.corr_matrices_wl = validate_array(in_dict.get('self.corr_matrices_wl')) preprocessor.psd_matrix_wl = validate_array(in_dict['self.psd_matrix_wl']) preprocessor.psd_matrices_wl = validate_array(in_dict.get('self.psd_matrices_wl')) preprocessor.var_corr_wl = validate_array(in_dict.get('self.var_corr_wl')) preprocessor.var_psd_wl = validate_array(in_dict['self.var_psd_wl']) preprocessor.n_lines_wl = validate_array(in_dict['self.n_lines_wl']) preprocessor.m_lags_wl = validate_array(in_dict.get('self.m_lags_wl')) preprocessor.n_segments_wl = validate_array(in_dict['self.n_segments_wl']) preprocessor.corr_matrix_bt = validate_array(in_dict['self.corr_matrix_bt']) preprocessor.corr_matrices_bt = validate_array(in_dict.get('self.corr_matrices_bt')) preprocessor.psd_matrix_bt = validate_array(in_dict['self.psd_matrix_bt']) preprocessor.n_lines_bt = validate_array(in_dict['self.n_lines_bt']) preprocessor.m_lags_bt = validate_array(in_dict.get('self.m_lags_bt')) preprocessor.n_segments_bt = validate_array(in_dict['self.n_segments_bt']) preprocessor.var_corr_bt = validate_array(in_dict['self.var_corr_bt']) except KeyError as e: # loading data saved with old version, spectral values must be recomputed logger.warning(f'Failed to load part of the saved file at Key {e}') return preprocessor def _remove_channel_from_quantity(self, channel, quant_list, quant_name): """If *channel* is in *quant_list*, warn and remove it.""" if channel in quant_list: logger.warning( f'Channel {self.channel_headers[channel]} is already defined' f' as a {quant_name} channel. Removing') quant_list.remove(channel) def validate_channels(self, channels, quant_check=False): if quant_check: accel_channels = self.accel_channels velo_channels = self.velo_channels disp_channels = self.disp_channels for channel in channels: if channel < 0: raise ValueError('A channel number cannot be negative!') if channel > self.num_analised_channels - 1: raise ValueError('A channel number cannot be greater' ' than the number of all channels!') if quant_check: self._remove_channel_from_quantity(channel, accel_channels, 'acceleration') self._remove_channel_from_quantity(channel, velo_channels, 'velocity') self._remove_channel_from_quantity(channel, disp_channels, 'displacement') def _channel_numbers(self, channels=None, refs=None): """ Method to return channel numbers Interpretation of the argument values: * None: a list of all channel indices * list-of-int: a validated list of given channel indices * list-of-str: a list of channel indices for each channel name in the given order * int: a single-item list of the given channel index * str: a single-item list of the channel index for the given name * 'auto' (only refs): a single item list corresponding to the respective channel Parameters ---------- channels: None, list-of-int, list-of-str, int, str The selected channels. refs: 'auto', list-of-indices, optional The reference channel indices to be contained in the reference channel list Returns ------- channel_numbers: list The generated channel indices ref_numbers: list-of-lists The corresponding reference channels for each channel in channel_numbers, such that it can be looped over in an inner loop. """ channel_numbers = self._resolve_channel_list(channels) ref_numbers = self._resolve_ref_list(refs, channel_numbers) return channel_numbers, ref_numbers def _resolve_channel_list(self, channels): """Convert *channels* argument to a list of integer channel indices.""" if channels is None: return list(range(self.num_analised_channels)) if isinstance(channels, int): return [channels] if isinstance(channels, str): return [self._str_channel_to_index(channels)] # list / tuple / ndarray return self._channel_list_to_indices(channels) def _str_channel_to_index(self, channel): """Convert a string channel specifier to an integer index.""" try: return int(channel) except ValueError: return self.channel_headers.index(channel) def _channel_list_to_indices(self, channels): """Convert a sequence of channel specifiers to a list of integer indices.""" result = [] for channel in channels: if isinstance(channel, (int, np.int32, np.int64)): result.append(int(channel)) elif isinstance(channel, str): result.append(self._str_channel_to_index(channel)) else: raise ValueError( f'Channel {channel} in channels is an invalid channel definition.') return result def _resolve_ref_list(self, refs, channel_numbers): """Convert *refs* argument to a list-of-lists of reference channel indices.""" n = len(channel_numbers) if refs is None: return [self.ref_channels] * n if refs == 'auto': return [[ind] for ind in channel_numbers] if isinstance(refs, int): return [[refs]] * n if isinstance(refs, str): return [[self.channel_headers.index(refs)]] * n if isinstance(refs, (list, tuple, np.ndarray)): return [self._refs_list_to_indices(refs)] * n raise ValueError(f'{refs} not a valid reference channel specification.') def _refs_list_to_indices(self, refs): """Convert a list of reference channel specifiers to integer indices.""" result = [] for channel in refs: if isinstance(channel, int): result.append(channel) elif isinstance(channel, str): result.append(self.channel_headers.index(channel)) else: raise ValueError( f'Channel {channel} in refs is an invalid channel definition.') return result @property def ref_channels(self): return self._ref_channels @ref_channels.setter def ref_channels(self, ref_channels): ref_channels, _ = self._channel_numbers(ref_channels) self.validate_channels(ref_channels) self._clear_spectral_values() self._ref_channels = ref_channels @property def accel_channels(self): return self._accel_channels @accel_channels.setter def accel_channels(self, accel_channels): accel_channels, _ = self._channel_numbers(accel_channels) self.validate_channels(accel_channels, True) self._accel_channels = accel_channels @property def velo_channels(self): return self._velo_channels @velo_channels.setter def velo_channels(self, velo_channels): velo_channels, _ = self._channel_numbers(velo_channels) self.validate_channels(velo_channels, True) self._velo_channels = velo_channels @property def disp_channels(self): return self._disp_channels @disp_channels.setter def disp_channels(self, disp_channels): disp_channels, _ = self._channel_numbers(disp_channels) self.validate_channels(disp_channels, True) self._disp_channels = disp_channels @property def num_ref_channels(self): return len(self.ref_channels) @property def num_analised_channels(self): return self.signals.shape[1] @property def total_time_steps(self): return self.signals.shape[0] @property def duration(self): return self.total_time_steps / self.sampling_rate @property def dt(self): return 1 / self.sampling_rate @property def t(self): N = self.total_time_steps fs = self.sampling_rate # t[-1] != self.duration to ensure sample_spacing == self.dt return np.linspace(0, N / fs, N, False) @property def n_lines(self): if self._last_meth == 'welch': return self.n_lines_wl elif self._last_meth == 'blackman-tukey': return self.n_lines_bt else: return None @property def freqs(self): ''' Returns ---------- freqs: np.ndarray (n_lines, ) Array with the frequency lines corresponding to the spectral values ''' if self.n_lines: n_lines = self.n_lines fs = self.sampling_rate return np.fft.rfftfreq(n_lines, 1 / fs) @property def freqs_wl(self): ''' Returns ---------- freqs: np.ndarray (n_lines, ) Array with the frequency lines corresponding to the spectral values ''' if self.m_lags_wl: n_lines = self.n_lines_wl fs = self.sampling_rate return np.fft.rfftfreq(n_lines, 1 / fs) @property def freqs_bt(self): ''' Returns ---------- freqs: np.ndarray (n_lines, ) Array with the frequency lines corresponding to the spectral values ''' if self.n_lines_bt: n_lines = self.n_lines_bt fs = self.sampling_rate return np.fft.rfftfreq(n_lines, 1 / fs) @property def lags(self): if self.m_lags: m_lags = self.m_lags fs = self.sampling_rate return np.linspace(0, m_lags / fs, m_lags, False) @property def lags_wl(self): if self.m_lags_wl: m_lags = self.m_lags_wl fs = self.sampling_rate return np.linspace(0, m_lags / fs, m_lags, False) @property def lags_bt(self): if self.m_lags_bt: m_lags = self.m_lags_bt fs = self.sampling_rate return np.linspace(0, m_lags / fs, m_lags, False) @property def m_lags(self): if self._last_meth == 'welch': return self.m_lags_wl elif self._last_meth == 'blackman-tukey': return self.m_lags_bt else: return None @property def n_segments(self): if self._last_meth == 'welch': return self.n_segments_wl elif self._last_meth == 'blackman-tukey': return self.n_segments_bt else: return None # @property # def m_lags_wl(self): # if self.n_lines_wl: # return self.n_lines_wl // 2 + 1 # # @property # def m_lags_bt(self): # if self.n_lines_bt: # return self.n_lines_bt // 2 + 1 @property def corr_matrix(self): if self._last_meth == 'welch': return self.corr_matrix_wl elif self._last_meth == 'blackman-tukey': return self.corr_matrix_bt else: return None @property def corr_matrices(self): if self._last_meth == 'welch': return self.corr_matrices_wl elif self._last_meth == 'blackman-tukey': return self.corr_matrices_bt else: return None @property def psd_matrix(self): if self._last_meth == 'welch': return self.psd_matrix_wl elif self._last_meth == 'blackman-tukey': return self.psd_matrix_bt else: return None @property def signal_power(self): if not np.all(np.isclose(np.mean(self.signals, axis=0), 0)): logger.warning("Signal has constant offsets. Power values may be errorneous") return np.mean(np.square(self.signals), axis=0) @property def signal_rms(self): return np.sqrt(self.signal_power)
[docs] def add_noise(self, amplitude=0, snr=0): """Add Gaussian white noise to the signals (useful for simulation studies). Parameters ---------- amplitude : float, optional Absolute noise amplitude (standard deviation). Ignored when *snr* is non-zero. snr : float, optional Noise amplitude as a fraction of the per-channel RMS. At least one of *amplitude* or *snr* must be non-zero. """ logger.info( 'Adding Noise with Amplitude {} and {} percent RMS'.format( amplitude, snr * 100)) if amplitude == 0 and snr == 0: raise ValueError("At least one of 'amplitude' or 'snr' must be non-zero.") self.save_undo_snapshot() if snr != 0 and amplitude == 0: rms = self.signal_rms amplitude = rms * snr else: amplitude = [ amplitude for channel in range( self.num_analised_channels)] for channel in range(self.num_analised_channels): self.signals[:, channel] += np.random.normal(0, amplitude[channel], self.total_time_steps) self._clear_spectral_values()
[docs] def correct_offset(self): ''' corrects a constant offset from measured signals ..TODO:: * remove linear, ... ofsets as well ''' logger.info('Correcting offset of measured signals') self.save_undo_snapshot() self.signals -= self.signals.mean(axis=0) self._clear_spectral_values() return
[docs] def precondition_signals(self, method='iqr'): """Remove the DC offset and scale each channel by its spread. First calls :meth:`correct_offset`, then divides each channel by either the inter-quartile range (IQR, 5th-95th percentile) or the full signal range. Scaling factors are stored in ``self.channel_factors``. Parameters ---------- method : {'iqr', 'range'}, optional Spreading measure used for normalisation. Default is ``'iqr'``. """ if method not in ['iqr', 'range']: raise ValueError(f"method must be one of 'iqr', 'range', got {method!r}") self.correct_offset() for i in range(self.signals.shape[1]): tmp = self.signals[:, i] if method == 'iqr': factor = np.subtract(*np.percentile(tmp, [95, 5])) elif method == 'range': factor = np.max(tmp) - np.min(tmp) self.signals[:, i] /= factor self.channel_factors[i] = factor self._clear_spectral_values()
[docs] def delete_channels(self, channels): """Remove one or more channels from the signals and all associated channel-index bookkeeping. Parameters ---------- channels : int, str, or list of int/str The channel(s) to remove, by index or name (see :meth:`_channel_numbers` for accepted formats). """ channels = sorted(set(self._channel_numbers(channels)[0]), reverse=True) self.validate_channels(channels) if len(channels) >= self.num_analised_channels: raise ValueError('Cannot delete all channels.') self.save_undo_snapshot() def _reindex(chan_list): result = [] for chan in chan_list: if chan in channels: continue shift = sum(1 for deleted in channels if deleted < chan) result.append(chan - shift) return result new_ref_channels = _reindex(self.ref_channels) new_accel_channels = _reindex(self.accel_channels) new_velo_channels = _reindex(self.velo_channels) new_disp_channels = _reindex(self.disp_channels) new_chan_dofs = [] for chan_dof in self.chan_dofs: chan = chan_dof[0] if chan in channels: continue shift = sum(1 for deleted in channels if deleted < chan) new_chan_dofs.append([chan - shift] + list(chan_dof[1:])) for channel in channels: logger.info('Now removing channel {} ({})!'.format( channel, self.channel_headers[channel])) self.channel_headers = [h for i, h in enumerate(self.channel_headers) if i not in channels] self.channel_factors = [f for i, f in enumerate(self.channel_factors) if i not in channels] self.signals = np.delete(self.signals, channels, axis=1) self.chan_dofs = new_chan_dofs self._ref_channels = new_ref_channels self._accel_channels = new_accel_channels self._velo_channels = new_velo_channels self._disp_channels = new_disp_channels self._clear_spectral_values()
@staticmethod def _default_filter_order(ftype, ftype_list): """Return default filter order (4 for IIR, 21 for FIR).""" return 4 if ftype_list.index(ftype) < 5 else 21 @staticmethod def _resolve_btype_freqs(lowpass, highpass): """Build frequency list and filter type string from lowpass/highpass.""" freqs = [] if lowpass is not None: freqs.append(float(lowpass)) if highpass is not None: freqs.append(float(highpass)) if len(freqs) == 2: freqs.sort() return freqs, 'bandpass' if highpass is not None: return freqs, 'highpass' return freqs, 'lowpass' def _setup_filter_params(self, lowpass, highpass, order, ftype, RpRs): """Validate filter parameters and derive (ftype_list, order, nyq, freqs, btype, RpRs).""" if RpRs is None: RpRs = [3, 3] if highpass is None and lowpass is None: raise ValueError('Neither a lowpass or a highpass corner frequency was provided.') ftype_list = ['butter', 'cheby1', 'cheby2', 'ellip', 'bessel', 'moving_average', 'brickwall'] if ftype not in ftype_list: raise ValueError(f'Filter type {ftype} is not any of the available types: {ftype_list}') if order is None: order = self._default_filter_order(ftype, ftype_list) if order < 1: raise ValueError('Order must be greater equal 1') nyq = self.sampling_rate / 2 freqs, btype = self._resolve_btype_freqs(lowpass, highpass) freqs = list(np.array(freqs) / nyq) return ftype_list, order, nyq, freqs, btype, RpRs def _run_filter(self, ftype, ftype_list, freqs, btype, order, RpRs): """Apply the chosen filter to self.signals; return (signals_filtered, sos, fir_irf).""" measurement = self.signals if ftype in ftype_list[0:5]: # IIR filter signals_filtered, sos = self._apply_iir_filter( measurement, freqs, btype, ftype, order, RpRs) fir_irf = None else: # FIR filter signals_filtered, fir_irf = self._apply_fir_filter( measurement, freqs, btype, ftype, order) sos = None return signals_filtered, sos, fir_irf
[docs] def filter_signals(self, lowpass=None, highpass=None, overwrite=True, order=None, ftype='butter', RpRs=None, plot_ax=None): """Apply a zero-phase IIR or FIR filter to the measurement signals. Parameters ---------- lowpass : float, optional Lowpass corner frequency in Hz. highpass : float, optional Highpass corner frequency in Hz. At least one of *lowpass* or *highpass* must be given; providing both creates a bandpass filter. overwrite : bool, optional If ``True`` (default), store the filtered signals in ``self.signals``. If ``False``, return the filtered array without modifying ``self``. order : int, optional Filter order. Defaults to 4 for IIR types and 21 for FIR types. ftype : str, optional Filter type: ``'butter'``, ``'cheby1'``, ``'cheby2'``, ``'ellip'``, ``'bessel'``, ``'moving_average'``, or ``'brickwall'``. RpRs : list of float, optional ``[rp, rs]`` — maximum passband ripple and minimum stopband attenuation (dB) for Chebyshev/elliptic filters. plot_ax : matplotlib.axes.Axes or list of Axes, optional When provided, plot the filter frequency response (and optionally impulse response) into the given axes. Returns ------- np.ndarray Filtered signal array (returned regardless of *overwrite*). """ logger.info('Filtering signals in the band: {} .. {} with a {} order {} filter.'.format(highpass, lowpass, order, ftype)) ftype_list, order, nyq, freqs, btype, RpRs = self._setup_filter_params( lowpass, highpass, order, ftype, RpRs) signals_filtered, sos, fir_irf = self._run_filter(ftype, ftype_list, freqs, btype, order, RpRs) if np.isnan(signals_filtered).any(): logger.warning('Your filtered signals contain NaNs. Check your filter settings! Continuing...') if plot_ax is not None: self._plot_filter_response(plot_ax, ftype, ftype_list, sos, fir_irf, order, nyq) if overwrite: self.save_undo_snapshot() self.signals = signals_filtered if self.F is not None: self.F = self.F_filt self.signals_filtered = signals_filtered self._clear_spectral_values() return signals_filtered
def _apply_iir_filter(self, measurement, freqs, btype, ftype, order, RpRs): """Design and apply a zero-phase IIR filter; return (filtered_signals, sos).""" order = int(order) Wn = freqs[0] if len(freqs) == 1 else freqs sos = scipy.signal.iirfilter( order, Wn, rp=RpRs[0], rs=RpRs[1], btype=btype, ftype=ftype, output='sos') signals_filtered = scipy.signal.sosfiltfilt(sos, measurement, axis=0) if self.F is not None: self.F_filt = scipy.signal.sosfiltfilt(sos, self.F, axis=0) return signals_filtered, sos def _apply_fir_filter(self, measurement, freqs, btype, ftype, order): """Design and apply a causal FIR filter; return (filtered_signals, fir_irf).""" if ftype == 'brickwall': fir_irf = scipy.signal.firwin(numtaps=order, cutoff=freqs, pass_zero=btype, fs=np.pi) else: # moving_average if freqs: logger.warning('For the moving average filter, no cutoff frequencies can be defined.') fir_irf = np.ones((order)) / order signals_filtered = scipy.signal.lfilter(fir_irf, [1.0], measurement, axis=0) if self.F is not None: self.F_filt = scipy.signal.lfilter(fir_irf, [1.0], self.F, axis=0) return signals_filtered, fir_irf def _plot_filter_response(self, plot_ax, ftype, ftype_list, sos, fir_irf, order, nyq): """Plot the filter frequency (and optionally impulse) response.""" N = 2048 dt = 1 / self.sampling_rate if isinstance(plot_ax, (list, np.ndarray)): freq_ax = plot_ax[1] tim_ax = plot_ax[0] else: freq_ax = plot_ax tim_ax = None if ftype in ftype_list[0:5]: # IIR Filter self._plot_iir_response(freq_ax, tim_ax, sos, nyq, N, dt) else: # FIR Filter self._plot_fir_response(freq_ax, tim_ax, fir_irf, order, N, dt) def _plot_iir_response(self, freq_ax, tim_ax, sos, nyq, N, dt): """Plot IIR filter frequency and optional impulse response.""" w, h = scipy.signal.sosfreqz(sos, worN=np.fft.rfftfreq(N) * 2 * np.pi) # convert to decibels (square: double filtering; factor 20: RMS quantity) frf = 20 * np.log10(abs(h) ** 2) freq_ax.plot((nyq / np.pi) * w, frf, color='lightgrey', ls='dashed') if tim_ax is not None: irf = np.fft.irfft(h, n=10 * N) logger.debug(f'IRF Integral {np.sum(irf)*dt}') dur = N * dt t = np.linspace(0, dur - dt, 10 * N) tim_ax.plot(t, irf, color='lightgrey') def _plot_fir_response(self, freq_ax, tim_ax, fir_irf, order, N, dt): """Plot FIR filter frequency and optional impulse response.""" dur = order * dt # zero-pad the FRF to achieve spectral-interpolated IRF frf = np.fft.fft(fir_irf) if order % 2: # odd numtaps: maximum frequency present as conjugate neg = frf[order // 2 + 1:order] pos = frf[:order // 2 + 1] else: # even numtaps: only minimum frequency present pos = frf[:order // 2] neg = frf[order // 2:order] pos = np.hstack([pos, np.conj(neg[0:1])]) frf_pad = np.hstack([pos, np.zeros((N - order // 2 * 2 - 1,), dtype=complex), neg]) irf_fine = np.fft.ifft(frf_pad) if np.max(irf_fine.imag) > np.finfo(np.float64).eps: raise RuntimeError( "Interpolated IRF has a non-negligible imaginary part " f"(max={np.max(irf_fine.imag)!r}); the IFFT result is not real-valued as expected." ) irf_fine = irf_fine.real dt_new = dur / N irf_fine /= dt_new / dt logger.debug(f'IRF Integral {np.sum(fir_irf) * dt}, {np.sum(irf_fine) * dt_new}') # zero-pad the IRF to achieve high-resolution FRF irf_pad = np.zeros((N,)) irf_pad[:order] = fir_irf frf_fine = 20 * np.log10(abs(np.fft.fft(irf_pad))) freq_ax.plot(np.fft.fftshift(np.fft.fftfreq(N, dt)), np.fft.fftshift(frf_fine), color='lightgrey', ls='dashed') if tim_ax is not None: t = np.linspace(-dur / 2, dur / 2 - dt_new, N) tim_ax.plot(t, irf_fine, color='lightgrey') @staticmethod def _resolve_decimate_filter_params(order, filter_type, decimate_factor): """Derive the anti-aliasing filter order and RpRs for decimation.""" if order is None: if filter_type in ['brickwall', 'moving_average']: order = 21 * decimate_factor - 1 # odd to avoid errors when highpass filtering else: order = 8 else: order = abs(order) if not isinstance(order, int): raise TypeError(f"order must be int, got {type(order).__name__!r}") if not (order > 1): raise ValueError(f"order must be > 1, got {order}") if filter_type in ('cheby1', 'cheby2', 'ellip'): RpRs = [0.05, 0.05] # standard for signal.decimate else: RpRs = [None, None] return order, RpRs def _apply_downsampling(self, sig_filtered, decimate_factor): """Downsample *sig_filtered* by *decimate_factor* and update self.signals/sampling_rate.""" self.sampling_rate /= decimate_factor N_dec = int(np.floor(self.total_time_steps / decimate_factor)) # ceil would also work, but breaks indexing for aliasing noise estimation # with floor though, care must be taken to shorten the time domain signal to N_dec full blocks before slicing sig_decimated = np.copy(sig_filtered[0:N_dec * decimate_factor:decimate_factor, :]) # correct for power loss due to decimation # https://en.wikipedia.org/wiki/Downsampling_(signal_processing)#Anti-aliasing_filter sig_decimated *= decimate_factor if self.F is not None: self.F = self.F_filt[slice(None, None, decimate_factor)] self.signals = sig_decimated self._clear_spectral_values()
[docs] def decimate_signals(self, decimate_factor, nyq_rat=2.5, highpass=None, order=None, filter_type='cheby1'): """Decimate the signals by an integer factor. An anti-aliasing lowpass filter is applied before downsampling. To achieve large total reduction factors, call this method multiple times with moderate per-step factors (e.g. two passes of x3 instead of one pass of x9). Parameters ---------- decimate_factor : int Integer downsampling factor (must be >= 1). nyq_rat : float, optional The lowpass corner frequency is set to ``sampling_rate / (decimate_factor * nyq_rat)``. Must be >= 2. Default is 2.5. highpass : float or None, optional Additional highpass corner frequency in Hz applied simultaneously. order : int, optional Anti-aliasing filter order. Defaults to 8 (IIR) or ``21 * decimate_factor - 1`` (FIR). filter_type : str, optional Filter type passed to :meth:`filter_signals`. Default is ``'cheby1'``. """ if highpass: logger.info(f'Decimating signals by factor {decimate_factor}' f' and additional highpass filtering at {highpass}' f' to a sampling rate of {self.sampling_rate/decimate_factor} Hz') else: logger.info(f'Decimating signals by factor {decimate_factor}' f' to a sampling rate of {self.sampling_rate/decimate_factor} Hz') # input validation decimate_factor = abs(decimate_factor) if not isinstance(decimate_factor, int): raise TypeError(f"decimate_factor must be int, got {type(decimate_factor).__name__!r}") if not (decimate_factor >= 1): raise ValueError(f"decimate_factor must be >= 1, got {decimate_factor}") if not (nyq_rat >= 2.0): raise ValueError(f"nyq_rat must be >= 2.0, got {nyq_rat}") self.save_undo_snapshot() order, RpRs = self._resolve_decimate_filter_params(order, filter_type, decimate_factor) nyq = self.sampling_rate / decimate_factor sig_filtered = self.filter_signals( lowpass=nyq / nyq_rat, highpass=highpass, overwrite=False, order=order, ftype=filter_type, RpRs=RpRs,) self._apply_downsampling(sig_filtered, decimate_factor)
def _clear_spectral_values(self): """ Convenience method to clear all previously computed spectral values. To be called when any modifications, such as filtering, decimation, etc. are applied to the signals. """ self.scaling_factors = None self._last_meth = None self.psd_matrix_bt = None self.psd_matrix_wl = None self.n_lines_wl = None self.n_lines_bt = None self.n_segments_bt = None self.n_segments_wl = None self.corr_matrix_bt = None self.corr_matrix_wl = None self.var_corr_bt = None self.var_psd_wl = None self.s_vals_psd = None @property def undo_available(self): """Whether a single-step undo snapshot is available. Stub: snapshot capture is not implemented yet, so this is always False. Once :meth:`save_undo_snapshot`/:meth:`undo` are filled in, this should reflect whether a snapshot has been captured and not yet consumed by :meth:`undo`. """ return False
[docs] def save_undo_snapshot(self): """Capture the current state for a single-step undo. Stub: not yet implemented. Intended to be called at the start of each mutating action (:meth:`correct_offset`, :meth:`add_noise`, :meth:`filter_signals`, :meth:`decimate_signals`, :meth:`delete_channels`, ...) so :meth:`undo` can restore exactly the state before that action. Only one snapshot is kept - a new call overwrites the previous one (single-step, not a full history). """
[docs] def undo(self): """Restore the state captured by the last :meth:`save_undo_snapshot` call. Stub: not yet implemented. """ raise NotImplementedError('Single-step undo is not implemented yet.')
[docs] def psd_welch(self, n_lines=None, n_segments=None, refs_only=True, window='hamming', **kwargs): ''' Estimate the (cross- and auto-) power spectral densities (PSD), according to Welch's method. No overlapping is allowed (deliberately to ensure statistical independence of blocks for variance estimation). Segments are n_lines // 2 long and zero padded to n_lines to allow estimation of the full correlation sequence, which is twice as long as the input signal. Normalization is applied w.r.t. conservation of energy, i.e. magnitudes will change with n_lines but power stays constant. Parameters ---------- n_lines: integer, optional Number of frequency lines (positive + negative) n_segments: integer, optional Number of segments to perform averaging over resulting segment length must be smaller or equal n_lines refs_only: bool, optional Compute cross-PDSs only with reference channels window: str or tuple or array_like, optional Desired window to use. See scipy.signal.get_window() for more information Other Parameters ---------------- kwargs : Additional kwargs are passed to scipy.signals.csd Returns ------- psd_matrix: np.ndarray Array of shape (num_channels, num_ref_channels, n_lines // 2 + 1) containing the power density values of the respective channels and frequencies ''' N = self.total_time_steps n_lines, n_segments, N_segment, _n_segments = \ self._resolve_psd_welch_params(n_lines, n_segments, N) self._last_meth = 'welch' cached = self._check_psd_welch_cache(n_lines, n_segments, refs_only, kwargs, _n_segments, window) if cached is not None: return cached logger.info(f"Estimating PSD by Welch's method with {n_lines}" f' frequency lines, {_n_segments} non-overlapping' f' segments and a {window} window...') psd_matrix = self._compute_psd_welch( n_lines, n_segments, N_segment, _n_segments, refs_only, window, **kwargs) if self.scaling_factors is None: self.scaling_factors = psd_matrix.max(axis=2) self.psd_matrix_wl = psd_matrix self.n_lines_wl = n_lines self.n_segments_wl = n_segments self.m_lags_wl = None self.corr_matrix_wl = None self.corr_matrices_wl = None self.var_corr_wl = None self.s_vals_psd = None return psd_matrix
@staticmethod def _validate_n_lines(n_lines, N): """Validate n_lines and ensure it is even; return corrected n_lines.""" if not isinstance(n_lines, int): raise ValueError( f"{n_lines} is not a valid number of n_lines for a spectral densities") if n_lines % 2: n_lines += 1 logger.warning( f"Only even number of frequency lines are supported setting n_lines={n_lines}") if n_lines > 2 * N: logger.warning( f'Number of frequency lines {n_lines} should not' f'be larger than twice the number of timesteps {N}') return n_lines @staticmethod def _validate_n_segments(n_segments): """Validate that n_segments is an integer.""" if not isinstance(n_segments, int): raise ValueError(f"{n_segments} is not a valid number of segments") def _welch_load_cached_params(self, n_lines, n_segments): """Load n_lines/n_segments from Welch cache when both are None.""" if n_lines is None and n_segments is None: n_lines = self.n_lines_wl n_segments = self.n_segments_wl if n_lines is None and n_segments is None: raise RuntimeError('Either n_lines or n_segments must be provided on first run.') return n_lines, n_segments @staticmethod def _welch_resolve_cases(n_lines, n_segments, N): """Resolve (n_lines, _n_segments, N_segment) for Welch cases 2–4.""" if n_segments is None: N_segment = n_lines return n_lines, N // N_segment, N_segment if n_lines is None: N_segment = N // n_segments return N_segment, n_segments, N_segment N_segment = min(N // n_segments, n_lines) return n_lines, n_segments, N_segment def _resolve_psd_welch_params(self, n_lines, n_segments, N): """Validate and resolve n_lines/n_segments for psd_welch. Returns (n_lines, n_segments, N_segment, _n_segments). """ if n_lines is not None: n_lines = self._validate_n_lines(n_lines, N) if n_segments is not None: self._validate_n_segments(n_segments) n_lines, n_segments = self._welch_load_cached_params(n_lines, n_segments) n_lines, _n_segments, N_segment = self._welch_resolve_cases(n_lines, n_segments, N) if n_lines % 2: n_lines += 1 if N_segment > n_lines: raise ValueError( f"The segment length {N_segment} must not be larger than " f"the number of frequency lines {n_lines}") if N_segment < n_lines / 2: logger.warning( f"The segment length {N_segment} is much smaller than " f"the number of frequency lines {n_lines} (zero-padded)") return n_lines, n_segments, N_segment, _n_segments def _check_psd_welch_cache(self, n_lines, n_segments, refs_only, kwargs, _n_segments, window): """Return cached psd_matrix_wl if still valid, else return None.""" if kwargs: logger.debug("Not returning because: kwargs provided") return None if self.psd_matrix_wl is None: logger.debug("Not returning because: self.psd_matrix_wl not available") return None if self.n_lines_wl != n_lines: logger.debug("Not returning because: n_lines differs from previous") return None if n_segments is not None and self.psd_matrices_wl.shape[0] != n_segments: logger.debug("Not returning because: n_segments differs from previous") return None if (self.psd_matrix_wl.shape[1] == self.num_ref_channels) != refs_only: logger.debug("Not returning because: non-/reference-based not matching previous") return None logger.debug(f"Returning PSD by Welch's method with {n_lines}" f' frequency lines, {_n_segments} non-overlapping' f' segments and a {window} window...') return self.psd_matrix_wl @staticmethod def _compute_channel_pair_psd(sig_block, channel_1, ref_channel, fs, win, seg_params, **kwargs): """Compute normalised cross-PSD for one channel pair in one segment block.""" N_segment, n_lines, _n_segments = seg_params _, Pxy_den = scipy.signal.csd( sig_block[:, channel_1], sig_block[:, ref_channel], fs, window=win, nperseg=N_segment, nfft=n_lines, noverlap=0, return_onesided=True, scaling='density', **kwargs) if channel_1 == ref_channel: if not np.isclose(Pxy_den.imag, 0).all(): raise RuntimeError( "Auto-PSD (channel_1 == ref_channel) has a non-negligible " "imaginary part; expected a real-valued result from the Welch " "cross-spectral density computation." ) Pxy_den.imag = 0 # compensate averaging over segments Pxy_den *= _n_segments Pxy_den *= fs # reverse 1/Hz of scaling="density" Pxy_den /= 2 # compensate onesided Pxy_den /= 2 # compensate zero-padding Pxy_den *= n_lines # compensate energy loss through short segments return Pxy_den def _compute_psd_welch(self, n_lines, n_segments, N_segment, _n_segments, refs_only, window, **kwargs): """Compute PSD matrices for all segments and return the mean.""" fs = self.sampling_rate num_analised_channels = self.num_analised_channels if refs_only: num_ref_channels = self.num_ref_channels ref_channels = self.ref_channels else: num_ref_channels = num_analised_channels ref_channels = list(range(num_ref_channels)) signals = self.signals psd_matrix_shape = (num_analised_channels, num_ref_channels, n_lines // 2 + 1) psd_matrices = [] win = scipy.signal.get_window(window, N_segment, fftbins=True) pbar = simplePbar(_n_segments * num_analised_channels * num_ref_channels) for i_seg in range(_n_segments): this_psd_matrix = np.empty(psd_matrix_shape, dtype=complex) this_signals_block = signals[i_seg * N_segment:(i_seg + 1) * N_segment, :] for channel_1 in range(num_analised_channels): for channel_2, ref_channel in enumerate(ref_channels): next(pbar) Pxy_den = self._compute_channel_pair_psd( this_signals_block, channel_1, ref_channel, fs, win, (N_segment, n_lines, _n_segments), **kwargs) this_psd_matrix[channel_1, channel_2, :] = Pxy_den psd_matrices.append(this_psd_matrix) psd_matrix = np.mean(psd_matrices, axis=0) self.psd_matrices_wl = np.stack(psd_matrices, axis=0) self.var_psd_wl = np.var(psd_matrices, axis=0) return psd_matrix
[docs] def corr_welch(self, m_lags=None, n_segments=None, refs_only=True, **kwargs): ''' Estimate the (cross- and auto-) correlation functions (C/ACF), by the inverse Fourier Transform of Power Spectral Densities, estimated according to Welch's method. Bias due to windowing of the underlying PSD persists. Normalization is done according to the unbiased estimator, i.e. 0-lag correlation value must be multiplied by n_lines to get the signals cross-power. Note that: m_lags = n_lines // 2 + 1 n_lines = (m_lags - 1) * 2 N_segment = N // n_segments Parameters ---------- m_lags: integer, optional Total number of lags (positive). Note: this includes the 0-lag, therefore excludes the m_lags-lag. n_segments: integer, optional Number of segments to perform averaging over resulting segment length must be smaller or equal n_lines refs_only: bool, optional Compute cross-ACFss only with reference channels Other Parameters ---------------- kwargs : Additional kwargs are passed to self.psd_welch and further Returns ------- corr_matrix: np.ndarray Array of shape (num_channels, num_ref_channels, m_lags) containing the correlation values of the respective channels and lags See also -------- psd_welch: PSD estimation algorithm used by this method. .. TODO :: * deconvolve window (if possible) ''' self._last_meth = 'welch' if m_lags is not None: if not isinstance(m_lags, int): raise ValueError( f"{m_lags} is not a valid number of lags for a correlation sequence") if n_segments is not None: if not isinstance(n_segments, int): raise ValueError(f"{n_segments} is not a valid number of segments") N = self.total_time_steps m_lags, n_segments, _n_segments, _n_lines = \ self._resolve_corr_welch_params(m_lags, n_segments, N) cached = self._check_corr_welch_cache(m_lags, n_segments, refs_only, kwargs) if cached is not None: return cached # onesided RFFT suffices for real inputs; correlation is real so IRFFT suffices self.psd_welch(n_lines=_n_lines, n_segments=_n_segments, refs_only=refs_only, **kwargs) logger.info("Estimating Correlation Function by Welch's method with" f" {m_lags} time lags and {_n_segments} non-overlapping" f" segments.") corr_matrix = self._compute_corr_welch(m_lags, n_segments, refs_only) self.corr_matrix_wl = corr_matrix self.m_lags_wl = m_lags return corr_matrix
def _corr_welch_from_cache(self, m_lags, n_segments): """Resolve m_lags/n_segments from cached values; raise if not available.""" if self.m_lags_wl is not None: m_lags = self.m_lags_wl elif self.n_lines_wl is not None: m_lags = self.n_lines_wl // 2 + 1 n_segments = self.n_segments_wl if m_lags is None and n_segments is None: raise RuntimeError('Either m_lags or n_segments must be provided on first run.') return m_lags, n_segments @staticmethod def _corr_welch_both_given(m_lags, n_segments, N): """Resolve _n_segments/_n_lines when both m_lags and n_segments are given.""" _n_segments = n_segments _n_lines = (m_lags - 1) * 2 N_segment = min(N // n_segments, _n_lines) if N_segment > (m_lags - 1) * 2: raise ValueError( f"The segment length {N_segment} must not be larger than " f"the number of frequency lines {(m_lags - 1) * 2}") return _n_segments, _n_lines def _resolve_corr_welch_params(self, m_lags, n_segments, N): """Resolve corr_welch parameters; return (m_lags, n_segments, _n_segments, _n_lines).""" # case 1: no arguments — use cached if m_lags is None and n_segments is None: m_lags, n_segments = self._corr_welch_from_cache(m_lags, n_segments) # case 2: no variance requested if n_segments is None and m_lags is not None: _n_segments = N // ((m_lags - 1) * 2) _n_lines = None # case 3: variance requested, lags not specified elif n_segments is not None and m_lags is None: _n_segments = n_segments m_lags = N // n_segments // 2 + 1 _n_lines = None # case 4: both specified else: _n_segments, _n_lines = self._corr_welch_both_given(m_lags, n_segments, N) return m_lags, n_segments, _n_segments, _n_lines def _check_corr_welch_cache(self, m_lags, n_segments, refs_only, kwargs): """Return cached corr_matrix_wl slice if still valid, else return None.""" if kwargs: logger.debug("Not returning because: kwargs provided") return None if self.corr_matrix_wl is None: logger.debug("Not returning because: self.corr_matrix_wl not available") return None if self.m_lags_wl < m_lags: logger.debug("Not returning because: m_lags differs from previous") return None if n_segments is not None and self.n_segments_wl != n_segments: logger.debug("Not returning because: n_segments differs from previous") return None if (self.corr_matrix_wl.shape[1] == self.num_ref_channels) != refs_only: logger.debug("Not returning because: non-/reference-based not matching previous") return None logger.debug("Returning Correlation Function by Welch's method with" f" {m_lags} time lags and {self.n_segments_wl} non-overlapping segments.") return self.corr_matrix_wl[..., :m_lags] def _compute_corr_welch(self, m_lags, n_segments, refs_only): """Compute correlation matrices from precomputed psd_matrices_wl; store and return mean.""" if n_segments is None or n_segments == 1: psd_matrices = self.psd_matrix_wl[np.newaxis, ...] n_segments = 1 else: psd_matrices = self.psd_matrices_wl num_analised_channels = self.num_analised_channels if refs_only: num_ref_channels = self.num_ref_channels else: num_ref_channels = num_analised_channels corr_matrix_shape = (num_analised_channels, num_ref_channels, m_lags) corr_matrices = [] pbar = simplePbar(n_segments * num_analised_channels * num_ref_channels) for i_segment in range(n_segments): this_corr_matrix = np.empty(corr_matrix_shape) this_psd_matrix = psd_matrices[i_segment, ...] for channel_1 in range(num_analised_channels): for channel_2 in range(num_ref_channels): next(pbar) this_psd = this_psd_matrix[channel_1, channel_2, :] this_corr = np.fft.irfft(this_psd) if not np.all(np.isclose(this_corr.imag, 0)): raise RuntimeError( "Correlation function computed via IFFT has a non-negligible " "imaginary part; expected a real-valued result from the inverse " "FFT of the PSD matrix." ) this_corr = this_corr[:m_lags].real this_corr /= (m_lags - 1) * 2 this_corr_matrix[channel_1, channel_2, :] = this_corr corr_matrices.append(this_corr_matrix) corr_matrix = np.mean(corr_matrices, axis=0) self.corr_matrices_wl = np.stack(corr_matrices, axis=0) self.var_corr_wl = np.var(corr_matrices, axis=0) return corr_matrix
[docs] def corr_blackman_tukey(self, m_lags, n_segments=None, refs_only=True, **kwargs): ''' Estimate the (cross- and auto-) correlation functions (C/ACF), by direct computation of the standard un-biased estimator: .. math:: \\hat{R}_{fg}[m] = \\frac{1}{N - m}\\sum_{n=0}^{N - m - 1} f[n] g[n + m] Computes correlation functions of all channels with selected reference channels up to, but excluding, a time lag of m_lags. Normalization is done according to the unbiased estimator, i.e. 0-lag correlation value must be multiplied by n_lines to get the signals cross-power. Variance estimation for each time lag is performed by dividing the signals into n_segments non-overlapping blocks for individual estimation of correlation functions. With increasing numbers of non-overlapping blocks the confidence intervals of the correlation functions increase ("worsen"), especially at higher lags and short block lengths, due to a larger number of time steps being discarded. Note that: m_lags = n_lines // 2 + 1 n_lines = (m_lags - 1) * 2 Parameters ---------- m_lags: integer, optional Number of lags (positive). Note: this includes the 0-lag, therefore excludes the "m_lags"-lag. n_segments: integer, optional Number of blocks to perform averaging over. If blocks are shorter than m_lags it raises a ValueError. refs_only: bool, optional Compute cross-ACFss only with reference channels Other Parameters ---------------- kwargs : Additional kwargs are currently not used Returns ------- corr_matrix: np.ndarray Array of shape (num_channels, num_ref_channels, m_lags) containing the correlation values of the respective channels and lags See also -------- corr_welch: Correlation function estimation by Welch's method, possibly faster, but distorted for short segments and biased through windowing. ''' self._last_meth = 'blackman-tukey' if m_lags is not None: if not isinstance(m_lags, int): raise ValueError(f"{m_lags} is not a valid number of lags for a correlation sequence") if n_segments is not None: if not isinstance(n_segments, int): raise ValueError(f"{n_segments} is not a valid number of blocks") N = self.total_time_steps m_lags, n_segments, N_block = self._corr_bt_resolve_params(m_lags, n_segments, N) cached = self._check_corr_bt_cache(m_lags, n_segments, refs_only, kwargs) if cached is not None: return cached return self._corr_blackman_tukey_core(m_lags, n_segments, N_block, refs_only)
def _corr_bt_load_cached_params(self, m_lags, n_segments): """Load m_lags/n_segments from BT cache when both are None.""" if m_lags is None and n_segments is None: m_lags = self.m_lags_bt n_segments = self.n_segments_bt if m_lags is None and n_segments is None: raise RuntimeError('Either m_lags or n_segments must be provided on first run.') return m_lags, n_segments def _corr_bt_resolve_params(self, m_lags, n_segments, N): """Resolve m_lags, n_segments, N_block for corr_blackman_tukey.""" m_lags, n_segments = self._corr_bt_load_cached_params(m_lags, n_segments) if n_segments is None: m_lags, n_segments, N_block = self._corr_bt_case2(m_lags, N) elif m_lags is None: m_lags = N // n_segments N_block = m_lags else: N_block = N // n_segments if N_block < m_lags: raise ValueError( f"The segment length {N_block} must not be shorther than the number of lags {m_lags}") return m_lags, n_segments, N_block def _corr_bt_case2(self, m_lags, N): """Resolve case 2: m_lags given, n_segments not given.""" if self.n_segments_bt is None: N_block = N n_segments = 1 else: n_segments = self.n_segments_bt N_block = N // n_segments if N_block < m_lags: n_segments = 1 N_block = N return m_lags, n_segments, N_block def _check_corr_bt_cache(self, m_lags, n_segments, refs_only, kwargs): """Return cached corr_matrix_bt slice if still valid, else None.""" if kwargs: logger.debug("Not returning because: kwargs provided") return None if self.corr_matrix_bt is None: logger.debug("Not returning because: self.corr_matrix_bt not available") return None if self.m_lags_bt < m_lags: logger.debug("Not returning because: m_lags differs from previous") return None if n_segments is not None and self.n_segments_bt != n_segments: logger.debug("Not returning because: n_segments differs from previous") return None if (self.corr_matrix_bt.shape[1] == self.num_ref_channels) != refs_only: logger.debug("Not returning because: non-/reference-based not matching previous") return None logger.debug("Using previously computed Correlation Functions (BT)...") return self.corr_matrix_bt[..., :m_lags] def _corr_blackman_tukey_core(self, m_lags, n_segments, N_block, refs_only): """Compute Blackman-Tukey correlation functions; store and return result.""" logger.info(f'Estimating Correlation Functions (BT) with m_lags=' f'{m_lags} and n_segments={n_segments}...') num_analised_channels = self.num_analised_channels if refs_only: num_ref_channels = self.num_ref_channels ref_channels = self.ref_channels else: num_ref_channels = num_analised_channels ref_channels = list(range(num_ref_channels)) signals = self.signals corr_matrix_shape = (num_analised_channels, num_ref_channels, m_lags) corr_matrices = [] pbar = simplePbar(m_lags * n_segments) for block in range(n_segments): this_corr_matrix = np.empty(corr_matrix_shape) this_signals_block = signals[block * N_block:(block + 1) * N_block, :] for lag in range(m_lags): next(pbar) y_r = this_signals_block[:N_block - lag, ref_channels] y_a = this_signals_block[lag:, :] # standard un-biased estimator (revert rectangular window) this_corr_matrix[:, :, lag] = (y_a.T @ y_r) / (N_block - lag) corr_matrices.append(this_corr_matrix) corr_matrix = np.mean(corr_matrices, axis=0) if not np.all(corr_matrix.shape == corr_matrix_shape): raise RuntimeError( f"Computed correlation matrix shape {corr_matrix.shape} does not match " f"expected shape {corr_matrix_shape}; internal block-Toeplitz construction error." ) self.corr_matrix_bt = corr_matrix self.corr_matrices_bt = np.stack(corr_matrices, axis=0) self.var_corr_bt = np.var(corr_matrices, axis=0) self.m_lags_bt = m_lags self.n_segments_bt = n_segments self.psd_matrix_bt = None self.s_vals_psd = None return corr_matrix
[docs] def psd_blackman_tukey(self, n_lines=None, refs_only=True, window='hamming', **kwargs): ''' Estimate the (cross- and auto-) power spectral densities (PSD), by Fourier Transform of correlation functions estimated according to Blackman-Tukey's method. Non-negativeness of the PSD is ensured by using a lag window, i.e. convolving the temporal window with itself. Normalization is applied w.r.t. conservation of energy, i.e. magnitudes will change with n_lines but power stays constant. Note that: m_lags = n_lines // 2 + 1 n_lines = (m_lags - 1) * 2 Parameters ---------- n_lines: integer, optional Number of frequency lines (positive + negative) refs_only: bool, optional Compute cross-PDSs only with reference channels window: str or tuple or array_like, optional Desired temporal window to be applied to the correlation sequence after conversion to a lag window by "self-convolution" See scipy.signal.get_window() for more information Other Parameters ---------------- kwargs : Additional kwargs are passed to self.corr_blackman_tukey Returns ------- psd_matrix: np.ndarray Array of shape (num_channels, num_ref_channels, n_lines // 2 + 1) containing the power density values of the respective channels and frequencies ''' logger.debug(f'Arguments psd_blackman_tukey: n_lines={n_lines}, refs_only={refs_only}, window={window}, {kwargs}') self._last_meth = 'blackman-tukey' N = self.total_time_steps n_lines = self._psd_bt_validate_n_lines(n_lines, N) # .. TODO:: implement multi-block psd n_segments = None n_lines, _ = self._psd_bt_resolve_params(n_lines, n_segments, N) cached = self._check_psd_bt_cache(n_lines, refs_only, kwargs) if cached is not None: return cached return self._psd_bt_compute(n_lines, refs_only, window, **kwargs)
def _psd_bt_validate_n_lines(self, n_lines, N): """Validate n_lines for psd_blackman_tukey; return corrected n_lines or None.""" if n_lines is not None: if not isinstance(n_lines, int): raise ValueError( f"{n_lines} is not a valid number of n_lines for a spectral densities") if n_lines % 2: n_lines += 1 logger.warning( f"Only even number of frequency lines are supported setting n_lines={n_lines}") if n_lines > 2 * N: logger.warning( f'Number of frequency lines {n_lines} should not' f'be larger than twice the number of timesteps {self.total_time_steps}') return n_lines def _bt_load_cached_params(self, n_lines, n_segments): """Load n_lines/n_segments from BT cache when both are None.""" if n_lines is None and n_segments is None: if self.n_lines_bt is None and self.m_lags_bt is not None: n_lines = (self.m_lags_bt - 1) * 2 else: n_lines = self.n_lines_bt n_segments = self.n_segments_bt if n_lines is None and n_segments is None: raise RuntimeError('Either n_lines or n_segments must be provided on first run.') return n_lines, n_segments @staticmethod def _bt_resolve_cases(n_lines, n_segments, N): """Resolve (n_lines, N_segment) for BT cases 2–4.""" if n_segments is None: return n_lines, n_lines if n_lines is None: N_segment = N // n_segments return N_segment, N_segment return n_lines, min(N // n_segments, n_lines) def _psd_bt_resolve_params(self, n_lines, n_segments, N): """Resolve n_lines/n_segments for psd_blackman_tukey; return (n_lines, N_segment).""" n_lines, n_segments = self._bt_load_cached_params(n_lines, n_segments) n_lines, N_segment = self._bt_resolve_cases(n_lines, n_segments, N) if n_lines % 2: n_lines += 1 if N_segment > n_lines: raise ValueError( f"The segment length {N_segment} must not be larger than " f"the number of frequency lines {n_lines}") if N_segment < n_lines / 2: logger.warning( f"The segment length {N_segment} is much smaller than " f"the number of frequency lines {n_lines} (zero-padded)") return n_lines, N_segment def _check_psd_bt_cache(self, n_lines, refs_only, kwargs): """Return cached psd_matrix_bt if still valid, else None.""" if kwargs: logger.debug("Not returning because: kwargs provided") return None if self.psd_matrix_bt is None: logger.debug("Not returning because: self.psd_matrix_bt not available") return None if self.psd_matrix_bt.shape[2] != n_lines // 2 + 1: logger.debug("Not returning because: n_lines differs from previous") return None if (self.psd_matrix_bt.shape[1] == self.num_ref_channels) != refs_only: logger.debug("Not returning because: non-/reference-based not matching previous") return None logger.debug("Using previously computed Power Spectral Density (BT)...") return self.psd_matrix_bt def _psd_bt_compute(self, n_lines, refs_only, window, **kwargs): """Compute and store the Blackman-Tukey PSD matrix; return it.""" logger.info("Estimating Power Spectral Density by Blackman-Tukey's method...") corr_matrix = self.corr_blackman_tukey(n_lines // 2 + 1, refs_only=refs_only, **kwargs) num_analised_channels = self.num_analised_channels if refs_only: num_ref_channels = self.num_ref_channels else: num_ref_channels = num_analised_channels psd_matrix_shape = (num_analised_channels, num_ref_channels, n_lines // 2 + 1) psd_matrix = np.empty(psd_matrix_shape, dtype=complex) # create a symmetrical window, i.e. lacking the last 0 (for an even number of lines) win = scipy.signal.get_window(window, n_lines // 2, fftbins=True) # Zero-Pad both sides (= zero pad once and circular convolution) # to allow the window to "slide along" the correct number of lags in np.convolve = 3 * n_lines//2 - 1 # here first (!) zero pad is n_lines//2-1 because it is convolve win_pad = np.concatenate((np.zeros(n_lines // 2 - 1), win, np.zeros(n_lines // 2))) # Convolve zero-padded and unpadded window # resulting shape: M - N + 1 = (3 * n_lines//2 - 1) - (n_lines//2) + 1 = 2 * n_lines//2 = n_lines corr_win = np.convolve(win_pad, win, 'valid') corr_win /= n_lines // 2 # unbiased not needed here, because it is "windowed" # normalization factor for power equivalence norm_fact = self.total_time_steps # equivalent noise bandwidth of the window for density scaling eq_noise_bw = np.sum(win ** 2) / np.sum(win) ** 2 * (n_lines // 2) pbar = simplePbar(num_analised_channels * num_ref_channels) for channel_1 in range(num_analised_channels): for channel_2 in range(num_ref_channels): next(pbar) corr_seq = corr_matrix[channel_1, channel_2, :] # https://en.wikipedia.org/wiki/Cross-correlation#Properties corr_sequence = np.concatenate( (np.flip(corr_seq)[:n_lines // 2], corr_seq[:n_lines // 2])) corr_sequence *= norm_fact spec_btr = np.fft.fft(corr_sequence * corr_win) spec_btr = spec_btr[:n_lines // 2 + 1] spec_btr *= 2 # compensate one-sided spec_btr *= eq_noise_bw # compensate window psd_matrix[channel_1, channel_2, :] = spec_btr logger.debug(f'PSD Auto-/Cross-Powers: {np.mean(np.abs(psd_matrix), axis=2)}') if self.scaling_factors is None: self.scaling_factors = psd_matrix.max(axis=2) self.n_lines_bt = n_lines self.psd_matrix_bt = psd_matrix self._last_meth = 'blackman-tukey' return psd_matrix def welch(self, n_lines, **kwargs): logger.warning("DeprecationWarning: method welch() will soon be dropped. Use psd_welch and/or corr_welch instead") psd_matrix = self.psd_welch(n_lines, **kwargs) corr_matrix = self.corr_welch() return corr_matrix, psd_matrix
[docs] def correlation(self, m_lags=None, method=None, **kwargs): ''' A convenience method for obtaining the correlation sequence by the default or any specified estimation method. Parameters ---------- m_lags: integer, optional Number of lags (positive). Note: this includes the 0-lag, therefore excludes the m_lags-lag. method: str, optional The method to use for spectral estimation Other Parameters ----------------- kwargs: Additional parameters are passed to the spectral estimation method Returns ------- corr_matrix: np.ndarray Array of shape (num_channels, num_ref_channels, m_lags) containing the correlation values of the respective channels and lags ''' logger.debug(f'Arguments correlation: m_lags={m_lags}, method={method}, {kwargs}') if method is None: if self._last_meth is None: method = 'blackman-tukey' else: method = self._last_meth if method == 'welch': return self.corr_welch(m_lags, **kwargs) elif method == 'blackman-tukey': return self.corr_blackman_tukey(m_lags, **kwargs) else: raise ValueError(f'Unknown method {method}')
[docs] def psd(self, n_lines=None, method=None, **kwargs): ''' A convenience method for obtaining the PSD by the default or any specified estimation method. Parameters ---------- n_lines: integer, optional Number of frequency lines (positive + negative) method: The method to use for spectral estimation Other Parameters ---------------- **kwargs: Additional parameters are passed to the spectral estimation method Returns ------- psd_matrix: np.ndarray Array of shape (num_channels, num_ref_channels, n_lines // 2 + 1) containing the power density values of the respective channels and frequencies ''' logger.debug(f'Arguments psd: n_lines={n_lines}, method={method}, {kwargs}') if method is None: if self._last_meth is None: method = 'welch' else: method = self._last_meth if method == 'welch': # if n_lines is None: # n_lines = self.n_lines_wl # if not isinstance(n_lines, int): # raise ValueError(f"{n_lines} is not a valid number of frequency lines for a psd sequence") return self.psd_welch(n_lines, **kwargs) elif method == 'blackman-tukey': # if n_lines is None: # if self.n_lines_bt is not None: # n_lines = self.n_lines_bt # elif self.m_lags_bt is not None: # n_lines = (self.m_lags_bt - 1) * 2 # if not isinstance(n_lines, int): # raise ValueError(f"{n_lines} is not a valid number of frequency lines for a psd sequence") return self.psd_blackman_tukey(n_lines, **kwargs) else: raise ValueError(f'Unknown method {method}')
[docs] def sv_psd(self, n_lines=None, **kwargs): ''' Compute the singular values of the power spectral density matrices, for which the complete (all cross spectral densities) matrices are used. Parameters ---------- n_lines: integer, optional Number of frequency lines (positive + negative) Other Parameters ---------------- kwargs: Additional parameters are passed to the spectral estimation method ''' if self.s_vals_psd is not None and (n_lines is None or self.s_vals_psd.shape[1] == n_lines // 2 + 1): return self.s_vals_psd psd_matrix = self.psd(n_lines, # refs_only=False, **kwargs) n_sigma = np.min(psd_matrix.shape[:2]) # n_sigma = self.num_analised_channels n_lines = self.n_lines s_vals_psd = np.empty((n_sigma, n_lines // 2 + 1)) for k in range(n_lines // 2 + 1): # might use only real part to account for slightly asynchronous data # see [Au (2017): OMA, Chapter 7.5] s_vals_psd[:, k] = np.linalg.svd(psd_matrix[:,:, k], True, False) self.s_vals_psd = s_vals_psd return s_vals_psd
def signal_clarity_score(self): signal = self.signals signal = signal - np.mean(signal, axis=0)[None,:] corr = (signal.T @ signal) / signal.shape[0] #/ signal.shape[0] corr /= np.sqrt(np.diag(corr))[None,:] * np.sqrt(np.diag(corr))[:,None] s_vals_corr = np.linalg.svd(corr, True, False) return 1 - s_vals_corr[-1]
[docs] class SignalPlot(object): """Plotting helper for :class:`PreProcessSignals`. Provides :meth:`plot_signals`, :meth:`plot_timeseries`, :meth:`plot_correlation`, :meth:`plot_psd`, and :meth:`plot_svd_spectrum` as convenience wrappers around the spectral estimation methods of :class:`PreProcessSignals`. Parameters ---------- prep_signals : PreProcessSignals The pre-processed signal object to visualise. """
[docs] def __init__(self, prep_signals): """ Parameters ---------- prep_signals : PreProcessSignals The pre-processed signal object to visualise. """ if not isinstance(prep_signals, PreProcessSignals): logger.warning(f'Argument prep_signals ist not of type PreProcessSignals but {type(prep_signals)}') self.prep_signals = prep_signals
def _plot_signals_setup_axes(self, per_channel_axes, psd_scale, num_channels, axest, axesf): """Create or validate axes for plot_signals; return (axest, axesf).""" if axest is None or axesf is None: axest, axesf = self._plot_signals_create_axes( per_channel_axes, psd_scale, num_channels, axest, axesf) # validate sizes if per_channel_axes: if len(axest) < num_channels: raise ValueError( f'The number of provided axes objects ' f'(time domain) = {len(axest)} does not match the ' f'number of channels={num_channels}') if psd_scale != 'svd' and len(axesf) < num_channels: raise ValueError( f'The number of provided axes objects ' f'(frequency domain) = {len(axesf)} does not match the ' f'number of channels={num_channels}') else: axest = self._broadcast_axes(axest, num_channels, 'time domain') axesf = self._broadcast_axes(axesf, num_channels, 'frequency domain') return axest, axesf @staticmethod def _create_per_channel_axes(psd_scale, num_channels, axest, axesf): """Create figure axes when per_channel_axes=True.""" if psd_scale != 'svd': _, axes = plt.subplots(nrows=num_channels, ncols=2, sharey='col', sharex='col') if axest is None: axest = axes[:, 0] if axesf is None: axesf = axes[:, 1] else: if axest is None: nxn = int(np.ceil(np.sqrt(num_channels))) _, axest = plt.subplots(nrows=int(np.ceil(num_channels / nxn)), ncols=nxn, sharey=True, sharex=True) axest = axest.flatten() if axesf is None: _, axesf = plt.subplots(nrows=1, ncols=1) axesf = np.repeat(axesf, num_channels) return axest, axesf @staticmethod def _create_shared_axes(num_channels, axest, axesf): """Create figure axes when per_channel_axes=False.""" if axest is None: _, axest = plt.subplots(nrows=1, ncols=1) axest = np.repeat(axest, num_channels) if axesf is None: _, axesf = plt.subplots(nrows=1, ncols=1) axesf = np.repeat(axesf, num_channels) return axest, axesf def _plot_signals_create_axes(self, per_channel_axes, psd_scale, num_channels, axest, axesf): """Create axes figures for plot_signals; return (axest, axesf).""" if per_channel_axes: return self._create_per_channel_axes(psd_scale, num_channels, axest, axesf) return self._create_shared_axes(num_channels, axest, axesf) @staticmethod def _broadcast_axes(axes, num_channels, label): """Expand a single-element axes to num_channels; raise if too short.""" if not isinstance(axes, (tuple, list, np.ndarray)): return np.repeat(axes, num_channels) if len(axes) == 1: return np.repeat(axes, num_channels) if len(axes) < num_channels: raise ValueError( f'The number of provided axes objects ({label}) = {len(axes)} ' f'does not match the number of channels={num_channels}') return axes def _plot_one_channel(self, axt, axf, channel, prep_signals, plot_ctx): """Plot time-domain and frequency-domain data for one channel. Parameters ---------- plot_ctx : dict with keys timescale, psd_scale, refs, plot_kwarg_dict, refs_only, method """ timescale = plot_ctx['timescale'] psd_scale = plot_ctx['psd_scale'] refs = plot_ctx['refs'] plot_kwarg_dict = plot_ctx['plot_kwarg_dict'] refs_only = plot_ctx['refs_only'] method = plot_ctx['method'] if timescale == 'lags': self.plot_correlation(prep_signals.m_lags, [channel], axt, timescale, refs, plot_kwarg_dict.copy(), refs_only=refs_only, method=method) else: self.plot_timeseries(channels=[channel], ax=axt, scale='timescale', **plot_kwarg_dict.copy()) axt.grid(True, axis='y', ls='dotted') self.plot_psd(prep_signals.n_lines, [channel], axf, psd_scale, refs, plot_kwarg_dict.copy(), refs_only=refs_only, method=method)
[docs] def plot_signals(self, channels=None, axest=None, axesf=None, plot_kwarg_dict=None, **kwargs): ''' Plot time domain and/or frequency domain signals in various configurations: 1. time history and spectrum of a single channel in two axes -> set channels = [channel] goto 2 2. time history of multiple channels (all channels or specified) * if axes arguments are not None:must be (tuples, lists, ndarrays) of size = (num_channels,) regardless of the actual figure layout * else: generate axes for each channel and arrange them in lists a. time domain overlay in a single axes -> single axes is repeated in the axes list i. spectrum overlay in a single axes -> single axes is repeated in the axes list ii. svd spectrum in a single axes -> needs an additional argument b. in multiple axes' in a grid figure -> axes are generated as subplots i. spectrum in multiple axes' -> axes are generated as subplots ii. svd spectrum in a single axes -> needs an additional argument Parameters ---------- channels : None, list-of-int, list-of-str, int, str The selected channels (see self._channel_numbers for explanation) axest: ndarray of size num_channels of matplotlib.axes.Axes objects User provided axes objects, into which to plot time domain signals axesf: ndarray of size num_channels of matplotlib.axes.Axes objects User provided axes objects, into which to plot spectra Other Parameters ---------------- per_channel_axes: bool Whether to plot all channels into a single or multiple axes timescale: str ['time', 'samples', 'lags'] Whether to display time, sample or lag values on the horizontal axis 'lags' implies plotting (auto)-correlations instead of raw time histories psd_scale: str, ['db', 'power', 'rms', 'svd', 'phase'] Scaling/Output quantity of the ordinate (value axis) plot_kwarg_dict: A dictionary to pass arguments to matplotlib.plot kwargs: Additional kwargs are passed to the spectral estimation method .. TO DO:: * share y-axis scaling on axes' only between channels of the same measurement quantity (acceleration, velocity, displacement/strains) ''' per_channel_axes = kwargs.pop('per_channel_axes', False) timescale = kwargs.pop('timescale', 'time') psd_scale = kwargs.pop('psd_scale', 'db') if plot_kwarg_dict is None: plot_kwarg_dict = {} prep_signals = self.prep_signals refs = kwargs.pop('refs', None) channel_numbers, ref_numbers = prep_signals._channel_numbers(channels, refs) all_ref_numbers = set(sum(ref_numbers, [])) # if all requested reference channels are in prep_signals.ref_channels, # a reduced correlation function may be computed refs_only = all_ref_numbers.issubset(prep_signals.ref_channels) # if not all are needed, but the user requested so, compute full correlation matrix if (refs_only and not kwargs.pop('refs_only', True)) or psd_scale == 'svd': refs_only = False num_channels = len(channel_numbers) axest, axesf = self._plot_signals_setup_axes( per_channel_axes, psd_scale, num_channels, axest, axesf) # precompute relevant spectral matrices n_lines = kwargs.pop('n_lines', None) method = kwargs.pop('method', None) prep_signals.psd(n_lines, method, refs_only=refs_only, **kwargs.copy()) if timescale == 'lags': prep_signals.correlation(prep_signals.m_lags, method, refs_only=refs_only, **kwargs.copy()) plot_ctx = { 'timescale': timescale, 'psd_scale': psd_scale, 'refs': refs, 'plot_kwarg_dict': plot_kwarg_dict, 'refs_only': refs_only, 'method': method, } for axt, axf, channel in zip(axest, axesf, channel_numbers): self._plot_one_channel(axt, axf, channel, prep_signals, plot_ctx) if not per_channel_axes: axest[-1].legend() axesf[-1].legend() else: figt = axest[0].get_figure() figt.legend() figf = axesf[0].get_figure() figf.legend() return axest, axesf
@staticmethod def _channel_quantity_label(prep_signals, channel): """Return the single-letter quantity label ('a', 'v', 'd', or 'f') for a channel.""" if channel in prep_signals.accel_channels: return 'a' if channel in prep_signals.velo_channels: return 'v' if channel in prep_signals.disp_channels: return 'd' return 'f'
[docs] def plot_timeseries(self, channels=None, ax=None, scale='time', **kwargs): ''' Plots the time histories of the signals Parameters ---------- channels : int, list, tuple, np.ndarray The channels to plot, may be names, indices, etc. ax: matplotlib.axes.Axes, optional Matplotlib Axes object to plot into scale: str, ['lags','samples'] Whether to display time or sample values on the horizontal axis Other Parameters ---------------- kwargs : Additional kwargs are passed to matplotlib.plot Returns ------- ax: matplotlib.axes.Axes, optional Matplotlib Axes object containing the graphs .. TODO:: * correct labeling of channels and axis (using accel\\_, velo\\_, and disp\\_channels) ''' prep_signals = self.prep_signals signals = prep_signals.signals t = prep_signals.t if scale == 'samples': t *= prep_signals.sampling_rate xlabel = r'$n\,[-]$' ylabel = r'$f[n]$' else: xlabel = r'$t\,[\mathrm{s}]$' ylabel = r'$f(t)$' channel_numbers, _ = prep_signals._channel_numbers(channels) if ax is None: ax = plt.subplot(111) for channel in channel_numbers: f = self._channel_quantity_label(prep_signals, channel) channel_name = prep_signals.channel_headers[channel] ax.plot(t, signals[:, channel], label=rf'${f}_\mathrm{{{channel_name}}}$', **kwargs) ax.set_xlim((0, prep_signals.duration)) if ax.get_subplotspec().is_last_row(): ax.set_xlabel(xlabel) if ax.get_subplotspec().is_first_col(): ax.set_ylabel(ylabel) return ax
[docs] def plot_correlation(self, m_lags=None, channels=None, ax=None, scale='lags', refs=None, plot_kwarg_dict=None, **kwargs): ''' Plots the Cross- and Auto-Correlation sequences of the signals. If correlations have not been estimated yet and no method parameter is supplied, Blackman-Tukeys's method is used, else the most recently used estimation method is employed. Parameters ---------- m_lags: integer, optional Number of lags (positive). Note: this includes the 0-lag, therefore excludes the m_lags-lag. channels : int, list, tuple, np.ndarray The channels to plot, may be names, numbers/indices, etc. ax: matplotlib.axes.Axes, optional Matplotlib Axes object to plot into scale: str, ['lags','samples'] Whether to display lag or sample values on the horizontal axis refs: 'auto', list-of-indices, optional Reference channels to consider for cross-correlations Other Parameters ---------------- method: The method to use for spectral estimation plot_kwarg_dict: A dictionary to pass arguments to matplotlib.plot **kwargs : Additional kwargs are passed to the spectral estimation method or contain figure/axes formatting options Returns ------- ax: matplotlib.axes.Axes, optional Matplotlib Axes object containing the graphs .. TODO:: * correct labeling of channels and axis (using accel\\_, velo\\_, and disp\\_channels) ''' if plot_kwarg_dict is None: plot_kwarg_dict = {} prep_signals = self.prep_signals method = kwargs.pop('method', prep_signals._last_meth) channel_numbers, ref_numbers = prep_signals._channel_numbers(channels, refs) all_ref_numbers = set(sum(ref_numbers, [])) refs_only = all_ref_numbers.issubset(prep_signals.ref_channels) refs_only = self._resolve_corr_refs_only(refs_only, method, prep_signals, kwargs) corr_matrix = prep_signals.correlation(m_lags, refs_only=refs_only, method=method, **kwargs) if refs_only is not (prep_signals.num_ref_channels == corr_matrix.shape[1]): raise ValueError( f"refs_only={refs_only!r} is inconsistent with the returned correlation matrix: " f"num_ref_channels={prep_signals.num_ref_channels}, corr_matrix.shape[1]={corr_matrix.shape[1]}" ) lags = prep_signals.lags if scale == 'samples': lags *= prep_signals.sampling_rate xlabel = r'$m\,[-]$' ylabel = r'$\hat{R}_{i,j}[m]$' else: xlabel = r'$\tau\,[\mathrm{s}]$' ylabel = r'$\hat{R}_{i,j}(\tau)$' if ax is None: plt.figure() ax = plt.subplot(111) norm_fact = self._corr_norm_factor(prep_signals) for channel_number, current_ref_numbers in zip(channel_numbers, ref_numbers): channel_name = prep_signals.channel_headers[channel_number] for ref_index, ref_number in enumerate(current_ref_numbers): corr = self._extract_corr(corr_matrix, channel_number, ref_index, ref_number, refs_only) label = self._corr_label(channel_name, ref_number, channel_number, prep_signals.channel_headers) ax.plot(lags, corr * norm_fact, label=label, **plot_kwarg_dict) ax.set_xlim((0, lags.max())) ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) return ax
@staticmethod def _extract_corr(corr_matrix, channel_number, ref_index, ref_number, refs_only): """Extract one correlation trace from the correlation matrix.""" if refs_only: return corr_matrix[channel_number, ref_index, :] return corr_matrix[channel_number, ref_number, :] @staticmethod def _corr_label(channel_name, ref_number, channel_number, channel_headers): """Build a correlation plot label for one channel pair.""" if ref_number == channel_number: return rf'$\hat{{R}}_\mathrm{{{channel_name}}}$' return rf'$\hat{{R}}_\mathrm{{{channel_headers[ref_number]},{channel_name}}}$' @staticmethod def _resolve_corr_refs_only(refs_only, method, prep_signals, kwargs): """Resolve refs_only for plot_correlation based on precomputed matrices and user input.""" if refs_only: if method == 'welch' and prep_signals.corr_matrix_wl is not None: refs_only = prep_signals.num_ref_channels == prep_signals.corr_matrix_wl.shape[1] logger.debug('reverting refs_only: False -> Welch precomputed') elif method == 'blackman-tukey' and prep_signals.corr_matrix_bt is not None: refs_only = prep_signals.num_ref_channels == prep_signals.corr_matrix_bt.shape[1] logger.debug('reverting refs_only: False -> Blackman-Tukey precomputed') if not kwargs.pop('refs_only', True): refs_only = False logger.debug('reverting refs_only: False -> User input') return refs_only @staticmethod def _corr_norm_factor(prep_signals): """Return the normalisation factor for correlation plotting.""" if prep_signals._last_meth == 'welch': return prep_signals.n_lines_wl if prep_signals._last_meth == 'blackman-tukey': return prep_signals.total_time_steps raise RuntimeError('Last used method was not stored in prep_signals object.')
[docs] def plot_psd(self, n_lines=None, channels=None, ax=None, scale='db', refs=None, plot_kwarg_dict=None, **kwargs): ''' Plots the Cross- and Auto-Power-Spectral Density of the signals. PSD estimation is performed by default using Welch's method. Parameters ---------- n_lines: integer, optional Number of frequency lines (positive + negative) channels : int, list, tuple, np.ndarray The channels to plot, may be names, indices, etc. ax: matplotlib.axes.Axes, optional Matplotlib Axes object to plot into scale: str, ['db', 'power', 'rms', 'svd', 'phase'] Scaling/Output quantity of the ordinate (value axis) refs: 'auto', list-of-indices, optional Reference channels to consider for cross-correlations Other Parameters ---------------- method: The method to use for spectral estimation plot_kwarg_dict: A dictionary to pass arguments to matplotlib.plot **kwargs : Additional kwargs are passed to the spectral estimation method Returns ------- ax: matplotlib.axes.Axes, optional Matplotlib Axes object containing the graphs .. TODO:: * correct labeling of channels and axis (using accel\\_, velo\\_, and disp\\_channels) * do we need a svd in non-db scale? * do we need sample scaling on the abscissa ''' if plot_kwarg_dict is None: plot_kwarg_dict = {} prep_signals = self.prep_signals if scale not in ['db', 'power', 'rms', 'svd', 'phase']: raise ValueError( f"scale must be one of 'db', 'power', 'rms', 'svd', 'phase', got {scale!r}") method = kwargs.pop('method', None) channel_numbers, ref_numbers, refs_only, psd_matrix = self._psd_resolve_matrix( prep_signals, scale, channels, refs, n_lines, method, kwargs) freqs = prep_signals.freqs if ax is None: plt.figure() ax = plt.subplot(111) for channel_number, current_ref_numbers in zip(channel_numbers, ref_numbers): channel_name = prep_signals.channel_headers[channel_number] for ref_index, ref_number in enumerate(current_ref_numbers): psd, label = self._psd_channel_data( psd_matrix, channel_number, ref_index, ref_number, channel_name, refs_only, scale, prep_signals) ax.plot(freqs, psd, label=label, **plot_kwarg_dict) ax.set_xlim((0, freqs.max())) ax.set_xlabel(r'$f\,[\mathrm{Hz}]$') self._psd_set_ylabel(ax, scale) return ax
@staticmethod def _refine_refs_only(refs_only, method, prep_signals, kwargs): """Narrow refs_only based on cached matrix dimensions and kwarg override.""" if refs_only: if method == 'welch' and prep_signals.psd_matrix_wl is not None: refs_only = prep_signals.num_ref_channels == prep_signals.psd_matrix_wl.shape[1] elif method == 'blackman-tukey' and prep_signals.psd_matrix_bt is not None: refs_only = prep_signals.num_ref_channels == prep_signals.psd_matrix_bt.shape[1] if not kwargs.pop('refs_only', True): refs_only = False return refs_only @staticmethod def _psd_resolve_matrix(prep_signals, scale, channels, refs, n_lines, method, kwargs): """Resolve psd_matrix and channel/ref lists for plot_psd.""" if scale == 'svd': if refs is not None or kwargs.pop('refs_only', False): logger.warning("Reference channels are not used in SVD PSD.") channel_numbers, ref_numbers = prep_signals._channel_numbers(channels, [0]) psd_matrix = prep_signals.sv_psd(n_lines, method=method, refs_only=False, **kwargs) return channel_numbers, ref_numbers, False, psd_matrix channel_numbers, ref_numbers = prep_signals._channel_numbers(channels, refs) all_ref_numbers = set(sum(ref_numbers, [])) refs_only = all_ref_numbers.issubset(prep_signals.ref_channels) refs_only = SignalPlot._refine_refs_only(refs_only, method, prep_signals, kwargs) psd_matrix = prep_signals.psd(n_lines, refs_only=refs_only, method=method, **kwargs) if refs_only is not (prep_signals.num_ref_channels == psd_matrix.shape[1]): raise ValueError( f"refs_only={refs_only!r} is inconsistent with the returned PSD matrix: " f"num_ref_channels={prep_signals.num_ref_channels}, " f"psd_matrix.shape[1]={psd_matrix.shape[1]}" ) return channel_numbers, ref_numbers, refs_only, psd_matrix @staticmethod def _psd_channel_data(psd_matrix, channel_number, ref_index, ref_number, channel_name, refs_only, scale, prep_signals): """Extract and scale psd values and construct label for one channel pair.""" if scale == 'svd': psd = 10 * np.log10(np.abs(psd_matrix[channel_number, :])) label = rf'$\hat{{\sigma}}_\mathrm{{{channel_number}}}$' return psd, label if refs_only: psd = psd_matrix[channel_number, ref_index, :] else: psd = psd_matrix[channel_number, ref_number, :] if scale == 'db': psd = 10 * np.log10(np.abs(psd)) elif scale == 'power': psd = np.abs(psd) elif scale == 'rms': psd = np.sqrt(np.abs(psd)) elif scale == 'phase': psd = np.angle(psd) / np.pi * 180 if ref_number == channel_number: label = rf'$\hat{{S}}_\mathrm{{{channel_name}}}$' else: ref_name = prep_signals.channel_headers[ref_number] label = rf'$\hat{{S}}_\mathrm{{{ref_name},{channel_name}}}$' return psd, label @staticmethod def _psd_set_ylabel(ax, scale): """Set the y-axis label on *ax* according to *scale*.""" labels = { 'svd': 'Singular Value Magnitude [dB]', 'db': 'PSD [dB]', 'power': 'Power Spectral Density [...]', 'rms': 'Magnitude Spectral Density [...]', 'phase': 'Cross Spectrum Phase[°]', } ax.set_ylabel(labels.get(scale, '')) def plot_svd_spectrum(self, NFFT=512, log_scale=True, ax=None): prep_signals = self.prep_signals logger.warning("DeprecationWarning: method plot_svd_spectrum() will soon be dropped. Use plot_psd(scale='svd')") if not log_scale: raise NotImplementedError("Log scale for SVD plots cannot be deactivated") return prep_signals.plot_psd(n_lines=NFFT, ax=ax, scale='svd')
[docs] def load_measurement_file(fname, **kwargs): ''' assign this function to the class before instantiating the object PreProcessSignals.load_measurement_file = load_measurement_file ''' # define a function to return the following variables headers = ['channel_name', 'channel_name'] units = ['unit', 'unit', ] start_time = datetime.datetime() sample_rate = float() measurement = np.array([]) # channels im columns if not (measurement.shape[0] > measurement.shape[1]): raise ValueError( f"measurement must have more rows (time steps) than columns (channels), " f"got shape {measurement.shape}" ) return headers, units, start_time, sample_rate, measurement
[docs] def main(): pass
[docs] def spectral_estimation(): # signal parameters N = 2 ** 15 fs = 128 _dt = 1 / fs t, y, omegas, psd, corr = SDOF_ambient(N, fs) # spectral estimation parameters nperseg_fac = 1 _window = np.hamming n_lines = N // nperseg_fac _tau = np.linspace(0, n_lines / fs, n_lines, False) _omegasr = np.fft.rfftfreq(n_lines, 1 / fs) * 2 * np.pi do_plot = True if do_plot: _fig1, axes = plt.subplots(2, 2, sharex='row', sharey='row') ax1, ax2, ax3, ax4 = axes.flat for ax in axes.flat: ax.axhline(0, color='gray', linewidth=0.5) handles = [] if do_plot: ax1.plot(np.fft.fftshift(omegas) / 2 / np.pi, np.fft.fftshift(psd), label='analytic', color='black', lw=0.5) ax3.plot(t, corr, label='analytic', color='black', lw=0.5) ax2.plot(np.fft.fftshift(omegas) / 2 / np.pi, np.fft.fftshift(psd), label='analytic', color='black', lw=0.5) handles.append(ax4.plot(t, corr, label='analytic', color='black', lw=0.5)[0]) print(f'Theoretic powers') print(f'PSD: {np.mean(psd)}') # print(f'0-lag correlation: {correlation[0]}') prep_signals = PreProcessSignals(y[:, np.newaxis], fs) prep_signals.plot_signals(timescale='lags', axest=[ax3], axesf=[ax1], dbscale=False) plt.show()
[docs] def SDOF_ambient(N=2 ** 15, fs=128): _dt = 1 / fs omegas = np.fft.fftfreq(N, 1 / fs) * 2 * np.pi t = np.linspace(0, N / fs, N, False) # generate sdof system zeta = 0.05 omega = 5 * 2 * np.pi * np.sqrt(1 - zeta ** 2) # damped f = 5 Hz m = 1 k = omega ** 2 * m # c = zeta*2*sqrt(m*k) H = -omegas ** 2 / (k * (1 + 2j * zeta * omegas / omega - (omegas / omega) ** 2)) # generate ambient input forces f_scale = 10 phase = np.random.uniform(-np.pi, np.pi, (N // 2 + 1,)) ampli = np.exp(1j * np.concatenate((phase[:N // 2 + N % 2], -1 * np.flip(phase[1:])))) Pomega = f_scale * np.ones(N, dtype=complex) * ampli # make the ifft real-valued Pomega.imag[0] = 0 Pomega[N // 2 + N % 2] = np.abs(Pomega[N // 2 + N % 2]) H.imag[0] = 0 H[N // 2 + N % 2] = np.abs(H[N // 2 + N % 2]) # generate the ambient response signal y = np.fft.ifft(H * Pomega) # add noise noise = np.random.normal(0, 0.125, N) # noise adds zero energy due to zero mean? # discard machine-precision zero imaginary parts if np.all(np.isclose(y.imag, 0)): y = y.real else: raise RuntimeError() power = np.sum(y ** 2) power_noise = np.sum(noise ** 2) print(f'Power time-domain: {power}') print(f'SNR={10*np.log10(power/power_noise)} dB') # compute analytical spectrum and correlation functions with correct scaling # psd = np.abs(H)**2*f_scale**2 psd = omegas ** 4 / (k ** 2 * (1 + (4 * zeta ** 2 - 2) * (omegas / omega) ** 2 + (omegas / omega) ** 4)) * f_scale ** 2 psd /= np.mean(psd) # analytical solution for convolution difficult, use numerical inverse of analytical PSD corr = np.fft.ifft(psd) # discard machine-precision zero imaginary parts if np.all(np.isclose(corr.imag, 0)): corr = corr.real else: raise RuntimeError() return t, (y + noise) / np.sqrt(power), omegas, psd, corr
[docs] def system_frf(N=2 ** 16, fmax=130, L=200, E=2.1e11, rho=7850, A=0.0343, zeta=0.01): df = fmax / (N // 2 + 1) dt = 1 / df / N _fs = 1 / dt omegas = np.linspace(0, fmax, N // 2 + 1, False) * 2 * np.pi expected_domega = (omegas[-1] - omegas[0]) / (N // 2 + 1 - 1) if df * 2 * np.pi != expected_domega: raise RuntimeError( f"Frequency resolution mismatch: df*2*pi={df * 2 * np.pi!r} " f"!= (omegas[-1]-omegas[0])/(N//2)={expected_domega!r}; internal frequency grid inconsistency." ) num_modes = int(np.floor((fmax * 2 * np.pi * np.sqrt(rho / E) * L / np.pi * 2 + 1) / 2)) omegans = (2 * np.arange(1, num_modes + 1) - 1) / 2 * np.pi / L * np.sqrt(E / rho) frf = np.zeros((N // 2 + 1,), dtype=complex) zetas = np.zeros_like(omegans) zetas[:] = zeta kappas = omegans ** 2 # kappas[:]=E*A/L for j, (omegan, zeta) in enumerate(zip(omegans, zetas)): frf += -omegan ** 2 / (kappas[j] * (1 + 2 * 1j * zeta * omegas / omegan - (omegas / omegan) ** 2)) # Accelerance return omegas, frf
if __name__ == '__main__': main()