Source code for g2elin_core.modal.toolbox

"""The rest of ``Functions/modal_analysis.m``'s toolbox: eigenvalue
sensitivity, mode shapes, closed-form free-motion response, and MIMO step
response. ``modal/analysis.py``'s own docstring already flagged these as
"not ported yet" -- this is that follow-up, read directly from the MATLAB
source (not guessed) to keep the math and the plotting choices faithful.

Everything here is pure linear algebra on the eigendecomposition
:func:`~g2elin_core.modal.analysis.analyze` already computes, or (for step
response) a thin :mod:`scipy.signal` wrapper over the same ``A``/``B``/
``C``/``D`` :func:`~g2elin_core.pipeline.linearize_network` already
produces -- no new model type, no new derivation.
"""

from __future__ import annotations

import math
from dataclasses import dataclass

import numpy as np
import scipy.signal

from g2elin_core.interconnect import AssembledSystem
from g2elin_core.modal.analysis import ModalAnalysisResult


[docs] @dataclass(frozen=True) class SensitivityEntry: row_state: str col_state: str value: float
[docs] @dataclass(frozen=True) class SensitivityResult: mode: int matrix: np.ndarray # |sensitivity|, masked to A's structurally-nonzero entries, shape (n, n) top: list[SensitivityEntry] # top-8 entries, matching modal_analysis.m's max_sens table column
[docs] def eigenvalue_sensitivity(modal: ModalAnalysisResult, A: np.ndarray, mode: int, n_top: int = 8) -> SensitivityResult: """``sensMatrix(k,j,i) = leftV(i,k)*rightV(j,i) / (leftV(i,:)*rightV(:,i))`` for the chosen mode ``i`` (``modal_analysis.m`` Part 3) -- the standard eigenvalue-to-matrix-element sensitivity (Kundur eq. 12.75-ish): ``d(lambda_i)/d(A[k,j]) = w_i[k] * v_i[j]``, ``w_i``/``v_i`` the left/ right eigenvectors of mode ``i``. Masked to where ``A`` is actually nonzero (``modal_analysis.m``'s ``Anum_ones``) -- a sensitivity value at a structurally-zero ``A`` entry isn't a "sensitivity to a real parameter," it's noise from an entry that was never a parameter to begin with. """ w_i = modal.left_eigenvectors[mode, :] v_i = modal.right_eigenvectors[:, mode] denom = w_i @ v_i # ~1 by construction (W@V=I), kept explicit rather than assumed sens = np.abs(np.outer(w_i, v_i) / denom) mask = A != 0 sens = sens * mask flat_idx = np.argsort(sens, axis=None)[::-1][:n_top] top = [] for idx in flat_idx: k, j = np.unravel_index(idx, sens.shape) if sens[k, j] <= 0: continue top.append(SensitivityEntry(row_state=modal.state_names[k], col_state=modal.state_names[j], value=float(sens[k, j]))) return SensitivityResult(mode=mode, matrix=sens, top=top)
[docs] @dataclass(frozen=True) class ModeShapeResult: mode: int states: list[str] angles_deg: list[float] # magnitude is always 1 by construction (modal_analysis.m's ModeMatrix)
[docs] def mode_shape(modal: ModalAnalysisResult, mode: int, n_top: int = 5) -> ModeShapeResult: """Phase angle of the top-``n_top`` participating states' right eigenvector components, for polar plotting (``modal_analysis.m`` Part: "Plotting the mode shape"). Magnitude is fixed at 1 in the original (``ModeMatrix(k,1:2,i) = [1 angle(rightV(k,i))]``) -- it's a *shape* plot (relative phase between states' oscillation), not a magnitude one. """ part_col = np.abs(modal.participation[:, mode]) top_idx = np.argsort(part_col)[::-1][:n_top] v = modal.right_eigenvectors[:, mode] return ModeShapeResult( mode=mode, states=[modal.state_names[i] for i in top_idx], angles_deg=[math.degrees(float(np.angle(v[i]))) for i in top_idx], )
[docs] def free_response(modal: ModalAnalysisResult, perturb_index: int, offset: float, t: np.ndarray) -> np.ndarray: """Closed-form modal-expansion response to an initial-condition perturbation, no ODE integration needed (``modal_analysis.m``'s "Free Motion" section): ``x(t) = V @ (c * exp(eig*t))``, ``c = W @ x0`` the modal coordinates of the perturbation ``x0`` (a unit vector at ``perturb_index`` scaled by ``offset``). Returns ``x(t)`` for *every* state (shape ``(n_states, len(t))``) -- cheap (this is an O(n^2) matrix product per timestep for these state counts, not a solve), so there's no reason to compute only one state's row and force a second call for another. """ n = len(modal.state_names) x0 = np.zeros(n, dtype=complex) x0[perturb_index] = offset c = modal.left_eigenvectors @ x0 # modal coordinates, shape (n,) exp_terms = np.exp(np.outer(modal.eigenvalues, t)) # (n_modes, n_t) x_t = modal.right_eigenvectors @ (c[:, None] * exp_terms) # (n_states, n_t) return np.real(x_t) # imaginary parts cancel across conjugate-pair modes up to float noise
[docs] def step_response( system: AssembledSystem, input_name: str, output_name: str, amplitude: float, t: np.ndarray ) -> np.ndarray: """MIMO step response between one chosen input/output pair (``modal_analysis.m``'s "step_resp" section), via a SISO reduction of the already-linearized ``(A, B, C, D)`` -- pick the one column of ``B`` and row of ``C``/``D`` the chosen input/output correspond to, scale the unit-step response by ``amplitude`` afterward (linear system, so this is exact, not an approximation). """ i = system.input_names.index(input_name) j = system.output_names.index(output_name) b_col = system.B[:, i : i + 1] c_row = system.C[j : j + 1, :] d_val = system.D[j : j + 1, i : i + 1] sys = scipy.signal.StateSpace(system.A, b_col, c_row, d_val) _, y = scipy.signal.step(sys, T=t) return amplitude * y