# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2025 Simon Marwitz, Volkmar Zabel, Andrei Udrea et al.
"""Variance estimation for the pLSCF method.
Propagates the uncertainty of the positive half-spectra onto the identified modal
parameters by the delta method, following Steffensen, Döhler, Tcherniak & Thomsen
(MSSP 223, 2025), "Variance estimation of modal parameters from the poly-reference
least-squares complex frequency-domain algorithm".
The paper enters the propagation with the covariance of measured FRFs, obtained
from the H1 estimator and the multiple coherence (their Eq. 26-27). pyOMA is
output-only, so no input spectrum and hence no coherence exists, and that entry
point has no counterpart here. Instead the half-spectra are re-estimated block-wise
from the block correlation functions that :meth:`~pyOMA.core.PreProcessingTools.PreProcessSignals.corr_blackman_tukey`
already provides, and their sample covariance over blocks is used. This is the one
deliberate deviation from the paper's derivation; all Jacobians downstream are the
paper's.
The covariance is carried as a *factor* rather than a matrix: the block deviations
are scaled such that ``cov(h) = B B^T`` for the re/im-stacked slice ``B`` of
:attr:`VarPLSCF.spec_block_factor`, at the scale of the covariance *of the estimate*
(the role the paper's ``1/N_avg`` plays). Every downstream perturbation is then
evaluated per block and variances follow as ``einsum('ij,ij->i', U, U)``, mirroring
:mod:`~pyOMA.core.VarSSIRef`. A consequence worth noting: this retains the
correlations across frequency lines and output channels that the paper's Eq. 40
discards for tractability -- with an empirical covariance, imposing that
independence would be extra work rather than less.
"""
import dataclasses
import numpy as np
import logging
logger = logging.getLogger(__name__)
logger.setLevel(level=logging.INFO)
from .PLSCF import PLSCF
from .Helpers import simplePbar, validate_array, ConfigFile
[docs]
@dataclasses.dataclass
class Stage1Scores:
"""First-order perturbations of the Stage-1 quantities, one column per block.
Every array carries the block index as its last axis: column *j* is the
estimator's linearisation applied to the deviation of block *j*, scaled such
that contracting the array with itself over that axis yields a variance.
Attributes
----------
U_theta : np.ndarray
Free denominator coefficients, shape ``(order * n_r, n_r, n_b)``, real.
d_lambda : np.ndarray
Continuous-time eigenvalues, shape ``(n_modes, n_b)``, complex.
d_frequency, d_damping : np.ndarray
Modal frequencies and damping ratios, shape ``(n_modes, n_b)``, real.
d_participation : np.ndarray
Normalised participation vectors, shape ``(n_r, n_modes, n_b)``, complex.
Modes are ordered as :meth:`~pyOMA.core.PLSCF.PLSCF.modal_analysis_residuals`
returns them.
"""
U_theta: np.ndarray
d_lambda: np.ndarray
d_frequency: np.ndarray
d_damping: np.ndarray
d_participation: np.ndarray
[docs]
class VarPLSCF(PLSCF):
"""pLSCF identification with uncertainty quantification.
Extends :class:`~pyOMA.core.PLSCF.PLSCF` with standard deviations of the
identified modal parameters. The workflow matches the base class, except that
:meth:`build_half_spectra` must be given *num_blocks*:
1. :meth:`build_half_spectra` — half-spectra plus their block-covariance factor.
2. :meth:`compute_modal_params` — modal parameters and their standard deviations.
Attributes
----------
spec_block_factor : np.ndarray or None
Covariance factor of the positive half-spectra, complex, shape
``(n_l, n_r, num_omega, n_b)`` with the block index last. Column *j* holds
the deviation of block *j* from the block mean, scaled by
``1 / sqrt(n_b * (n_b - 1))``, so that contracting the re/im-stacked slice
with itself yields the covariance of the mean half-spectrum.
Not persisted by :meth:`save_state`: it is reconstructible from
``prep_signals`` and would dominate the archive size.
std_frequencies, std_damping : np.ndarray or None
Standard deviations of the identified modal frequencies and damping
ratios, shaped like :attr:`~pyOMA.core.PLSCF.PLSCF.modal_frequencies`.
std_participation_vectors : np.ndarray or None
Standard deviations of the participation vectors, shaped like
:attr:`~pyOMA.core.PLSCF.PLSCF.participation_vectors`; the standard
deviation of the real part is stored in ``.real`` and that of the
imaginary part in ``.imag``, as in
:class:`~pyOMA.core.VarSSIRef.VarSSIRef`.
block_weights : np.ndarray or None
The *post-hoc* block weights the current ``std_*`` arrays reflect,
renormalised to sum to one, or *None* for uniform. Distinct from
:attr:`~pyOMA.core.PLSCF.PLSCF.weights`, which records the *build-time*
weights that moved the point estimate: post-hoc reweighting
(:meth:`apply_block_weights`, :meth:`compute_modal_params_weighted`)
leaves the point estimates alone and requires an unweighted build.
block_weight_convention : str or None
The convention the current ``std_*`` arrays were reweighted under; see
:meth:`_block_weight_factor`.
Notes
-----
Post-hoc reweighting keeps the point estimates and every Jacobian at their
original linearisation: it is the delta-method covariance of the reweighted
estimator *around the original point estimate*, first-order consistent for
moderate weight changes. It does **not** relocate a point estimate
contaminated by a bad block -- passing *weights* to
:meth:`build_half_spectra` is the honest route for that.
"""
def __init__(self, *args, cache_variance_factors=False, cache='full',
cache_dtype=np.float32, **kwargs):
"""
Parameters
----------
cache_variance_factors: bool, optional
Keep the per-mode uncertainty factors of every order, so that
:meth:`apply_block_weights` can refresh the standard deviations from
them without re-identifying. Off by default: the caches cost roughly
``n_modes * 2 * (1 + n_r + n_l) * n_b`` numbers per order.
cache: str, optional
``'full'`` (default) caches the factors of all four ``std_*``
arrays; ``'freqdamp'`` caches only the frequency/damping factor,
which is the bulk of the saving, and leaves the participation and
mode-shape standard deviations untouched on reweighting.
cache_dtype: numpy.dtype, optional
Storage precision of the caches, ``numpy.float32`` by default. The
reweighting itself runs in double precision; only the stored factors
are truncated, which costs about six significant digits in the
reweighted standard deviations.
*args, **kwargs
Passed to :class:`~pyOMA.core.PLSCF.PLSCF`.
"""
super().__init__(*args, **kwargs)
if cache not in ('full', 'freqdamp'):
raise ValueError(f"cache must be 'full' or 'freqdamp', got {cache!r}.")
self.spec_block_factor = None
self.std_frequencies = None
self.std_damping = None
self.std_participation_vectors = None
self.std_mode_shapes = None
self.block_weights = None
self.block_weight_convention = None
self.cache_variance_factors = cache_variance_factors
self.cache = cache
self.cache_dtype = cache_dtype
self._U_fixi_cache = None
self._U_L_cache = None
self._U_phii_cache = None
[docs]
@classmethod
def init_from_config(cls, conf_file, prep_signals):
"""Build and identify a :class:`VarPLSCF` from a config file.
Overrides :meth:`~pyOMA.core.PLSCF.PLSCF.init_from_config`: that
version never supplies *num_blocks*, which :meth:`build_half_spectra`
requires here to estimate the half-spectrum covariance. Cache options
(``cache_variance_factors``, ``cache``, ``cache_dtype``) are
constructor-time kwargs, left at their defaults, exactly as
:meth:`~pyOMA.core.VarSSIRef.VarSSIRef.init_from_config` leaves its own
``cache_variance_factors`` kwarg unexposed.
"""
cfg = ConfigFile(conf_file)
begin_frequency = cfg.float('Begin Frequency')
end_frequency = cfg.float('End Frequency')
nperseg = cfg.int('Samples per time segment')
max_model_order = cfg.int('Maximum Model Order')
num_blocks = cfg.int('Number of Blocks')
pLSCF_object = cls(prep_signals)
pLSCF_object.build_half_spectra(
nperseg, begin_frequency, end_frequency, num_blocks=num_blocks)
pLSCF_object.compute_modal_params(max_model_order)
return pLSCF_object
[docs]
def write_config(self, conf_file):
ConfigFile.write(conf_file, {
'Begin Frequency': self.begin_frequency,
'End Frequency': self.end_frequency,
'Samples per time segment': self.nperseg,
'Maximum Model Order': self.max_model_order,
'Number of Blocks': self.num_blocks,
})
@staticmethod
def _block_weight_factor(weights, num_blocks, convention='substitution'):
"""Build the block-weighting matrix ``W``, to be applied to a cached factor.
Any uncertainty factor of this class carries the block index on its last
axis and holds *centered* block deviations, so reweighting is a right
multiplication ``F @ W`` on that axis, with
.. math:: W(w) = s(w) \\, (I - w \\mathbf{1}^T) \\, \\mathrm{diag}(\\sqrt{w})
Column *j* of ``W`` is ``s(w) sqrt(w_j) (e_j - w)``: the ``(I - w 1^T)``
re-centers the cached deviations at the new weighted mean (a linear
combination of columns that are already centered), ``diag(sqrt(w))``
applies the weights, and ``s(w)`` restores the intended normalisation.
This generalises the paper's ``1 / N_avg`` covariance scaling (their Eq. 26,
with *N_avg* the number of equally weighted Welch averages) to ``1 / n_eff``:
at uniform weights ``n_eff == num_blocks == N_avg`` and Eq. 26 is recovered.
The paper itself has no block weighting and no post-hoc reweighting; that is
the whole of its relevance here.
``W`` is real, which is what makes ``F @ W`` valid for the conjugate-linear
parts of the score chain (:meth:`_theta_scores`, :meth:`_mode_shape_scores`).
Complex weights are not admissible.
Parameters
----------
weights: (num_blocks,) array_like or None
Per-block weights; see :meth:`_validate_weights`. *None* yields
uniform weights, for which ``W`` acts as the identity on any
centered factor.
num_blocks: integer
Number of blocks that entered the estimate.
convention: str, optional
How the reweighted covariance is scaled. All three agree at
uniform weights and differ only in ``s(w)``:
* ``'substitution'`` (default) — read the result as if it came
from ``n_eff`` uniformly weighted blocks. This is the
convention under which post-hoc reweighting reproduces a
build-time weighted estimate, and under which a zero weight
reproduces a from-scratch computation with that block deleted
(a free jackknife).
* ``'reliability'`` — treat the weights as relative
reliabilities; variances are ``n_b / n_eff`` times the
``'substitution'`` ones.
* ``'precision'`` — read the weights as inverse variances,
``cov_w = sum_j w_j d_j d_j^T / (n_b - 1)``. Its scalar
``sqrt(n_b)`` is constant in *w*; ``W`` still depends on *w*
through the centering and ``diag(sqrt(w))``.
Returns
-------
W: (num_blocks, num_blocks) numpy.ndarray
Real weighting matrix.
Notes
-----
The names carry their meaning over from
:class:`~pyOMA.core.VarSSIRef.VarSSIRef`, but **not** their formulae: that
class pre-inflates its block deviations by ``num_blocks`` and normalises by
``sqrt(n_b^2 (n_b - 1))``, whereas this class normalises by
``sqrt(n_b (n_b - 1))`` -- a plain standard error of the mean. Each
normalisation needs its own ``s(w)``; transplanting the other class's
scalars here is wrong by ``sqrt(n_b)`` and does not even reduce to the
unweighted factor at uniform weights.
"""
conventions = ('substitution', 'reliability', 'precision')
if convention not in conventions:
raise ValueError(
f'Unknown convention {convention!r}, must be one of {conventions}.')
weights, n_eff = VarPLSCF._validate_weights(weights, num_blocks)
if weights is None:
weights = np.full(num_blocks, 1.0 / num_blocks)
n_b = float(num_blocks)
if n_eff <= 1.0:
# all weight mass sits on a single block: no scatter is left to
# estimate a variance from
if convention == 'reliability':
raise ValueError(
"convention='reliability' is undefined for n_eff <= 1 (all weight "
'mass on a single block); no variance can be estimated from it.')
if convention == 'substitution':
logger.warning(
'All weight mass is on a single block (n_eff <= 1); the reweighted '
'standard deviations are zero and carry no information.')
return np.zeros((num_blocks, num_blocks))
# 'precision' needs no guard: its scalar is finite, and every column
# of (I - w 1^T) diag(sqrt(w)) vanishes on its own
if convention == 'substitution':
s_w = np.sqrt(n_b * (n_b - 1.0) / (n_eff - 1.0))
elif convention == 'reliability':
s_w = np.sqrt(n_eff * (n_b - 1.0) / (n_eff - 1.0))
else:
s_w = np.sqrt(n_b)
# column j of W is s_w * sqrt(w_j) * (e_j - w):
W = np.eye(num_blocks) - weights[:, np.newaxis] # (I - w 1^T)
W = s_w * W * np.sqrt(weights)[np.newaxis,:] # ... @ diag(sqrt(w))
return W
[docs]
def build_half_spectra(self, nperseg=None,
begin_frequency=None, end_frequency=None,
window_decay=0.001, num_blocks=None, training_blocks=None,
weights=None, corr_matrices=None, **kwargs):
'''
Construct the positive half-spectra and their block-covariance factor.
Behaves as :meth:`~pyOMA.core.PLSCF.PLSCF.build_half_spectra`, but
*num_blocks* is required: the variance of the half-spectra is estimated
from the scatter of the individual blocks about their mean, and that mean
is the half-spectrum the model is identified from.
Passing *weights* propagates them into the covariance factor as well as
the point estimate; the covariance then scales as ``1 / n_eff``, which
generalises the ``1 / N_avg`` of Steffensen-2025 Eq. 26 (with *N_avg* the
number of equally weighted Welch averages) and recovers it at uniform
weights. To change the weights *after* identification instead, leave them
out here and use :meth:`apply_block_weights`.
Parameters
----------
nperseg: integer, optional
Number of (positive) frequency lines to consider (rfft)
begin_frequency, end_frequency: float, optional
Frequency range to restrict the identified system.
window_decay: float, (0,1)
Final value of the exponential window, that is applied to the
correlation functions.
num_blocks: integer, required
The number of blocks to split the signal into. At least two
blocks are needed for a variance estimate; the estimate becomes
meaningful only for substantially more.
training_blocks: list, optional
The selected blocks to use for system identification
(=training). Defaults to all blocks. The covariance factor is
built from these blocks only, i.e. from the blocks that entered
the point estimate.
weights: (len(training_blocks),) array_like, optional
Non-negative per-training-block weights; see
:meth:`~pyOMA.core.PLSCF.PLSCF.build_half_spectra`. At least two
blocks must carry weight.
corr_matrices: (num_blocks, n_l, n_r, >= nperseg) array_like, optional
Externally supplied block correlation functions; see
:meth:`~pyOMA.core.PLSCF.PLSCF.build_half_spectra`. Lets each
block be an independent realization, which is the data model the
block covariance assumes anyway.
Other Parameters
----------------
kwargs :
Additional kwargs are passed to prep_signals.correlation
'''
if num_blocks is None:
raise ValueError(
'Argument num_blocks is required for variance estimation: the '
'half-spectrum covariance is estimated from the scatter of the '
'individual blocks. Use PLSCF for an identification without it.'
)
super().build_half_spectra(
nperseg, begin_frequency, end_frequency, window_decay,
num_blocks, training_blocks, weights, corr_matrices, **kwargs)
self.spec_block_factor = self._build_spec_block_factor()
def _build_spec_block_factor(self):
"""Build the covariance factor of the positive half-spectra.
With build-time weights, the block deviations are taken about the
*weighted* mean -- the half-spectrum the model was actually identified
from -- and scaled by ``sqrt(w_j) / sqrt(n_eff - 1)``, which reduces to
the unweighted ``1 / sqrt(n_b (n_b - 1))`` at uniform weights.
Returns
-------
spec_block_factor: (n_l, n_r, num_omega, n_b) numpy.ndarray
Scaled block deviations; see :attr:`VarPLSCF.spec_block_factor`.
"""
training_blocks = self.training_blocks
n_b = training_blocks.shape[0]
if n_b < 2:
raise ValueError(
f'At least two training blocks are needed to estimate a variance, got {n_b}.')
if self.n_eff <= 1.0:
raise ValueError(
'All weight mass is on a single block (n_eff <= 1); no scatter is left '
'to estimate a variance from.')
if self.n_eff < 10:
logger.warning(
f'Estimating the half-spectrum covariance from only n_eff={self.n_eff:.1f} '
f'effective blocks (of {n_b}); the resulting standard deviations are '
f'themselves highly uncertain.')
corr_blocks = self._block_correlations(training_blocks)
# the windowed rFFT is linear, so the (weighted) mean of the block spectra
# is exactly the half-spectrum the model was identified from
_, block_spectra, _ = self._windowed_half_spectrum(
corr_blocks, self.nperseg, self.window_decay,
self.begin_frequency, self.end_frequency) # (n_b, n_l, n_r, num_omega)
weights = self.weights
if weights is None:
deviations = block_spectra - np.mean(block_spectra, axis=0, keepdims=True)
deviations /= np.sqrt(n_b * (n_b - 1))
else:
block_mean = np.tensordot(weights, block_spectra, axes=(0, 0))
deviations = block_spectra - block_mean[np.newaxis, ...]
deviations *= (np.sqrt(weights) / np.sqrt(self.n_eff - 1.0)
)[:, np.newaxis, np.newaxis, np.newaxis]
return np.moveaxis(deviations, 0, -1)
def _theta_scores(self, ctx, spec_block_factor):
"""Perturbations of the free denominator coefficients, per block.
pyOMA constrains the *last* denominator block, so the estimating equation
of the reduced normal equations is ``[M theta]_{:n*n_r} = 0`` with
``theta = [theta_b; I_n_r]``, and the free coefficients are already the
companion-matrix coefficients -- the paper's Appendix B transformation has
no counterpart here. Differentiating that equation gives
.. math:: M_{aa} \\Delta\\theta_b = -[\\Delta M \\theta]_{:n \\cdot n_r}
Only ``Y_o`` depends on the half-spectra, and linearly so. Writing the
assembly out with ``Z_o = X_o Q_o - Y_o theta`` (the negated equation error
of output *o*) and ``V_o = RS_o^T X_o^H - Y_o^H`` collapses the four terms
of the product rule into
.. math::
-[\\Delta M \\theta] = 2 \\sum_o \\left[
\\Re(\\Delta Y_o^H Z_o) + \\Re(V_o \\Delta Y_o \\theta) \\right]
The Kronecker structure of ``Delta Y_o`` is never materialised: it reduces
every product to a contraction of ``X_o`` with the block deviations.
Parameters
----------
ctx: NormalEquationsContext
Assembly to differentiate, from
:meth:`~pyOMA.core.PLSCF.PLSCF._assemble_normal_equations`.
spec_block_factor: (n_l, n_r, num_omega, n_b) numpy.ndarray
Covariance factor of the half-spectra to propagate; passed in
rather than read from the attribute, so that a reweighted factor
can be propagated without disturbing the stored one.
Returns
-------
U_theta: (order * n_r, n_r, n_b) numpy.ndarray
Perturbation of ``alpha_b``, one column per block.
"""
n_l = self.prep_signals.num_analised_channels
n_r = self.prep_signals.num_ref_channels
order = ctx.order
num_omega = self.num_omega
n_b = spec_block_factor.shape[-1]
X_o = ctx.X_o
X_o_conj = np.conj(X_o)
X_o_H = X_o_conj.T
# the polynomial evaluated at every frequency line: alpha(Omega_f)
alpha_omega = np.einsum('fp,pqs->fqs', X_o,
ctx.alpha.reshape(order + 1, n_r, n_r), optimize=True)
rhs = np.zeros(((order + 1) * n_r, n_r, n_b))
for i_l in range(n_l):
H_o = self.pos_half_spectra[i_l,:,:] # (n_r, num_omega)
D_o = spec_block_factor[i_l,:,:,:] # (n_r, num_omega, n_b)
# Y_o[f, p * n_r + q] = -X_o[f, p] * H_o[q, f]; the kron of estimate_model
Y_o = (-X_o[:,:, np.newaxis] * H_o.T[:, np.newaxis,:]).reshape(num_omega, -1)
RS_o = ctx.RS_solutions[:,:, i_l]
# R_o^-1 S_o theta, already available as the numerator coefficients
Q_o = -ctx.beta_l_i[:,:, i_l]
Z_o = X_o @ Q_o - Y_o @ ctx.alpha # (num_omega, n_r)
V_o = RS_o.T @ X_o_H - np.conj(Y_o.T) # ((order + 1) * n_r, num_omega)
# (Delta Y_o theta)[f, s] = -sum_q Delta H_o[q, f] alpha_omega[f, q, s]
dY_theta = -np.einsum('qfj,fqs->fsj', D_o, alpha_omega, optimize=True)
# (Delta Y_o^H Z_o)[p * n_r + q, s] =
# -sum_f conj(X_o[f, p]) conj(Delta H_o[q, f]) Z_o[f, s]
term_a = -np.einsum('fp,qfj,fs->pqsj', X_o_conj, np.conj(D_o), Z_o,
optimize=True)
term_b = np.einsum('pf,fsj->psj', V_o, dY_theta, optimize=True)
rhs += np.real(term_a).reshape((order + 1) * n_r, n_r, n_b) + np.real(term_b)
# the factor 2 that estimate_model applies to the whole sum over outputs;
# it cancels against M_aa, but keeping both consistent avoids a trap
rhs *= 2
U_theta = np.linalg.solve(ctx.M_aa, rhs[:order * n_r,:,:].reshape(order * n_r, -1))
return U_theta.reshape(order * n_r, n_r, n_b)
@staticmethod
def _companion_column_scores(U_theta, order, n_r):
"""Perturbation of the only varying part of the companion matrix, per block.
``A_c``'s block superdiagonal is a constant identity, and its first block
column holds the free denominator blocks reversed and negated: the
block-solve in
:meth:`~pyOMA.core.PLSCF.PLSCF._build_companion_matrix_residuals` divides
by the *constrained* block ``alpha[-n_r:] = I``, which is exact and
therefore contributes nothing. Reversing the blocks by slicing is the
paper's permutation matrix, without building it.
Returns
-------
dA_c1: (order * n_r, n_r, n_b) numpy.ndarray
Perturbation of ``A_c[:, :n_r]``, one column per block.
"""
n_b = U_theta.shape[-1]
blocks = U_theta.reshape(order, n_r, n_r, n_b)[::-1,:,:,:]
return -blocks.reshape(order * n_r, n_r, n_b)
def _pole_scores(self, dA_c1, modal_ctx):
"""Perturbations of the continuous-time eigenvalues, per block.
Uses the standard first-order eigenvalue perturbation
``dz_r = y_r^H dA_c x_r / (y_r^H x_r)``; since only the first block column
of ``dA_c`` is nonzero, only the first ``n_r`` entries of ``x_r``
contribute. Both the formula and ``lambda = log(z) f_s`` are invariant to
the arbitrary scaling of the eigenvectors.
Returns
-------
d_lambda: (n_modes, n_b) numpy.ndarray
Complex perturbation of the eigenvalues, in the order in which
:meth:`~pyOMA.core.PLSCF.PLSCF.modal_analysis_residuals` returns
the modes.
"""
sampling_rate = self.prep_signals.sampling_rate
n_r = dA_c1.shape[1]
inds = modal_ctx.mode_indices
eigvecs_r = modal_ctx.eigvecs_r[:, inds]
eigvecs_l = modal_ctx.eigvecs_l[:, inds]
eigvals_z = modal_ctx.eigvals_z[inds]
denominator = np.einsum('kr,kr->r', np.conj(eigvecs_l), eigvecs_r, optimize=True)
numerator = np.einsum('kr,kmj,mr->rj', np.conj(eigvecs_l), dA_c1,
eigvecs_r[:n_r,:], optimize=True)
d_z = numerator / denominator[:, np.newaxis]
return d_z * sampling_rate / eigvals_z[:, np.newaxis]
def _frequency_damping_scores(self, eigenvalues, d_lambda):
"""Perturbations of modal frequencies and damping ratios, per block.
pyOMA defines ``f = |lambda| / 2 pi`` and damping in percent with the
exponential-window correction (see
:meth:`~pyOMA.core.PLSCF.PLSCF._compute_damping`), rather than the paper's
Eq. 14; these Jacobians are derived for pyOMA's convention and replace the
paper's Eq. 42. With ``2 pi f = |lambda|`` the damping ratio is
``zeta% = -100 (Re lambda - a f_s) / |lambda|``.
Returns
-------
d_frequency, d_damping: (n_modes, n_b) numpy.ndarray
"""
sampling_rate = self.prep_signals.sampling_rate
# _compute_damping applies no correction at all if factor_a is None,
# which is what a vanishing correction term amounts to
factor_a = self.factor_a if self.factor_a is not None else 0.0
abs_lambda = np.abs(eigenvalues)[:, np.newaxis]
lambda_re = np.real(eigenvalues)[:, np.newaxis]
lambda_im = np.imag(eigenvalues)[:, np.newaxis]
d_abs = (lambda_re * np.real(d_lambda)
+ lambda_im * np.imag(d_lambda)) / abs_lambda
d_frequency = d_abs / (2 * np.pi)
d_damping = -100 * (np.real(d_lambda) / abs_lambda
- (lambda_re - factor_a * sampling_rate)
/ abs_lambda ** 2 * d_abs)
return d_frequency, d_damping
def _participation_scores(self, dA_c1, modal_ctx):
"""Perturbations of the normalised participation vectors, per block.
The participation vectors are the last ``n_r`` entries of the left
eigenvectors of ``A_c``. Perturbing ``A_c^H y_r = conj(z_r) y_r`` gives
.. math:: (A_c^H - \\bar{z}_r I) \\Delta y_r =
\\overline{\\Delta z_r} y_r - \\Delta A_c^H y_r
whose right-hand side is the projector of the paper's Eq. 46 applied to
``-Delta A_c^H y_r``. The system determines ``Delta y_r`` only up to a
multiple of ``y_r``; the pseudo-inverse picks the component orthogonal to
it, which is immaterial because the normalisation below is invariant to a
scaling of ``y_r``.
Returns
-------
d_participation: (n_r, n_modes, n_b) numpy.ndarray
Complex perturbation of the normalised participation vectors.
"""
n_r = dA_c1.shape[1]
n_b = dA_c1.shape[-1]
inds = modal_ctx.mode_indices
A_c_H = np.conj(modal_ctx.A_c.T)
identity = np.eye(A_c_H.shape[0])
d_participation = np.zeros((n_r, len(inds), n_b), dtype=complex)
for i, ind in enumerate(inds):
z_r = modal_ctx.eigvals_z[ind]
x_r = modal_ctx.eigvecs_r[:, ind]
y_r = modal_ctx.eigvecs_l[:, ind]
projector = identity - np.outer(y_r, np.conj(x_r)) / (np.conj(x_r) @ y_r)
G_r = -np.linalg.pinv(A_c_H - np.conj(z_r) * identity) @ projector
# dA_c is real and only its first block column is nonzero, so
# dA_c^H y_r is nonzero only in its first n_r entries
rhs = np.einsum('kmj,k->mj', dA_c1, y_r, optimize=True)
d_L = (G_r[:, :n_r] @ rhs)[-n_r:,:]
# normalisation to the largest component, c.p. paper Eq. 48/49; that
# component is exactly one, so its perturbation -- and hence its
# standard deviation -- is exactly zero
L_r = y_r[-n_r:]
k = np.argmax(np.abs(L_r))
L_tilde = L_r / L_r[k]
d_participation[:, i,:] = (
d_L - L_tilde[:, np.newaxis] * d_L[k,:][np.newaxis,:]) / L_r[k]
return d_participation
def _stage1_scores(self, ctx, modal_ctx, eigenvalues, spec_block_factor):
"""Run the Stage-1 propagation for a single model order.
Parameters
----------
ctx: NormalEquationsContext
Assembly of the reduced normal equations at this order.
modal_ctx: ModalContext
Eigendecomposition and mode selection at this order.
eigenvalues: (n_modes,) numpy.ndarray
Continuous-time eigenvalues, as returned by
:meth:`~pyOMA.core.PLSCF.PLSCF.modal_analysis_residuals`.
spec_block_factor: (n_l, n_r, num_omega, n_b) numpy.ndarray
Covariance factor of the half-spectra to propagate.
Returns
-------
scores: Stage1Scores
"""
n_r = self.prep_signals.num_ref_channels
U_theta = self._theta_scores(ctx, spec_block_factor)
dA_c1 = self._companion_column_scores(U_theta, ctx.order, n_r)
d_lambda = self._pole_scores(dA_c1, modal_ctx)
d_frequency, d_damping = self._frequency_damping_scores(eigenvalues, d_lambda)
d_participation = self._participation_scores(dA_c1, modal_ctx)
return Stage1Scores(U_theta=U_theta, d_lambda=d_lambda,
d_frequency=d_frequency, d_damping=d_damping,
d_participation=d_participation)
def _collect_modal_state(self):
"""Extend the PLSCF archive with the standard deviations.
The block factor itself is not stored: it is large, and rebuilding it
with :meth:`build_half_spectra` is cheap. A loaded object therefore
carries its standard deviations but cannot recompute them until the
half-spectra are rebuilt.
The per-mode caches *are* stored when they exist, so that
:meth:`apply_block_weights` survives a round-trip: it reweights the
caches and needs neither the block factor nor the correlations. Note
that :meth:`~pyOMA.core.PLSCF.PLSCF.save_state` persists neither
*num_blocks*, *training_blocks* nor *window_decay*, so a loaded object
cannot rebuild the block factor and Tier B stays unavailable to it until
:meth:`build_half_spectra` is called again.
"""
out_dict = super()._collect_modal_state()
out_dict.update({
'self.std_frequencies': self.std_frequencies,
'self.std_damping': self.std_damping,
'self.std_participation_vectors': self.std_participation_vectors,
'self.std_mode_shapes': self.std_mode_shapes,
'self.block_weights': self.block_weights,
'self.block_weight_convention': self.block_weight_convention,
})
# object arrays: the caches are dicts keyed by order, with a mode count
# that varies between orders
for attr, key in (('_U_fixi_cache', 'self.U_fixi_cache'),
('_U_L_cache', 'self.U_L_cache'),
('_U_phii_cache', 'self.U_phii_cache')):
cache = getattr(self, attr)
if cache is not None:
out_dict[key] = np.array(cache, dtype=object)
return out_dict
@classmethod
def _restore_modal_state(cls, pLSCF_object, in_dict):
"""Restore the standard deviations alongside the modal parameters."""
super()._restore_modal_state(pLSCF_object, in_dict)
# absent from archives written by PLSCF, which identifies no variances
for name in ('std_frequencies', 'std_damping',
'std_participation_vectors', 'std_mode_shapes'):
setattr(pLSCF_object, name, in_dict.get(f'self.{name}', None))
pLSCF_object.block_weights = validate_array(in_dict.get('self.block_weights', None))
pLSCF_object.block_weight_convention = validate_array(
in_dict.get('self.block_weight_convention', None))
# a missing cache disables Tier A; Tier B is unaffected
for attr, key in (('_U_fixi_cache', 'self.U_fixi_cache'),
('_U_L_cache', 'self.U_L_cache'),
('_U_phii_cache', 'self.U_phii_cache')):
stored = in_dict.get(key, None)
setattr(pLSCF_object, attr, stored.item() if stored is not None else None)
def _mode_shape_scores(self, lsfd_ctx, scores, spec_block_factor):
'''
Propagate the block factor and the Stage-1 scores onto the mode shapes,
by differentiating the least-squares frequency-domain fit.
The fit solves :math:`X = A^+ h` (c.p. Steffensen-2025 Eq. 21-24). The
data enters twice: directly through *h*, and through *A*, which is built
from the poles and participation vectors identified in Stage 1. For a
full-column-rank *A*,
.. math::
\\Delta X = A^+ \\Delta h - A^+ \\Delta A X
+ (A^T A)^{-1} \\Delta A^T (h - A X),
the last term contributing only in so far as the model does not fit the
half-spectra exactly. Perturbing every pole and participation vector
jointly per block retains the Stage-1/Stage-2 and cross-frequency
correlations that Steffensen-2025 Eq. 51-52 tracks explicitly.
Parameters
----------
lsfd_ctx: LSFDContext
Assembly of the mode-shape fit at this order.
scores: Stage1Scores
Stage-1 perturbations, in the returned mode order.
spec_block_factor: (n_l, n_r, num_omega, n_b) numpy.ndarray
Covariance factor of the half-spectra to propagate; must be the
one the *scores* were built from.
Returns
-------
d_mode_shape: (n_l, n_modes, n_b) numpy.ndarray
Perturbations of the integrated and normalised mode shapes, in
the returned mode order.
'''
accel_channels = self.prep_signals.accel_channels
velo_channels = self.prep_signals.velo_channels
n_l = self.prep_signals.num_analised_channels
n_r = self.prep_signals.num_ref_channels
num_omega = self.num_omega
n_b = spec_block_factor.shape[-1]
mode_order = lsfd_ctx.mode_order
n_modes = len(mode_order)
# the fit ran in its own mode order; scatter the Stage-1 scores into it,
# so that every quantity below is indexed by fit column
d_lambda = np.empty_like(scores.d_lambda)
d_lambda[mode_order,:] = scores.d_lambda
d_participation = np.empty_like(scores.d_participation)
d_participation[:, mode_order,:] = scores.d_participation
# omega = |lambda| = 2 pi f, hence d_omega = 2 pi df
d_omega = np.empty_like(scores.d_frequency)
d_omega[mode_order,:] = scores.d_frequency * 2 * np.pi
X = lsfd_ctx.X
pinv_A = lsfd_ctx.pinv_A
res = lsfd_ctx.residual
# (A^T A)^-1 without assembling and inverting this stage's normal
# equations a second time
G = pinv_A @ pinv_A.T
Df1 = lsfd_ctx.Df1
Df2 = lsfd_ctx.Df2
L = lsfd_ctx.participation_vectors
X_modes = X[:2 * n_modes,:]
G_modes = G[:, :2 * n_modes]
d_mode_shapes_raw = np.zeros((n_l, n_modes, n_b), dtype=complex)
for i_b in range(n_b):
# h is a real/imaginary reordering of pos_half_spectra, so its
# perturbation is the same reordering of the block factor
D_b = spec_block_factor[:,:,:, i_b]
dh = np.concatenate([np.real(D_b).transpose(2, 1, 0),
np.imag(D_b).transpose(2, 1, 0)],
axis=1).reshape(num_omega * 2 * n_r, n_l)
# d/dlambda (1j omega - lambda)^-1 = (1j omega - lambda)^-2
dLDf1 = (d_participation[np.newaxis,:,:, i_b] * Df1[:, np.newaxis,:]
+ L[np.newaxis,:,:] * (Df1 ** 2)[:, np.newaxis,:]
* d_lambda[np.newaxis, np.newaxis,:, i_b])
dLDf2 = (np.conj(d_participation[np.newaxis,:,:, i_b]) * Df2[:, np.newaxis,:]
+ np.conj(L[np.newaxis,:,:]) * (Df2 ** 2)[:, np.newaxis,:]
* np.conj(d_lambda[np.newaxis, np.newaxis,:, i_b]))
# the block pattern of A_f, differentiated; the residual columns of A
# are constants and drop out, so only the mode columns are built
top = np.concatenate([np.real(dLDf1) + np.real(dLDf2),
-np.imag(dLDf1) + np.imag(dLDf2)], axis=2)
bottom = np.concatenate([np.imag(dLDf1) + np.imag(dLDf2),
np.real(dLDf1) - np.real(dLDf2)], axis=2)
dA = np.concatenate([top, bottom], axis=1).reshape(
num_omega * 2 * n_r, 2 * n_modes)
dX = (pinv_A @ dh - pinv_A @ (dA @ X_modes)
+ G_modes @ (dA.T @ res))
d_mode_shapes_raw[:,:, i_b] = (dX[:n_modes,:].T
+ 1j * dX[n_modes:2 * n_modes,:].T)
mode_shapes_raw = X.T[:, :n_modes] + 1j * X.T[:, n_modes:2 * n_modes]
d_mode_shape = np.zeros((n_l, n_modes, n_b), dtype=complex)
for i_m in range(n_modes):
omega = np.abs(lsfd_ctx.eigenvalues[i_m])
# integrate_quantities scales channel c by k_c * omega**-p_c (p = 2
# for accelerations, 1 for velocities, 0 for displacements), so its
# derivative w.r.t. omega is -p_c / omega times the factor itself.
# Taking the factor from integrate_quantities keeps the two in step.
factor = self.integrate_quantities(
np.ones(n_l, dtype=complex), accel_channels, velo_channels, omega)
exponent = np.zeros(n_l)
exponent[accel_channels] = 2
exponent[velo_channels] = 1
d_factor = -exponent * factor / omega
phi_raw = mode_shapes_raw[:, i_m]
phi = factor * phi_raw
d_phi = (factor[:, np.newaxis] * d_mode_shapes_raw[:, i_m,:]
+ (d_factor * phi_raw)[:, np.newaxis] * d_omega[i_m,:][np.newaxis,:])
# normalisation to the largest component, as rescale_mode_shape does;
# that component is exactly one and carries no uncertainty
k = np.argmax(np.abs(phi))
phi_tilde = phi / phi[k]
d_mode_shape[:, i_m,:] = (
d_phi - phi_tilde[:, np.newaxis] * d_phi[k,:][np.newaxis,:]) / phi[k]
# back to the returned mode order
return d_mode_shape[:, mode_order,:]
[docs]
def compute_modal_params(self, max_model_order, complex_coefficients=False,
algo='residuals', modal_contrib=None, validation_blocks=None,
cache_variance_factors=None):
'''
Perform a multi-order computation of modal parameters and their standard
deviations.
Behaves as :meth:`~pyOMA.core.PLSCF.PLSCF.compute_modal_params`, but
additionally propagates the block-covariance of the half-spectra onto the
identified modal parameters at every order.
Parameters
----------
max_model_order: integer
Maximum model order, where to interrupt the algorithm.
complex_coefficients: bool, optional
Must be False: the propagation is derived for real coefficients.
algo: str, optional
Must be 'residuals': the propagation differentiates the
residual-based modal analysis.
modal_contrib: bool, optional
Synthesize modal spectra and estimate modal contributions.
validation_blocks: list, optional
Forwarded to :meth:`~pyOMA.core.PLSCF.PLSCF.synthesize_spectrum`.
cache_variance_factors: bool, optional
Cache the per-mode uncertainty factors for
:meth:`apply_block_weights`. Defaults to the constructor's
setting.
'''
self._check_variance_prerequisites(complex_coefficients, algo)
if cache_variance_factors is None:
cache_variance_factors = self.cache_variance_factors
# a fresh identification invalidates whatever the caches held
self._U_fixi_cache = None
self._U_L_cache = None
self._U_phii_cache = None
self._compute_modal_params_impl(
max_model_order, complex_coefficients, algo, modal_contrib,
validation_blocks, self.spec_block_factor, cache_variance_factors)
self.block_weights = None
self.block_weight_convention = None
[docs]
def compute_modal_params_weighted(self, max_model_order, weights,
convention='substitution',
complex_coefficients=False, algo='residuals',
modal_contrib=None, validation_blocks=None):
'''
Recompute the standard deviations under block weights, from scratch.
Reweights the cached covariance factor and re-runs the whole multi-order
loop against it. Because every step from the block factor to the mode
shapes is real-linear and homogeneous in the block deviations, applied
per block column with no coupling between blocks, reweighting the factor
once up front is equivalent to reweighting every downstream quantity:
``L(F) @ W == L(F @ W)``.
This is the memory-free reference path, not a fast one. Unlike
:class:`~pyOMA.core.VarSSIRef.VarSSIRef`, pLSCF has no separate
sensitivity-preparation stage, so re-running the loop repeats the whole
identification and saves only the block-spectrum FFTs. Prefer
:meth:`apply_block_weights` for sweeping weights; this method is its
oracle.
The point estimates are recomputed identically -- the weights enter only
the covariance. To move the point estimate too, pass *weights* to
:meth:`build_half_spectra` instead.
Parameters
----------
max_model_order: integer
Maximum model order, where to interrupt the algorithm.
weights: (n_b,) array_like or None
Non-negative per-training-block weights; *None* is uniform, which
reproduces :meth:`compute_modal_params`. See
:meth:`_block_weight_factor`.
convention: str, optional
One of ``'substitution'`` (default), ``'reliability'`` or
``'precision'``; see :meth:`_block_weight_factor`.
complex_coefficients, algo, modal_contrib, validation_blocks
As in :meth:`compute_modal_params`.
'''
self._check_variance_prerequisites(complex_coefficients, algo)
if self.weights is not None:
raise NotImplementedError(
'Post-hoc reweighting requires an unweighted build: this object was '
'built with weights, so its covariance factor is already weighted and '
'its point estimates are not the unweighted ones. Rebuild with '
'build_half_spectra(weights=None) to reweight post-hoc, or rebuild '
'with the weights you want.')
n_b = self.spec_block_factor.shape[-1]
weight_factor = self._block_weight_factor(weights, n_b, convention)
weights, n_eff = self._validate_weights(weights, n_b)
if weights is not None and n_eff < 10:
logger.warning(
f'The weights concentrate the covariance on n_eff={n_eff:.1f} effective '
f'blocks (of {n_b}); the block covariance is itself noisy at that count.')
# never mutate the stored factor. The caches are deliberately left
# intact: they hold unweighted factors and stay valid across this call,
# which is where VarSSIRef would have wiped them.
self._compute_modal_params_impl(
max_model_order, complex_coefficients, algo, modal_contrib,
validation_blocks, self.spec_block_factor @ weight_factor, False)
self.block_weights = weights
self.block_weight_convention = convention
[docs]
def apply_block_weights(self, weights=None, convention='substitution'):
'''
Refresh the standard deviations under new block weights, from the caches.
Reweights the cached per-mode uncertainty factors instead of
re-identifying, so a weight sweep over one identification costs a matrix
product per order. Requires ``compute_modal_params(cache_variance_factors
=True)`` to have run; there is no automatic fallback to
:meth:`compute_modal_params_weighted`, which is this method's oracle.
The point estimates and every Jacobian stay at their original
linearisation -- see the class notes. ``weights=None`` restores the
uniform standard deviations.
With ``cache='freqdamp'`` only :attr:`std_frequencies` and
:attr:`std_damping` are refreshed; the participation and mode-shape
standard deviations keep whatever weighting they were computed under.
Parameters
----------
weights: (n_b,) array_like or None
Non-negative per-training-block weights; see
:meth:`_block_weight_factor`. *None* restores uniform weighting.
convention: str, optional
One of ``'substitution'`` (default), ``'reliability'`` or
``'precision'``; see :meth:`_block_weight_factor`.
'''
if not self.state[1]:
raise RuntimeError('Call compute_modal_params() first.')
if self.weights is not None:
raise NotImplementedError(
'Post-hoc reweighting requires an unweighted build: this object was '
'built with weights, so its cached factors are already weighted and '
'its point estimates are not the unweighted ones. Rebuild with '
'build_half_spectra(weights=None) to reweight post-hoc.')
if self._U_fixi_cache is None:
raise RuntimeError(
'No cached uncertainty factors: re-run compute_modal_params with '
'cache_variance_factors=True, or use compute_modal_params_weighted '
'to reweight without a cache.')
n_b = next(iter(self._U_fixi_cache.values())).shape[-1]
weight_factor = self._block_weight_factor(weights, n_b, convention)
weights, n_eff = self._validate_weights(weights, n_b)
if weights is not None and n_eff < 10:
logger.warning(
f'The weights concentrate the covariance on n_eff={n_eff:.1f} effective '
f'blocks (of {n_b}); the block covariance is itself noisy at that count.')
n_l = self.prep_signals.num_analised_channels
n_r = self.prep_signals.num_ref_channels
for order, U_fixi in self._U_fixi_cache.items():
var = self._reweighted_variances(U_fixi, weight_factor)
n_modes = var.shape[0]
self.std_frequencies[order, :n_modes] = np.sqrt(var[:, 0])
self.std_damping[order, :n_modes] = np.sqrt(var[:, 1])
if self._U_L_cache is not None:
for order, U_L in self._U_L_cache.items():
var = self._reweighted_variances(U_L, weight_factor)
n_modes = var.shape[0]
self.std_participation_vectors.real[:, :n_modes, order] = np.sqrt(var[:, :n_r]).T
self.std_participation_vectors.imag[:, :n_modes, order] = np.sqrt(var[:, n_r:]).T
if self._U_phii_cache is not None:
for order, U_phi in self._U_phii_cache.items():
var = self._reweighted_variances(U_phi, weight_factor)
n_modes = var.shape[0]
self.std_mode_shapes.real[:, :n_modes, order] = np.sqrt(var[:, :n_l]).T
self.std_mode_shapes.imag[:, :n_modes, order] = np.sqrt(var[:, n_l:]).T
self.block_weights = weights
self.block_weight_convention = convention
@staticmethod
def _reweighted_variances(U, weight_factor):
"""Variances of a cached ``(n_modes, n_rows, n_b)`` factor under *weight_factor*.
The cache may be single precision; the product promotes to the weighting
matrix's double precision, and the factor itself is never modified.
"""
UW = U @ weight_factor
return np.einsum('mij,mij->mi', UW, UW)
def _check_variance_prerequisites(self, complex_coefficients, algo):
"""Reject the configurations the propagation is not derived for."""
if complex_coefficients:
raise NotImplementedError(
'Variance estimation is derived for real denominator coefficients; '
'call PLSCF.compute_modal_params for complex_coefficients=True.')
if algo != 'residuals':
raise NotImplementedError(
"Variance estimation requires algo='residuals': the propagation "
'differentiates the residual-based modal analysis, which is also '
'the only one identifying participation vectors.')
if not self.state[0]:
raise RuntimeError("Call build_half_spectra() first.")
if self.spec_block_factor is None:
# e.g. a state loaded from an archive, which does not carry the factor
raise RuntimeError(
'The half-spectrum covariance factor is missing; call '
'build_half_spectra() on this object to (re)build it.')
def _compute_modal_params_impl(self, max_model_order, complex_coefficients, algo,
modal_contrib, validation_blocks, spec_block_factor,
cache_variance_factors):
'''
Run the multi-order identification and propagate *spec_block_factor*.
Shared by :meth:`compute_modal_params` and
:meth:`compute_modal_params_weighted`, which differ in the factor they
hand in and in whether they populate the caches.
'''
algo, modal_contrib = self._setup_compute_params(
max_model_order, algo, modal_contrib
)
n_l = self.prep_signals.num_analised_channels
n_r = self.prep_signals.num_ref_channels
logger.info('Computing modal parameters and their standard deviations...')
max_modes = max_model_order * n_r // 2
modal_frequencies = np.zeros((max_model_order, max_modes))
modal_damping = np.zeros((max_model_order, max_modes))
mode_shapes = np.zeros((n_l, max_modes, max_model_order), dtype=complex)
eigenvalues = np.zeros((max_model_order, max_modes), dtype=complex)
modal_contributions = np.zeros(
(max_model_order, max_modes,), dtype=complex) if modal_contrib else None
participation_vectors = np.zeros((n_r, max_modes, max_model_order), dtype=complex)
std_frequencies = np.zeros((max_model_order, max_modes))
std_damping = np.zeros((max_model_order, max_modes))
std_participation_vectors = np.zeros(
(n_r, max_modes, max_model_order), dtype=complex)
std_mode_shapes = np.zeros((n_l, max_modes, max_model_order), dtype=complex)
# keyed by order: the number of modes varies with it
U_fixi_cache = {} if cache_variance_factors else None
cache_shapes = cache_variance_factors and self.cache == 'full'
U_L_cache = {} if cache_shapes else None
U_phii_cache = {} if cache_shapes else None
pbar = simplePbar(max_model_order)
for order in range(1, max_model_order):
next(pbar)
# the assembly is needed in full, so estimate_model is bypassed
ctx = self._assemble_normal_equations(order, complex_coefficients)
f, d, phi, lamda = self.modal_analysis_residuals(ctx.alpha, ctx.beta_l_i)
n_modes = len(f)
scores = self._stage1_scores(ctx, self._modal_ctx, lamda, spec_block_factor)
if modal_contrib:
_, delta = self.synthesize_spectrum(
ctx.alpha, ctx.beta_l_i, True, validation_blocks=validation_blocks)
modal_contributions[order, :n_modes] = delta
modal_frequencies[order, :n_modes] = f
modal_damping[order, :n_modes] = d
eigenvalues[order, :n_modes] = lamda
mode_shapes[:, :n_modes, order] = phi
participation_vectors[:, :n_modes, order] = self._participation_vectors
std_frequencies[order, :n_modes] = np.sqrt(
np.einsum('rj,rj->r', scores.d_frequency, scores.d_frequency))
std_damping[order, :n_modes] = np.sqrt(
np.einsum('rj,rj->r', scores.d_damping, scores.d_damping))
# real and imaginary parts are propagated jointly, then split as in
# VarSSIRef: std of the real part into .real, of the imaginary into .imag
U_L = np.concatenate([np.real(scores.d_participation),
np.imag(scores.d_participation)], axis=0)
var_L = np.einsum('crj,crj->cr', U_L, U_L)
std_participation_vectors.real[:, :n_modes, order] = np.sqrt(var_L[:n_r,:])
std_participation_vectors.imag[:, :n_modes, order] = np.sqrt(var_L[n_r:,:])
d_mode_shape = self._mode_shape_scores(self._lsfd_ctx, scores, spec_block_factor)
U_phi = np.concatenate([np.real(d_mode_shape),
np.imag(d_mode_shape)], axis=0)
var_phi = np.einsum('crj,crj->cr', U_phi, U_phi)
std_mode_shapes.real[:, :n_modes, order] = np.sqrt(var_phi[:n_l,:])
std_mode_shapes.imag[:, :n_modes, order] = np.sqrt(var_phi[n_l:,:])
if cache_variance_factors:
# mode index first, block index last, as apply_block_weights reads them
U_fixi_cache[order] = np.stack(
[scores.d_frequency, scores.d_damping], axis=1).astype(self.cache_dtype)
if cache_shapes:
U_L_cache[order] = np.moveaxis(U_L, 1, 0).astype(self.cache_dtype)
U_phii_cache[order] = np.moveaxis(U_phi, 1, 0).astype(self.cache_dtype)
self.max_model_order = max_model_order
self.eigenvalues = eigenvalues
self.modal_frequencies = modal_frequencies
self.modal_damping = modal_damping
self.mode_shapes = mode_shapes
self.modal_contributions = modal_contributions
self.participation_vectors = participation_vectors
self.std_frequencies = std_frequencies
self.std_damping = std_damping
self.std_participation_vectors = std_participation_vectors
self.std_mode_shapes = std_mode_shapes
if cache_variance_factors:
self._U_fixi_cache = U_fixi_cache
self._U_L_cache = U_L_cache
self._U_phii_cache = U_phii_cache
self.state[1] = True