# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2025 Simon Marwitz, Volkmar Zabel, Andrei Udrea et al.
"""Base class shared by all pyOMA system-identification methods."""
from .PreProcessingTools import PreProcessSignals
import numpy as np
from collections import deque
import os
import logging
logger = logging.getLogger(__name__)
logger.setLevel(level=logging.INFO)
[docs]
class ModalBase(object):
"""Base class from which all pyOMA system-identification classes inherit.
Provides shared functionality (conjugate removal, mode-shape integration,
rescaling, persistence) so that derived classes only implement the
method-specific identification steps. Post-processing tools (stabilization
diagram, mode-shape plot) accept any :class:`ModalBase` subclass instance.
Attributes
----------
prep_signals : PreProcessSignals or None
The signal object from which this analysis was created.
setup_name : str
Human-readable label for the measurement setup.
start_time : datetime.datetime or None
Timestamp of the measurement.
num_analised_channels : int or None
Total number of analysis channels.
num_ref_channels : int or None
Number of reference channels.
max_model_order : int or None
Maximum model order used in the identification.
modal_frequencies : np.ndarray or None
Identified natural frequencies (Hz), shape ``(max_model_order, n_modes)``.
modal_damping : np.ndarray or None
Identified modal damping ratios (%), same shape as ``modal_frequencies``.
mode_shapes : np.ndarray or None
Identified mode shapes, shape ``(n_channels, n_modes, max_model_order)``.
eigenvalues : np.ndarray or None
Identified (complex) eigenvalues.
"""
def __init__(self, prep_signals=None):
"""
Parameters
----------
prep_signals : PreProcessSignals, optional
Pre-processed signal object. When ``None``, channel metadata
attributes are initialised to ``None`` and must be set manually
(e.g. when loading a saved state).
"""
super().__init__()
if prep_signals is not None:
if not isinstance(prep_signals, PreProcessSignals):
logger.warning(f'Argument prep_signals is wrong object type {type(prep_signals)}')
self.setup_name = prep_signals.setup_name
self.start_time = prep_signals.start_time
self.num_analised_channels = prep_signals.num_analised_channels
self.num_ref_channels = prep_signals.num_ref_channels
else:
self.setup_name = ''
self.start_time = None
self.num_analised_channels = None
self.num_ref_channels = None
self.prep_signals = prep_signals
self.max_model_order = None
self.eigenvalues = None
self.modal_damping = None
self.modal_frequencies = None
self.mode_shapes = None
[docs]
@staticmethod
def remove_conjugates(eigval, eigvec_r=None, eigvec_l=None, inds_only=False):
'''
This method finds complex conjugate modes, and removes unstable and
overdamped poles.
A complex conjugate is defined as:
:math:`\\lambda_i = \\overline{\\lambda_j} \\text{ for } i \\neq j`
Unstable poles, i.e. negatively damped poles, are defined by:
:math:`[\\ln(|\\lambda|)<0]: |\\lambda_i|> 1`
Overdamped poles, are purely real poles:
:math:`[\\operatorname{atan}(\\Im/\\Re)=0]: \\Im(\\lambda_i)=0`
The method keeps the second occurance of a conjugate pair (usually the one
with the negative imaginary part) and either returns a truncated set of
eigenvalues and eigenvectors or a list of (physical) poles that can be
iterated.
Parameters
----------
eigval: (order,) numpy.ndarray
Complex array of all eigenvalues
eigvec_r, eigvec_l: (order, n_channels) numpy.ndarray, optional
Complex array(s) of all right (left) eigenvectors
inds_only: bool, optional
Whether to return a list of pole indices, or a reduced set of
eigenvalues and eigenvectors
Returns
-------
conj_indices: list
list of (physical) pole indices
eigval: (order,) numpy.ndarray
Complex array of reduced (physical) eigenvalues
eigvec_l, eigvec_r: (order, n_channels) numpy.ndarray, optional
Complex array(s) of reduced (physical) left (right) eigenvectors
'''
num_val = len(eigval)
conj_indices = deque()
for i in range(num_val):
this_val = eigval[i]
this_conj_val = np.conj(this_val)
# remove overdamped poles i.e. real eigvals
# use isclose instead of == to handle tiny floating-point imaginary residuals
# produced by some LAPACK implementations (e.g. MKL on Windows)
if np.isclose(this_val.imag, 0.0):
conj_indices.append(i)
# remove negatively damped poles i.e. unstable poles
elif np.abs(this_val) > 1:
conj_indices.append(i)
# catches unordered conjugates but takes slightly longer
for j in range(i + 1, num_val):
if np.isclose(eigval[j] , this_conj_val):
conj_indices.append(j)
break
conj_indices = list(set(range(num_val)).difference(conj_indices))
if inds_only:
return conj_indices
if eigvec_l is None:
eigvec_r = eigvec_r[:, conj_indices]
eigval = eigval[conj_indices]
return eigval, eigvec_r
else:
eigvec_l = eigvec_l[:, conj_indices]
eigvec_r = eigvec_r[:, conj_indices]
eigval = eigval[conj_indices]
return eigval, eigvec_l, eigvec_r
[docs]
@classmethod
def init_from_config(cls, conf_file, prep_signals):
"""Initialise a modal analysis object from a text configuration file.
This is a stub that must be fully reimplemented by every derived class.
Derived implementations typically read analysis parameters (e.g. model
order, frequency range) from *conf_file*, call the relevant computation
methods, and return the populated object.
Parameters
----------
conf_file : str
Path to a tab-separated key-value configuration file compatible
with :class:`~pyOMA.core.Helpers.ConfigFile`.
prep_signals : PreProcessSignals
Pre-processed signal object for this setup.
Returns
-------
ModalBase
Populated subclass instance.
"""
if not os.path.exists(conf_file):
raise FileNotFoundError(f"Configuration file not found: {conf_file!r}")
if not isinstance(prep_signals, PreProcessSignals):
raise TypeError(f"prep_signals must be PreProcessSignals, got {type(prep_signals).__name__!r}")
with open(conf_file, 'r') as _:
# read configuration parameters line by line
pass
modal_object = cls(prep_signals)
return modal_object
[docs]
def write_config(self, conf_file):
"""Write the analysis parameters used to compute this object to a
text configuration file readable by :meth:`init_from_config`.
This is a stub that must be fully reimplemented by every derived
class (mirrors :meth:`init_from_config`): write the same keys that
class's own :meth:`init_from_config` reads, via
:meth:`~pyOMA.core.Helpers.ConfigFile.write`.
Parameters
----------
conf_file : str
Path to write the configuration file to.
"""
from .Helpers import ConfigFile
ConfigFile.write(conf_file, {})
[docs]
@staticmethod
def integrate_quantities(vector, accel_channels, velo_channels, omega):
'''
Rescales mode shapes from modal accelerations / velocities to modal
displacements, by multiplication of the relevant modal coordinates
(where accelerometers, or velocimeters were used, with
$-1 \\omega^2$ or $i \\omega$, respectively,
Parameters
----------
vector: (n_channels,) numpy.ndarray
Complex modeshape for all n_channels
accel_channels: list
A list containing the channel numbers of all acceleration channels
velo_channels: list
A list containing the channel numbers of all velocity channels
omega: float
The circular frequency of the corresponding mode ($\\omega = 2 \\pi f$)
Returns
-------
vector: (n_channels,) numpy.ndarray
Rescaled complex modeshape for all n_channels
'''
# input quantities = [a, v, d]
# output quantities = [d, d, d]
# converts amplitude and phase
# phase + 180; magn / omega^2
vector = np.copy(vector)
vector[accel_channels] *= -1 / (omega ** 2)
# phase + 90; magn / omega
vector[velo_channels] *= 1j / omega
return vector
[docs]
@staticmethod
def rescale_mode_shape(modeshape, rotate_only=False):
'''
Rescales and rotates modeshapes in the complex plane. Default behaviour
is to scale the larges component to unit modal displacement. If argument
rotate_only is provided, the method given in Appendix C2 of Doehler 2013
(doi:0.1016/j.ymssp.2012.11.011) is used to rotate but not rescale the
mode shape. Note: The scale of identified mode shapes is arbitrary in most
OMA methods.
Parameters
----------
modeshape: (n_channels,) numpy.ndarray
Complex modeshape for all n_channels
rotate_only: bool, optional
Whether to rotate, but not rescale, the mode shape.
Returns
-------
modeshape: (n_channels,) numpy.ndarray
Rescaled complex modeshape for all n_channels
'''
# scaling of mode shape
if rotate_only:
k = np.argmax(np.abs(modeshape))
alpha = np.angle(modeshape[k])
return modeshape * np.exp(-1j * alpha)
else:
modeshape = modeshape / modeshape[np.argmax(np.abs(modeshape))]
return modeshape
[docs]
def save_state(self, fname):
"""Save the current computation state to a compressed NumPy archive.
Must be fully reimplemented by every derived class.
Parameters
----------
fname : str
Destination file path (without ``.npz`` extension).
Raises
------
NotImplementedError
Always, unless overridden by a derived class.
"""
raise NotImplementedError(
'save_state must be reimplemented by every derived class.')
[docs]
@classmethod
def load_state(cls, fname, prep_signals):
"""Restore a modal-analysis object from a previously saved archive.
Must be fully reimplemented by every derived class.
Parameters
----------
fname : str
Path to the ``.npz`` archive written by :meth:`save_state`.
prep_signals : PreProcessSignals
Signal object for the same setup; used to validate the archive.
Returns
-------
ModalBase
Restored subclass instance.
Raises
------
NotImplementedError
Always, unless overridden by a derived class.
"""
raise NotImplementedError(
'load_state must be reimplemented by every derived class.')