Source code for vane.modal.labeler

"""Physics-aware labeling of system modes.

Each mode is labeled by the physical degree-of-freedom category that dominates
its shape. Participation magnitudes are aggregated per :class:`DofCategory`
(via :func:`vane.config.dof_map.classify_dof`), the dominant category names the
mode (e.g. "1st Tower Fore-Aft"), and the fraction of participation it carries is
reported as a confidence — a low value flags an ambiguous, mixed mode.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

import numpy as np

from vane.config.dof_map import DofCategory, classify_dof

if TYPE_CHECKING:
    from collections.abc import Sequence

    import numpy.typing as npt

    from vane.modal.eigensolver import ModalSolution
    from vane.modal.participation import ParticipationResult

__all__ = [
    "ModeLabel",
    "category_to_label",
    "label_mode",
    "label_modes",
    "label_solution",
]

_LABELS: dict[DofCategory, str] = {
    DofCategory.PLATFORM_SURGE: "Platform Surge",
    DofCategory.PLATFORM_SWAY: "Platform Sway",
    DofCategory.PLATFORM_HEAVE: "Platform Heave",
    DofCategory.PLATFORM_ROLL: "Platform Roll",
    DofCategory.PLATFORM_PITCH: "Platform Pitch",
    DofCategory.PLATFORM_YAW: "Platform Yaw",
    DofCategory.TOWER_FORE_AFT_1: "1st Tower Fore-Aft",
    DofCategory.TOWER_SIDE_SIDE_1: "1st Tower Side-Side",
    DofCategory.TOWER_FORE_AFT_2: "2nd Tower Fore-Aft",
    DofCategory.TOWER_SIDE_SIDE_2: "2nd Tower Side-Side",
    DofCategory.NACELLE_YAW: "Nacelle Yaw",
    DofCategory.GENERATOR_AZIMUTH: "Rotor/Generator",
    DofCategory.DRIVETRAIN_TORSION: "Drivetrain Torsion",
    DofCategory.ROTOR_FURL: "Rotor Furl",
    DofCategory.TAIL_FURL: "Tail Furl",
    DofCategory.TEETER: "Teeter",
    DofCategory.BLADE_FLAP_1: "1st Blade Flap",
    DofCategory.BLADE_FLAP_2: "2nd Blade Flap",
    DofCategory.BLADE_EDGE_1: "1st Blade Edge",
    DofCategory.BLADE_PITCH: "Blade Pitch",
    DofCategory.UNKNOWN: "Unidentified",
}


[docs] def category_to_label(category: DofCategory) -> str: """Return the human-readable label for a DOF category. Parameters ---------- category : DofCategory The category to name. Returns ------- str The display label (``"Unidentified"`` for unknown categories). """ return _LABELS.get(category, _LABELS[DofCategory.UNKNOWN])
[docs] @dataclass(frozen=True) class ModeLabel: """Physical label for a single mode. Parameters ---------- category : DofCategory The dominant DOF category. label : str Human-readable name of the dominant category. confidence : float Fraction of total participation carried by the dominant category, in ``[0, 1]``; low values indicate a mixed or ambiguous mode. dominant_dofs : list[str] Descriptions of the highest-participation degrees of freedom (up to three). multiblade : str or None For a blade mode after the MBC transform, the multi-blade type ``"collective"``, ``"regressive"``, ``"progressive"``, or ``"cyclic"`` (a standing cyclic mode with no whirl direction); ``None`` for non-blade modes or when no multi-blade information is available. """ category: DofCategory label: str confidence: float dominant_dofs: list[str] multiblade: str | None = None
[docs] def label_mode( magnitudes: npt.NDArray[np.float64], descriptions: Sequence[str], *, multiblade: str | None = None, ) -> ModeLabel: """Label one mode from its per-DOF participation magnitudes. Parameters ---------- magnitudes : npt.NDArray[np.float64] Non-negative participation magnitude of each DOF, shape ``(n_dof,)``. descriptions : Sequence[str] DOF descriptions parallel to ``magnitudes``. multiblade : str or None, optional Multi-blade type (``"collective"``/``"regressive"``/``"progressive"``) for a blade mode; appended to the label and stored on the result. Returns ------- ModeLabel The dominant category, its label, the confidence, and the top DOFs. Raises ------ ValueError If ``magnitudes`` and ``descriptions`` have different lengths. """ if magnitudes.shape[0] != len(descriptions): msg = ( f"magnitudes length {magnitudes.shape[0]} does not match " f"{len(descriptions)} descriptions" ) raise ValueError(msg) totals: dict[DofCategory, float] = {} for magnitude, description in zip(magnitudes.tolist(), descriptions, strict=True): category = classify_dof(description).category totals[category] = totals.get(category, 0.0) + float(magnitude) total = sum(totals.values()) if not totals or total <= 0.0: return ModeLabel( DofCategory.UNKNOWN, _LABELS[DofCategory.UNKNOWN], 0.0, [], multiblade ) dominant = max(totals, key=lambda category: totals[category]) confidence = totals[dominant] / total order = np.argsort(magnitudes)[::-1] dominant_dofs = [descriptions[i] for i in order[:3].tolist() if magnitudes[i] > 0.0] label = _LABELS[dominant] if multiblade is not None: label = f"{label} ({multiblade.capitalize()})" return ModeLabel(dominant, label, confidence, dominant_dofs, multiblade)
def _multiblade_type( shape: npt.NDArray[np.complex128], mbc_coordinates: Sequence[str] ) -> str | None: r"""Classify a mode as collective/regressive/progressive from its MBC coordinates. Collective vs cyclic is decided by which multi-blade coordinates carry the mode; for a cyclic mode the whirl direction is the sign of :math:`\operatorname{Im}(q_s \, \overline{q_c})` for the dominant cosine/sine pair, which is invariant to the mode's overall phase. Returns ``None`` when the mode carries no multi-blade participation. Parameters ---------- shape : npt.NDArray[np.complex128] Complex mode shape over the degrees of freedom. mbc_coordinates : Sequence[str] Multi-blade coordinate of each DOF (``"collective"``/``"cosine"``/``"sine"`` or ``""``), aligned with ``shape``. Returns ------- str or None ``"collective"``, ``"regressive"``, ``"progressive"``, ``"cyclic"`` (a standing cyclic mode), or ``None``. """ if not mbc_coordinates: return None magnitude = np.abs(shape) total = float(magnitude.sum()) if total <= 0.0: return None collective = float( sum(magnitude[i] for i, c in enumerate(mbc_coordinates) if c == "collective") ) cyclic_indices = [ i for i, c in enumerate(mbc_coordinates) if c in ("cosine", "sine") ] cyclic = float(sum(magnitude[i] for i in cyclic_indices)) # Only a blade-dominated mode gets a multi-blade type: a non-blade mode (e.g. # tower) with tiny coupling into the blade coordinates must not be mislabeled. if collective + cyclic < 0.5 * total: return None if collective >= cyclic: return "collective" dominant = max(cyclic_indices, key=lambda i: float(magnitude[i])) cosine_index, sine_index = ( (dominant, dominant + 1) if mbc_coordinates[dominant] == "cosine" else (dominant - 1, dominant) ) if not (0 <= cosine_index < shape.shape[0] and 0 <= sine_index < shape.shape[0]): return None cosine_value, sine_value = shape[cosine_index], shape[sine_index] intensity = float(np.abs(cosine_value) ** 2 + np.abs(sine_value) ** 2) if intensity <= 0.0: return "cyclic" whirl = float((sine_value * np.conj(cosine_value)).imag) # Circularity in [-1, 1]: +/-1 for a pure forward/backward whirl, ~0 for a # standing pattern OR when one coordinate is mere numerical leakage (both # whirl and intensity then collapse). Only a genuinely whirling mode -- both # coordinates significant AND in quadrature -- is given a whirl direction. circularity = 2.0 * whirl / intensity if abs(circularity) < 0.2: return "cyclic" # Sign calibrated against the known Coleman split: the lower-frequency backward # whirl (w0 - Omega) is regressive, the higher one progressive. return "regressive" if circularity > 0.0 else "progressive"
[docs] def label_modes( participation: ParticipationResult, descriptions: Sequence[str] ) -> list[ModeLabel]: """Label every mode of a participation result. Parameters ---------- participation : ParticipationResult Per-DOF participation (its ``magnitude`` columns are one mode each). descriptions : Sequence[str] DOF descriptions parallel to the participation rows. Returns ------- list[ModeLabel] One label per mode (column). """ return [ label_mode(participation.magnitude[:, col], descriptions) for col in range(participation.magnitude.shape[1]) ]
[docs] def label_solution(solution: ModalSolution) -> list[ModeLabel]: """Label every mode of a modal solution using its mode shapes. Parameters ---------- solution : ModalSolution A solution whose ``dof_descriptions`` are populated (i.e. produced with descriptions, as by :func:`vane.modal.eigensolver.modes_from_mbc`). Returns ------- list[ModeLabel] One label per mode. Raises ------ ValueError If the solution carries no DOF descriptions. """ if not solution.dof_descriptions: msg = "ModalSolution has no dof_descriptions; cannot label modes" raise ValueError(msg) magnitudes = np.abs(solution.mode_shapes) mbc_coordinates = solution.dof_mbc_coordinates return [ label_mode( magnitudes[:, col], solution.dof_descriptions, multiblade=_multiblade_type(solution.mode_shapes[:, col], mbc_coordinates), ) for col in range(magnitudes.shape[1]) ]