Source code for vane.sysid.rom

# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University
r"""Reduced-order modal models from a linearized state space.

A full OpenFAST linearization carries hundreds of states, many of them fast,
weakly observable, or numerically marginal aero/actuator modes that a downstream
estimator neither needs nor can stabilise. This module reduces the system to the few
**structural modes that actually drive a quantity of interest**, in a real,
block-diagonal modal realization that preserves the inputs and outputs.

Each retained underdamped mode :math:`\lambda = \sigma + j\omega` (with right
eigenvector :math:`\mathbf v` and left eigenvector :math:`\mathbf w`,
:math:`\mathbf w^{\mathsf H}\mathbf v = 1`) becomes a real :math:`2\times2` block

.. math::

   \mathbf A_k = \begin{bmatrix}\sigma & -\omega\\ \omega & \sigma\end{bmatrix},\quad
   \mathbf B_k = \begin{bmatrix}\operatorname{Re}(\mathbf w^{\mathsf H}\mathbf B)\\
   \operatorname{Im}(\mathbf w^{\mathsf H}\mathbf B)\end{bmatrix},\quad
   \mathbf C_k = \big[\,2\operatorname{Re}(\mathbf C\mathbf v)\ \;
   -2\operatorname{Im}(\mathbf C\mathbf v)\,\big],

with the feed-through :math:`\mathbf D` unchanged. The block's eigenvalues are exactly
:math:`\sigma\pm j\omega`, so the retained modal frequencies and damping are preserved
to machine precision. Modes are ranked by their **residue** in a chosen output (the
load-observability ranking): the residue of mode :math:`k` in output :math:`i` is
:math:`|\mathbf C_i\mathbf v_k|`, i.e. how strongly that mode appears in that load.

This is the reduction engine the digital-twin / reduced-estimator path depends on. It is
the modal counterpart to balanced truncation; unlike balanced truncation it tolerates
the unstable aero poles a turbine linearization contains (they are simply not selected,
and any retained pole can optionally be reflected into the stable half-plane).
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

import numpy as np
from scipy.linalg import eig as _eig

from vane.sysid.state_space import StateSpace, static_gain

if TYPE_CHECKING:
    from collections.abc import Sequence

    import numpy.typing as npt

__all__ = ["ModalReduction", "modal_reduction", "output_mode_residues"]

_TWO_PI = 2.0 * np.pi
_IMAG_TOLERANCE = 1e-8
# Eigenvalues this close are treated as one degenerate cluster (a genuinely repeated
# mode). Their modal projections are de-coupled together via the cluster dual rather
# than normalized independently, which would not be bi-orthogonal within the repeated
# subspace. The tolerance is tight so distinct-but-near (veering) modes are not
# clustered — those keep the well-conditioned per-vector normalization.
_CLUSTER_RTOL = 1e-8
_CLUSTER_ATOL = 1e-12
# A repeated eigenvalue is *defective* (non-diagonalizable, a Jordan chain) when its
# right eigenvectors do not span its eigenspace: the columns SciPy returns for the
# cluster are then (numerically) parallel and ``cond(V_c)`` blows up. A diagonalizable
# repeated cluster keeps ``cond(V_c)`` near O(1), so above this floor the cluster is not
# realizable as independent modal blocks and the reduction is rejected. The conditioning
# of the unit-norm eigenvector matrix is basis- and scale-independent, unlike a single
# diagonal overlap ``|w_i^H v_i|`` (which depends on LAPACK's arbitrary eigenbasis).
_DEFECTIVE_COND = 1e8
# Do not normalize numerical residue leakage into a full-strength QoI vote. A selected
# row is active only when its candidate-mode residue norm is material relative to the
# row norm of C; sqrt(eps) separates numerical from resolved first-order projection.
_REL_RESIDUE_TOL = np.sqrt(np.finfo(np.float64).eps)


[docs] @dataclass class ModalReduction: """A modally reduced state space and the modes it retained. Parameters ---------- system : StateSpace The reduced, real, block-diagonal continuous state space (two states per retained mode), with the original inputs and outputs. frequencies_hz : npt.NDArray[np.float64] Natural frequency of each retained mode (Hz), in the reduced state order. damping_ratios : npt.NDArray[np.float64] Damping ratio of each retained mode, consistent with the realized reduced ``A`` block: a mode reflected into the stable half-plane (see ``n_stabilized``) reports its post-stabilisation (positive) damping, not the original value. residues : npt.NDArray[np.float64] The ranking residue of each retained mode (its magnitude in the ranked output, or its observability norm when no output was specified). energy_retained : float Fraction of the candidate modes' total squared residue captured by the retained modes, in ``[0, 1]`` — a cheap measure of how much of the ranked output's modal content the reduction keeps. n_full_states : int Number of states in the original system. n_stabilized : int How many retained modes had an unstable eigenvalue reflected into the stable half-plane (``0`` unless the system had unstable poles among those kept). state_projection : npt.NDArray[np.float64] or None Real map from a full physical state into the retained modal coordinates. state_reconstruction : npt.NDArray[np.float64] or None Real map from retained modal coordinates back to the selected full-state subspace. Together these maps permit a consistently based periodic/LPV projection without independently reducing and re-phasing every azimuth. stabilized : npt.NDArray[np.bool_] or None Per retained mode, ``True`` where an unstable eigenvalue was reflected into the stable half-plane (``n_stabilized`` is its count). The physical (pre-reflection) damping of a reflected mode is ``-damping_ratios[k]``; its natural frequency is unchanged. ``None`` for a hand-built reduction that does not record it. """ system: StateSpace frequencies_hz: npt.NDArray[np.float64] damping_ratios: npt.NDArray[np.float64] residues: npt.NDArray[np.float64] energy_retained: float n_full_states: int n_stabilized: int state_projection: npt.NDArray[np.float64] | None = None state_reconstruction: npt.NDArray[np.float64] | None = None stabilized: npt.NDArray[np.bool_] | None = None
def _resolve_output(system: StateSpace, output: int | str) -> int: """Resolve an output index or name to a row index of ``C``.""" if isinstance(output, (int, np.integer)): n = 0 if system.c is None else system.c.shape[0] index = int(output) if not 0 <= index < n: msg = f"output index {output} out of range [0, {n})" raise ValueError(msg) return index if not isinstance(output, str): msg = ( f"output must be an integer index or string name, got " f"{type(output).__name__}" ) raise ValueError(msg) # noqa: TRY004 exact = [i for i, name in enumerate(system.output_names) if name == output] if exact: return exact[0] contains = [i for i, name in enumerate(system.output_names) if output in name] if not contains: msg = f"no output matches {output!r}; have {system.output_names}" raise ValueError(msg) if len(contains) > 1: hits = ", ".join(repr(system.output_names[i]) for i in contains) msg = ( f"output {output!r} matches multiple channels ({hits}); use a more " "specific name so the ranked output is unambiguous" ) raise ValueError(msg) return contains[0] def _resolve_input(system: StateSpace, input_channel: int | str) -> int: """Resolve an input index or name to a column index of ``B``.""" if isinstance(input_channel, (int, np.integer)): index = int(input_channel) if not 0 <= index < system.n_inputs: msg = f"input index {input_channel} out of range [0, {system.n_inputs})" raise ValueError(msg) return index if not isinstance(input_channel, str): msg = ( f"input must be an integer index or string name, got " f"{type(input_channel).__name__}" ) raise ValueError(msg) # noqa: TRY004 exact = [i for i, name in enumerate(system.input_names) if name == input_channel] if exact: return exact[0] contains = [i for i, name in enumerate(system.input_names) if input_channel in name] if not contains: msg = f"no input matches {input_channel!r}; have {system.input_names}" raise ValueError(msg) if len(contains) > 1: hits = ", ".join(repr(system.input_names[i]) for i in contains) msg = ( f"input {input_channel!r} matches multiple channels ({hits}); use a more " "specific name so the transfer pair is unambiguous" ) raise ValueError(msg) return contains[0]
[docs] def output_mode_residues( system: StateSpace, output: int | str ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]]: r"""Rank the underdamped modes by their residue in one output (load). The residue of mode :math:`k` in the output is :math:`|\\mathbf C_i\\mathbf v_k|` — how strongly the mode appears in that load. This is the load-observability ranking that drives :func:`modal_reduction` and is the basis for choosing which modes a load estimator must retain. Parameters ---------- system : StateSpace A continuous system with a ``C`` matrix. output : int or str The output row index, or an output name (exact match, else substring). Returns ------- tuple of npt.NDArray[np.float64] ``(frequencies_hz, damping_ratios, residues)`` for the underdamped modes, sorted by descending residue. Raises ------ ValueError If the system is discrete, has no ``C`` matrix, or the output cannot be resolved. """ if system.dt is not None: msg = "output_mode_residues requires a continuous-time system (dt is None)" raise ValueError(msg) if system.c is None: msg = "system has no C matrix; cannot rank modes by output residue" raise ValueError(msg) row = _resolve_output(system, output) eigvals, eigvecs = np.linalg.eig(system.a) keep = eigvals.imag > _IMAG_TOLERANCE lam = eigvals[keep] vecs = eigvecs[:, keep] residues = np.abs(system.c[row] @ vecs) order = np.argsort(residues)[::-1] lam = lam[order] magnitude = np.abs(lam) freqs = magnitude / _TWO_PI # natural frequency |lambda| damping = np.where(magnitude > 0.0, -lam.real / magnitude, 0.0) return freqs, damping, np.asarray(residues[order], dtype=np.float64)
def _cluster_indices( eigvals: npt.NDArray[np.complex128], index: int ) -> npt.NDArray[np.int_]: """Return the indices of the degenerate cluster (equal eigenvalues) of ``index``.""" return np.flatnonzero( np.isclose(eigvals, eigvals[index], rtol=_CLUSTER_RTOL, atol=_CLUSTER_ATOL) ) def _reject_defective_modes( eigvals: npt.NDArray[np.complex128], eigvecs: npt.NDArray[np.complex128], selected: npt.NDArray[np.int_], ) -> None: """Raise if a selected mode belongs to a defective (non-diagonalizable) cluster. A real ``2x2`` modal block can only reproduce a *diagonalizable* mode. A diagonalizable repeated eigenvalue has a full-dimensional eigenspace, so the right eigenvectors SciPy returns for the cluster are independent and ``cond(V_c)`` stays near O(1). A defective eigenvalue (a Jordan chain) has a deficient eigenspace, so those columns are (numerically) parallel and ``cond(V_c)`` blows up. The test uses the conditioning of the whole unit-norm cluster matrix (basis- and scale-free) rather than a single diagonal overlap, whose value depends on LAPACK's arbitrary eigenbasis within the eigenspace. Distinct (singleton) modes are always diagonalizable. Raises ------ ValueError If any selected mode belongs to a defective repeated-eigenvalue cluster. """ seen: set[int] = set() for i in selected: cluster = _cluster_indices(eigvals, int(i)) root = int(cluster[0]) if cluster.size == 1 or root in seen: continue seen.add(root) if np.linalg.cond(eigvecs[:, cluster]) > _DEFECTIVE_COND: msg = ( "modal_reduction cannot reduce a defective (non-diagonalizable) " f"repeated mode near eigenvalue {eigvals[i]:.6g}: its eigenvectors do " "not span the eigenspace (a Jordan chain), so a block-diagonal modal " "realization would drop the generalized-eigenvector dynamics." ) raise ValueError(msg) def _dual_rows( eigvals: npt.NDArray[np.complex128], eigvecs: npt.NDArray[np.complex128], left_vecs: npt.NDArray[np.complex128], selected: npt.NDArray[np.int_], ) -> npt.NDArray[np.complex128]: r"""Return each selected mode's bi-orthonormal left-projection row (rows of V^-1). Left and right eigenvectors of *distinct* eigenvalues are orthogonal automatically, so a mode only needs decoupling from its own degenerate cluster (equal eigenvalues). For a singleton cluster the dual is just :math:`\mathbf w_i` scaled to :math:`\mathbf w_i^{\mathsf H}\mathbf v_i = 1`; for a repeated-eigenvalue cluster it is the small dual :math:`(\mathbf W_c^{\mathsf H}\mathbf V_c)^{-1}\mathbf W_c^{\mathsf H}`, which restores bi-orthogonality *within* the cluster without inverting the full (possibly ill-conditioned) eigenvector matrix. """ rows = np.empty((selected.size, eigvecs.shape[0]), dtype=np.complex128) for k, i in enumerate(selected): cluster = _cluster_indices(eigvals, int(i)) if cluster.size == 1: w = left_vecs[:, i].conj() rows[k] = w / (w @ eigvecs[:, i]) continue w_c = left_vecs[:, cluster].conj().T # (m, n) v_c = eigvecs[:, cluster] # (n, m) dual_c = np.linalg.solve( w_c @ v_c, w_c ) # (m, n) = rows of V^-1 for the cluster pos = int(np.flatnonzero(cluster == i)[0]) rows[k] = dual_c[pos] return rows def _build_reduced( system: StateSpace, eigvals: npt.NDArray[np.complex128], eigvecs: npt.NDArray[np.complex128], left_vecs: npt.NDArray[np.complex128], selected: npt.NDArray[np.int_], *, stabilize: bool, ) -> ModalReduction: """Assemble the real block-diagonal reduced system over the selected modes.""" c = system.c assert c is not None # guaranteed by the caller b = system.b # Reject defective (Jordan-chain) repeated modes up front: they cannot be realized # as independent modal blocks, regardless of whether an input matrix is present. _reject_defective_modes(eigvals, eigvecs, selected) dual = _dual_rows(eigvals, eigvecs, left_vecs, selected) n_modes = selected.size n_states = 2 * n_modes a_r = np.zeros((n_states, n_states), dtype=np.float64) c_r = np.zeros((c.shape[0], n_states), dtype=np.float64) b_r = None if b is None else np.zeros((n_states, b.shape[1]), dtype=np.float64) freqs = np.empty(n_modes, dtype=np.float64) damping = np.empty(n_modes, dtype=np.float64) state_projection = np.empty((n_states, system.n_states), dtype=np.float64) state_reconstruction = np.empty((system.n_states, n_states), dtype=np.float64) stabilized = np.zeros(n_modes, dtype=np.bool_) for k, i in enumerate(selected): lam = eigvals[i] omega = float(lam.imag) sigma = float(lam.real) if stabilize and sigma > 0.0: sigma = -sigma stabilized[k] = True block = 2 * k a_r[block, block], a_r[block, block + 1] = sigma, -omega a_r[block + 1, block], a_r[block + 1, block + 1] = omega, sigma cv = c @ eigvecs[:, i] c_r[:, block] = 2.0 * cv.real c_r[:, block + 1] = -2.0 * cv.imag state_projection[block] = dual[k].real state_projection[block + 1] = dual[k].imag state_reconstruction[:, block] = 2.0 * eigvecs[:, i].real state_reconstruction[:, block + 1] = -2.0 * eigvecs[:, i].imag if b_r is not None and b is not None: wb = dual[k] @ b b_r[block] = wb.real b_r[block + 1] = wb.imag magnitude = abs(lam) freqs[k] = magnitude / _TWO_PI # natural frequency |lambda| # Use the post-stabilization sigma so the reported damping matches the realized # A-block: a stabilized mode (sigma flipped negative) reports positive damping, # not the original unstable value. |lambda| is unchanged by the sign flip. damping[k] = -sigma / magnitude if magnitude > 0.0 else 0.0 state_names = [f"modal_{k}_{part}" for k in range(n_modes) for part in ("re", "im")] reduced = StateSpace( a=a_r, b=b_r, c=c_r, d=system.d, state_names=state_names, input_names=list(system.input_names), output_names=list(system.output_names), dt=None, output_mbc_coordinates=list(system.output_mbc_coordinates), ) return ModalReduction( system=reduced, frequencies_hz=freqs, damping_ratios=damping, residues=np.empty(n_modes, dtype=np.float64), energy_retained=0.0, n_full_states=system.a.shape[0], n_stabilized=int(np.count_nonzero(stabilized)), state_projection=state_projection, state_reconstruction=state_reconstruction, stabilized=stabilized, )