Source code for pyOMA.core.StabilDiagram

# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2025  Simon Marwitz, Volkmar Zabel, Andrei Udrea et al.
"""Stabilization diagram computation (StabilCalc, StabilCluster) and static plot (StabilPlot)."""

from .SSICovRef import PogerSSICovRef
from .ModalBase import ModalBase
from .Helpers import simplePbar, calculateMAC, calculateMPC, calculateMPD
import numpy as np

import scipy.cluster
import scipy.spatial
import scipy.stats

import os
import warnings
import dataclasses
from typing import Optional, Tuple

import collections
from operator import itemgetter
from random import shuffle

# check if python is running in headless mode i.e. as a server script
# if 'DISPLAY' in os.environ:
#     matplotlib.use("Qt5Agg", force=True)
from matplotlib import rcParams
from matplotlib.figure import Figure
from matplotlib.text import TextPath, FontProperties
from matplotlib.path import Path
from matplotlib.markers import MarkerStyle
from matplotlib.widgets import Cursor
import matplotlib.cm
import matplotlib.pyplot as plot

plot.rc('figure', figsize=[8.5039399474194, 5.255723925793184], dpi=100,)
plot.rc('font', size=10)
plot.rc('legend', fontsize=10, labelspacing=0.1)
plot.rc('axes', linewidth=0.2)
plot.rc('xtick.major', width=0.2)
plot.rc('ytick.major', width=0.2)
# plot.ioff()

NoneType = type(None)

# Namedtuples used to group related arrays and reduce parameter counts
_ScalarDiffs = collections.namedtuple('_ScalarDiffs', ['lambda_diffs', 'freq_diffs', 'damp_diffs'])
_ModalDiffs = collections.namedtuple(
    '_ModalDiffs',
    ['MAC_diffs', 'MPC_matrix', 'MPD_matrix', 'MP_matrix', 'MPD_diffs', 'MP_diffs'])
_SelectedResults = collections.namedtuple(
    '_SelectedResults',
    ['freq', 'damp', 'order', 'modes', 'MC', 'MPC', 'MP', 'MPD', 'stdf', 'stdd', 'stdmsh'])

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


[docs] @dataclasses.dataclass class StabCriteria: """Grouping of all stabilization threshold parameters. Pass an instance to :meth:`StabilCalc.calculate_stabilization_masks` or :meth:`StabilCalc.update_stabilization_masks` instead of individual keyword arguments. Parameters ---------- order_range : tuple, optional ``(start, step, stop)`` for the model-order range. d_range : tuple, optional ``(min, max)`` damping ratio [%] band. stdf_max : float, optional Maximum relative standard deviation of frequency [%]. stdd_max : float, optional Maximum relative standard deviation of damping [%]. mpc_min : float, optional Minimum Modal Phase Collinearity value. mpd_max : float, optional Maximum Mean Phase Deviation [°]. mtn_min : float, optional Minimum Modal Transfer Norm. df_max : float, optional Maximum relative frequency difference for stabilization. dd_max : float, optional Maximum relative damping difference for stabilization. dmac_max : float, optional Maximum MAC difference for stabilization. dev_min : float, optional Minimum relative eigenvalue difference. dmtn_min : float, optional Minimum Modal Transfer Norm difference. MC_min : float, optional Minimum Modal Contribution. """ order_range: Optional[Tuple] = None d_range: Optional[Tuple] = None stdf_max: Optional[float] = None stdd_max: Optional[float] = None mpc_min: Optional[float] = None mpd_max: Optional[float] = None mtn_min: Optional[float] = None df_max: Optional[float] = None dd_max: Optional[float] = None dmac_max: Optional[float] = None dev_min: Optional[float] = None dmtn_min: Optional[float] = None MC_min: Optional[float] = None
[docs] class StabilCalc(object): """Stabilisation diagram computation and pole selection. Computes stabilisation masks by comparing modal parameters between successive model orders, applies physical criteria (frequency, damping, MPC, MPD, MAC), and manages pole selection for export. Optionally delegates automatic clearing and clustering to :class:`StabilCluster`. Parameters ---------- modal_data : ModalBase Any pyOMA system-identification result object (must be a subclass of :class:`~pyOMA.core.ModalBase.ModalBase`). prep_signals : PreProcessSignals, optional Deprecated — ignored; ``modal_data.prep_signals`` is used instead. .. TODO:: * scale markers right on every platform * frequency range as argument or from ssi params, sampling freq * add switch to choose between "unstable only in ..." or "stable in ..." * distinguish between stabilization criteria and filtering criteria * rework mask logic (currently it is very difficult to understand) * Merge DataCursor and JupyterGUI.SnappingCursor """
[docs] def __init__(self, modal_data, prep_signals=None, **kwargs): super().__init__() if not isinstance(modal_data, ModalBase): raise TypeError(f"Expected ModalBase for 'modal_data', got {type(modal_data).__name__!r}.") self.modal_data = modal_data self.extra_func = None self.setup_name = modal_data.setup_name self.start_time = modal_data.start_time if prep_signals is not None: logger.warning('Providing prep_signals is not required anymore. Ignoring argument!') self.prep_signals = modal_data.prep_signals self.capabilities = self._build_capabilities() self._init_masked_arrays() self._init_masks() self._init_thresholds() self.select_modes = [] self.select_callback = None self.state = 0 self.callbacks = {'add_mode': [], 'remove_mode': []}
def _build_capabilities(self): """Build and return the capabilities dict from modal_data attributes.""" md = self.modal_data return { 'f': 1, 'd': 1, 'msh': md.__dict__.get('mode_shapes') is not None, 'std': md.__dict__.get('std_frequencies') is not None, 'ev': md.__dict__.get('eigenvalues') is not None, 'mtn': 0, 'MC': md.__dict__.get('modal_contributions') is not None, 'auto': isinstance(self, StabilCluster), 'data': md.prep_signals is not None, } def _init_masked_arrays(self): """Allocate masked frequency, damping, eigenvalue and order arrays.""" if self.capabilities['ev']: self.masked_lambda = np.ma.array( self.modal_data.eigenvalues, fill_value=0) self.masked_frequencies = np.ma.array( self.modal_data.modal_frequencies, copy=True, fill_value=0) self.masked_frequencies[np.isnan(self.masked_frequencies)] = 0 self.masked_damping = np.ma.array( self.modal_data.modal_damping, fill_value=0) max_model_order = self.modal_data.max_model_order self.num_solutions = self.modal_data.modal_frequencies.shape[1] self.order_dummy = np.ma.array( [[order] * self.num_solutions for order in range(max_model_order)], fill_value=0) def _init_masks(self): """Initialise stable-in and only-unstable-in mask dicts to None.""" self.masks = { 'mask_pre': None, # some constraints (f>0.0, order_range, etc) 'mask_ad': None, # absolute damping 'mask_stdf': None, # uncertainty frequency 'mask_stdd': None, # uncertainty damping 'mask_mpc': None, # absolute modal phase collinearity 'mask_mpd': None, # absolute mean phase deviation 'mask_mtn': None, # absolute modal transfer norm 'mask_df': None, # difference frequency 'mask_dd': None, # difference damping 'mask_dmac': None, # difference mac 'mask_dev': None, # difference eigenvalue 'mask_dmtn': None, # difference modal transfer norm 'mask_stable': None, # stable in all criteria } self.nmasks = { 'mask_ad': None, # absolute damping 'mask_stdf': None, # uncertainty frequency 'mask_stdd': None, # uncertainty damping 'mask_ampc': None, # absolute modal phase collinearity 'mask_ampd': None, # absolute mean phase deviation 'mask_amtn': None, # absolute modal transfer norm 'mask_df': None, # difference frequency 'mask_dd': None, # difference damping 'mask_dmac': None, # difference mac 'mask_dev': None, # difference eigenvalue 'mask_dmtn': None, # difference modal transfer norm } def _init_thresholds(self): """Set default stabilization threshold attributes.""" self.order_range = (0, 1, self.modal_data.max_model_order) self.d_range = (0, 100) self.stdf_max = 100 self.stdd_max = 100 self.mpc_min = 0 self.mpd_max = 90 self.mtn_min = 0 self.df_max = 0.01 self.dd_max = 0.05 self.dmac_max = 0.02 self.dev_min = 0.02 self.dmtn_min = 0.02 self.MC_min = 0 def add_callback(self, name, func): if name not in ['add_mode', 'remove_mode']: raise ValueError(f"'name' must be one of {['add_mode', 'remove_mode']}, got {name!r}.") self.callbacks[name].append(func) def calculate_soft_critera_matrices(self): logger.info('Checking stabilisation criteria...') # Direction 1: model order, Direction 2: current pole, Direction 3: # previous pole: max_model_order = self.modal_data.max_model_order num_solutions = self.num_solutions capabilities = self.capabilities scalar_diffs, modal_diffs = self._init_criteria_matrices( max_model_order, num_solutions, capabilities) # Initialise previous-order state prev_state = self._get_initial_order_state(capabilities, 0) pbar = simplePbar(max_model_order - 1) for curr_order in range(1, max_model_order): next(pbar) curr_non_zero_entries, curr_length = self._get_non_zero_entries( capabilities, curr_order) # print(curr_length) if not curr_length: continue curr_state = self._get_curr_order_state( capabilities, curr_order, curr_non_zero_entries) self._update_scalar_diff_matrices( capabilities, curr_order, curr_non_zero_entries, prev_state, curr_state, scalar_diffs) if capabilities['msh']: self._update_mac_mpc_matrices( curr_order, curr_non_zero_entries, curr_length, prev_state, curr_state, modal_diffs) prev_state = curr_state if capabilities['ev']: self.lambda_diffs = scalar_diffs.lambda_diffs self.freq_diffs = scalar_diffs.freq_diffs self.damp_diffs = scalar_diffs.damp_diffs self.MAC_diffs = modal_diffs.MAC_diffs self.MPD_diffs = modal_diffs.MPD_diffs self.MP_diffs = modal_diffs.MP_diffs self.MPD_matrix = modal_diffs.MPD_matrix self.MP_matrix = modal_diffs.MP_matrix self.MPC_matrix = modal_diffs.MPC_matrix self.state = 1 # ------------------------------------------------------------------ # Private helpers for calculate_soft_critera_matrices # ------------------------------------------------------------------ def _init_criteria_matrices(self, max_model_order, num_solutions, capabilities): """Allocate all zero-filled difference and absolute matrices. Returns ------- scalar_diffs : _ScalarDiffs modal_diffs : _ModalDiffs """ shape3d = (max_model_order, num_solutions, num_solutions) shape2d = (max_model_order, num_solutions) scalar_diffs = _ScalarDiffs( lambda_diffs=np.ma.zeros(shape3d, fill_value=0), freq_diffs=np.ma.zeros(shape3d, fill_value=0), damp_diffs=np.ma.zeros(shape3d, fill_value=0), ) if capabilities['msh']: modal_diffs = _ModalDiffs( MAC_diffs=np.ma.zeros(shape3d, fill_value=0), MPC_matrix=np.ma.zeros(shape2d, fill_value=0), MPD_matrix=np.ma.zeros(shape2d, fill_value=0), MP_matrix=np.ma.zeros(shape2d, fill_value=0), MPD_diffs=np.ma.zeros(shape3d, fill_value=0), MP_diffs=np.ma.zeros(shape3d, fill_value=0), ) else: modal_diffs = _ModalDiffs( MAC_diffs=None, MPC_matrix=None, MPD_matrix=None, MP_matrix=None, MPD_diffs=None, MP_diffs=None, ) return scalar_diffs, modal_diffs def _get_initial_order_state(self, capabilities, order): """Build the previous-order state dict for order 0.""" if capabilities['ev']: prev_lambda_row = self.masked_lambda.data[order, :] prev_freq_row = self.masked_frequencies[order, :] prev_damp_row = self.modal_data.modal_damping[order, :] if capabilities['ev']: nze = np.where( (~np.isnan(prev_lambda_row.imag)) & (prev_lambda_row.imag != 0)) else: nze = np.where( (~np.isnan(prev_freq_row)) & (prev_freq_row != 0)) length = len(nze[0]) freq = prev_freq_row[nze] damp = prev_damp_row[nze] state = {'nze': nze, 'length': length, 'freq': freq, 'damp': damp} if capabilities['ev']: state['lambda'] = prev_lambda_row[nze] if capabilities['msh']: prev_msh_row = self.modal_data.mode_shapes[:, :, order] msh = prev_msh_row[:, nze[0]] mpd, mp_new = calculateMPD(msh) mp_new[mp_new > 90] -= 180 # in range [-90,90] state['msh'] = msh state['MPD'] = mpd state['MP_new'] = mp_new return state def _get_non_zero_entries(self, capabilities, curr_order): """Return (non_zero_entries_tuple, count) for the current order.""" if capabilities['ev']: row = self.masked_lambda.data[curr_order, :] nze = np.where((~np.isnan(row.imag)) & (row.imag != 0)) else: row = self.masked_frequencies[curr_order, :] nze = np.where((~np.isnan(row)) & (row != 0)) return nze, len(nze[0]) def _get_curr_order_state(self, capabilities, curr_order, nze): """Build the current-order state dict from raw data.""" freq_row = self.masked_frequencies[curr_order, :] damp_row = self.modal_data.modal_damping[curr_order, :] state = { 'nze': nze, 'length': len(nze[0]), 'freq': freq_row[nze], 'damp': damp_row[nze], } if capabilities['ev']: state['lambda'] = self.masked_lambda.data[curr_order, :][nze] if capabilities['msh']: msh_row = self.modal_data.mode_shapes[:, :, curr_order] msh = msh_row[:, nze[0]] mpd, mp = calculateMPD(msh[:, :state['length']]) mp_new = np.copy(mp) mp_new[mp_new > 90] -= 180 state['msh'] = msh state['MPD'] = mpd state['MP'] = mp state['MP_new'] = mp_new return state def _rel_diff_matrix(self, prev_vals, curr_vals): """Compute element-wise relative difference matrix (prev x curr).""" div = np.maximum( np.repeat(np.expand_dims(np.abs(prev_vals), axis=1), curr_vals.shape[0], axis=1), np.repeat(np.expand_dims(np.abs(curr_vals), axis=0), prev_vals.shape[0], axis=0)) return np.abs(( np.repeat(np.expand_dims(prev_vals, axis=1), curr_vals.shape[0], axis=1) - curr_vals) / div).T def _update_scalar_diff_matrices( self, capabilities, curr_order, nze, prev, curr, scalar_diffs): """Fill lambda/freq/damp relative-difference slices.""" prev_length = prev['length'] if capabilities['ev']: div_lambda = np.maximum( np.repeat(np.expand_dims(np.ma.abs(prev['lambda']), axis=1), curr['lambda'].shape[0], axis=1), np.repeat(np.expand_dims(np.ma.abs(curr['lambda']), axis=0), prev['lambda'].shape[0], axis=0)) scalar_diffs.lambda_diffs[curr_order, nze[0], :prev_length] = np.abs( (np.repeat(np.expand_dims(prev['lambda'], axis=1), curr['lambda'].shape[0], axis=1) - curr['lambda']) / div_lambda).T scalar_diffs.freq_diffs[curr_order, nze[0], :prev_length] = self._rel_diff_matrix( prev['freq'], curr['freq']) scalar_diffs.damp_diffs[curr_order, nze[0], :prev_length] = self._rel_diff_matrix( prev['damp'], curr['damp']) def _update_mac_mpc_matrices( self, curr_order, nze, curr_length, prev, curr, modal_diffs): """Fill MAC, MPC, MPD and MP difference slices.""" prev_length = prev['length'] mac_diffs = np.transpose( 1 - calculateMAC( prev['msh'][:, :prev_length], curr['msh'][:, :curr_length])) modal_diffs.MAC_diffs[curr_order, nze[0], :prev_length] = mac_diffs modal_diffs.MPC_matrix[curr_order, nze[0]] = calculateMPC( curr['msh'][:, :curr_length]) modal_diffs.MPD_matrix[curr_order, nze[0]] = curr['MPD'] modal_diffs.MP_matrix[curr_order, nze[0]] = curr['MP'] modal_diffs.MPD_diffs[curr_order, nze[0], :len(prev['MPD'])] = ( self._rel_diff_matrix(prev['MPD'], curr['MPD'])) modal_diffs.MP_diffs[curr_order, nze[0], :len(prev['MP_new'])] = ( self._rel_diff_matrix(prev['MP_new'], curr['MP_new'])) def export_results(self, fname, binary=False): (selected_freq, selected_damp, selected_modes, _, selected_order, selected_MC, selected_MPC, selected_MP, selected_MPD, selected_stdf, selected_stdd, selected_stdmsh) = self.get_selected_modal_values() results = _SelectedResults( freq=selected_freq, damp=selected_damp, order=selected_order, modes=selected_modes, MC=selected_MC, MPC=selected_MPC, MP=selected_MP, MPD=selected_MPD, stdf=selected_stdf, stdd=selected_stdd, stdmsh=selected_stdmsh, ) dirname, _ = os.path.split(fname) if not os.path.isdir(dirname): os.makedirs(dirname) if binary: self._export_binary(fname, results) else: export_modes = self._build_text_export(results) with open(fname, 'w') as fh: fh.write(export_modes) # ------------------------------------------------------------------ # Private helpers for export_results # ------------------------------------------------------------------ @staticmethod def _fmt_field(fmt, vals): """Join all values in *vals* formatted by *fmt*.""" return ''.join(fmt.format(v) for v in vals) def _build_export_scalar_strings(self, results): """Return per-column string accumulators for scalar fields.""" has_msh = self.capabilities['msh'] has_std = self.capabilities['std'] has_mc = self.capabilities['MC'] fmt = self._fmt_field return ( fmt('{:<3.3f}\t\t', results.freq), fmt('{:<3.3f}\t\t', results.damp), fmt('{:<6d}\t\t', results.order), fmt('{:<3.3f}\t \t', results.MPC) if has_msh else None, fmt('{:<3.2f}\t\t', results.MP) if has_msh else None, fmt('{:<3.2f}\t\t', results.MPD) if has_msh else None, fmt('{:<3.3e}\t\t', results.stdf) if has_std else None, fmt('{:<3.3e}\t\t', results.stdd) if has_std else None, fmt('{:<3.3f}\t\t', results.MC) if has_mc else None, ) def _get_chan_dofs(self): """Return the channel-DOF list appropriate for the current modal data.""" if isinstance(self.modal_data, PogerSSICovRef): return self.modal_data.merged_chan_dofs if self.capabilities['data']: return self.prep_signals.chan_dofs return [] def _row_label(self, row, chan_dofs): """Return the mode-shape row label string for *row*.""" for chan_dof in chan_dofs: chan, node, az, elev = chan_dof[:4] if chan == row: return ( f'\n{node.ljust(10)} ({az: <+3.2f}, {elev: >+3.2f})' ' \t') return '\n ' def _build_mode_shape_string(self, selected_modes, selected_stdmsh): """Return (msh_str, std_msh_str) for mode shape rows.""" has_std = self.capabilities['std'] chan_dofs = self._get_chan_dofs() msh_parts = [] std_parts = [] if has_std else None for row in range(selected_modes.shape[0]): msh_parts.append(self._row_label(row, chan_dofs)) if has_std: std_parts.append('\n \t\t') for col in range(selected_modes.shape[1]): msh_parts.append('{:+<3.4f}\t'.format(selected_modes[row, col])) if has_std: std_parts.append('{:+<3.3e} \t'.format( selected_stdmsh[row, col])) msh_str = ''.join(msh_parts) std_msh_str = ''.join(std_parts) if has_std else None return msh_str, std_msh_str def _build_text_export(self, results): """Compose the human-readable text export string.""" (freq_str, damp_str, ord_str, mpc_str, mp_str, mpd_str, std_freq_str, std_damp_str, MC_str) = self._build_export_scalar_strings(results) if self.capabilities['msh']: msh_str, std_msh_str = self._build_mode_shape_string( results.modes, results.stdmsh) else: msh_str = None std_msh_str = None lines = ['MANUAL MODAL ANALYSIS\n', '=======================\n'] lines.append('Frequencies [Hz]: \t' + freq_str + '\n') if self.capabilities['std']: lines.append('Standard deviations of the Frequencies [Hz]:\t' + std_freq_str + '\n') lines.append('Damping [%]: \t' + damp_str + '\n') if self.capabilities['std']: lines.append('Standard deviations of the Damping [%]: \t' + std_damp_str + '\n') if self.capabilities['MC']: lines.append('Modal Contributions of the mode [-]: \t' + MC_str + '\n') if self.capabilities['msh']: lines.append('Node (Azimuth, Elevation) \tMode shapes:' + msh_str + '\n') if self.capabilities['std']: lines.append('Standard Deviations of the Mode shapes: \t' + std_msh_str + '\n') lines.append('Model order: \t' + ord_str + '\n') if self.capabilities['msh']: lines.append('MPC [-]: \t' + mpc_str + '\n') lines.append('MP [\u00b0]: \t' + mp_str + '\n') lines.append('MPD [-]: \t' + mpd_str + '\n\n') return ''.join(lines) def _export_binary(self, fname, results): """Save selected results as a compressed NumPy archive.""" out_dict = { 'selected_freq': results.freq, 'selected_damp': results.damp, 'selected_order': results.order, } if self.capabilities['msh']: out_dict['selected_MPC'] = results.MPC out_dict['selected_MP'] = results.MP out_dict['selected_MPD'] = results.MPD out_dict['selected_modes'] = results.modes if self.capabilities['std']: out_dict['selected_stdf'] = results.stdf out_dict['selected_stdd'] = results.stdd out_dict['selected_stdmsh'] = results.stdmsh np.savez_compressed(fname, **out_dict) _CRITERIA_FLAT_KEYS = frozenset({ 'order_range', 'd_range', 'stdf_max', 'stdd_max', 'mpc_min', 'mpd_max', 'mtn_min', 'df_max', 'dd_max', 'dmac_max', 'dev_min', 'dmtn_min', 'MC_min', }) @staticmethod def _resolve_criteria(criteria, kwargs): """Return a :class:`StabCriteria` from *criteria* or flat *kwargs*. When flat keyword arguments are passed instead of a ``StabCriteria`` object, a :class:`DeprecationWarning` is emitted and a temporary ``StabCriteria`` is built from the kwargs so the rest of the code path stays the same. """ if criteria is not None: if not isinstance(criteria, StabCriteria): raise TypeError( f'criteria must be a StabCriteria instance, got ' f'{type(criteria).__name__!r}') return criteria return StabilCalc._build_criteria_from_kwargs(kwargs) @staticmethod def _build_criteria_from_kwargs(kwargs): """Build a :class:`StabCriteria` from flat keyword arguments.""" flat_keys = StabilCalc._CRITERIA_FLAT_KEYS used_flat = {k: v for k, v in kwargs.items() if k in flat_keys and v is not None} if used_flat: warnings.warn( 'Pass a StabCriteria object instead of individual threshold ' 'arguments', DeprecationWarning, stacklevel=4) return StabCriteria(**{k: v for k, v in kwargs.items() if k in flat_keys})
[docs] def calculate_stabilization_masks(self, criteria=None, **kwargs): """Compute all stabilization masks from scratch. Parameters ---------- criteria : StabCriteria, optional Threshold bundle. Pass this *or* the individual keyword arguments below (old call style, deprecated). **kwargs : optional Individual threshold values (deprecated — use *criteria* instead). Accepted keys: order_range, d_range, stdf_max, stdd_max, mpc_min, mpd_max, mtn_min, df_max, dd_max, dmac_max, dev_min, dmtn_min, MC_min. """ if self.state < 1: self.calculate_soft_critera_matrices() c = self._resolve_criteria(criteria, kwargs) # Fill in defaults when not provided by the caller defaults = StabCriteria( order_range=(0, 1, self.modal_data.max_model_order), d_range=(0, 100), stdf_max=100, stdd_max=100, mpc_min=0, mpd_max=90, mtn_min=0, df_max=0.01, dd_max=0.05, dmac_max=0.02, dev_min=0.02, dmtn_min=0.02, MC_min=0, ) merged = StabCriteria( **{ field.name: ( getattr(c, field.name) if getattr(c, field.name) is not None else getattr(defaults, field.name) ) for field in dataclasses.fields(StabCriteria) } ) self.state = 2 self.update_stabilization_masks(merged)
[docs] def update_stabilization_masks(self, criteria=None, **kwargs): """Update the stabilization masks with new threshold values. Parameters ---------- criteria : StabCriteria, optional Threshold bundle. Pass this *or* the individual keyword arguments below (old call style, deprecated). **kwargs : optional Individual threshold values (deprecated — use *criteria* instead). Accepted keys: order_range, d_range, stdf_max, stdd_max, mpc_min, mpd_max, mtn_min, df_max, dd_max, dmac_max, dev_min, dmtn_min, MC_min. """ if self.state < 2: self.calculate_stabilization_masks() c = self._resolve_criteria(criteria, kwargs) # Merge non-None fields from criteria into instance attributes self._apply_criteria(c) self.masked_frequencies.mask = np.ma.nomask self.order_dummy.mask = np.ma.nomask mask_pre = self._compute_mask_pre(c.order_range) self.masks['mask_pre'] = mask_pre self._compute_absolute_masks(mask_pre, c) self._compute_diff_masks(mask_pre, c) self._finalize_stable_mask(mask_pre)
# ------------------------------------------------------------------ # Private helpers for update_stabilization_masks # ------------------------------------------------------------------ def _apply_criteria(self, c): """Copy non-None fields of *c* onto instance attributes.""" for field in dataclasses.fields(StabCriteria): val = getattr(c, field.name) if val is not None: setattr(self, field.name, val) def _compute_mask_pre(self, order_range): """Return the base pre-mask (valid frequencies and selected orders).""" mask_pre = ( (~np.isnan(self.masked_frequencies)) & (self.masked_frequencies != 0)) if order_range is not None: start, step, stop = order_range start = max(0, start) stop = min(stop, self.modal_data.max_model_order) mask_order = np.zeros_like(mask_pre) for order in range(start, stop, step): mask_order = np.logical_or( mask_order, self.order_dummy == order) mask_pre = np.logical_and(mask_pre, mask_order) return mask_pre def _compute_absolute_masks(self, mask_pre, c): """Compute absolute-criterion masks (damping, std, MPC, MPD, MC).""" self._apply_damping_mask(mask_pre, c) self._apply_std_masks(mask_pre, c) self._apply_mpc_mpd_masks(mask_pre, c) self._apply_mc_mask(mask_pre, c) def _apply_damping_mask(self, mask_pre, c): """Compute and store the absolute-damping mask.""" if c.d_range is None: return if not isinstance(c.d_range, (tuple, list)): raise TypeError( f"d_range must be a tuple or list, got " f"{type(c.d_range).__name__!r}") if len(c.d_range) != 2: raise ValueError( f"d_range must have length 2, got {len(c.d_range)}") mask = np.logical_and( mask_pre, self.modal_data.modal_damping >= c.d_range[0]) self.masks['mask_ad'] = np.logical_and( mask, self.modal_data.modal_damping <= c.d_range[1]) def _apply_std_masks(self, mask_pre, c): """Compute and store standard-deviation masks for frequency and damping.""" if not self.capabilities['std']: return num_blocks = self.modal_data.num_blocks t_factor = scipy.stats.t.ppf(0.95, num_blocks) sqrt_nb = np.sqrt(num_blocks) if c.stdf_max is not None: mask = t_factor * self.modal_data.std_frequencies / sqrt_nb <= c.stdf_max self.masks['mask_stdf'] = np.logical_and(mask_pre, mask) if c.stdd_max is not None: mask = t_factor * self.modal_data.std_damping / sqrt_nb <= c.stdd_max self.masks['mask_stdd'] = np.logical_and(mask_pre, mask) def _apply_mpc_mpd_masks(self, mask_pre, c): """Compute and store MPC and MPD masks.""" if c.mpc_min is not None: self.masks['mask_mpc'] = np.logical_and( mask_pre, self.MPC_matrix >= c.mpc_min) if c.mpd_max is not None: self.masks['mask_mpd'] = np.logical_and( mask_pre, self.MPD_matrix <= c.mpd_max) def _apply_mc_mask(self, mask_pre, c): """Compute and store the modal-contribution mask.""" if c.MC_min is None or not self.capabilities['MC']: return mc = self.modal_data.modal_contributions if np.issubdtype(mc.dtype, complex): contrib_mask = np.abs(mc) >= c.MC_min else: contrib_mask = mc >= c.MC_min self.masks['mask_MC'] = np.logical_and(mask_pre, contrib_mask) def _compute_diff_masks(self, mask_pre, c): """Compute difference-criterion masks (df, dd, dMAC) and collect them.""" full_masks = [] if c.df_max is not None: # rel freq diffs for each pole with all previous poles, # for all poles and orders results in 3d array # compare those rel freq diffs with df_max # and reduce 3d array to 2d array, by applying logical_or # along each poles axis (diff with all previous) mask_sf_all = np.logical_and( self.freq_diffs != 0, self.freq_diffs <= c.df_max) mask_sf_red = np.any(mask_sf_all, axis=2) self.masks['mask_df'] = np.logical_and(mask_pre, mask_sf_red) full_masks.append(mask_sf_all) if c.dd_max is not None: mask_sd_all = np.logical_and( self.damp_diffs != 0, self.damp_diffs <= c.dd_max) mask_sd_red = np.any(mask_sd_all, axis=2) self.masks['mask_dd'] = np.logical_and(mask_pre, mask_sd_red) full_masks.append(mask_sd_all) if c.dmac_max is not None: mask_sv_all = np.logical_and( self.MAC_diffs != 0, self.MAC_diffs <= c.dmac_max) mask_sv_red = np.any(mask_sv_all, axis=2) self.masks['mask_dmac'] = np.logical_and(mask_pre, mask_sv_red) full_masks.append(mask_sv_all) return full_masks def _finalize_stable_mask(self, mask_pre): """Combine all individual masks into mask_stable and nmasks.""" stable_mask = self._combine_into_stable_mask(mask_pre) self.masks['mask_stable'] = stable_mask self.nmasks = self._build_nmasks(stable_mask) def _combine_into_stable_mask(self, mask_pre): """Return the combined stable mask from diff and absolute masks.""" c = StabCriteria(df_max=self.df_max, dd_max=self.dd_max, dmac_max=self.dmac_max) full_masks = self._compute_diff_masks(mask_pre, c) if full_masks: stable_mask_full = np.ones_like(full_masks[0]) for mask in full_masks: stable_mask_full = np.logical_and(stable_mask_full, mask) stable_mask = np.any(stable_mask_full, axis=2) else: stable_mask = mask_pre skip = {'mask_stable', 'mask_autosel', 'mask_autoclear'} for mask_name, mask in self.masks.items(): if mask_name not in skip and mask is not None: stable_mask = np.logical_and(stable_mask, mask) return stable_mask def _build_nmasks(self, stable_mask): """Build and return the only-unstable-in-... nmasks dict.""" _skip = {'mask_pre', 'mask_stable', 'mask_autosel', 'mask_autoclear'} nmasks = { name: np.logical_not(stable_mask) for name, mask in self.masks.items() if mask is not None} for nname, nmask in nmasks.items(): if nname in _skip: continue for name, mask in self.masks.items(): if mask is None or name in _skip: continue if name == nname: nmask = np.logical_and(nmask, np.logical_not(mask)) else: nmask = np.logical_and(nmask, mask) nmasks[nname] = nmask nmasks['mask_stable'] = stable_mask nmasks['mask_pre'] = self.masks['mask_pre'] nmasks['mask_autoclear'] = np.logical_not( self.masks.get('mask_autoclear', None)) nmasks['mask_autosel'] = np.logical_not( self.masks.get('mask_autosel', None)) return nmasks def get_stabilization_mask(self, name): # print(name) mask = self.nmasks.get(name) if mask is None: mask = self.nmasks['mask_pre'] logger.debug('Pre Mask is empty') return np.logical_not(mask) def get_max_f(self): if self.prep_signals is not None: return self.prep_signals.sampling_rate / 2 elif isinstance(self.modal_data, PogerSSICovRef): return self.modal_data.sampling_rate / 2 else: return float(np.amax(self.masked_frequencies))
[docs] def get_frequencies(self): ''' Returns ------- frequencies: list Identified frequencies of all currently selected modes. ''' selected_indices = self.select_modes frequencies = sorted([self.masked_frequencies[index[0], index[1]] for index in selected_indices]) return frequencies
[docs] def get_selected_modal_values(self): ''' Returns ------- frequencies: list Identified frequencies of all currently selected modes. ''' if not self.select_modes: return [np.array([]) for _ in range(12)] self.masked_frequencies.mask = np.ma.nomask self.order_dummy.mask = np.ma.nomask select_modes = self.select_modes selected_freq = [self.masked_frequencies[index] for index in self.select_modes] select_modes = [x for (_, x) in sorted( zip(selected_freq, select_modes), key=lambda pair: pair[0])] selected_freq = [self.masked_frequencies[index] for index in select_modes] selected_damp = [self.modal_data.modal_damping[index] for index in select_modes] selected_order = [self.order_dummy[index] for index in select_modes] selected_lambda = self._get_selected_lambda(select_modes) selected_MPC, selected_MP, selected_MPD = self._get_selected_mpc_mpd(select_modes) selected_stdf, selected_stdd, selected_stdmsh = self._get_selected_std(select_modes) selected_MC = self._get_selected_mc(select_modes) selected_modes, selected_stdmsh = self._get_selected_mode_shapes( select_modes, selected_stdmsh) return selected_freq, selected_damp, selected_modes, selected_lambda, \ selected_order, selected_MC, \ selected_MPC, selected_MP, selected_MPD, \ selected_stdf, selected_stdd, selected_stdmsh
# ------------------------------------------------------------------ # Private helpers for get_selected_modal_values # ------------------------------------------------------------------ def _get_selected_lambda(self, select_modes): """Return list of eigenvalues for selected modes, or None.""" if self.capabilities['ev']: return [self.modal_data.eigenvalues[index] for index in select_modes] return None def _get_selected_mpc_mpd(self, select_modes): """Return (MPC, MP, MPD) lists for selected modes, or (None, None, None).""" if self.capabilities['msh']: selected_MPC = [self.MPC_matrix[index] for index in select_modes] selected_MP = [self.MP_matrix[index] for index in select_modes] selected_MPD = [self.MPD_matrix[index] for index in select_modes] return selected_MPC, selected_MP, selected_MPD return None, None, None def _get_selected_std(self, select_modes): """Return (stdf, stdd, stdmsh) for selected modes, or (None, None, None).""" if self.capabilities['std']: selected_stdf = [self.modal_data.std_frequencies[index] for index in select_modes] selected_stdd = [self.modal_data.std_damping[index] for index in select_modes] selected_stdmsh = np.zeros( (self.modal_data.mode_shapes.shape[0], len(select_modes)), dtype=complex) return selected_stdf, selected_stdd, selected_stdmsh return None, None, None def _get_selected_mc(self, select_modes): """Return list of modal contributions for selected modes, or None.""" if self.capabilities['MC']: return [self.modal_data.modal_contributions[index] for index in select_modes] return None def _get_selected_mode_shapes(self, select_modes, selected_stdmsh): """Return (mode_shape_matrix, updated_stdmsh), or (None, selected_stdmsh).""" if not self.capabilities['msh']: return None, selected_stdmsh n_channels = self.modal_data.mode_shapes.shape[0] selected_modes = np.zeros( (n_channels, len(select_modes)), dtype=complex) for num, ind in enumerate(select_modes): row_index = ind[0] col_index = ind[1] mode_tmp = self.modal_data.mode_shapes[:, col_index, row_index] if self.capabilities['std']: std_mode = self.modal_data.std_mode_shapes[:, col_index, row_index] selected_stdmsh[:, num] = std_mode else: # scaling of mode shape abs_mode_tmp = np.abs(mode_tmp) this_max = mode_tmp[np.argmax(abs_mode_tmp)] mode_tmp = mode_tmp / this_max selected_modes[:, num] = mode_tmp return selected_modes, selected_stdmsh def _validate_index(self, i): """Raise if *i* is not a valid (order, solution) index pair.""" if not isinstance(i, (list, tuple)): raise TypeError(f"Expected list or tuple for 'i', got {type(i).__name__!r}.") if len(i) != 2: raise ValueError(f"'i' must have length 2, got {len(i)}.") if i[0] > self.modal_data.max_model_order: raise ValueError(f"i[0]={i[0]} exceeds max_model_order={self.modal_data.max_model_order}.") if i[1] > self.num_solutions: raise ValueError(f"i[1]={i[1]} exceeds num_solutions={self.num_solutions}.") def _get_msh_values(self, i): """Return (mpc, mp, mpd, dmp, dmpd) for index *i*, or NaN tuple.""" if not self.capabilities['msh']: return np.nan, np.nan, np.nan, np.nan, np.nan mpc = self.MPC_matrix[i] mp = self.MP_matrix[i] mpd = self.MPD_matrix[i] mp_diffs_row = self.MP_diffs[i] nz = np.nonzero(mp_diffs_row)[0] dmp = np.min(mp_diffs_row[nz]) if len(nz) >= 1 else 0 mpd_diffs_row = self.MPD_diffs[i] nz2 = np.nonzero(mpd_diffs_row)[0] dmpd = np.min(mpd_diffs_row[nz2]) if len(nz2) >= 1 else 0 return mpc, mp, mpd, dmp, dmpd def get_modal_values(self, i): # needed for gui self._validate_index(i) n = self.order_dummy[i] f = self.masked_frequencies[i] d = self.modal_data.modal_damping[i] mpc, mp, mpd, dmp, dmpd = self._get_msh_values(i) stdf = self.modal_data.std_frequencies[i] if self.capabilities['std'] else np.nan stdd = self.modal_data.std_damping[i] if self.capabilities['std'] else np.nan mtn = np.nan # not yet implemented MC = self.modal_data.modal_contributions[i] if self.capabilities['MC'] else np.nan ex_1, ex_2 = self.extra_func(self.modal_data, i, True) if self.extra_func is not None else (np.nan, np.nan) return n, f, stdf, d, stdd, mpc, mp, mpd, dmp, dmpd, mtn, MC, ex_1, ex_2 def get_mode_shape(self, i): self._validate_index(i) return self.modal_data.mode_shapes[:, i[1], i[0]] def add_mode(self, mode_ind): if mode_ind not in self.select_modes: self.select_modes.append(mode_ind) for callback in self.callbacks['add_mode']: callback(mode_ind, len(self.select_modes) - 1) return self.select_modes.index(mode_ind) def remove_mode(self, mode_ind): if mode_ind in self.select_modes: list_ind = self.select_modes.index(mode_ind) del self.select_modes[list_ind] for callback in self.callbacks['remove_mode']: callback(mode_ind, list_ind) return list_ind else: logger.warning(f'{mode_ind} not in self.select_modes') return None def save_state(self, fname): logger.info('Saving results to {}...'.format(fname)) dirname, _ = os.path.split(fname) if not os.path.isdir(dirname): os.makedirs(dirname) out_dict = self._collect_state_dict() np.savez_compressed(fname, **out_dict) def _collect_state_dict(self): """Build and return the dictionary that represents the full saved state.""" out_dict = { 'self.state': self.state, 'self.setup_name': self.setup_name, 'self.start_time': self.start_time, } if self.state >= 1: self._collect_state_diff_matrices(out_dict) if self.state >= 2: self._collect_state_criteria(out_dict) if self.capabilities['auto']: self._collect_state_auto(out_dict) out_dict['self.select_modes'] = self.select_modes return out_dict def _collect_state_diff_matrices(self, out_dict): """Add soft-criteria difference matrices to *out_dict* (state >= 1).""" if self.capabilities['ev']: out_dict['self.lambda_diffs'] = np.array(self.lambda_diffs) out_dict['self.freq_diffs'] = np.array(self.freq_diffs) out_dict['self.damp_diffs'] = np.array(self.damp_diffs) if self.capabilities['msh']: out_dict['self.MAC_diffs'] = np.array(self.MAC_diffs) out_dict['self.MPD_diffs'] = np.array(self.MPD_diffs) out_dict['self.MP_diffs'] = np.array(self.MP_diffs) out_dict['self.MPC_matrix'] = np.array(self.MPC_matrix) out_dict['self.MP_matrix'] = np.array(self.MP_matrix) out_dict['self.MPD_matrix'] = np.array(self.MPD_matrix) def _collect_state_criteria(self, out_dict): """Add stabilization criteria thresholds to *out_dict* (state >= 2).""" out_dict['self.order_range'] = self.order_range out_dict['self.d_range'] = self.d_range if self.capabilities['std']: out_dict['self.stdf_max'] = self.stdf_max out_dict['self.stdd_max'] = self.stdd_max if self.capabilities['msh']: out_dict['self.mpc_min'] = self.mpc_min out_dict['self.mpd_max'] = self.mpd_max out_dict['self.mtn_min'] = self.mtn_min out_dict['self.df_max'] = self.df_max out_dict['self.dd_max'] = self.dd_max if self.capabilities['msh']: out_dict['self.dmac_max'] = self.dmac_max out_dict['self.dev_min'] = self.dev_min if self.capabilities['mtn']: out_dict['self.dmtn_min'] = self.dmtn_min if self.capabilities['MC']: out_dict['self.MC_min'] = self.MC_min out_dict['self.masks'] = self.masks out_dict['self.nmasks'] = self.nmasks def _collect_state_auto(self, out_dict): """Add automatic clustering results to *out_dict* (auto mode).""" if self.state >= 3: out_dict['self.num_iter'] = self.num_iter out_dict['self.threshold'] = self.threshold out_dict['self.clear_ctr'] = self.clear_ctr if self.state >= 4: out_dict['self.use_stabil'] = self.use_stabil out_dict['self.proximity_matrix_sq'] = self.proximity_matrix_sq out_dict['self.cluster_assignments'] = self.cluster_assignments if self.state >= 5: out_dict['self.select_clusters'] = self.select_clusters out_dict['self.nr_poles'] = self.nr_poles out_dict['self.selection_cut_off'] = self.selection_cut_off @classmethod def load_state(cls, fname, modal_data): logger.info('Now loading previous results from {}'.format(fname)) in_dict = np.load(fname, allow_pickle=True) if 'self.state' not in in_dict: return None state = float(in_dict['self.state']) setup_name = str(in_dict['self.setup_name'].item()) if setup_name != modal_data.setup_name: raise ValueError( f"Setup name mismatch: expected {setup_name!r}, " f"got {modal_data.setup_name!r}" ) stabil_data = cls(modal_data) cls._parse_state_dict(stabil_data, in_dict, state) stabil_data.state = state return stabil_data @staticmethod def _parse_state_dict(stabil_data, in_dict, state): """Populate *stabil_data* from the loaded *in_dict*.""" if state >= 1: StabilCalc._parse_state_diff_matrices(stabil_data, in_dict) if state >= 2: StabilCalc._parse_state_criteria(stabil_data, in_dict) if stabil_data.capabilities['auto']: StabilCalc._parse_state_auto(stabil_data, in_dict, state) select_modes = [tuple(a) for a in in_dict['self.select_modes']] frequencies = [stabil_data.masked_frequencies[idx[0], idx[1]] for idx in select_modes] stabil_data.select_modes = [ x for _, x in sorted(zip(frequencies, select_modes))] @staticmethod def _parse_state_diff_matrices(stabil_data, in_dict): """Restore soft-criteria matrices (state >= 1).""" if stabil_data.capabilities['ev']: stabil_data.lambda_diffs = np.ma.array(in_dict['self.lambda_diffs']) stabil_data.freq_diffs = np.ma.array(in_dict['self.freq_diffs']) stabil_data.damp_diffs = np.ma.array(in_dict['self.damp_diffs']) if stabil_data.capabilities['msh']: stabil_data.MAC_diffs = np.ma.array(in_dict['self.MAC_diffs']) stabil_data.MPD_diffs = np.ma.array(in_dict['self.MPD_diffs']) stabil_data.MP_diffs = np.ma.array(in_dict['self.MP_diffs']) stabil_data.MPC_matrix = np.ma.array(in_dict['self.MPC_matrix']) stabil_data.MP_matrix = np.ma.array(in_dict['self.MP_matrix']) stabil_data.MPD_matrix = np.ma.array(in_dict['self.MPD_matrix']) @staticmethod def _parse_state_criteria(stabil_data, in_dict): """Restore stabilization criteria thresholds (state >= 2).""" stabil_data.order_range = tuple(in_dict['self.order_range']) stabil_data.d_range = tuple(in_dict['self.d_range']) if stabil_data.capabilities['std']: stabil_data.stdf_max = float(in_dict['self.df_max']) stabil_data.stdd_max = float(in_dict['self.stdd_max']) if stabil_data.capabilities['msh']: stabil_data.mpc_min = float(in_dict['self.mpc_min']) stabil_data.mpd_max = float(in_dict['self.mpd_max']) stabil_data.mtn_min = float(in_dict['self.mtn_min']) stabil_data.df_max = float(in_dict['self.df_max']) stabil_data.dd_max = float(in_dict['self.dd_max']) if stabil_data.capabilities['msh']: stabil_data.dmac_max = float(in_dict['self.dmac_max']) stabil_data.dev_min = float(in_dict['self.dev_min']) if stabil_data.capabilities['mtn']: stabil_data.dmtn_min = float(in_dict['self.dmtn_min']) if stabil_data.capabilities['MC']: stabil_data.MC_min = float(in_dict['self.MC_min']) stabil_data.masks = in_dict['self.masks'].item() stabil_data.nmasks = in_dict['self.nmasks'].item() @staticmethod def _parse_state_auto(stabil_data, in_dict, state): """Restore automatic clustering data (auto mode).""" if state >= 3: stabil_data.num_iter = int(in_dict['self.num_iter']) stabil_data.threshold = float(in_dict['self.threshold']) stabil_data.clear_ctr = in_dict['self.clear_ctr'] if state >= 4: stabil_data.use_stabil = bool(in_dict['self.use_stabil']) stabil_data.proximity_matrix_sq = in_dict['self.proximity_matrix_sq'] stabil_data.cluster_assignments = in_dict['self.cluster_assignments'] if state >= 5: stabil_data.select_clusters = list(in_dict['self.select_clusters']) stabil_data.nr_poles = list(in_dict['self.nr_poles']) stabil_data.selection_cut_off = float(in_dict['self.selection_cut_off'])
[docs] class StabilCluster(StabilCalc): """ The automatic modal analysis done in three stages clustering. 1st stage: values sorted according to their soft and hard criteria by a 2-means partitioning algorithm 2nd stage: hierarchical clustering with automatic or user defined intercluster distance the automatic distance is based on the 'df', 'dd' and 'MAC' values from the centroids obtained in the first stage :math:`d = weight*df + 1 - weight*MAC + weight*dd` 3rd stage: 2-means partitioning of the physical and spurious poles. E. Neu et al. 1. Identify mode candidates from a large number of system orders. -> OMA Algorithm with n_max sufficiently high, i.e. number of mathematical modes should exceed the number pf physical modes at n <= n_max 2. Remove as many mathematical modes as possible. (a) Remove certainly mathematical modes using hard validation criteria. Re(\\lambda_n)>= 0 or Im(\\lambda_n)==0-> remove conjugates in OMA algorithm (b) Split modes into consistent and non-consistent sets using k-means clustering. p_i = [d_lambda, d_f, d_zeta, 1-MAC, dMPD] power transformation eq 11 h_Ti = ln(p_i) normalize: h_Ni = (h_Ti - mean(h_Ti)) / std(h_Ti) initialize centroids with (+std(h_Ni), -std(h_Ni)) 3. Divide the remaining modes into homogeneous sets using hierarchical clustering. (a) Derive cutoff distance from the probability distribution of the consistent modes. np.percentile(a,95) (b) Cluster the mode candidates based on a complex distance measure. average linkage / single linkage (c) Remove all but one mode from a single system order in one cluster. walk over each cluster and ensure each model order exists only once in the cluster, else remove the mode with a higher distance to the cluster center 4. Remove the small sets, which typically consist of mathematical modes. (a) Reject sets that are smaller than a threshold derived from the largest set size. no recommendations given in paper (threshold 50 %) (b) Use outlier rejection to remove natural frequency and damping outliers. skip (c) Select a single mode representative from the remaining modes in each cluster. "multivariate" median """
[docs] def __init__(self, modal_data, prep_signals=None): ''' stab_* in % ''' super().__init__(modal_data, prep_signals) if not self.capabilities['ev']: raise RuntimeError("This functionality requires eigenvalues to be available. " "Ensure the modal analysis algorithm provides eigenvalues.") self.num_iter = 20000 self.weight_f = 1 self.weight_MAC = 1 self.weight_d = 1 self.weight_lambda = 1 self.threshold = None self.use_stabil = False
@staticmethod def decompress_flat_mask(compress_mask, flat_mask): # takes a flat mask generated on compressed data and restore it to its # decompressed form decompressed_mask = np.ma.copy(compress_mask.ravel()) flat_index = 0 for mask_index in range(decompressed_mask.shape[0]): if decompressed_mask[mask_index]: continue if flat_index >= len(flat_mask): decompressed_mask[mask_index] = True else: decompressed_mask[mask_index] = flat_mask[flat_index] flat_index += 1 return decompressed_mask.reshape(compress_mask.shape) def plot_mask(self, mask, save_path=None): plot.figure(tight_layout=1) od_mask = np.copy(self.order_dummy.mask) mf_mask = np.copy(self.masked_frequencies.mask) self.order_dummy.mask = self.get_stabilization_mask('mask_pre') self.masked_frequencies.mask = self.get_stabilization_mask('mask_pre') plot.scatter( self.masked_frequencies.compressed(), self.order_dummy.compressed(), marker='o', facecolors='none', edgecolors='grey', s=10) self.order_dummy.mask = mask self.masked_frequencies.mask = mask plot.scatter( self.masked_frequencies.compressed(), self.order_dummy.compressed(), marker='o', facecolors='none', edgecolors='black', s=10) self.order_dummy.mask = od_mask self.masked_frequencies.mask = mf_mask plot.ylim((0, 200)) plot.xlim((0, self.prep_signals.sampling_rate / 2)) plot.xlabel('Frequency [Hz]') plot.ylabel('Model Order ') plot.tight_layout() if save_path: plot.savefig(save_path + 'mask.pdf') else: plot.show() plot.pause(0.001) def automatic_clearing(self, num_iter=None): if self.state < 2: self.calculate_soft_critera_matrices() logger.info('Clearing physical modes automatically...') if num_iter is not None: if not isinstance(num_iter, int): raise TypeError(f"Expected int for 'num_iter', got {type(num_iter).__name__!r}.") if num_iter <= 0: raise ValueError(f"'num_iter' must be greater than 0, got {num_iter!r}.") self.num_iter = num_iter mask_pre = np.ma.array(self.get_stabilization_mask('mask_pre')) soft_criteria_matrices = self._build_soft_criteria_matrices(mask_pre) all_poles = self._whiten_poles(soft_criteria_matrices, mask_pre) mask_autoclear = self._run_kmeans_clearing(all_poles, mask_pre) self._compute_clearing_threshold(soft_criteria_matrices, mask_autoclear) self.masks['mask_autoclear'] = mask_autoclear self.update_stabilization_masks() self.state = 3 def _build_soft_criteria_matrices(self, mask_pre): """Return list of min-reduced soft-criteria 2D matrices.""" self.freq_diffs.mask = np.ma.nomask self.damp_diffs.mask = np.ma.nomask self.lambda_diffs.mask = np.ma.nomask if self.capabilities['msh']: self.MAC_diffs.mask = np.ma.nomask self.MP_diffs.mask = np.ma.nomask mask_pre_3d = self.freq_diffs == 0 soft = [] for matrix in [self.lambda_diffs, self.freq_diffs, self.damp_diffs]: matrix.mask = mask_pre_3d soft.append(matrix.min(axis=2)) if self.capabilities['msh']: self.MAC_diffs.mask = mask_pre_3d soft.append(self.MAC_diffs.min(axis=2)) self.MP_diffs.mask = mask_pre_3d soft.append(self.MP_diffs.min(axis=2)) for matrix in soft: matrix.mask = mask_pre return soft def _whiten_poles(self, soft_criteria_matrices, mask_pre): """Stack, log-transform and whiten the soft-criteria vectors.""" compressed = [m[2:, :].compressed() for m in soft_criteria_matrices] poles = np.vstack(compressed).T poles = np.log(poles) poles -= np.mean(poles, axis=0) poles /= np.std(poles, axis=0) return poles def _run_kmeans_clearing(self, all_poles, mask_pre): """Run 2-means and return the autoclear mask.""" std_dev = np.std(all_poles, axis=0) ctr_init = np.array([-std_dev, std_dev]) self.clear_ctr, idx = scipy.cluster.vq.kmeans2( all_poles, ctr_init, self.num_iter) logger.info( 'Possibly physical poles 1st stage: {0} Spurious poles 1st stage: {1}'.format( collections.Counter(idx)[0], collections.Counter(idx)[1])) mask_pre.mask = np.ma.nomask new_idx = np.hstack( (np.ones(np.sum(np.logical_not(mask_pre[:2, :]))), idx)) mask_autoclear = self.decompress_flat_mask(mask_pre, new_idx) return np.logical_or(mask_autoclear, mask_pre) def _compute_clearing_threshold(self, soft_criteria_matrices, mask_autoclear): """Set self.threshold as the 95th percentile of d_lambda + d_MAC.""" soft_criteria_matrices[0].mask = np.ma.nomask soft_criteria_matrices[3].mask = np.ma.nomask distance_mat = soft_criteria_matrices[0] + soft_criteria_matrices[3] distance_mat.mask = mask_autoclear self.threshold = np.percentile(distance_mat.compressed(), q=95) def automatic_classification(self, threshold=None, use_stabil=False): if self.state < 3 and not use_stabil: self.automatic_clearing() logger.info('Classifying physical modes automatically...') if use_stabil: mask_autoclear = self.get_stabilization_mask('mask_stable') else: mask_autoclear = self.get_stabilization_mask('mask_autoclear') self.use_stabil = use_stabil if threshold is not None: if not isinstance(threshold, int): raise TypeError(f"Expected int for 'threshold', got {type(threshold).__name__!r}.") self.threshold = threshold if self.threshold is None: self.threshold = self._auto_threshold(mask_autoclear) lambda_compressed, mode_shapes_compressed = self._compress_modal_data(mask_autoclear) self._cluster_modes(lambda_compressed, mode_shapes_compressed, mask_autoclear) self._log_double_orders(mask_autoclear) self.order_dummy.mask = np.ma.nomask logger.info('Number of classified clusters: {}'.format( max(self.cluster_assignments))) self.state = 4 def _auto_threshold(self, mask_autoclear): """Compute a 95th-percentile threshold from lambda+MAC distances.""" self.freq_diffs.mask = np.ma.nomask mask_pre_3d = self.freq_diffs == 0 self.lambda_diffs.mask = mask_pre_3d self.MAC_diffs.mask = mask_pre_3d distance_mat = (self.lambda_diffs.min(axis=2) + self.MAC_diffs.min(axis=2)) distance_mat.mask = mask_autoclear return np.percentile(distance_mat.compressed(), q=95) def _compress_modal_data(self, mask_autoclear): """Return compressed lambda and mode-shape arrays for unmasked poles.""" length_mat = int(np.prod(mask_autoclear.shape) - np.sum(mask_autoclear)) self.masked_lambda.mask = mask_autoclear lambda_compressed = self.masked_lambda.compressed() self.masked_lambda.mask = np.ma.nomask dim0, dim1 = mask_autoclear.shape mode_shapes_compressed = np.zeros( (self.modal_data.mode_shapes.shape[0], length_mat), dtype=np.complex128) n = 0 for i in range(dim0): for j in range(dim1): if not mask_autoclear[i, j]: mode_shapes_compressed[:, n] = self.modal_data.mode_shapes[:, j, i] n += 1 return lambda_compressed, mode_shapes_compressed def _cluster_modes(self, lambda_compressed, mode_shapes_compressed, mask_autoclear): """Build proximity matrix and perform hierarchical clustering.""" l = len(lambda_compressed) div_lambda = np.maximum( np.repeat(np.expand_dims(np.abs(lambda_compressed), axis=1), l, axis=1), np.repeat(np.expand_dims(np.abs(lambda_compressed), axis=0), l, axis=0)) lambda_prox = np.abs( lambda_compressed - lambda_compressed.reshape((l, 1))) / div_lambda mac_prox = 1 - calculateMAC(mode_shapes_compressed, mode_shapes_compressed) proximity_matrix = (self.weight_lambda * lambda_prox + self.weight_MAC * mac_prox) proximity_matrix[proximity_matrix < np.finfo(proximity_matrix.dtype).eps] = 0 self.proximity_matrix_sq = scipy.spatial.distance.squareform( proximity_matrix, checks=False) linkage_matrix = scipy.cluster.hierarchy.linkage( self.proximity_matrix_sq, method='average') self.cluster_assignments = scipy.cluster.hierarchy.fcluster( linkage_matrix, self.threshold, criterion='distance') def _log_double_orders(self, mask_autoclear): """Log any model orders appearing more than once per cluster.""" for clusternr in range(1, max(self.cluster_assignments) + 1): flat_poles_ind = self.cluster_assignments != clusternr + 1 mask = self.decompress_flat_mask(mask_autoclear, flat_poles_ind) self.order_dummy.mask = mask for order in range(self.modal_data.max_model_order): if np.sum(self.order_dummy == order) > 1: logger.debug(f'Double Model Order: {self.order_dummy[order, :]}') def automatic_selection(self, number=0): if self.state < 4: self.automatic_classification() nr_poles, select_clusters = self._determine_cluster_selection(number) logger.info('Number of physical modes: {0}'.format( collections.Counter(select_clusters)[0])) self.select_clusters = select_clusters self.nr_poles = nr_poles self.selection_cut_off = np.inf for i, b in zip(self.nr_poles, self.select_clusters): if not b: self.selection_cut_off = min(i - 1, self.selection_cut_off) logger.info('Minimum number of elements in retained clusters: {}'.format( self.selection_cut_off)) mask_autoclear = ( self.get_stabilization_mask('mask_stable') if self.use_stabil else self.masks['mask_autoclear']) self.MAC_diffs.mask = self.MAC_diffs == 0 MAC_diffs = self.MAC_diffs.min(axis=2) self.MAC_diffs.mask = np.ma.nomask if 'mask_autosel' not in self.masks: self.masks['mask_autosel'] = [] for clusternr, inout in enumerate(select_clusters): if inout: continue self._select_cluster_representative(clusternr, mask_autoclear) for matrix in [self.masked_frequencies, self.masked_damping, MAC_diffs, self.MPC_matrix, self.MPD_matrix]: matrix.mask = np.ma.nomask self.state = 5 def _determine_cluster_selection(self, number): """Return (nr_poles, select_clusters) arrays for all clusters.""" poles = [np.where(self.cluster_assignments == c) for c in range(1, 1 + max(self.cluster_assignments))] nr_poles = np.array([len(a[0]) for a in poles], dtype=np.float64) max_nr = float(np.max(nr_poles)) if number == 0: _, select_clusters = scipy.cluster.vq.kmeans2( nr_poles, np.array([max_nr, 1e-12]), self.num_iter) else: meta_list = sorted(enumerate(nr_poles), key=itemgetter(1), reverse=True) select_clusters = [1] * len(nr_poles) for i in range(number): select_clusters[meta_list[i][0]] = 0 return nr_poles, select_clusters def _select_cluster_representative(self, clusternr, mask_autoclear): """Select the multivariate-median pole from cluster *clusternr*.""" flat_poles_ind = self.cluster_assignments != clusternr + 1 mask = self.decompress_flat_mask(mask_autoclear, flat_poles_ind) self.masks['mask_autosel'].append(np.ma.copy(mask)) num_poles_left = int(np.prod(mask.shape) - np.sum(mask)) while num_poles_left > 1: ind = [] for matrix in [self.masked_frequencies, self.masked_damping]: matrix.mask = mask val = np.ma.median(matrix) min_ = np.min(matrix) max_ = np.max(matrix) if val - min_ <= max_ - val: ind.append(np.where(matrix == max_)) else: ind.append(np.where(matrix == min_)) for k in range(min(len(ind), num_poles_left - 1)): mask[ind[k]] = True num_poles_left = int(np.prod(mask.shape) - np.sum(mask)) select_mode = np.where(np.logical_not(mask)) self.add_mode((select_mode[0][0], select_mode[1][0])) if self.select_callback is not None: self.select_callback(self.select_modes[-1]) def plot_clearing(self, save_path=None): mask_autoclear = self.masks['mask_autoclear'] mask_pre = self.get_stabilization_mask('mask_pre') self.plot_mask(mask_autoclear, save_path) crits, labels = self._get_clearing_crits() self._plot_clearing_pairs(crits, labels, mask_autoclear, mask_pre, save_path) for crit in crits: crit.mask = np.ma.nomask def _get_clearing_crits(self): """Return (crits, labels) lists for clearing scatter plots.""" def _min_nonzero(matrix): matrix.mask = matrix == 0 result = matrix.min(axis=2) matrix.mask = np.ma.nomask return result freq_diffs = _min_nonzero(self.freq_diffs) MAC_diffs = _min_nonzero(self.MAC_diffs) damp_diffs = _min_nonzero(self.damp_diffs) crits = [freq_diffs, damp_diffs, MAC_diffs, self.MPC_matrix, self.MPD_matrix] labels = ['df', 'dd', 'MAC', 'MPC', 'MPD'] return crits, labels def _plot_clearing_pairs(self, crits, labels, mask_autoclear, mask_pre, save_path): """Plot each unique pair of clearing criteria against each other.""" new_crits = [] for j, b in enumerate(crits): new_crits.append(b) for i, a in enumerate(new_crits): if a is b: continue self._plot_one_clearing_pair( a, b, labels[i], labels[j], mask_autoclear, mask_pre, save_path) def _plot_one_clearing_pair(self, a, b, labela, labelb, mask_autoclear, mask_pre, save_path): """Draw a single clearing scatter plot for criteria *a* vs *b*.""" plot.figure(tight_layout=1) a.mask = mask_autoclear b.mask = mask_autoclear plot.plot(a.compressed(), b.compressed(), ls='', marker=',') plot.plot(np.mean(a), np.mean(b), ls='', marker='d', color='black') a.mask = mask_pre b.mask = mask_pre plot.plot(a.compressed(), b.compressed(), ls='', marker=',', color='grey') plot.plot(np.mean(a), np.mean(b), ls='', marker='d', color='grey') plot.xlabel(labela) plot.ylabel(labelb) plot.xlim((0, 1)) plot.ylim((0, 1)) if save_path is not None: plot.savefig(save_path + 'clear_{}_{}.pdf'.format(labela, labelb)) else: plot.show() plot.pause(0.01) def plot_classification(self, save_path=None): rel_matrix = scipy.cluster.hierarchy.linkage( self.proximity_matrix_sq, method='average') lvs = scipy.cluster.hierarchy.leaves_list(rel_matrix) def _llf(_id): if len(lvs) > 500: if (np.where(_id == lvs)[0][0] % 100 == 0): return str(np.where(_id == lvs)[0][0]) else: return str('') else: if (np.where(_id == lvs)[0][0] % 10 == 0): return str(np.where(_id == lvs)[0][0]) else: return str('') fig = plot.figure(tight_layout=1) ax = fig.add_subplot(111) scipy.cluster.hierarchy.dendrogram( rel_matrix, leaf_label_func=_llf, color_threshold=self.threshold, leaf_font_size=16, leaf_rotation=40) # ax = plot.gca() ax.set_xlabel('Mode number [-]') ax.set_ylabel('Distance [-]') ax.axhline(self.threshold, c='r', ls='--', linewidth=3) plot.tight_layout() if save_path is not None: plot.savefig(save_path + 'dendrogram.pdf') else: # print('show') plot.show() plot.pause(0.001)
[docs] def plot_selection(self, save_path=None): """ Plot relevant results of the clustering.""" self._plot_cluster_sizes(save_path) self._plot_stabilization_clusters(save_path)
def _plot_cluster_sizes(self, save_path): """Plot bar chart of cluster sizes split into accepted/rejected.""" plot.figure(tight_layout=1) in_poles = sorted( self.nr_poles[self.nr_poles >= self.selection_cut_off], reverse=True) out_poles = sorted( self.nr_poles[(self.nr_poles < self.selection_cut_off) & (self.nr_poles > 0)], reverse=True) plot.bar(range(len(in_poles)), in_poles, facecolor='red', edgecolor='none', align='center') plot.bar(range(len(in_poles), len(in_poles) + len(out_poles)), out_poles, facecolor='blue', edgecolor='none', align='center') plot.xlim((0, len(self.nr_poles))) plot.tight_layout() if save_path is not None: plot.savefig(save_path + 'cluster_sizes.pdf') else: plot.show() plot.pause(0.001) def _plot_stabilization_clusters(self, save_path): """Plot stabilisation diagram with cluster frequency spans.""" fig = plot.figure(tight_layout=1) ax1 = fig.add_subplot(211) mask_autoclear = self.masks['mask_autoclear'] mask_pre = self.get_stabilization_mask('mask_pre') mask_pre_ = np.logical_not( np.logical_and(np.logical_not(mask_pre), mask_autoclear)) self._scatter_masked(ax1, mask_pre_, 'grey', 'pole') self._scatter_masked(ax1, mask_autoclear, 'black', 'stable pole') self.order_dummy.mask = np.ma.nomask self.masked_frequencies.mask = np.ma.nomask ax1.autoscale_view(tight=True) ax1.set_ylabel('Model order [-]') ax1.set_title('Stabilization Diagram') ax1.set_ylim((0, 200)) for mask in self.masks['mask_autosel']: self.masked_frequencies.mask = mask plot.axvspan(self.masked_frequencies.min(), self.masked_frequencies.max(), facecolor='blue', alpha=.3, edgecolor='none') self.masked_frequencies.mask = np.ma.nomask self.order_dummy.mask = np.ma.nomask for mode in self.select_modes: f = self.modal_data.modal_frequencies[mode] n = self.order_dummy[mode] ax1.scatter(f, n, facecolors='none', marker='o', edgecolors='red', s=10) num_poles, fpoles = [], [] for clusternr in range(1, 1 + max(self.cluster_assignments)): flat_poles_ind = self.cluster_assignments != clusternr mask = self.decompress_flat_mask(mask_autoclear, flat_poles_ind) self.masked_frequencies.mask = mask num_poles.append(np.prod(mask.shape) - np.sum(mask)) fpoles.append(np.ma.mean(self.masked_frequencies)) ax2 = fig.add_subplot(212, sharex=ax1) ax2.bar(fpoles, num_poles, width=0.01, align='center', edgecolor='none') ax2.axhline(self.selection_cut_off, c='r', ls='--', linewidth=2) ax2.set_xlabel('Frequency [Hz]') ax2.set_ylabel('Nr. of elements') ax2.set_title('Clusters') plot.tight_layout() plot.xlim((0, self.prep_signals.sampling_rate / 2)) if save_path is not None: plot.savefig(save_path + 'select_clusters.pdf') else: plot.show() plot.pause(0.001) def _scatter_masked(self, ax, mask, color, label): """Scatter plot of poles using *mask*, colored by *color*.""" self.masked_frequencies.mask = mask self.order_dummy.mask = mask ax.scatter( self.masked_frequencies.compressed(), self.order_dummy.compressed(), marker='o', facecolors='none', edgecolors=color, s=10, label=label) def return_results(self): all_f = [] all_d = [] all_n = [] all_std_f = [] all_std_d = [] all_MPC = [] all_MPD = [] all_MP = [] all_msh = [] all_MC = [] # for select_mode, mask in zip(self.select_modes, # self.masks['mask_autosel']): for select_mode in self.select_modes: n, f, stdf, d, stdd, mpc, mp, mpd, _dmp, _dmpd, _mtn, MC, _ex_1, _ex_2 = self.get_modal_values( select_mode) msh = self.get_mode_shape(select_mode) all_n.append(n) all_f.append(f) all_std_f.append(stdf) all_d.append(d) all_std_d.append(stdd) all_MPC.append(mpc) all_MP.append(mp) all_MPD.append(mpd) all_MC.append(MC) all_msh.append(msh) continue return np.array(all_n), np.array(all_f), np.array(all_std_f), np.array(all_d), np.array( all_std_d), np.array(all_MPC), np.array(all_MP), np.array(all_MPD), np.array(all_MC), np.array(all_msh),
[docs] class StabilPlot(object): """Static matplotlib stabilisation diagram renderer. Draws poles from a :class:`StabilCalc` object onto a matplotlib figure, colour-coded by their stabilisation status (stable / partially stable / unstable). Used as the backend for both the interactive :class:`~pyOMA.GUI.StabilGUI.StabilGUI` and the Jupyter :class:`~pyOMA.GUI.JupyterGUI.JupyterGUI`. Parameters ---------- stabil_calc : StabilCalc Populated stabilisation-calculation object. fig : matplotlib.figure.Figure, optional External figure to draw into. A new figure is created when ``None``. """
[docs] def __init__(self, stabil_calc, fig=None): """ Parameters ---------- stabil_calc : StabilCalc Populated stabilisation-calculation object. fig : matplotlib.figure.Figure, optional External figure to draw into. A new figure is created when ``None``. """ super().__init__() if not isinstance(stabil_calc, StabilCalc): logger.warning(f'Argument stabil_calc is wrong object type {type(stabil_calc)}') self.stabil_calc = stabil_calc if fig is None: self.fig = Figure(facecolor='white') # , dpi=100, figsize=(16, 12)) self.fig.set_tight_layout(True) # canvas = FigureCanvasBase(self.fig) else: self.fig = fig self.ax = self.fig.add_subplot(111) # self.ax2 = self.ax.twinx() # self.ax2.set_navigate(False) # if self.fig.canvas: if False: self.init_cursor() else: self.cursor = None marker_obj_1 = MarkerStyle('o') path_1 = marker_obj_1.get_path().transformed( marker_obj_1.get_transform()) marker_obj_2 = MarkerStyle('+') path_2 = marker_obj_2.get_path().transformed( marker_obj_2.get_transform()) path_stab = Path.make_compound_path(path_1, path_2) marker_obj_2 = MarkerStyle('x') path_2 = marker_obj_2.get_path().transformed( marker_obj_2.get_transform()) path_auto = Path.make_compound_path(path_1, path_2) fp = FontProperties(family='monospace', weight=0, size='large') self.psd_plot = [] self.stable_plot = { 'plot_pre': None, # 'plot_ad': None, # 'plot_df': None, # 'plot_dd': None, 'plot_stable': None, } self.colors = { 'plot_pre': 'grey', # 'plot_ad': 'grey', # 'plot_df': 'black', # 'plot_dd': 'black', 'plot_stable': 'black', } self.markers = { 'plot_pre': 'o', # 'plot_ad': TextPath((-2, -4), '\u00b7 d', prop=fp, size=10), # 'plot_df': TextPath((-2, -4), '\u00b7 f', prop=fp, size=10), # 'plot_dd': TextPath((-2, -4), '\u00b7 d', prop=fp, size=10), 'plot_stable': path_stab, # } self.labels = { 'plot_pre': 'all poles', # 'plot_ad': 'damping criterion', # 'plot_df': 'unstable in frequency', # 'plot_dd': 'unstable in damping', 'plot_stable': 'stable poles', } if self.stabil_calc.capabilities['std']: self.stable_plot['plot_stdf'] = None # uncertainty frequency self.stable_plot['plot_stdd'] = None # uncertainty damping self.colors['plot_stdf'] = 'grey' self.colors['plot_stdd'] = 'grey' self.labels['plot_stdf'] = 'uncertainty bounds frequency criterion' self.labels['plot_stdd'] = 'uncertainty bounds damping criterion' self.markers['plot_stdf'] = 'd' self.markers['plot_stdd'] = 'd' if self.stabil_calc.capabilities['msh']: # absolute modal phase collineratity self.stable_plot['plot_mpc'] = None # absolute mean phase deviation self.stable_plot['plot_mpd'] = None self.stable_plot['plot_dmac'] = None # difference mac # self.colors['plot_mpc'] = 'grey' # self.colors['plot_mpd']= 'grey' # self.colors['plot_dmac']= 'black' # self.labels['plot_mpc']= 'modal phase collinearity criterion' # self.labels['plot_mpd']= 'mean phase deviation criterion' # self.labels['plot_dmac']= 'unstable in mac' # self.markers['plot_mpc']= TextPath((-2, -4), '\u00b7 v', prop=fp, size=10) # self.markers['plot_mpd']= TextPath((-2, -4), '\u00b7 v', prop=fp, size=10) # self.markers['plot_dmac']= TextPath((-2, -4), '\u00b7 v', prop=fp, size=10) if self.stabil_calc.capabilities['auto']: # auto clearing by 2Means Algorithm self.stable_plot['plot_autoclear'] = None # autoselection by 2 stage hierarchical clustering self.stable_plot['plot_autosel'] = None self.colors['plot_autoclear'] = 'black' self.colors['plot_autosel'] = 'rainbow' self.labels['plot_autoclear'] = 'autoclear poles' self.labels['plot_autosel'] = 'autoselect poles' self.markers['plot_autoclear'] = path_auto self.markers['plot_autosel'] = 'o' if self.stabil_calc.capabilities['MC']: # absolute modal error contribution self.stable_plot['plot_MC'] = None self.colors['plot_MC'] = 'grey' self.labels['plot_MC'] = 'modal error contribution criterion' self.markers['plot_MC'] = 'x' if self.stabil_calc.capabilities['mtn']: # difference modal transfer norm self.stable_plot['plot_dmtn'] = None self.stable_plot['plot_mtn'] = None # absolute modal transfer norm self.colors['plot_dmtn'] = 'black' self.colors['plot_mtn'] = 'grey' self.labels['plot_mtn'] = 'modal transfer norm criterion' self.labels['plot_dmtn'] = 'unstable in modal transfer norm' self.markers['plot_mtn'] = '>' self.markers['plot_dmtn'] = '>' if False: self.stable_plot['plot_dev'] = None # difference eigenvalue self.colors['plot_dev'] = 'grey' self.labels['plot_dev'] = 'unstable in eigenvalue' self.markers['plot_dev'] = TextPath( (-2, -4), '\u00b7 \u03bb', prop=fp, size=10), self.zorders = {key: key != 'plot_pre' for key in self.labels.keys()} self.zorders['plot_autosel'] = 2 self.sizes = {key: 30 for key in self.labels.keys()} self.prepare_diagram() # that list should eventually be replaced by a matplotlib.collections # collection self.scatter_objs = [None for _ in self.stabil_calc.select_modes] self.stabil_calc.add_callback('add_mode', self.add_mode) self.stabil_calc.add_callback('remove_mode', self.remove_mode) if stabil_calc.select_modes: for mode in stabil_calc.select_modes: list_ind = self.stabil_calc.select_modes.index(mode) self.add_mode(mode, list_ind)
def init_cursor(self, visible=True): self.cursor = DataCursor( ax=self.ax, horizOn=visible, vertOn=visible, order_data=self.stabil_calc.order_dummy, f_data=self.stabil_calc.masked_frequencies, datalist=self.stabil_calc.select_modes, color='black', useblit=True) self.fig.canvas.mpl_connect( 'button_press_event', self.mode_selected) self.fig.canvas.mpl_connect( 'resize_event', self.cursor.fig_resized) return self.cursor def prepare_diagram(self): self.ax.set_ylim((0, self.stabil_calc.modal_data.max_model_order)) self.ax.locator_params( 'y', tight=True, nbins=self.stabil_calc.modal_data.max_model_order // 5) x_lims = (0, self.stabil_calc.get_max_f()) self.ax.set_xlim(x_lims) self.ax.autoscale_view(tight=True) self.ax.set_xlabel('Frequency [Hz]') self.ax.set_ylabel('Model Order') def _update_criterion_plots(self, criteria): caps = self.stabil_calc.capabilities criterion_plot_map = [ ('stdf_max', 'std', 'plot_stdf'), ('stdd_max', 'std', 'plot_stdd'), ('mtn_min', 'mtn', 'plot_mtn'), ('dmtn_min', 'mtn', 'plot_dmtn'), ('MC_min', 'MC', 'plot_MC'), ] for criterion, cap, plot_name in criterion_plot_map: if criterion in criteria and caps[cap]: self.plot_stabil(plot_name) def _update_auto_plots(self): if not self.stabil_calc.capabilities['auto']: return if self.stabil_calc.state >= 3 and not self.stabil_calc.use_stabil: self.plot_stabil('plot_autoclear') if self.stabil_calc.state >= 5: self.plot_stabil('plot_autosel') def update_stabilization(self, **criteria): self.stabil_calc.update_stabilization_masks(**criteria) self._update_criterion_plots(criteria) self.plot_stabil('plot_pre') self.plot_stabil('plot_stable') self._update_auto_plots() if self.stabil_calc.capabilities['std']: self.plot_stabil('plot_stdf') if self.cursor: cursor_name_mask = self.cursor.name_mask cursor_mask = self.stabil_calc.get_stabilization_mask( cursor_name_mask) self.cursor.set_mask(cursor_mask, cursor_name_mask) def plot_stabil_autosel(self, color, marker, zorder, size, label): name = 'plot_autosel' if self.stable_plot[name] is not None: for plot in self.stable_plot[name]: plot.remove() visibility = True masks = self.stabil_calc.masks['mask_autosel'] colors = list(matplotlib.cm.gist_rainbow( np.linspace( 0, 1, len(masks)))) # @UndefinedVariable shuffle(colors) self.stable_plot[name] = [] for color, mask in zip(colors, masks): self.stabil_calc.masked_frequencies.mask = mask self.stabil_calc.order_dummy.mask = mask self.stable_plot[name].append(self.ax.scatter( self.stabil_calc.masked_frequencies.compressed(), self.stabil_calc.order_dummy.compressed(), zorder=zorder, facecolors=color, edgecolors='none', marker=marker, alpha=0.4, s=size, label=label, visible=visibility)) return def plot_stabil_stdf(self, name, color, zorder, label): if self.stable_plot[name] is not None: try: children = self.stable_plot[name].get_children() if children: visibility = children[0].get_visible() self.stable_plot[name].remove() except IndexError as e: logger.debug(f'Failed to remove stabil_stdf objects {e}') visibility = True else: visibility = True mask = self.stabil_calc.get_stabilization_mask('mask_stable') self.stabil_calc.masked_frequencies.mask = mask self.stabil_calc.order_dummy.mask = mask if self.stabil_calc.capabilities['std']: std_frequencies = np.ma.array( self.stabil_calc.modal_data.std_frequencies) std_frequencies.mask = mask # standard error num_blocks = self.stabil_calc.modal_data.num_blocks std_error = std_frequencies.compressed() / np.sqrt(num_blocks) # 95 % confidence interval -> student t (tabulated percentage # points) * std_error (approx 2* std_error) self.stable_plot[name] = self.ax.errorbar(self.stabil_calc.masked_frequencies.compressed(), self.stabil_calc.order_dummy.compressed(), xerr=scipy.stats.t.ppf(0.95, num_blocks) * std_error, zorder=zorder, fmt='none', ecolor=color, label=label, visible=visibility) return def plot_stabil(self, name): # print(name) color = self.colors[name] marker = self.markers[name] # print(marker, name) zorder = self.zorders[name] size = self.sizes[name] label = self.labels[name] if name == 'plot_autosel': self.plot_stabil_autosel(color, marker, zorder, size, label) elif name == 'plot_stdf': self.plot_stabil_stdf(name, color, zorder, label) else: if self.stable_plot[name] is not None: visibility = self.stable_plot[name].get_visible() self.stable_plot[name].remove() else: visibility = True mask = self.stabil_calc.get_stabilization_mask( name.replace('plot', 'mask')) self.stabil_calc.masked_frequencies.mask = mask self.stabil_calc.order_dummy.mask = mask self.stable_plot[name] = self.ax.scatter( self.stabil_calc.masked_frequencies.compressed(), self.stabil_calc.order_dummy.compressed(), zorder=zorder, facecolors='none', edgecolors=color, marker=marker, s=size, label=label, visible=visibility) mask_stable = self.stabil_calc.get_stabilization_mask('mask_pre') self.stabil_calc.masked_frequencies.mask = mask_stable self.stabil_calc.order_dummy.mask = mask_stable self.fig.canvas.draw_idle() def show_MC(self, b=False): if b: if not self.stabil_calc.capabilities['MC']: logger.warning('Modal contributions are not computed and cannot be displayed.') return ylim = self.fig.axes[0].get_ylim() if len(self.fig.axes) < 2: self.fig.add_subplot(1, 2, 2, sharey=self.fig.axes[0]) gs = matplotlib.gridspec.GridSpec( 1, 2, width_ratios=(6, 1), wspace=0.01, hspace=0) self.fig.axes[0].set_subplotspec(gs[0]) self.fig.axes[1].set_subplotspec(gs[1]) ax = self.fig.axes[1] MCs = np.zeros((self.stabil_calc.modal_data.max_model_order)) for order in range(self.stabil_calc.modal_data.max_model_order): sum_mc = np.sum( self.stabil_calc.modal_data.modal_contributions[order,:]) if np.iscomplex(sum_mc): # abs used for complex modal contributions (pLSCF) sum_mc = np.abs(sum_mc) MCs[order] = sum_mc ax.plot( MCs, list( range( self.stabil_calc.modal_data.max_model_order)), marker='o', fillstyle='full', markerfacecolor='white', markeredgecolor='grey', color='darkgrey', markersize=4) ax.grid(True, axis='x') ax.set_ylim(ylim) ax.yaxis.tick_right() ax.set_xlim([0, 1]) ax.set_xticks([ 0.25, 0.5, 0.75, 1]) ax.set_xticklabels([ '0.25', '0.5', '0.75', '1']) else: if len(self.fig.axes) < 2: return ax = self.fig.axes[1] self.fig.delaxes(ax) gs = matplotlib.gridspec.GridSpec(1, 1, wspace=0, hspace=0) self.fig.axes[0].set_subplotspec(gs[0]) self.fig.canvas.draw_idle() def _handle_existing_psd_lines(self, b, NFFT): """Toggle visibility or remove existing PSD lines. Returns True if the caller should return immediately, None if no plot exists. """ if not self.psd_plot: return None if not b or NFFT == self.stabil_calc.prep_signals.n_lines: for channel in self.psd_plot: for line in channel: line._visible = b self.fig.canvas.draw_idle() return True for channel in self.psd_plot: for line in channel: line.remove() self.psd_plot = [] return False
[docs] def plot_sv_psd(self, b, NFFT=None): ''' .. TODO:: * add GUI for choosing PSD parameters ''' if self._handle_existing_psd_lines(b, NFFT) is True: return if self.stabil_calc.prep_signals is None: raise RuntimeError('Measurement Data was not provided!') if not b: return if NFFT is None and self.stabil_calc.prep_signals.n_lines is None: NFFT = 2048 sv_psd = self.stabil_calc.prep_signals.sv_psd(NFFT) freq_psd = self.stabil_calc.prep_signals.freqs sv_psd_db_scaled = 10 * np.log10(sv_psd) sv_psd_db_scaled -= np.min(sv_psd_db_scaled) sv_psd_db_scaled /= 2 * np.max(sv_psd_db_scaled) n_channels = sv_psd.shape[0] for channel in range(n_channels): self.psd_plot.append(self.ax.plot( freq_psd, sv_psd_db_scaled[channel, :], color='grey', alpha=(n_channels - channel) / n_channels, linestyle='solid', visible=b, zorder=-1, transform=self.ax.get_xaxis_transform())) self.fig.canvas.draw_idle()
def update_xlim(self, xlim): self.ax.set_xlim(xlim) self.fig.canvas.draw_idle() def update_ylim(self, ylim): self.ax.set_ylim(ylim) self.fig.canvas.draw_idle() # @pyqtSlot(int) def toggle_df(self, b): plot_obj = self.stable_plot['plot_df'] if plot_obj is None: return plot_obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_stdf(self, b): plot_obj = self.stable_plot['plot_stdf'] if plot_obj is None: return for obj in plot_obj.get_children(): if obj is None: continue obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_stdd(self, b): plot_obj = self.stable_plot['plot_stdd'] if plot_obj is None: return plot_obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_ad(self, b): plot_obj = self.stable_plot['plot_ad'] if plot_obj is None: return plot_obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_dd(self, b): plot_obj = self.stable_plot['plot_dd'] if plot_obj is None: return plot_obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_dmac(self, b): plot_obj = self.stable_plot['plot_dmac'] if plot_obj is None: return plot_obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_mpc(self, b): plot_obj = self.stable_plot['plot_mpc'] if plot_obj is None: return plot_obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_mpd(self, b): plot_obj = self.stable_plot['plot_dmac'] if plot_obj is None: return plot_obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_mtn(self, b): plot_obj = self.stable_plot['plot_mtn'] if plot_obj is None: return plot_obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_dev(self, b): plot_obj = self.stable_plot['plot_dev'] if plot_obj is None: return plot_obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_dmtn(self, b): plot_obj = self.stable_plot['plot_dmtn'] if plot_obj is None: return plot_obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_stable(self, b): # print('plot_stable',b) plot_obj = self.stable_plot['plot_stable'] if plot_obj is None: return plot_obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_clear(self, b): # print('plot_autoclear',b) plot_obj = self.stable_plot['plot_autoclear'] if plot_obj is None: return plot_obj.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_select(self, b): plot_obj = self.stable_plot['plot_autosel'] if plot_obj is None: return for plot_obj_ in plot_obj: plot_obj_.set_visible(b) self.fig.canvas.draw_idle() # @pyqtSlot(bool) # @pyqtSlot(int) def toggle_all(self, b): plot_obj = self.stable_plot['plot_pre'] if plot_obj is None: # print('plot_pre not found') return plot_obj.set_visible(b) self.fig.canvas.draw_idle() def save_figure(self, fname=None): startpath = rcParams.get('savefig.directory', '') startpath = os.path.expanduser(startpath) # start = os.path.join(startpath, self.fig.canvas.get_default_filename()) if fname: if startpath == '': # explicitly missing key or empty str signals to use cwd rcParams['savefig.directory'] = startpath else: # save dir for next time rcParams['savefig.directory'] = os.path.dirname(str(fname)) try: self.fig.canvas.print_figure(str(fname)) except Exception: import traceback traceback.print_exc()
[docs] def mode_selected(self, event): ''' connect this function to the button press event of the canvas ''' if event.name == "button_press_event" and event.inaxes == self.ax: # Check if in zooming or panning mode; credit: https://stackoverflow.com/questions/48446351/ zooming_panning = False try: # Qt Backend cursor_shape = self.fig.canvas.cursor().shape() # PyQt5's Qt.ArrowCursor is a plain int (0); PyQt6's # Qt.CursorShape.ArrowCursor is an Enum member with .value == 0 # and is never == 0 itself, so compare via .value when present. cursor_shape = getattr(cursor_shape, 'value', cursor_shape) zooming_panning = (cursor_shape != 0) # 0 is the arrow, which means we are not zooming or panning. except Exception: pass try: # nbAgg Backend zooming_panning = str(self.fig.canvas.toolbar.cursor) != 'Cursors.POINTER' except Exception: pass if zooming_panning: logger.debug('In zooming or panning mode') return ind = self.cursor.i if ind is None: logger.warning('Empty mode index for the button_press_event. Ensure cursor is working.') return if ind not in self.stabil_calc.select_modes: self.stabil_calc.add_mode(ind) else: self.stabil_calc.remove_mode(ind)
def toggle_mode(self, datapoint): datapoint = tuple(datapoint) if datapoint in self.stabil_calc.select_modes: self.stabil_calc.remove_mode(datapoint) else: self.stabil_calc.add_mode(datapoint) def add_mode(self, datapoint, list_ind): # datapoint = tuple(datapoint) # list_ind = self.stabil_calc.add_mode(datapoint) if len(self.scatter_objs) <= list_ind: self.scatter_objs.append(None) if self.scatter_objs[list_ind] is not None: self.scatter_objs[list_ind].remove() x = self.stabil_calc.masked_frequencies[datapoint] y = self.stabil_calc.order_dummy[datapoint] # x, y = self.x[datapoint], self.y[datapoint] self.scatter_objs[list_ind] = self.ax.scatter( x, y, facecolors='none', edgecolors='red', s=200, visible=True, zorder=3) # TODO:: improve Performance by blitting the scatter_objs if False: # if self.useblit: if self.background is not None: self.fig.canvas.restore_region(self.background) for scatter in self.scatter_objs: scatter.set_visible(True) self.ax.draw_artist(scatter) scatter.set_visible(False) self.ax.draw_artist(self.linev) self.ax.draw_artist(self.lineh) self.fig.canvas.blit(self.ax.bbox) else: # for scatter in self.scatter_objs: # scatter.set_visible(True) self.fig.canvas.draw() # def add_modes(self, datalist): # # convenience function for add_datapoint # for datapoint in datalist: # self.add_mode(datapoint) def remove_mode(self, datapoint, list_ind): # datapoint = tuple(datapoint) # list_ind = self.stabil_calc.remove_mode(datapoint) if list_ind is not None: self.scatter_objs[list_ind].remove() del self.scatter_objs[list_ind] self.fig.canvas.draw()
# def remove_modes(self, datalist): # # convenience function for remove_datapoint # for datapoint in datalist: # self.remove_mode(datapoint)
[docs] class DataCursor(Cursor): # create and edit an instance of the matplotlib default Cursor widget # show_current_info = pyqtSignal(tuple) # mode_selected = pyqtSignal(tuple) # mode_deselected = pyqtSignal(tuple)
[docs] def __init__( self, ax, order_data, f_data, mask=None, useblit=True, datalist=None, **lineprops): if datalist is None: datalist = [] Cursor.__init__(self, ax, useblit=useblit, **lineprops) # QObject.__init__(self) self.callbacks = {'show_current_info':lambda *args, **kwargs: None, 'mode_selected':lambda *args, **kwargs: None, 'mode_deselected':lambda *args, **kwargs: None, } self.ax = ax self.y = order_data self.y.mask = np.ma.nomask self.x = f_data self.x.mask = np.ma.nomask if mask is not None: self.mask = mask else: self.mask = np.ma.nomask self.name_mask = 'mask_stable' self.i = None
# that list should eventually be replaced by a matplotlib.collections # collection def add_callback(self, name, func): if name not in self.callbacks: raise ValueError(f"Unknown callback {name!r}. Known: {list(self.callbacks)}.") self.callbacks[name] = func def set_mask(self, mask, name): self.mask = mask self.fig_resized() self.name_mask = name def fig_resized(self, event=None): # self.background = self.ax.figure.canvas.copy_from_bbox(self.ax.figure.bbox) # if event is not None: # self.width, self.height = event.width, event.height # else: # self.width, self.height = self.ax.get_figure( # ).canvas.get_width_height() self.xpix, self.ypix = self.ax.transData.transform( np.vstack([self.x.flatten(), self.y.flatten()]).T).T self.xpix.shape = self.x.shape self.xpix.mask = self.mask self.ypix.shape = self.y.shape self.ypix.mask = self.mask
[docs] def onmove(self, event): if self.ignore(event): return # 1. Override event.data to force it to snap-to nearest data item # 2. On a mouse-click, select the data item and append it to a list of selected items # 3. The second mouse-click on a previously selected item, removes it from the list if (self.xpix.mask).all(): # i.e. no stable poles return if event.name == "motion_notify_event": # get cursor coordinates xdata = event.xdata ydata = event.ydata if xdata is None or ydata is None: return xData_yData_pixels = self.ax.transData.transform( np.vstack([xdata, ydata]).T) xdata_pix, ydata_pix = xData_yData_pixels.T self.fig_resized() self.i = self.findIndexNearestXY(xdata_pix[0], ydata_pix[0]) xnew, ynew = self.x[self.i], self.y[self.i] if xdata == xnew and ydata == ynew: return # set the cursor and draw event.xdata = xnew event.ydata = ynew self.callbacks['show_current_info'](self.i) # select item by mouse-click only if the cursor is active and in the # main plot Cursor.onmove(self, event)
# for scatter in self.scatter_objs: scatter.set_visible(False) def _update(self): if self.useblit: if self.background is not None: self.canvas.restore_region(self.background) # for scatter in self.scatter_objs: # scatter.set_visible(True) # self.ax.draw_artist(scatter) # scatter.set_visible(False) self.ax.draw_artist(self.linev) self.ax.draw_artist(self.lineh) self.canvas.blit(self.ax.bbox) else: if self.horizOn or self.vertOn: # for scatter in self.scatter_objs: # scatter.set_visible(True) self.canvas.draw_idle() return False
[docs] def findIndexNearestXY(self, x_point, y_point): ''' Finds the nearest neighbour .. TODO:: currently a very inefficient brute force implementation should be replaced by e.g. a k-d-tree nearest neighbour search `https://en.wikipedia.org/wiki/K-d_tree` ''' distance = np.square( self.ypix - y_point) + np.square(self.xpix - x_point) index = np.argmin(distance) index = np.unravel_index(index, distance.shape) return index
[docs] def nearly_equal(a, b, sig_fig=5): return (a == b or int(a * 10 ** sig_fig) == int(b * 10 ** sig_fig) )
if __name__ == '__main__': pass