"""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])
]