# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2025 Simon Marwitz, Volkmar Zabel, Andrei Udrea et al.
"""Post-processing tools including PoSER multi-setup merging (MergePoSER)."""
import numpy as np
import datetime
from .PreProcessingTools import PreProcessSignals
from .ModalBase import ModalBase
from .StabilDiagram import StabilCalc
from .Helpers import calculateMAC, calculateMPC, calculateMPD
import os
import logging
logger = logging.getLogger(__name__)
logger.setLevel(level=logging.INFO)
[docs]
class MergePoSER(object):
"""Post-Separate Estimation and Re-scaling (PoSER) multi-setup merger.
Combines modal results from multiple measurement setups that share a common
set of reference channels. Each setup is added via :meth:`add_setup`; the
merged frequencies, damping, and mode shapes are computed by
:meth:`merge_mode_shapes`.
The resulting object has the same interface expected by
:class:`~pyOMA.core.PlotMSH.ModeShapePlot` for multi-setup results.
Notes
-----
For each setup the following objects must be provided:
* ``prep_signals`` — :class:`~pyOMA.core.PreProcessingTools.PreProcessSignals`
with ``chan_dofs`` and ``ref_channels`` defined.
* ``modal_data`` — any :class:`~pyOMA.core.ModalBase.ModalBase` subclass
with ``modal_frequencies``, ``modal_damping``, and ``mode_shapes``.
* ``stabil_data`` — :class:`~pyOMA.core.StabilDiagram.StabilCalc` with
``select_modes`` set.
"""
[docs]
def __init__(self,):
"""Initialise an empty merger; add setups with :meth:`add_setup`."""
self.setups = []
self.merged_chan_dofs = []
self.merged_num_channels = None
self.mean_frequencies = None
self.mean_damping = None
self.merged_mode_shapes = None
self.std_frequencies = None
self.std_damping = None
self.setup_name = 'merged_poser'
self.start_time = datetime.datetime.now()
self.state = [False, False]
def _validate_add_setup_args(self, prep_signals, modal_data, stabil_data):
"""Validate types and name consistency for :meth:`add_setup` arguments."""
if not isinstance(prep_signals, PreProcessSignals):
raise TypeError(
f"prep_signals must be a PreProcessSignals instance, got {type(prep_signals)}")
if not isinstance(modal_data, ModalBase):
raise TypeError(
f"modal_data must be a ModalBase instance, got {type(modal_data)}")
if not isinstance(stabil_data, StabilCalc):
raise TypeError(
f"stabil_data must be a StabilCalc instance, got {type(stabil_data)}")
if prep_signals.setup_name != modal_data.setup_name:
raise ValueError(
f"prep_signals and modal_data belong to different setups: "
f"{prep_signals.setup_name!r} vs {modal_data.setup_name!r}")
if modal_data.setup_name != stabil_data.setup_name:
raise ValueError(
f"modal_data and stabil_data belong to different setups: "
f"{modal_data.setup_name!r} vs {stabil_data.setup_name!r}")
if not prep_signals.chan_dofs:
raise ValueError(
"prep_signals.chan_dofs must be assigned before merging setups.")
if not stabil_data.select_modes:
raise ValueError(
"No modes selected in stabil_data. Call select_modes before adding the setup.")
def add_setup(
self,
prep_signals,
modal_data,
stabil_data,
override_ref_channels=None):
# does not check, if same method was used for each setup, also anaylsis
# parameters should be similar
if override_ref_channels:
raise RuntimeWarning('This function is not implemented yet!')
self._validate_add_setup_args(prep_signals, modal_data, stabil_data)
# extract needed information and store them in a dictionary
self.setups.append({'setup_name': prep_signals.setup_name,
'chan_dofs': prep_signals.chan_dofs,
'num_channels': prep_signals.num_analised_channels,
'ref_channels': prep_signals.ref_channels,
'modal_frequencies': [modal_data.modal_frequencies[index] for index in stabil_data.select_modes],
'modal_damping': [modal_data.modal_damping[index] for index in stabil_data.select_modes],
'mode_shapes': [modal_data.mode_shapes[:, index[1], index[0]] for index in stabil_data.select_modes]
})
self.start_time = min(self.start_time, prep_signals.start_time)
logger.info('Added setup "%s" with %d channels and %d selected modes.',
prep_signals.setup_name, prep_signals.num_analised_channels,
len(stabil_data.select_modes))
self.state[0] = True
@staticmethod
def _pair_modes_inner(frequencies_1, frequencies_2):
"""Pair modes between two setups by minimal relative frequency difference."""
delta_matrix = np.ma.array(
np.zeros((len(frequencies_1), len(frequencies_2))))
for index, frequency in enumerate(frequencies_1):
delta_matrix[index, :] = np.abs(
(frequencies_2 - frequency) / frequency)
mode_pairs = []
while True:
row, col = np.unravel_index(
np.argmin(delta_matrix), delta_matrix.shape)
delta_matrix[row, :] = np.ma.masked
delta_matrix[:, col] = np.ma.masked
mode_pairs.append((row, col))
if len(mode_pairs) == len(frequencies_1):
break
if len(mode_pairs) == len(frequencies_2):
break
return mode_pairs
@staticmethod
def _pair_channels(chan_dofs_base, chan_dofs_this):
"""Match reference channels between two setups by node and direction."""
pairs = []
for chan_dof_base in chan_dofs_base:
chan_base, node_base, az_base, elev_base = chan_dof_base[0:4]
for chan_dof_this in chan_dofs_this:
chan_this, node_this, az_this, elev_this = chan_dof_this[0:4]
if node_this == node_base and az_this == az_base and elev_this == elev_base:
pairs.append((chan_base, chan_this))
return pairs
def _build_channel_and_mode_pairing(self, setups, chan_dofs_base,
frequencies_base, auto_pairing):
"""Build channel and (optionally) mode pairings for all non-base setups."""
total_dofs = len(chan_dofs_base)
channel_pairing = []
mode_pairing = [] if auto_pairing else None
for setup in setups:
these_pairs = self._pair_channels(chan_dofs_base, setup['chan_dofs'])
channel_pairing.append(these_pairs)
total_dofs += setup['num_channels'] - len(these_pairs)
if auto_pairing:
mode_pairs = self._pair_modes_inner(
np.array(frequencies_base), np.array(setup['modal_frequencies']))
mode_pairing.append(mode_pairs)
return channel_pairing, mode_pairing, total_dofs
@staticmethod
def _mode_in_all_setups(mode_pairing, mode_num):
"""Return True if *mode_num* appears in every setup's pairing list."""
for mode_pairs in mode_pairing:
for mode_pair in mode_pairs:
if mode_pair[0] == mode_num:
break
else:
return False
return True
@staticmethod
def _remove_mode_from_pairing(mode_pairing, mode_num):
"""Remove all pairs with *mode_num* as the base index from *mode_pairing*."""
for mode_pairs in mode_pairing:
while True:
for index, mode_pair in enumerate(mode_pairs):
if mode_pair[0] == mode_num:
del mode_pairs[index]
break
else:
break
def _filter_common_modes(self, mode_pairing, frequencies_base):
"""Remove modes not common to all setups from *mode_pairing* in-place."""
for mode_num in range(len(frequencies_base) - 1, -1, -1):
if not self._mode_in_all_setups(mode_pairing, mode_num):
self._remove_mode_from_pairing(mode_pairing, mode_num)
def _copy_base_modes(self, mode_shapes, f_list, d_list, mode_pairing,
base_data, new_mode_nums, num_channels_base):
"""Copy modal data from the base setup into the output arrays."""
mode_shapes_base, frequencies_base, damping_base = base_data
for mode_num_base, _ in mode_pairing[0]:
mode_index = new_mode_nums.index(mode_num_base)
mode_base = mode_shapes_base[mode_num_base]
mode_shapes[0:num_channels_base, 0, mode_index] = mode_base
f_list[0, mode_index] = frequencies_base[mode_num_base]
d_list[0, mode_index] = damping_base[mode_num_base]
@staticmethod
def _process_roving_channels(ref_channels_this, num_channels_this,
split_mat_refs_this, split_mat_rovs_this,
chan_dofs_this, start_dof, chan_dofs_base):
"""Fill selection matrices and accumulate roving chan_dofs."""
row_ref = row_rov = 0
for channel in range(num_channels_this):
if channel in ref_channels_this:
split_mat_refs_this[row_ref, channel] = 1
row_ref += 1
else:
split_mat_rovs_this[row_rov, channel] = 1
for chan_dof_this in chan_dofs_this:
chan, node, az, elev = chan_dof_this[0:4]
if chan == channel:
chan = int(start_dof + row_rov)
chan_dofs_base.append([chan, node, az, elev])
row_rov += 1
def _build_split_matrices(self, these_pairs, num_channels_base,
num_channels_this, ref_counts,
chan_dofs_this, start_dof, chan_dofs_base):
"""Build selection matrices for reference and roving channels."""
num_ref_channels, num_remain_channels = ref_counts
ref_channels_base = [pair[0] for pair in these_pairs]
ref_channels_this = [pair[1] for pair in these_pairs]
logger.debug('Next Instance: %s %s', ref_channels_base, ref_channels_this)
split_mat_refs_base = np.zeros((num_ref_channels, num_channels_base))
split_mat_refs_this = np.zeros((num_ref_channels, num_channels_this))
split_mat_rovs_this = np.zeros((num_remain_channels, num_channels_this))
row_ref = 0
for channel in range(num_channels_base):
if channel in ref_channels_base:
split_mat_refs_base[row_ref, channel] = 1
row_ref += 1
self._process_roving_channels(ref_channels_this, num_channels_this,
split_mat_refs_this, split_mat_rovs_this,
chan_dofs_this, start_dof, chan_dofs_base)
return split_mat_refs_base, split_mat_refs_this, split_mat_rovs_this
def _rescale_and_merge_modes(self, setup, setup_num, mode_pairing,
split_matrices, base_ctx, output_arrays, roving_ctx):
"""Rescale and store mode contributions from one non-base setup."""
split_mat_refs_base, split_mat_refs_this, split_mat_rovs_this = split_matrices
mode_shapes_base, new_mode_nums = base_ctx
mode_shapes, f_list, d_list, scale_factors = output_arrays
start_dof, num_remain_channels = roving_ctx
for mode_num_base, mode_num_this in mode_pairing[setup_num]:
mode_index = new_mode_nums.index(mode_num_base)
mode_base = mode_shapes_base[mode_num_base]
mode_refs_base = np.dot(split_mat_refs_base, mode_base)
mode_this = setup['mode_shapes'][mode_num_this]
mode_refs_this = np.dot(split_mat_refs_this, mode_this)
mode_rovs_this = np.dot(split_mat_rovs_this, mode_this)
numer = np.dot(np.transpose(np.conjugate(mode_refs_this)), mode_refs_base)
denom = np.dot(np.transpose(np.conjugate(mode_refs_this)), mode_refs_this)
scale_fact = numer / denom
scale_factors[setup_num, mode_index] = scale_fact
mode_shapes[start_dof:start_dof + num_remain_channels, 0, mode_index] = (
scale_fact * mode_rovs_this)
f_list[setup_num + 1, mode_index] = setup['modal_frequencies'][mode_num_this]
d_list[setup_num + 1, mode_index] = setup['modal_damping'][mode_num_this]
def _normalise_and_compute_stats(self, mode_shapes, f_list, d_list,
mode_pairing, new_mode_nums, common_modes):
"""Normalise merged mode shapes and compute frequency/damping statistics.
Parameters
----------
mode_shapes : np.ndarray
Merged mode-shape array (modified in place).
f_list, d_list : np.ndarray
Per-setup frequency and damping arrays.
mode_pairing : list of list
Mode pairings.
new_mode_nums : list of int
Ordered mode indices.
common_modes : int
Number of common modes.
Returns
-------
mean_frequencies, std_frequencies, mean_damping, std_damping : np.ndarray
Statistical summaries over the merged setups.
"""
mean_frequencies = np.zeros((common_modes,))
std_frequencies = np.zeros((common_modes,))
mean_damping = np.zeros((common_modes,))
std_damping = np.zeros((common_modes,))
for mode_num_base, _ in mode_pairing[0]:
mode_index = new_mode_nums.index(mode_num_base)
mode_tmp = mode_shapes[:, 0, mode_index]
this_max = mode_tmp[np.argmax(np.abs(mode_tmp))]
mode_shapes[:, 0, mode_index] = mode_tmp / this_max
mean_frequencies[mode_index] = np.mean(f_list[:, mode_index], axis=0)
std_frequencies[mode_index] = np.std(f_list[:, mode_index], axis=0)
mean_damping[mode_index] = np.mean(d_list[:, mode_index], axis=0)
std_damping[mode_index] = np.std(d_list[:, mode_index], axis=0)
return mean_frequencies, std_frequencies, mean_damping, std_damping
def _init_merge_outputs(self, setups, num_channels_base, common_modes, channel_pairing):
"""Compute total DOFs and allocate output arrays for :meth:`merge`."""
total_dofs = num_channels_base
for i, setup in enumerate(setups):
total_dofs += setup['num_channels'] - len(channel_pairing[i])
mode_shapes = np.zeros((total_dofs, 1, common_modes), dtype=complex)
f_list = np.zeros((len(setups) + 1, common_modes))
d_list = np.zeros((len(setups) + 1, common_modes))
scale_factors = np.zeros((len(setups), common_modes), dtype=complex)
return total_dofs, mode_shapes, f_list, d_list, scale_factors
def _store_merge_results(self, chan_dofs_base, total_dofs, mode_shapes,
mean_frequencies, std_frequencies,
mean_damping, std_damping):
"""Persist merged results to instance attributes."""
self.merged_chan_dofs = chan_dofs_base
self.merged_num_channels = total_dofs
self.merged_mode_shapes = mode_shapes
self.mean_frequencies = np.expand_dims(mean_frequencies, axis=1)
self.std_frequencies = np.expand_dims(std_frequencies, axis=1)
self.mean_damping = np.expand_dims(mean_damping, axis=1)
self.std_damping = np.expand_dims(std_damping, axis=1)
self.state[1] = True
[docs]
def merge(self, base_setup_num=0, mode_pairing=None):
'''
generate new_chan_dofs
assign modes from each setup
::
for each mode:
for each setup:
rescale
merge
.. TODO::
* rescale w.r.t to the average solution from all setups rather than specifying a base setup
* compute scaling factors for each setup with each setup and average them for each setup before rescaling
* corresponding standard deviations can be used to asses the quality of fit
'''
setups = self.setups
chan_dofs_base = setups[base_setup_num]['chan_dofs']
num_channels_base = setups[base_setup_num]['num_channels']
mode_shapes_base = setups[base_setup_num]['mode_shapes']
frequencies_base = setups[base_setup_num]['modal_frequencies']
damping_base = setups[base_setup_num]['modal_damping']
del setups[base_setup_num]
if mode_pairing is None:
auto_pairing = True
mode_pairing = []
else:
auto_pairing = False
logger.info('The provided mode pairs will be applied without any further checks.')
channel_pairing, auto_mode_pairing, _ = self._build_channel_and_mode_pairing(
setups, chan_dofs_base, frequencies_base, auto_pairing)
if auto_pairing:
mode_pairing = auto_mode_pairing
self._filter_common_modes(mode_pairing, frequencies_base)
lengths = [len(mode_pairs) for mode_pairs in mode_pairing]
common_modes = min(lengths)
new_mode_nums = [mode_num[0] for mode_num in mode_pairing[0]]
total_dofs, mode_shapes, f_list, d_list, scale_factors = self._init_merge_outputs(
setups, num_channels_base, common_modes, channel_pairing)
self._copy_base_modes(mode_shapes, f_list, d_list, mode_pairing,
(mode_shapes_base, frequencies_base, damping_base),
new_mode_nums, num_channels_base)
start_dof = num_channels_base
for setup_num, setup in enumerate(setups):
these_pairs = channel_pairing[setup_num]
num_ref_channels = len(these_pairs)
num_remain_channels = setup['num_channels'] - num_ref_channels
split_matrices = self._build_split_matrices(
these_pairs, num_channels_base, setup['num_channels'],
(num_ref_channels, num_remain_channels),
setup['chan_dofs'], start_dof, chan_dofs_base)
self._rescale_and_merge_modes(
setup, setup_num, mode_pairing,
split_matrices,
(mode_shapes_base, new_mode_nums),
(mode_shapes, f_list, d_list, scale_factors),
(start_dof, num_remain_channels))
start_dof += num_remain_channels
mean_frequencies, std_frequencies, mean_damping, std_damping = \
self._normalise_and_compute_stats(
mode_shapes, f_list, d_list, mode_pairing, new_mode_nums, common_modes)
self._store_merge_results(chan_dofs_base, total_dofs, mode_shapes,
mean_frequencies, std_frequencies,
mean_damping, std_damping)
def save_state(self, fname):
dirname, _ = os.path.split(fname)
if not os.path.isdir(dirname):
os.makedirs(dirname)
out_dict = {}
out_dict['self.state'] = self.state
out_dict['self.setup_name'] = self.setup_name
out_dict['self.start_time'] = self.start_time
if self.state[0]:
out_dict['self.setups'] = self.setups
if self.state[1]:
out_dict['self.merged_chan_dofs'] = self.merged_chan_dofs
out_dict['self.merged_num_channels'] = self.merged_num_channels
out_dict['self.merged_mode_shapes'] = self.merged_mode_shapes
out_dict['self.mean_frequencies'] = self.mean_frequencies
out_dict['self.std_frequencies'] = self.std_frequencies
out_dict['self.mean_damping'] = self.mean_damping
out_dict['self.std_damping'] = self.std_damping
np.savez_compressed(fname, **out_dict)
@classmethod
def load_state(cls, fname):
logger.info('Loading results from %s', fname)
in_dict = np.load(fname, allow_pickle=True)
if 'self.state' in in_dict:
state = list(in_dict['self.state'])
else:
return
for this_state, state_string in zip(state, ['Setups added',
'Setups merged',
]):
if this_state:
logger.info(state_string)
postprocessor = cls()
setup_name = str(in_dict['self.setup_name'].item())
start_time = in_dict['self.start_time'].item()
postprocessor.setup_name = setup_name
postprocessor.start_time = start_time
if state[0]:
postprocessor.setups = list(in_dict['self.setups'])
if state[1]:
postprocessor.merged_chan_dofs = [[int(float(chan_dof[0])),
str(chan_dof[1]),
float(chan_dof[2]),
float(chan_dof[3]),
str(chan_dof[-1])] for chan_dof in in_dict['self.merged_chan_dofs']]
postprocessor.merged_num_channels = in_dict['self.merged_num_channels']
postprocessor.merged_mode_shapes = in_dict['self.merged_mode_shapes']
postprocessor.mean_frequencies = in_dict['self.mean_frequencies']
postprocessor.std_frequencies = in_dict['self.std_frequencies']
postprocessor.mean_damping = in_dict['self.mean_damping']
postprocessor.std_damping = in_dict['self.std_damping']
return postprocessor
def _build_export_text(self, selected_freq, selected_damp, selected_stdf,
selected_stdd, mode_metrics, selected_modes, num_modes):
"""Build the plain-text export string for modal results."""
selected_MPC, selected_MP, selected_MPD = mode_metrics
freq_str = ''
damp_str = ''
ord_str = ''
msh_str = ''
mpc_str = ''
mp_str = ''
mpd_str = ''
std_freq_str = ''
std_damp_str = ''
for col in range(num_modes):
freq_str += '{:3.3f} \t\t'.format(selected_freq[col, 0])
damp_str += '{:3.3f} \t\t'.format(selected_damp[col, 0])
mpc_str += '{:3.3f}\t \t'.format(selected_MPC[col])
mp_str += '{:3.2f} \t\t'.format(selected_MP[col])
mpd_str += '{:3.2f} \t\t'.format(selected_MPD[col])
std_damp_str += '{:3.3e} \t\t'.format(selected_stdd[col, 0])
std_freq_str += '{:3.3e} \t\t'.format(selected_stdf[col, 0])
for row in range(num_modes):
msh_str += '\n \t\t'
for col in range(self.merged_num_channels):
msh_str += '{:+3.4f} \t'.format(selected_modes[col, 0, row])
export_modes = 'MANUAL MODAL ANALYSIS\n'
export_modes += '=======================\n'
export_modes += 'Frequencies [Hz]:\t' + freq_str + '\n'
export_modes += 'Standard deviations of the Frequencies [Hz]:\t' + std_freq_str + '\n'
export_modes += 'Damping [%]:\t\t' + damp_str + '\n'
export_modes += 'Standard deviations of the Damping [%]:\t' + std_damp_str + '\n'
export_modes += 'Mode shapes:\t\t' + msh_str + '\n'
export_modes += 'Model order:\t\t' + ord_str + '\n'
export_modes += 'MPC [-]:\t\t' + mpc_str + '\n'
export_modes += 'MP [°]:\t\t' + mp_str + '\n'
export_modes += 'MPD [-]:\t\t' + mpd_str + '\n\n'
return export_modes
def _export_binary(self, fname, selected_freq, selected_damp, selected_stdf,
selected_stdd, mode_metrics, selected_modes):
"""Save modal results in compressed NumPy binary format."""
selected_MPC, selected_MP, selected_MPD = mode_metrics
out_dict = {
'selected_freq': selected_freq,
'selected_damp': selected_damp,
'selected_MPC': selected_MPC,
'selected_MP': selected_MP,
'selected_MPD': selected_MPD,
'selected_modes': selected_modes,
'selected_stdf': selected_stdf,
'selected_stdd': selected_stdd,
}
np.savez_compressed(fname, **out_dict)
def export_results(self, fname, binary=False):
selected_freq = self.mean_frequencies
selected_damp = self.mean_damping
num_modes = len(selected_freq)
selected_MPC = calculateMPC(self.merged_mode_shapes[:, 0, :])
selected_MP, selected_MPD = calculateMPD(self.merged_mode_shapes[:, 0, :])
selected_stdf = self.std_frequencies
selected_stdd = self.std_damping
selected_modes = self.merged_mode_shapes
dirname, _ = os.path.split(fname)
if not os.path.isdir(dirname):
os.makedirs(dirname)
mode_metrics = (selected_MPC, selected_MP, selected_MPD)
if binary:
self._export_binary(fname, selected_freq, selected_damp,
selected_stdf, selected_stdd,
mode_metrics, selected_modes)
else:
export_modes = self._build_export_text(
selected_freq, selected_damp, selected_stdf, selected_stdd,
mode_metrics, selected_modes, num_modes)
with open(fname, 'w') as f:
f.write(export_modes)
def _resolve_candidate(row, col, row_ind, col_ind, del_row, del_col,
mac_matrix, debug_str):
"""Resolve the best candidate when a mode has multiple close matches."""
if del_row and del_col:
return row, col, debug_str
best = np.nanargmax([mac_matrix[row_ind, col],
mac_matrix[row, col_ind],
mac_matrix[row, col]])
if best == 0:
return row_ind, col, debug_str + f'Chose alternative match for "Mode A" at {row_ind}, '
if best == 1:
return row, col_ind, debug_str + f'Chose alternative match for "Mode B" at {col_ind}, '
if not del_row:
return row, col, debug_str + f'Reject alternative match for "Mode A" at {row_ind}, '
return row, col, debug_str + f'Reject alternative match for "Mode B" at {col_ind}, '
def _check_threshold_debug(row, col, delta_matrix, mac_matrix,
freq_thresh, mac_thresh, debug_str):
"""Append threshold-check information to *debug_str* when DEBUG logging is active.
Parameters
----------
row, col : int
Current candidate pair indices.
delta_matrix : np.ma.MaskedArray
Relative frequency-difference matrix.
mac_matrix : np.ndarray
MAC matrix.
freq_thresh, mac_thresh : float
Acceptance thresholds.
debug_str : str
Running debug string.
Returns
-------
debug_str : str
Updated debug string.
"""
if delta_matrix[row, col] < freq_thresh or mac_matrix[row, col] > mac_thresh:
debug_str += "Thresholds are within limits for: "
if delta_matrix[row, col] < freq_thresh:
debug_str += "freq, "
if mac_matrix[row, col] > mac_thresh:
debug_str += "mac, "
else:
debug_str += "Thresholds are out of limits, "
return debug_str
def _build_freq_mac_matrices(freq_a, freq_b, shapes_a, shapes_b):
"""Build relative-frequency-difference and MAC matrices for mode pairing."""
shape = (len(freq_a), len(freq_b))
delta_matrix = np.ma.array(np.zeros(shape), mask=np.zeros(shape))
for index, frequency in enumerate(freq_a):
delta_matrix[index, :] = np.abs(
(freq_b - frequency) / (0.5 * (freq_b + frequency)))
delta_matrix.mask = np.isnan(delta_matrix)
return delta_matrix, calculateMAC(shapes_a, shapes_b)
def _find_col_ambiguity(delta_matrix, row, col):
"""Check if *col* is the unambiguous argmin-row for any other column."""
for col_ind in range(delta_matrix.shape[1]):
if col_ind == col:
continue
if delta_matrix[:, col_ind].mask.all():
continue
if np.nanargmin(delta_matrix[:, col_ind]) == row:
return col_ind, False
return col, True
def _find_row_ambiguity(delta_matrix, row, col):
"""Check if *row* is the unambiguous argmin-col for any other row."""
for row_ind in range(delta_matrix.shape[0]):
if row_ind == row:
continue
if delta_matrix[row_ind, :].mask.all():
continue
if np.nanargmin(delta_matrix[row_ind, :]) == col:
return row_ind, False
return row, True
[docs]
def pair_modes(freq_a, freq_b,
shapes_a, shapes_b,
freq_thresh=0.2, mac_thresh=0.8):
'''
A function to pair two sets of modes (here: a and b) based on frequency
differences and mode shape similarity. The number of modes in both sets may
be different and relative complements of both arrays may be non-empty.
The threshold where pairing stops is based on normalized frequency differences
AND modal assurance criteria.
Parameters
----------
f_a, f_b: np.ndarray
Arrays holding the natural frequencies of both sets of modes. The
dimension (number of modes) of both sets can be different.
d_a, d_b: np.ndarray
Arrays holding the damping ratios of both sets of modes. The dimension
(number of modes) of both sets can be different.
phi_a, phi_b: np.ndarray
Arrays holding the mode shapes of both sets of modes. The first
dimension is the number of channels, that must match in both arrays.
Other Parameters
----------------
kwargs :
Additional kwargs are passed to pair_modes
Returns
-------
inds_a, inds_b: np.ndarray,
Arrays holding the indices of paired modes sorted by ascending
frequencies (set a). Length represents the number of common modes.
unp_a, unp_b: np.ndarray
Arrays holding the indices of modes that could not be paired
'''
delta_matrix, mac_matrix = _build_freq_mac_matrices(freq_a, freq_b, shapes_a, shapes_b)
indices_a = []
indices_b = []
delta_values = []
mac_values = []
while ~np.all(delta_matrix.mask):
row, col = np.unravel_index(np.nanargmin(delta_matrix), delta_matrix.shape)
col_ind, del_col = _find_col_ambiguity(delta_matrix, row, col)
row_ind, del_row = _find_row_ambiguity(delta_matrix, row, col)
debug_str = f"Current Minimum at {row}:{col}, "
row, col, debug_str = _resolve_candidate(
row, col, row_ind, col_ind, del_row, del_col, mac_matrix, debug_str)
debug_str = _check_threshold_debug(
row, col, delta_matrix, mac_matrix, freq_thresh, mac_thresh, debug_str)
if delta_matrix[row, col] < freq_thresh and mac_matrix[row, col] > mac_thresh:
delta_values.append(delta_matrix[row, col])
mac_values.append(mac_matrix[row, col])
indices_a.append(row)
indices_b.append(col)
debug_str += "Selecting candidate."
else:
debug_str += "Rejecting candidate."
delta_matrix[row, :] = np.ma.masked
delta_matrix[:, col] = np.ma.masked
logger.debug(debug_str)
sort_inds = np.argsort(freq_a[indices_a])
indices_a = np.array(indices_a)[sort_inds]
indices_b = np.array(indices_b)[sort_inds]
unp_a = list(np.setdiff1d(np.arange(len(freq_a)), indices_a))
unp_b = list(np.setdiff1d(np.arange(len(freq_b)), indices_b))
return indices_a, indices_b, unp_a, unp_b
[docs]
def compare_modes(f_a, d_a, phi_a, f_b, d_b, phi_b, **kwargs):
'''
Compares two sets of modes (set a and set b) by first pairing them and then displaying
statistics on the identified pairs and a full MAC matrix for manual assessment.
Parameters
----------
f_a, f_b: np.ndarray
Arrays holding the natural frequencies of both sets of modes. The
dimension (number of modes) of both sets can be different.
d_a, d_b: np.ndarray
Arrays holding the damping ratios in percent of both sets of modes. The dimension
(number of modes) of both sets can be different.
phi_a, phi_b: np.ndarray
Arrays holding the mode shapes of both sets of modes. The first
dimension is the number of channels, that must match in both arrays.
Other Parameters
----------------
kwargs :
Additional kwargs are passed to pair_modes
Returns
-------
inds_a, inds_b: np.ndarray
Arrays holding the indices of paired modes
unp_a, unp_b: np.ndarray
Arrays holding the indices of modes that could not be paired
'''
import matplotlib.pyplot as plt
inds_a, inds_b, unp_a, unp_b = pair_modes(f_a, f_b, phi_a, phi_b, **kwargs)
if inds_a.shape[0] == 0:
logger.warning('Could not match any modes. Consider raising freq_thresh or lowering mac_thresh.')
return inds_a, inds_b, unp_a, unp_b
if np.max(d_a) <= 1:
logger.warning('First set damping values do not seem to be given in percent.')
if np.max(d_b) <= 1:
logger.warning('Second set damping values do not seem to be given in percent.')
all_inds_b = np.concatenate((inds_b, unp_b))
corr_inds_a = np.ma.concatenate([np.ma.array(inds_a, mask=np.zeros_like(inds_a, dtype=bool)), np.ma.array(np.zeros_like(unp_b), mask=np.ones_like(unp_b, dtype=bool), dtype=int)])
# indices of "modes 1" in the order of "modes 2" (for each mode 2 the index of mode 1 of nan)
corr_inds_a_sort = corr_inds_a[np.argsort(all_inds_b)]
freqs_a_corr = np.ma.array(f_a[corr_inds_a_sort],
mask=corr_inds_a_sort.mask,
fill_value=np.nan
).filled()
damps_a_corr = np.ma.array(d_a[corr_inds_a_sort],
mask=corr_inds_a_sort.mask,
fill_value=np.nan
).filled()
msh_a_corr = np.ma.array(phi_a[:, corr_inds_a_sort],
mask=np.repeat(np.ma.getmaskarray(corr_inds_a_sort)[np.newaxis, :], phi_a.shape[0], axis=0),
fill_value=np.nan
).filled()
freq_diffs = freqs_a_corr - f_b
damp_diffs = damps_a_corr - d_b
mac_matrix = calculateMAC(msh_a_corr, phi_b)
macs = np.diag(mac_matrix)
# create the alpha mask: put 0.5 into every row corresponding to unp 1 and every column corresponding to unp 2
mac_matrix = calculateMAC(phi_a, phi_b)
alpha_mask = np.ones_like(mac_matrix)
alpha_mask[unp_a, :] = 0.25
alpha_mask[:, unp_b] = 0.25
plt.matshow(mac_matrix, alpha=alpha_mask, cmap='viridis_r', vmin=0, vmax=1)
plt.yticks(ticks=np.arange(f_a.shape[0]), labels=[f"{v:1.2f} Hz" for v in f_a])
plt.xticks(ticks=np.arange(f_b.shape[0]), labels=[f"{v:1.2f} Hz" for v in f_b], rotation=90)
plt.scatter(inds_b, inds_a, color='r', marker='+')
logger.info(f'''Statistics on identification:
Δf = {np.nanmean(freq_diffs):1.3f}± {np.nanstd(freq_diffs):1.3f},
Δd = {np.nanmean(damp_diffs):1.3f}± {np.nanstd(damp_diffs):1.3f},
MAC: mean = {np.nanmean(macs):1.3f}, min= {np.nanmin(macs):1.3f},
Number of unmatched modes: "a" {len(unp_a)}, "b" {len(unp_b)}''')
plt.figure()
plt.plot(f_a, d_a, marker='x', color='black', ls='none')
plt.plot(f_b, d_b, marker='+', color='black', ls='none')
for ind_a, ind_b in zip(inds_a, inds_b):
fs = (f_a[ind_a], f_b[ind_b])
ds = (d_a[ind_a], d_b[ind_b])
plt.plot(fs, ds, color='red')
plt.annotate(f'''Statistics on identification:
Δf = {np.nanmean(freq_diffs):1.3f}± {np.nanstd(freq_diffs):1.3f},
Δd = {np.nanmean(damp_diffs):1.3f}± {np.nanstd(damp_diffs):1.3f},
MAC: mean = {np.nanmean(macs):1.3f}, min= {np.nanmin(macs):1.3f},
Number of unmatched modes: "a" {len(unp_a)}, "b" {len(unp_b)}''',
(0.55, 0.7), xycoords='figure fraction')
return inds_a, inds_b, unp_a, unp_b
if __name__ == '__main__':
main()