Source code for vane.viz.modeshape_static

# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University
"""Static mode-shape (participation) plotting.

Since OpenFAST mode shapes are expressed over modal degrees of freedom rather than
spatial coordinates, a mode is visualized as a horizontal bar chart of the signed
participation of its most active degrees of freedom: bar length is the relative
contribution and the sign encodes the in-/out-of-phase relationship to the dominant
degree of freedom. The plot is therefore modal *participation*, not spatial geometry —
hence the primary name :func:`plot_mode_participation`. Following the lab's mode-shape
conventions, the mode is named in the legend with its natural frequency, a thin grey
line marks zero, and ``short_names=True`` names the degrees of freedom briefly
(``"Tower FA1"``, ``"Flap 1 coll."``).
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import numpy as np

from vane.modal.participation import participation_from_modes
from vane.viz._style import (
    STANDARD_COLORS,
    VANE_STYLE,
    new_axes,
    resolve_size,
    short_dof_name,
    static_style,
)

if TYPE_CHECKING:
    from pathlib import Path

    from matplotlib.axes import Axes
    from matplotlib.figure import Figure

    from vane.modal.eigensolver import ModalSolution
    from vane.viz.publication import FigureSize

__all__ = ["plot_mode_participation", "plot_mode_shape"]

_BAR_COLOR = STANDARD_COLORS[2]


[docs] def plot_mode_participation( solution: ModalSolution, mode_index: int, *, top_n: int = 10, ax: Axes | None = None, color: str = _BAR_COLOR, style: str | dict[str, Any] | Path | None = VANE_STYLE, size: str | float | FigureSize | None = None, short_names: bool = False, title: str | None = None, ) -> Figure: """Plot the signed participation of a mode's most active degrees of freedom. Parameters ---------- solution : ModalSolution A solution whose ``dof_descriptions`` are populated. mode_index : int Index of the mode to plot. top_n : int, optional Number of highest-participation degrees of freedom to show. ax : matplotlib.axes.Axes or None, optional Draw into these axes instead of creating a new figure (for composing a multi-panel report). The axes keep their own appearance; the parent figure is returned. color : str, optional Bar colour; blue, the third standard line colour, by default. style : str, dict, pathlib.Path or None, optional Matplotlib style in effect while drawing. ``"vane"`` (the default) is the VANE style (:func:`vane.viz.plot_style`); ``None`` keeps the current ``rcParams``; any other value is passed to :func:`matplotlib.style.context`. size : str, float, FigureSize or None, optional Figure size and fonts (see :func:`vane.viz.figure_size`); ``None`` uses ``"single"`` for a new figure. short_names : bool, optional Label the bars with short DOF names (``"Tower FA1"``, ``"Flap 1 coll."``) instead of the full state descriptions (the default). Short names suit a printed figure. title : str or None, optional Axes title; ``None`` (the default) draws none. The legend names the mode and its natural frequency. Returns ------- matplotlib.figure.Figure The bar-chart figure. Raises ------ ValueError If the solution has no DOF descriptions, ``mode_index`` is out of range, or ``top_n`` is not positive. """ if not solution.dof_descriptions: msg = "ModalSolution has no dof_descriptions; cannot plot mode shape" raise ValueError(msg) if not 0 <= mode_index < solution.n_modes: msg = f"mode_index {mode_index} out of range [0, {solution.n_modes})" raise ValueError(msg) if top_n < 1: msg = f"top_n must be at least 1, got {top_n}" raise ValueError(msg) participation = participation_from_modes(solution) signed = participation.signed_magnitude[:, mode_index] order = np.argsort(np.abs(signed))[::-1][:top_n] coordinates = solution.dof_mbc_coordinates labels = [ short_dof_name( solution.dof_descriptions[i], coordinates[i] if i < len(coordinates) else "", ) if short_names else solution.dof_descriptions[i] for i in order.tolist() ] values = signed[order] frequency = float(solution.natural_frequencies_hz[mode_index]) count = len(order) aspect = ( min(1.0, max(0.5, 0.075 * count + 0.25)), min(0.6, max(0.3, 0.04 * count + 0.15)), ) resolved = resolve_size(size, "single", ax=ax, style=style, aspect=aspect) with static_style(style, resolved): figure, axes = new_axes(ax) positions = np.arange(len(order)) axes.barh( positions, values, color=color, height=0.7, label=f"Mode {mode_index} ({frequency:.4g} Hz)", ) axes.set_yticks(positions) axes.set_yticklabels(labels) axes.invert_yaxis() axes.axvline(0.0, color="0.5", linewidth=0.6) limit = max(1.0, float(np.max(np.abs(values)))) if values.size else 1.0 axes.set_xlim(-1.08 * limit, 1.08 * limit) axes.set_xlabel("Signed participation (-)") axes.legend( loc="lower left", bbox_to_anchor=(0.0, 1.02), borderaxespad=0.0, handlelength=1.2, ) if title is not None: axes.set_title(title) return figure
[docs] def plot_mode_shape( solution: ModalSolution, mode_index: int, *, top_n: int = 10, ax: Axes | None = None, color: str = _BAR_COLOR, style: str | dict[str, Any] | Path | None = VANE_STYLE, size: str | float | FigureSize | None = None, short_names: bool = False, title: str | None = None, ) -> Figure: """Compatibility alias for :func:`plot_mode_participation`. The plot shows signed modal **participation**, not spatial mode-shape geometry; :func:`plot_mode_participation` is the accurate name. This alias forwards all arguments unchanged. """ return plot_mode_participation( solution, mode_index, top_n=top_n, ax=ax, color=color, style=style, size=size, short_names=short_names, title=title, )