Source code for pyOMA.core.ERA

# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2025  Simon Marwitz, Volkmar Zabel, Andrei Udrea et al.
"""Eigensystem Realisation Algorithm (ERA) for operational modal analysis."""
import numpy as np
import scipy.linalg
from collections import deque
import os
from .PreProcessingTools import PreProcessSignals

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


[docs] class ERA(object): """Eigensystem Realisation Algorithm (ERA) for operational modal analysis. Identifies state-space models and modal parameters from impulse-response or free-decay data by constructing a Hankel matrix and decomposing it via SVD. When forced-response data are available, call :meth:`CalculateFRF` first to convert them to impulse responses before calling :meth:`build_hankel_matrix`. Parameters ---------- prep_signals : PreProcessSignals Pre-processed signal object providing ``signals``, ``sampling_rate``, and channel metadata. """ def __init__(self, prep_signals): """ Parameters ---------- prep_signals : PreProcessSignals Pre-processed signal object. """ super().__init__() if not isinstance(prep_signals, PreProcessSignals): raise TypeError( f"prep_signals must be PreProcessSignals, got {type(prep_signals).__name__!r}" ) self.prep_signals = prep_signals self.setup_name = prep_signals.setup_name self.start_time = prep_signals.start_time # 0 1 2 # self.state= [SHankelMatrix, State Mat., Modal Par. self.state = [False, False, False] self.num_block_columns = None self.num_block_rows = None self.toeplitz_matrix = None self.hankel_matrix = None # anil self.max_model_order = None self.state_matrix = None self.output_matrix = None self.modal_damping = None self.modal_frequencies = None self.mode_shapes = None
[docs] def CalculateFRF(self): ''' function by anil FRF(Frequency response function) is convertion of signal from time to frequency domain. The following function performs this conversion. ''' measurement = self.prep_signals.signals num_channels = measurement.shape[1] num_time_steps = self.prep_signals.F.shape[0] acceleration_fft = np.zeros( (num_time_steps // 2 + 1, num_channels), dtype=complex) F_fft = np.fft.rfft(np.hamming(num_time_steps) * self.prep_signals.F) for channel in range(num_channels): # loop over channels fft_this_channel = np.fft.rfft(np.hamming( num_time_steps) * measurement[:, channel]) acceleration_fft[:, channel] = fft_this_channel FRF = np.zeros_like(acceleration_fft) for channel in range(num_channels): FRF[:, channel] = acceleration_fft[:, channel] / F_fft IRF = np.zeros((num_time_steps, num_channels)) for channel in range(num_channels): # loop over channels ifft_this_channel = np.fft.irfft(FRF[:, channel]) IRF[:, channel] = ifft_this_channel self.IFRF = IRF.T
[docs] def build_hankel_matrix(self, num_block_columns): """Construct the shifted Hankel matrix from the impulse-response functions. Parameters ---------- num_block_columns : int Number of block columns in the Hankel matrix. The number of block rows is set to ``num_block_columns + 1``. """ IRFT = self.IFRF num_channels = self.prep_signals.num_analised_channels num_block_rows = num_block_columns + 1 self.num_block_columns = num_block_columns self.num_block_rows = num_block_rows Hankel_matrix = np.zeros( (num_channels * num_block_rows, num_block_columns), dtype=complex) for i in range(0, num_block_rows): j = i + 1 this_block = IRFT[0:num_channels, j:(num_block_columns + j)] begin_row = i * num_channels Hankel_matrix[begin_row:( begin_row + num_channels), 0:num_block_columns] = this_block self.hankel_matrix = Hankel_matrix self.state[0] = True
[docs] def compute_state_matrices(self, max_model_order=None): """Decompose the Hankel matrix and compute the observability matrix. Parameters ---------- max_model_order : int, optional Maximum model order to retain. When ``None``, the full rank of the Hankel matrix is used. """ if max_model_order is not None: if not isinstance(max_model_order, int): raise TypeError( f"max_model_order must be int, got {type(max_model_order).__name__!r}" ) if not self.state[0]: raise RuntimeError("Call build_hankel_matrix() first.") hankel_matrix = self.hankel_matrix # anil num_channels = self.prep_signals.num_analised_channels # num_block_columns = self.num_block_columns # num_block_rows = self.num_block_rows logger.info('Computing state matrices...') [U, S, _] = np.linalg.svd(hankel_matrix, 0) # anil # anil S1 = np.diag(S) S_sqrt = np.sqrt(S1) p1 = np.dot(U, S_sqrt) # p2=np.dot(S_sqrt,V_T) # A=np.dot(np.linalg.pinv(p1), hankel_matrix, np.linalg.pinv(p2)) # A=A.real C = p1[:num_channels,:] # C=C.real # p1=p1.real self.Oi = p1 # self.state_matrix = A self.output_matrix = C self.max_model_order = max_model_order self.state[1] = True self.state[2] = False # previous modal params are invalid now
def compute_modal_params(self, max_model_order=None): if max_model_order is not None: if max_model_order > self.max_model_order: raise ValueError( f"max_model_order ({max_model_order}) exceeds limit" f" self.max_model_order ({self.max_model_order})" ) self.max_model_order = max_model_order if not self.state[1]: raise RuntimeError("Call compute_state_matrices() first.") logger.info('Computing modal parameters...') max_model_order = self.max_model_order num_analised_channels = self.prep_signals.num_analised_channels num_block_rows = self.num_block_rows # state_matrix = self.state_matrix Oi = self.Oi output_matrix = self.output_matrix sampling_rate = self.prep_signals.sampling_rate modal_frequencies = np.zeros((max_model_order, max_model_order)) modal_damping = np.zeros((max_model_order, max_model_order)) eigenvalues = np.zeros( (max_model_order, max_model_order), dtype=complex) mode_shapes = np.zeros( (num_analised_channels, max_model_order, max_model_order), dtype=complex) for order in range(1, max_model_order, 1): Oi0 = Oi[:(num_analised_channels * (num_block_rows - 1)),:order] Oi1 = Oi[num_analised_channels:( num_analised_channels * num_block_rows),:order] a = np.dot(np.linalg.pinv(Oi0), Oi1) eigenvalues_paired, _, eigenvectors_paired = scipy.linalg.eig( a=a[0:order, 0:order], b=None, left=True, right=True) eigenvalues_single, eigenvectors_single = self.remove_conjugates_new( eigenvalues_paired, eigenvectors_paired) for index, k in enumerate(eigenvalues_single): lambda_k = np.log(complex(k)) * sampling_rate freq_j = np.abs(lambda_k) / (2 * np.pi) damping_j = np.real(lambda_k) / np.abs(lambda_k) * (-100) mode_shapes_j = np.dot( output_matrix[:, 0:order], eigenvectors_single[:, index]) modal_frequencies[order, index] = freq_j modal_damping[order, index] = damping_j eigenvalues[order, index] = k mode_shapes[:, index, order] = mode_shapes_j self.modal_frequencies = modal_frequencies self.modal_damping = modal_damping self.mode_shapes = mode_shapes self.eigenvalues = eigenvalues self.state[2] = True
[docs] @staticmethod def remove_conjugates_new(eigval, eigvec_r, eigvec_l=None): ''' removes conjugates eigvec_l.shape = [order+1, order+1] eigval.shape = [order+1,1] ''' # return vectors, eigval num_val = len(eigval) conj_indices = deque() for i in range(num_val): this_val = eigval[i] this_conj_val = np.conj(this_val) if this_val == this_conj_val: # remove real eigvals conj_indices.append(i) for j in range( i + 1, num_val): # catches unordered conjugates but takes slightly longer if eigval[j] == this_conj_val: # if not np.allclose(eigvec_l[j],eigvec_l[i].conj()): # print('eigval is complex conjugate but eigvec_l is not') # continue # if not np.allclose(eigvec_r[j],eigvec_r[i].conj()): # print('eigval is complex conjugate but eigvec_r is not') # continue conj_indices.append(j) break # print('indices of complex conjugate: {}'.format(conj_indices)) conj_indices = list(set(range(num_val)).difference(conj_indices)) # print('indices to keep and return: {}'.format(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] def save_state(self, fname): """Save the current computation state to a compressed NumPy archive. Parameters ---------- fname : str Destination file path (without ``.npz`` extension). """ dirname, _ = os.path.split(fname) if not os.path.isdir(dirname): os.makedirs(dirname) # 0 1 2 # self.state= [SHankelMatrix, State Mat., Modal Par.] out_dict = {'self.state': self.state} out_dict['self.setup_name'] = self.setup_name out_dict['self.start_time'] = self.start_time # out_dict['self.prep_signals']=self.prep_signals if self.state[0]: # SHankelMatrix # out_dict['self.toeplitz_matrix'] = self.toeplitz_matrix out_dict['self.hankel_matrix'] = self.hankel_matrix out_dict['self.num_block_columns'] = self.num_block_columns out_dict['self.num_block_rows'] = self.num_block_rows if self.state[1]: # state models out_dict['self.max_model_order'] = self.max_model_order out_dict['self.state_matrix'] = self.state_matrix out_dict['self.output_matrix'] = self.output_matrix if self.state[2]: # modal params out_dict['self.modal_frequencies'] = self.modal_frequencies out_dict['self.modal_damping'] = self.modal_damping out_dict['self.mode_shapes'] = self.mode_shapes out_dict['self.eigenvalues'] = self.eigenvalues np.savez_compressed(fname, **out_dict)
[docs] @classmethod def load_state(cls, fname, prep_signals): """Restore an :class:`ERA` object from a previously saved archive. 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 ------- ERA Restored object with all previously computed results. """ logger.info('Loading results from %s', fname) in_dict = np.load(fname) if 'self.state' not in in_dict: return state = list(in_dict['self.state']) for this_state, label in zip(state, [ 'Shifted Hankel Matrices Built', 'State Matrices Computed', 'Modal Parameters Computed']): if this_state: logger.info(label) cls._validate_prep_signals(prep_signals, in_dict) ssi_object = cls(prep_signals) ssi_object.state = state cls._restore_state_data(ssi_object, state, in_dict) return ssi_object
@staticmethod def _validate_prep_signals(prep_signals, in_dict): """Raise if *prep_signals* does not match the archive metadata.""" if not isinstance(prep_signals, PreProcessSignals): raise TypeError( f"prep_signals must be PreProcessSignals, got {type(prep_signals).__name__!r}" ) setup_name = str(in_dict['self.setup_name'].item()) if setup_name != prep_signals.setup_name: raise ValueError( f"setup_name mismatch: expected {setup_name!r}," f" got {prep_signals.setup_name!r}" ) start_time = prep_signals.start_time if start_time != prep_signals.start_time: raise ValueError( f"start_time mismatch: expected {start_time!r}," f" got {prep_signals.start_time!r}" ) @staticmethod def _restore_state_data(ssi_object, state, in_dict): """Populate *ssi_object* attributes from *in_dict* based on *state* flags.""" if state[0]: ssi_object.hankel_matrix = in_dict['self.hankel_matrix'] ssi_object.num_block_columns = int(in_dict['self.num_block_columns']) ssi_object.num_block_rows = int(in_dict['self.num_block_rows']) if state[1]: ssi_object.max_model_order = int(in_dict['self.max_model_order']) ssi_object.state_matrix = in_dict['self.state_matrix'] ssi_object.output_matrix = in_dict['self.output_matrix'] if state[2]: ssi_object.modal_frequencies = in_dict['self.modal_frequencies'] ssi_object.modal_damping = in_dict['self.modal_damping'] ssi_object.mode_shapes = in_dict['self.mode_shapes'] ssi_object.eigenvalues = in_dict['self.eigenvalues'] @staticmethod def rescale_mode_shape(modeshape): # scaling of mode shape modeshape = modeshape / modeshape[np.argmax(np.abs(modeshape))] return modeshape
if __name__ == '__main__': pass