Source code for pyOMA.core.SSIData

# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2025  Simon Marwitz, Volkmar Zabel, Andrei Udrea et al.
"""Data-driven SSI (SSIData, SSIDataMC) for operational modal analysis."""

import numpy as np
import scipy.linalg

import os

from .Helpers import lq_decomp, validate_array, simplePbar, ConfigFile

from .PreProcessingTools import PreProcessSignals
from .ModalBase import ModalBase

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

[docs] class SSIDataMC(ModalBase): """Reference-based Data-driven Stochastic Subspace Identification (SSI-Data/MC). Identifies modal parameters from raw time-series data by constructing a block-Hankel matrix, computing its LQ decomposition, and extracting state- space models via SVD. The standard workflow is: 1. :meth:`build_block_hankel` — build the projection matrix from raw data. 2. :meth:`compute_modal_params` — run the multi-order modal identification. 3. Pass the result to :class:`~pyOMA.core.StabilDiagram.StabilCalc` for stabilisation-diagram analysis. Parameters ---------- prep_signals : PreProcessSignals Pre-processed signal object providing raw time-series and channel metadata. .. TODO:: * define unit tests to check functionality after changes * add switch to keep synthesized time-histories """ def __init__(self, *args, **kwargs): """ Parameters ---------- *args, **kwargs Passed to :class:`~pyOMA.core.ModalBase.ModalBase`. """ super().__init__(*args, **kwargs) self.state = [False, False, False, False] self.num_block_rows = None self.num_blocks = 1 self.P_i_ref = None self.P_i_1 = None self.Y_i_i = None self.S = None self.U = None self.V_T = None self.max_model_order = None self.modal_contributions = None
[docs] @classmethod def init_from_config(cls, conf_file, prep_signals): cfg = ConfigFile(conf_file) num_block_rows = cfg.int('Number of Block-Columns') max_model_order = cfg.int('Maximum Model Order') ssi_object = cls(prep_signals) ssi_object.build_block_hankel(num_block_rows) ssi_object.compute_modal_params(max_model_order) return ssi_object
[docs] def write_config(self, conf_file): ConfigFile.write(conf_file, { 'Number of Block-Columns': self.num_block_rows, 'Maximum Model Order': self.max_model_order, })
[docs] def build_block_hankel(self, num_block_rows=None, reduced_projection=True): ''' Builds a Block-Hankel Matrix of the measured time series with varying time lags and estimates the subspace matrix from its LQ decomposition. :: <- num_time samples - num_block_rows-> _ [ y_0 y_1 ... y_(j-1) ]^ [ y_1 y_2 ... y_j ]num_block_rows (=i)*n_l [ ... ... ... ... ]v [ y_(2i-1) y_(2i) ... y_(2i+j-2) ]_ The notation mostly follows Peeters 1999. Parameters ------- num_block_rows: integer, required The number of block rows of the Subspace matrix ''' if num_block_rows is None: num_block_rows = self.num_block_rows if not isinstance(num_block_rows, int): raise TypeError(f"num_block_rows must be an int, got {type(num_block_rows)}") self.num_block_rows = num_block_rows signals = self.prep_signals.signals total_time_steps = self.prep_signals.total_time_steps ref_channels = sorted(self.prep_signals.ref_channels) n_l = self.prep_signals.num_analised_channels n_r = self.prep_signals.num_ref_channels q = num_block_rows p = num_block_rows N = int(total_time_steps - 2 * p) logger.info(f'Building Block-Hankel matrix with {p} block-columns and {q} block rows') Hankel_matrix = self._assemble_hankel_matrix(signals, ref_channels, n_l, n_r, p, q, N) logger.debug(Hankel_matrix.shape) l, q = lq_decomp(Hankel_matrix, mode='full') logger.info('Estimating subspace matrix...') L_red, Q_red, P_i_ref, P_i_1, Y_i_i, U, S, V_T = \ self._extract_subspace_matrices(l, q, Hankel_matrix, n_l, n_r, p, reduced_projection) self.L_red = L_red self.Q_red = Q_red self.P_i_1 = P_i_1 self.P_i_ref = P_i_ref self.Y_i_i = Y_i_i self.S = S self.U = U self.V_T = V_T self.state[0] = True
@staticmethod def _assemble_hankel_matrix(signals, ref_channels, n_l, n_r, p, q, N): """Fill and return the normalised block-Hankel data matrix. Parameters ---------- signals : numpy.ndarray, shape (total_time_steps, n_l) ref_channels : list of int n_l, n_r : int p, q : int Number of block rows for future/past halves (both equal to num_block_rows). N : int Number of columns (time windows). Returns ------- Hankel_matrix : numpy.ndarray, shape ((q*n_r + (p+1)*n_l), N) """ Y_minus = np.zeros((q * n_r, N)) Y_plus = np.zeros(((p + 1) * n_l, N)) for ii in range(q): Y_minus[(q - ii - 1) * n_r:(q - ii) * n_r, :] = signals[(ii):(ii + N), ref_channels].T for ii in range(p + 1): Y_plus[ii * n_l:(ii + 1) * n_l, :] = signals[(q + ii):(q + ii + N)].T Hankel_matrix = np.vstack((Y_minus, Y_plus)) Hankel_matrix /= np.sqrt(N) return Hankel_matrix @staticmethod def _extract_subspace_matrices(l, q, Hankel_matrix, n_l, n_r, p, reduced_projection): """Extract the projection / subspace matrices from the LQ factors. Returns ------- L_red, Q_red, P_i_ref, P_i_1, Y_i_i : numpy.ndarray U, S, V_T : numpy.ndarray SVD factors of either the reduced L block (reduced_projection=True) or P_i_ref (reduced_projection=False). V_T is None when reduced_projection=True. """ a = n_r * p b = n_r c = n_l - n_r d = n_l * p P_i_ref = l[a:a + b + c + d, :a] @ q[:a, :] if reduced_projection: U, S, V_T = np.linalg.svd(l[a:a + b + c + d, :a], full_matrices=False) V_T = None else: U, S, V_T = np.linalg.svd(P_i_ref, full_matrices=False) P_i_1 = l[a + b + c:a + b + c + d, :a + b] @ q[:a + b, :] # Y_i_i = l[a : a + b + c, : a + b + c] @ q[ : a + b + c, : ] Y_i_i = Hankel_matrix[a:a + b + c, :] L_red = l[a:, :a + b] Q_red = q[:a + b, :] return L_red, Q_red, P_i_ref, P_i_1, Y_i_i, U, S, V_T
[docs] def compute_modal_params(self, max_model_order=None, j=None, validation_blocks=None, synth_sig=True): ''' Perform a multi-order computation of modal parameters. Successively calls * estimate_state(order,) * modal_analysis(A,C) * synthesize_signals(A, C, Q, R, S, j) at ascending model orders, up to max_model_order. See the explanations in the the respective methods, for a detailed explanation of parameters. Parameters ---------- max_model_order: integer, optional Maximum model order, where to interrupt the algorithm. If not given, it is determined from the previously computed subspace matrix. ''' if not self.state[0]: raise RuntimeError("Call build_block_hankel() first.") if max_model_order is not None: if not isinstance(max_model_order, int): raise TypeError(f"Expected int for 'max_model_order', got {type(max_model_order).__name__!r}.") else: max_model_order = self.S.shape[0] if max_model_order > self.S.shape[0]: raise ValueError(f"max_model_order must be <= {self.S.shape[0]}, got {max_model_order}.") # num_block_rows = self.num_block_rows num_analised_channels = self.prep_signals.num_analised_channels # num_ref_channels = self.prep_signals.num_ref_channels # sampling_rate = self.prep_signals.sampling_rate if j is None and validation_blocks is None: j = self.prep_signals.total_time_steps elif validation_blocks is None: if j > self.prep_signals.signals.shape[0]: raise ValueError(f"j must be <= {self.prep_signals.signals.shape[0]}, got {j}.") logger.info('Computing modal parameters...') eigenvalues = np.zeros( (max_model_order, max_model_order), dtype=np.complex128) modal_frequencies = np.zeros((max_model_order, max_model_order)) modal_damping = np.zeros((max_model_order, max_model_order)) mode_shapes = np.zeros( (num_analised_channels, max_model_order, max_model_order), dtype=complex) if synth_sig: modal_contributions = np.zeros((max_model_order, max_model_order)) else: modal_contributions = None pbar = simplePbar(max_model_order - 1) for order in range(1, max_model_order): next(pbar) A, C, Q, R, S = self.estimate_state(order) f, d, phi, lamda, = self.modal_analysis(A, C) modal_frequencies[order,:order] = f modal_damping[order,:order] = d mode_shapes[:phi.shape[0],:order, order] = phi eigenvalues[order,:order] = lamda if synth_sig: _, delta = self.synthesize_signals(A, C, Q, R, S, j=j, validation_blocks=validation_blocks) modal_contributions[order,:order] = delta self.max_model_order = max_model_order self.modal_contributions = modal_contributions self.modal_frequencies = modal_frequencies self.modal_damping = modal_damping self.mode_shapes = mode_shapes self.eigenvalues = eigenvalues self.state[2] = True
[docs] def estimate_state(self, order,): ''' Estimate the state matrices A, C and noise covariances Q, R and S from the subspace / projection matrix. Several methods exist, e.g. * Peeters 1999 Reference Based Stochastic Subspace Identification for OMA * DeCock 2007 Subspace Identification Methods * the algorithm used in BRSSICovRef. Here, the first algorithm, a residual-based computation of Q, R and S, is implemented. Parameters ---------- order: integer, required The model order, at which to truncate the singular values of the projection Matrix P_i_ref Returns ------- A: numpy.ndarray State matrix: Array of shape (order, order) C: numpy.ndarray Output matrix: Array of shape (num_analised_channels, order) Q: numpy.ndarray state noise covariance matrix: Symmetric array of shape (order, order) R: numpy.ndarray signal noise covariance matrix: Array of shape (num_analised_channels, num_analised_channels) S: numpy.ndarray system noise - signal noise covariance matrix: Array of shape (order, num_analised_channels) ''' num_block_rows = self.num_block_rows num_analised_channels = self.prep_signals.num_analised_channels # num_ref_channels = self.prep_signals.num_ref_channels U = self.U[:,:order] S = self.S[:order] P_i_1 = self.P_i_1 # n_l * p x n_r * (p + 1) x N Y_i_i = self.Y_i_i # n_l x n_r *p + n_l x N # compute state-space model S_2 = np.power(S, 0.5) O = U * S_2[np.newaxis,:] O_i_1 = O[:num_analised_channels * num_block_rows,:order] O_i = O[:,:order] if self.V_T is not None: V_T = self.V_T[:order,:] X_i = S_2[:, np.newaxis] * V_T else: P_i_ref = self.P_i_ref # (p + 1) * n_l x n_r * p x N X_i = np.linalg.pinv(O_i) @ P_i_ref X_i_1 = np.linalg.pinv(O_i_1) @ P_i_1 X_i_1_Y_i = np.vstack((X_i_1, Y_i_i)) AC = X_i_1_Y_i @ np.linalg.pinv(X_i) A = AC[:order,:] C = AC[order:,:] roh_w_v = X_i_1_Y_i - AC @ X_i QSR = roh_w_v @ roh_w_v.T Q = QSR[:order,:order] S = QSR[:order, order:order + num_analised_channels] R = QSR[order:order + num_analised_channels, order:order + num_analised_channels] return A, C, Q, R, S
[docs] def modal_analysis(self, A, C, rescale_fun=None): ''' Computes the modal parameters from a given state space model as described by Peeters 1999 and Döhler 2012. Mode shapes are scaled to unit modal displacements. Complex conjugate and real modes are removed prior to further processing. Typically, order // 2 modes are in the returned arrays. Parameters ---------- A: numpy.ndarray State matrix: Array of shape (order, order) C: numpy.ndarray Output matrix: Array of shape (num_analised_channels, order) Returns ------- modal_frequencies: numpy.ndarray, shape (order,) Array holding the modal frequencies for each mode modal_damping: numpy.ndarray, shape (order,) Array holding the modal damping ratios (0,100) for each mode mode_shapes: numpy.ndarray, shape (n_l, order,) Complex array holding the mode shapes eigenvalues: numpy.ndarray, shape (order,) Complex array holding the eigenvalues for each mode ''' # collect variables accel_channels = self.prep_signals.accel_channels velo_channels = self.prep_signals.velo_channels sampling_rate = self.prep_signals.sampling_rate n_l = self.num_analised_channels order = A.shape[0] if order != A.shape[1]: raise RuntimeError(f"Internal error: A must be square, got shape {A.shape}.") # allocate output arrays modal_frequencies = np.full((order), np.nan) modal_damping = np.full((order), np.nan) mode_shapes = np.full((n_l, order), np.nan, dtype=complex) eigenvalues = np.full((order), np.nan, dtype=complex) # compute modal model eigvals, eigvecs_r = np.linalg.eig(A) Phi = C.dot(eigvecs_r) conj_indices = self.remove_conjugates(eigvals, eigvecs_r, inds_only=True) for i, ind in enumerate(conj_indices): lambda_i = eigvals[ind] mode_shape_i = Phi[:, ind] a_i = np.abs(np.arctan2(np.imag(lambda_i), np.real(lambda_i))) b_i = np.log(np.abs(lambda_i)) freq_i = np.sqrt(a_i ** 2 + b_i ** 2) * sampling_rate / 2 / np.pi damping_i = 100 * np.abs(b_i) / np.sqrt(a_i ** 2 + b_i ** 2) if rescale_fun is not None: mode_shape_i = rescale_fun(mode_shape_i) # scale modeshapes to modal displacements mode_shape_i = self.integrate_quantities( mode_shape_i, accel_channels, velo_channels, freq_i * 2 * np.pi) # rotate mode shape in complex plane mode_shape_i = self.rescale_mode_shape(mode_shape_i) modal_frequencies[i] = freq_i modal_damping[i] = damping_i mode_shapes[:mode_shape_i.shape[0], i] = mode_shape_i eigenvalues[i] = lambda_i argsort = np.argsort(modal_frequencies) return modal_frequencies[argsort], modal_damping[argsort], mode_shapes[:, argsort], eigenvalues[argsort],
@staticmethod def _solve_kalman_gain(CPCR, APCS): """Solve for the steady-state Kalman gain from the DARE solution. Raises a more informative LinAlgError than the bare numpy one when the innovation covariance CPCR is singular: this typically happens with noise-free/synthetic data, where the estimated noise covariances (Q, R, S) are degenerate; real measurement data with genuine ambient/process noise usually avoids it. """ try: return np.linalg.solve(CPCR.T, APCS.T).T except np.linalg.LinAlgError as exc: raise np.linalg.LinAlgError( f"{exc}: singular innovation covariance while solving for " "the Kalman gain. This typically happens with noise-free/" "synthetic data, where the estimated noise covariances " "(Q, R, S) are degenerate; real measurement data with " "genuine ambient/process noise usually avoids it." ) from exc
[docs] def synthesize_signals(self, A, C, Q, R, S, j=None, **kwargs): ''' Computes the modal response signals and the contribution of each mode. The algorithm follows Peeters 1999 and the Lyapunov equation is solved as a discrete-time algebraic Riccati equation (DARE). For long signals, the computation may become time-consuming, thus only time steps up to j may be used to synthesize the signal. Parameters ---------- A: numpy.ndarray State matrix: Array of shape (order, order) C: numpy.ndarray Output matrix: Array of shape (num_analised_channels, order) Q: numpy.ndarray state noise covariance matrix: Symmetric array of shape (order, order) R: numpy.ndarray signal noise covariance matrix: Array of shape (num_analised_channels, num_analised_channels) S: numpy.ndarray system noise - signal noise covariance matrix: Array of shape (order, num_analised_channels) j: integer, optional length of signal to synthesize (number of timesteps) Returns ------- sig_synth: numpy.ndarray, shape (num_analised_channels, j, order // 2) Array holding the modally decomposed input signals for each channel n_l and all modes modal_contributions: numpy.ndarray, shape (order, ) Array holding the contributions of each mode to the input signals. ''' order = A.shape[0] if order != A.shape[1]: raise RuntimeError(f"Internal error: A must be square, got shape {A.shape}.") if j is None: j = self.prep_signals.total_time_steps signals = self.prep_signals.signals[:j,:] n_l = self.prep_signals.num_analised_channels modal_contributions = np.zeros((order)) sig_synth = np.zeros((n_l, j, order // 2)) try: P = scipy.linalg.solve_discrete_are( a=A.T, b=C.T, q=Q, r=R, s=S, balanced=True) except Exception: logger.warning('Correlations of residuals are not symmetric. Skiping Modal Contributions') return sig_synth, modal_contributions APCS = A @ P @ C.T + S CPCR = C @ P @ C.T + R K = self._solve_kalman_gain(CPCR, APCS) eigvals, eigvecs_r = np.linalg.eig(A) conj_indices = self.remove_conjugates(eigvals, eigvecs_r, inds_only=True) A_0 = np.diag(eigvals) C_0 = C @ eigvecs_r K_0 = np.linalg.solve(eigvecs_r, K) states = np.zeros((order, j), dtype=complex) AKC = A_0 - K_0 @ C_0 K_0m = K_0 @ signals.T for k in range(j - 1): states[:, k + 1] = K_0m[:, k] + AKC @ states[:, k] Y = signals.T # norm = 1 / np.einsum('ji,ji->j', Y, Y) Sigma_data = np.einsum('ji,ji->j', Y, Y) Sigma_data_synth = np.zeros((n_l, order)) for i, ind in enumerate(conj_indices): lambda_i = eigvals[ind] ident = eigvals == lambda_i.conj() ident[ind] = 1 C_0I = C_0[:, ident] this_sig_synth = C_0I @ states[ident,:] if not np.all(np.isclose(this_sig_synth.imag, 0)): logger.warning(f'Synthetized signals are complex at mode index {order}:{ind}.') sig_synth[:,:, i] = this_sig_synth.real mYT = np.einsum('ji,ji->j', sig_synth[:,:, i], Y) Sigma_data_synth[:, i] = mYT # modal_contributions[i] = np.mean(norm * mYT) total_sig_synth = np.sum(sig_synth, axis=-1) # shape n_l, N Sigma_synth = np.einsum('ji,ji->j', total_sig_synth, total_sig_synth) modal_contributions[:] = np.mean(Sigma_data_synth / np.sqrt(Sigma_data * Sigma_synth)[:, np.newaxis], axis=0) self._sig_synth = sig_synth self._modal_conributions = modal_contributions return sig_synth, modal_contributions
[docs] 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.state': self.state} out_dict['self.setup_name'] = self.setup_name out_dict['self.start_time'] = self.start_time if self.state[0]: # subspace matrix out_dict['self.num_block_rows'] = self.num_block_rows out_dict['self.num_blocks'] = self.num_blocks # out_dict['self.P_i_1'] = self.P_i_1 # out_dict['self.P_i_ref'] = self.P_i_ref out_dict['self.L_red'] = self.L_red out_dict['self.Q_red'] = self.Q_red out_dict['self.Y_i_i'] = self.Y_i_i out_dict['self.S'] = self.S out_dict['self.U'] = self.U out_dict['self.V_T'] = self.V_T 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.eigenvalues'] = self.eigenvalues out_dict['self.mode_shapes'] = self.mode_shapes out_dict['self.modal_contributions'] = self.modal_contributions out_dict['self.max_model_order'] = self.max_model_order np.savez_compressed(fname, **out_dict)
[docs] @classmethod def load_state(cls, fname, prep_signals): logger.info('Loading results from {}'.format(fname)) in_dict = np.load(fname, allow_pickle=True) if 'self.state' in in_dict: # bool(...): entries loaded straight out of the .npz archive are # numpy.bool_, not plain Python bool. state = [bool(s) for s in in_dict['self.state']] else: return if not isinstance(prep_signals, PreProcessSignals): raise TypeError(f"Expected PreProcessSignals for 'prep_signals', 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' must be one of {[prep_signals.setup_name]}, got {setup_name!r}.") start_time = prep_signals.start_time if start_time != prep_signals.start_time: raise ValueError(f"'start_time' must match prep_signals.start_time {prep_signals.start_time!r}, got {start_time!r}.") # prep_signals = in_dict['self.prep_signals'].item() ssi_object = cls(prep_signals) if state[0]: # subspace matrix ssi_object.num_block_rows = validate_array(in_dict['self.num_block_rows']) ssi_object.num_blocks = validate_array(in_dict['self.num_blocks']) ssi_object.L_red = validate_array(in_dict['self.L_red']) ssi_object.Q_red = validate_array(in_dict['self.Q_red']) # rebuild projections from reduced L and Q matrices # saves ~ 95 % storage (Xe2 MB) compared to storing projections directly a = prep_signals.num_ref_channels * ssi_object.num_block_rows b = prep_signals.num_ref_channels c = prep_signals.num_analised_channels - prep_signals.num_ref_channels d = prep_signals.num_analised_channels * ssi_object.num_block_rows ssi_object.P_i_ref = ssi_object.L_red[:b + c + d,: a] @ ssi_object.Q_red[:a,:] ssi_object.P_i_1 = ssi_object.L_red[b + c:b + c + d,: ] @ ssi_object.Q_red[:,: ] ssi_object.Y_i_i = validate_array(in_dict['self.Y_i_i']) ssi_object.S = validate_array(in_dict['self.S']) ssi_object.U = validate_array(in_dict['self.U']) ssi_object.V_T = validate_array(in_dict['self.V_T']) ssi_object.S = validate_array(in_dict['self.S']) ssi_object.U = validate_array(in_dict['self.U']) ssi_object.V_T = validate_array(in_dict['self.V_T']) if state[2]: # modal params ssi_object.modal_frequencies = validate_array(in_dict['self.modal_frequencies']) ssi_object.modal_damping = validate_array(in_dict['self.modal_damping']) ssi_object.eigenvalues = validate_array(in_dict['self.eigenvalues']) ssi_object.mode_shapes = validate_array(in_dict['self.mode_shapes']) ssi_object.modal_contributions = validate_array(in_dict['self.modal_contributions']) ssi_object.max_model_order = validate_array(in_dict['self.max_model_order']) ssi_object.state = state return ssi_object
[docs] class SSIData(SSIDataMC): """Data-driven SSI without correlation synthesis (non-Monte-Carlo variant). Identical workflow to :class:`SSIDataMC` but skips the synthesis step (``synth_sig=False``), making it faster when variance estimation is not required. """
[docs] def compute_modal_params(self, max_model_order): # pylint: disable=arguments-differ ''' Perform a multi-order computation of modal parameters. Successively calls * estimate_state(order,) * modal_analysis(A,C) at ascending model orders, up to max_model_order. See the explanations in the the respective methods, for a detailed explanation of parameters. Parameters ---------- max_model_order: integer, optional Maximum model order, where to interrupt the algorithm. If not given, it is determined from the previously computed subspace matrix. ''' super().compute_modal_params(max_model_order, synth_sig=False)
[docs] def estimate_state(self, order, max_modes=None, algo='svd'): ''' Compute the state matrix A and output matrix C from the singular values and vectors of the projection matrix, truncated at the requested order. Estimation of the state matrix can be performed by QR decomposition or Singular Value decomposition of the shifted observability matrix. If max_modes is specified, the singular value decomposition is truncated additionally, also known as Crystal Clear SSI. Parameters ---------- order: integer, required Model order, at which the state matrices should be estimated max_modes: integer, optional Maximum number of modes, that are known to be present in the signal, to suppress noise modes algo: str, optional Algorithm to use for estimation of A. Either 'svd' or 'qr'. Returns ------- A: numpy.ndarray State matrix: Array of shape (order, order) C: numpy.ndarray Output matrix: Array of shape (num_analised_channels, order) ''' if order > self.S.shape[0]: raise RuntimeError(f'Order cannot be higher than {self.S.shape[0]}. Consider using more block_rows/block_columns.') if algo not in ['svd', 'qr']: raise ValueError(f"'algo' must be one of {['svd', 'qr']}, got {algo!r}.") n_l = self.num_analised_channels num_block_rows = self.num_block_rows U = self.U[:,:order] S = self.S[:order] # compute state-space model S_2 = np.power(S, 0.5) O = U * S_2[np.newaxis,:] On_up = O[:n_l * num_block_rows,:order] On_down = O[n_l:n_l * (num_block_rows + 1),:order] if algo == 'svd': if max_modes is not None: [u, s, v_t] = np.linalg.svd(On_up, 0) s = 1. / s[:max_modes] # On_up_i = np.dot(np.transpose(v_t[:max_modes, :]), np.multiply( # s[:, np.newaxis], np.transpose(u[:, :max_modes]))) On_up_i = v_t[:max_modes,:].T @ (s[:, np.newaxis] * u[:,:max_modes].T) else: On_up_i = np.linalg.pinv(On_up) # , rcond=1e-12) A = On_up_i @ On_down elif algo == 'qr': Q, R = np.linalg.qr(On_up) S = Q.T.dot(On_down) A = np.linalg.solve(R, S) C = O[:n_l,:order] # output matrix return A, C, None, None, None
[docs] class SSIDataCV(SSIDataMC): @staticmethod def _coerce_blocks_array(blocks, num_blocks, name): """Validate and coerce a blocks argument to a numpy array.""" if blocks is None: return np.arange(num_blocks) if isinstance(blocks, (list, tuple)): blocks = np.array(blocks) elif not isinstance(blocks, np.ndarray): raise RuntimeError(f"Argument {name!r} must be an iterable but is type {type(blocks)}") if blocks.max() >= num_blocks: raise ValueError(f"{name}.max() must be < {num_blocks}, got {blocks.max()}.") return blocks def _compute_lq_factors(self, hankel_matrices, training_blocks, K, K2, N_b, N, n_r, n_l, q, p, pbar): """Compute per-block LQ decompositions and assemble the combined unique Q matrix.""" n_training_blocks = training_blocks.shape[0] R_matrices = np.empty((n_r * q + n_l * (p + 1), K * n_training_blocks)) Q_matrices = [] Q_unique_matrices = np.empty((K2, N)) for i in range(n_training_blocks): i_block = training_blocks[i] next(pbar) L, Q = lq_decomp(hankel_matrices[i_block], mode='reduced', unique=True) R_matrices[:, i * K:(i + 1) * K] = L Q_matrices.append(Q) logger.debug(f'R shapes: actual: {R_matrices.shape} expected: {(n_r * p + n_l * (p + 1), K * n_training_blocks)}') R_full_breve, Q_full_breve = lq_decomp(R_matrices, mode='reduced', unique=True) _ = [next(pbar) for _ in range(n_training_blocks)] del R_matrices logger.debug(f'Q_breve shapes: actual: {Q_full_breve.shape} expected: ,{(K2, K * n_training_blocks)}') Q_breve_matrices = np.hsplit(Q_full_breve, np.arange(K, n_training_blocks * K, K)) logger.debug(f'Q_breve_j shapes: actual: {Q_breve_matrices[0].shape}, expected: {(K2, K)}') del Q_full_breve for i in range(n_training_blocks): next(pbar) Q_unique_matrices[:, i * N_b:(i + 1) * N_b] = Q_breve_matrices[i] @ Q_matrices[i] return R_full_breve, Q_unique_matrices
[docs] def build_block_hankel(self, num_block_rows=None, num_blocks=1, training_blocks=None, reduced_projection=True): ''' Builds serveral Block-Hankel Matrices of the measured time series with varying time lags and estimates the subspace matrices from their LQ decompositions. Uniqueness of the subspace estimates is ensured by an intermediate LQ decomposition, where the diagonals of the L matrices are constrained to positive values. A subspace matrix estimate is computed by the mean over the training blocks leaving any remainig blocks for validation. Note: Blocks are not completely i.i.d. as we borrow p+q timesteps from the previous block for the projection of a full block (assembly of Hankel matrix) .. TODO:: * investigate correct scaling of the subspace matrices [sqrt(N_b), sqrt(N_b * num_blocks), sqrt(N_b*n_training_blocks)] ? * use sparse SVD (scipy.sparse.svds) to save memory and cpu time Parameters ------- num_block_rows: integer, required The number of block rows of the Subspace matrix num_blocks: integer, optional The number of blocks, used for cross-validation training_blocks: list, optional The selected blocks to use for system identification (=training) ''' if num_block_rows is None: num_block_rows = self.num_block_rows if not isinstance(num_block_rows, int): raise TypeError(f"Expected int for 'num_block_rows', got {type(num_block_rows).__name__!r}.") if not isinstance(num_blocks, int): raise TypeError(f"Expected int for 'num_blocks', got {type(num_blocks).__name__!r}.") training_blocks = self._coerce_blocks_array(training_blocks, num_blocks, 'training_blocks') n_training_blocks = training_blocks.shape[0] self.num_block_rows = num_block_rows self.num_blocks = num_blocks signals = self.prep_signals.signals total_time_steps = self.prep_signals.total_time_steps ref_channels = sorted(self.prep_signals.ref_channels) n_l = self.prep_signals.num_analised_channels n_r = self.prep_signals.num_ref_channels q = num_block_rows p = num_block_rows logger.info(f'Building Block-Hankel matrix from {n_training_blocks} out of {num_blocks} signal blocks with {p} block-columns and {q} block rows.') N_b = int(np.floor((total_time_steps - q - p) / num_blocks)) if N_b < n_r * q: raise RuntimeError(f'Block-length ({N_b}) must not be smaller than the number of reference channels * number of block rows (={n_r * q}).') # might omit some timesteps in favor of equally sized blocks N = N_b * num_blocks # shorten signals by omitted samples to have them available for Kalman-Filter startup later N_offset = total_time_steps - q - p - N signals = signals[N_offset:,:] K = min((q * n_r) + (p + 1) * n_l, N_b) K2 = min(((q * n_r) + (p + 1) * n_l, K * n_training_blocks)) Y_minus = np.zeros((q * n_r, N)) Y_plus = np.zeros(((p + 1) * n_l, N)) for ii in range(q): Y_minus[(q - ii - 1) * n_r:(q - ii) * n_r,:] = signals[ii:ii + N, ref_channels].T for ii in range(p + 1): Y_plus[ii * n_l:(ii + 1) * n_l,:] = signals[q + ii:q + ii + N,:].T Hankel_matrix = np.vstack((Y_minus, Y_plus)) Hankel_matrix /= np.sqrt(N) logger.debug(Hankel_matrix.shape) hankel_matrices = np.hsplit(Hankel_matrix, np.arange(N_b, N_b * num_blocks, N_b)) np.hstack([hankel_matrices[i_block] for i_block in training_blocks]) pbar = simplePbar(n_training_blocks * 3) L, Q = self._compute_lq_factors(hankel_matrices, training_blocks, K, K2, N_b, N, n_r, n_l, q, p, pbar) logger.info('Estimating subspace matrix...') a = n_r * q b = n_r c = n_l - n_r d = n_l * p P_i_ref = L[a:a + b + c + d,: a] @ Q[:a,:] # (p + 1) * n_l x n_r * q x N if reduced_projection: [U, S, V_T] = np.linalg.svd(L[a:a + b + c + d,: a], full_matrices=False) V_T = None else: [U, S, V_T] = np.linalg.svd(P_i_ref, full_matrices=False) P_i_1 = L[a + b + c:a + b + c + d,: a + b] @ Q[: a + b,: ] # n_l * p x n_r * (q + 1) x N Y_i_i = L[a: a + b + c,: a + b + c] @ Q[: a + b + c,: ] # n_l x n_r *q + n_l x N self.L_red = L[a:,:a + b] # (p + 1) n_l x n_r * (q + 1) self.Q_red = Q[:a + b,:] # n_r * (q + 1) x N self.P_i_1 = P_i_1 self.P_i_ref = P_i_ref self.Y_i_i = Y_i_i self.S = S self.U = U self.V_T = V_T # self.max_model_order = self.S.shape[0] self.state[0] = True
def _run_kalman_block(self, i, validation_blocks, block_starts, signals, N_b, N_0_offset, N_offset, order, n_l, AKC, K_0, start_states): """Run the Kalman filter for a single validation block and return states and measurements.""" i_block = validation_blocks[i] start_state = start_states[i] if i_block == 0: _N_offset = N_0_offset elif start_state is not None: _N_offset = 1 else: _N_offset = N_offset states = np.zeros((order, N_b + _N_offset), dtype=complex) block_start = block_starts[i] - _N_offset block_end = block_starts[i] + N_b signals_block = signals[block_start:block_end,:] K_0m = K_0 @ signals_block.T if start_state is not None: states[:, 0] = start_state for k in range(N_b + _N_offset - 1): states[:, k + 1] = K_0m[:, k] + AKC @ states[:, k] start_states[i + 1] = states[:, -1] states = states[:, _N_offset:] Y = signals_block[_N_offset:,:].T return states, Y def _decompose_modes(self, states, Y, sig_synth, eigvals, C_0, conj_indices, order, n_l): """Compute per-mode synthesized signals and return Sigma_data_synth.""" Sigma_data = np.einsum('ji,ji->j', Y, Y) Sigma_data_synth = np.zeros((n_l, order)) for i, ind in enumerate(conj_indices): lambda_i = eigvals[ind] ident = eigvals == lambda_i.conj() ident[ind] = 1 C_0I = C_0[:, ident] this_sig_synth = C_0I @ states[ident,:] if not np.all(np.isclose(this_sig_synth.imag, 0)): logger.warning(f'Synthetized signals are complex at mode index {order}:{ind}.') sig_synth[:,:, i] = this_sig_synth.real Sigma_data_synth[:, i] = np.einsum('ji,ji->j', sig_synth[:,:, i], Y) total_sig_synth = np.sum(sig_synth, axis=-1) Sigma_synth = np.einsum('ji,ji->j', total_sig_synth, total_sig_synth) contrib = np.mean(Sigma_data_synth / np.sqrt(Sigma_data * Sigma_synth)[:, np.newaxis], axis=0) return sig_synth, contrib
[docs] def synthesize_signals(self, A, C, Q, R, S, validation_blocks=None, N_offset=None, **kwargs): ''' Computes the modal response signals and the contribution of each mode. The algorithm follows Peeters 1999 and the Lyapunov equation is solved as a discrete-time algebraic Riccati equation (DARE). For long signals, the computation may become time-consuming, thus only time steps up to j may be used to synthesize the signal. Parameters ---------- A: numpy.ndarray State matrix: Array of shape (order, order) C: numpy.ndarray Output matrix: Array of shape (num_analised_channels, order) Q: numpy.ndarray state noise covariance matrix: Symmetric array of shape (order, order) R: numpy.ndarray signal noise covariance matrix: Array of shape (num_analised_channels, num_analised_channels) S: numpy.ndarray system noise - signal noise covariance matrix: Array of shape (order, num_analised_channels) validation_blocks: list, optional The selected blocks to be synthethized and used for system validation. N_offset: integer, optional The number of samples to be used from any previous block for Kalman-Filter startup. Returns ------- sig_synth: numpy.ndarray, shape (num_analised_channels, j, order // 2) Array holding the modally decomposed input signals for each channel n_l and all modes modal_contributions: numpy.ndarray, shape (order, ) Array holding the contributions of each mode to the input signals. ''' order = A.shape[0] if order != A.shape[1]: raise RuntimeError(f"Internal error: A must be square, got shape {A.shape}.") num_blocks = self.num_blocks validation_blocks = self._coerce_blocks_array(validation_blocks, num_blocks, 'validation_blocks') n_validation_blocks = validation_blocks.shape[0] n_l = self.prep_signals.num_analised_channels signals = self.prep_signals.signals total_time_steps = self.prep_signals.total_time_steps q = self.num_block_rows p = self.num_block_rows N_b = int(np.floor((total_time_steps - q - p) / num_blocks)) # might omit some timesteps in favor of equally sized blocks N = N_b * num_blocks # blocks start at N_0_offset + p + q # (in training we virtually borrow p + q timesteps from the previous block) N_0_offset = total_time_steps - N if N_offset is None: N_offset = N_b // 5 if 0 in validation_blocks and N_0_offset < N_offset: logger.info(f"Block '0' is in the validation dataset, but only has {N_0_offset} startup-samples (recommended/chosen: {N_offset}) from any previous block for the Kalman Filter. Expect a degraded performance.") modal_contributions = np.zeros((order)) all_sig_synth = [] try: P = scipy.linalg.solve_discrete_are( a=A.T, b=C.T, q=Q, r=R, s=S, balanced=True) except Exception: logger.warning('Correlations of residuals are not symmetric. Skiping Modal Contributions') return all_sig_synth, modal_contributions APCS = A @ P @ C.T + S CPCR = C @ P @ C.T + R K = self._solve_kalman_gain(CPCR, APCS) eigvals, eigvecs_r = np.linalg.eig(A) conj_indices = self.remove_conjugates(eigvals, eigvecs_r, inds_only=True) C_0 = C @ eigvecs_r K_0 = np.linalg.solve(eigvecs_r, K) AKC = np.diag(eigvals) - K_0 @ C_0 block_starts = validation_blocks * N_b + N_0_offset start_states = [None for _ in range(num_blocks + 1)] for i in np.argsort(validation_blocks): sig_synth = np.zeros((n_l, N_b, order // 2)) states, Y = self._run_kalman_block( i, validation_blocks, block_starts, signals, N_b, N_0_offset, N_offset, order, n_l, AKC, K_0, start_states) sig_synth, contrib = self._decompose_modes( states, Y, sig_synth, eigvals, C_0, conj_indices, order, n_l) modal_contributions += contrib all_sig_synth.append(sig_synth) modal_contributions /= n_validation_blocks self._sig_synth = all_sig_synth self._modal_contributions = modal_contributions return all_sig_synth, modal_contributions
def plot_sig_synth(modal_data, modelist=None, channel_inds=None, ref_channel_inds=None, axes=None, i_block=None): import matplotlib.pyplot as plt prep_signals = modal_data.prep_signals sig_synth = modal_data._sig_synth if isinstance(sig_synth, list): # multi-block for cross-validation SSIDataCV sig_synth = sig_synth[i_block] num_blocks = modal_data.num_blocks total_time_steps = prep_signals.total_time_steps q = modal_data.num_block_rows p = modal_data.num_block_rows N_b = int(np.floor((total_time_steps - q - p) / num_blocks)) N = N_b * num_blocks N_0_offset = total_time_steps - q - p - N # N_offset = N_b // 15 block_starts = i_block * N_b + N_0_offset + p + q t = prep_signals.t[block_starts:block_starts + N_b] signals = prep_signals.signals.T[:, block_starts:block_starts + N_b] else: t = prep_signals.t signals = prep_signals.signals.T # ref_channels = modal_data.prep_signals.ref_channels sampling_rate = modal_data.prep_signals.sampling_rate channel_headers = modal_data.prep_signals.channel_headers modal_contributions = modal_data._modal_contributions if channel_inds is None: channel_inds = np.arange(modal_data.prep_signals.num_analised_channels) num_channels = len(channel_inds) if ref_channel_inds is None: ref_channel_inds = np.arange(modal_data.prep_signals.num_ref_channels) # num_ref_channels = len(ref_channel_inds) # Plot signals for each mode and each channel num_modes = sig_synth.shape[-1] if modelist is None: modelist = list(range(num_modes)) num_plots = len(modelist) + 2 fig1, axes = plt.subplots(num_plots, 1, sharex='col', sharey='col', squeeze=False) for ip, i in enumerate(modelist): rho = modal_contributions[i] this_signals = sig_synth[:,:, i] for j in range(num_channels): i_l = channel_inds[j] color = str(np.linspace(0, 1, len(channel_inds) + 2)[j + 1]) ls = 'solid' axes[ip, 0].plot(t, this_signals[i_l,:], color=color, ls=ls) axes[ip, 0].set_ylabel(f'$\\delta_{{{i + 1}}}$={rho:1.2f}', rotation=0, labelpad=40, va='center', ha='left') for j in range(num_channels): i_l = channel_inds[j] color = str(np.linspace(0, 1, len(channel_inds) + 2)[j + 1]) ls = 'solid' this_signals = signals[i_l,:] this_signals_synth = np.sum(sig_synth, axis=2)[i_l,:] axes[-1, 0].plot(t, this_signals, color=color, ls=ls, label=f'{channel_headers[i_l]}') axes[-2, 0].plot(t, this_signals_synth, color=color, ls=ls,) axes[-1, 0].set_ylabel('Measured', rotation=0, labelpad=50, va='center', ha='left') axes[-2, 0].set_ylabel(f'$\\sum\\delta$={np.sum(modal_contributions):1.2f}', rotation=0, labelpad=50, va='center', ha='left') axes[-1, 0].set_xlabel('$t$ [\\si{\\second}]') for ax in axes.flat: ax.set_yticks([]) fig1.legend(title='Channels') # Plot power spectral density functions for each channel and all modes fig2, axes = plt.subplots(num_channels, 1, sharex='col', sharey='col', squeeze=False) ft_freq = np.fft.rfftfreq(len(t), d=(1 / sampling_rate)) for j in range(num_channels): i_l = channel_inds[j] this_signals = signals[i_l,:] this_signals_synth = sig_synth[i_l,:,:] ft_meas = np.fft.rfft(this_signals * np.hanning(len(t))) if j == 0: label = f'Inp.' else: label = None axes[j, 0].plot(ft_freq, 10 * np.log10(np.abs(ft_meas)), ls='solid', color='k', label=label) for ip, i in enumerate(modelist): ft_synth = np.fft.rfft(this_signals_synth[:, i] * np.hanning(len(t))) color = str(np.linspace(0, 1, len(modelist) + 2)[ip + 1]) ls = ['-', '--', ':', '-.'][i % 4] if j == 0: label = f'm={i+1}' else: label = None axes[j, 0].plot(ft_freq, 10 * np.log10(np.abs(ft_synth)), color=color, ls=ls, label=label) axes[j, 0].set_ylabel(f'{channel_headers[i_l]}', rotation=0, labelpad=20, va='center', ha='center') axes[-1, 0].set_xlabel('$f$ [\\si{\\hertz}]') for ax in axes.flat: ax.set_yticks([]) ax.set_xlim(0, 1 / 2 * sampling_rate) fig2.legend(title='Mode') return fig1, fig2 def main(): pass if __name__ == '__main__': main()