# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University
"""Complex mode-shape composition views.
A mode shape is complex, and after the multi-blade-coordinate transform the blade
degrees of freedom split into collective, cosine, and sine coordinates. These views
expose that structure directly, without needing turbine geometry: a **phasor wheel**
plots each dominant DOF as a vector (length = participation magnitude, angle = phase),
its **animated** variant rotates the phasors through one modal period so the relative
phasing reads as motion, and the **blade-component** bar chart shows how a mode's
participation divides among the collective/cosine/sine multi-blade coordinates. The
phasor wheel and the bar chart also have static (Matplotlib) forms for publication.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
import numpy as np
import plotly.graph_objects as go
from vane.viz._style import (
AXES_COLOR,
STANDARD_COLORS,
VANE_STYLE,
resolve_size,
resolve_template,
series_colors,
short_dof_name,
static_style,
)
if TYPE_CHECKING:
from pathlib import Path
import numpy.typing as npt
from matplotlib.figure import Figure
from matplotlib.projections.polar import PolarAxes
from vane.modal.eigensolver import ModalSolution
from vane.viz.publication import FigureSize
__all__ = [
"plot_blade_components",
"plot_blade_components_static",
"plot_mode_phasor",
"plot_mode_phasor_animation",
"plot_mode_phasor_static",
]
_MARKER_COLOR = STANDARD_COLORS[2]
_SPOKE_COLOR = "#c8c8c8"
_RADIAL_LABEL = "Normalized |participation| (-)"
# Bar colour for each multi-blade coordinate: black, red, blue.
_BLADE_COMPONENT_COLORS = {
"collective": STANDARD_COLORS[0],
"cosine": STANDARD_COLORS[1],
"sine": STANDARD_COLORS[2],
}
def _polar_layout() -> dict[str, Any]:
"""Return the polar-axis layout shared by the phasor figures."""
return {
"radialaxis": {"range": [0.0, 1.05], "title": {"text": _RADIAL_LABEL}},
"angularaxis": {"ticksuffix": "°", "direction": "counterclockwise"},
}
def _title(default: str, title: str | None) -> str:
"""Return the title: the caller's, or the informative default."""
return default if title is None else title
[docs]
def plot_mode_phasor(
solution: ModalSolution,
mode_index: int,
*,
top_n: int = 10,
template: str | go.layout.Template | None = None,
title: str | None = None,
) -> go.Figure:
"""Plot a mode's dominant DOFs as phasors on a polar wheel.
Each degree of freedom is drawn as a vector whose length is its participation
magnitude (normalized to the most active DOF) and whose angle is its phase. Two
DOFs that are in phase point the same way; a 180-degree spread is anti-phase; a
spread of intermediate angles indicates a travelling (whirling) mode.
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.
template : str, plotly.graph_objects.layout.Template or None, optional
Plotly layout template to apply. ``None`` (the default) applies the VANE
template ``"vane"``.
title : str or None, optional
Figure title. ``None`` (the default) names the mode and its natural
frequency; ``""`` draws none.
Returns
-------
plotly.graph_objects.Figure
The polar phasor figure.
Raises
------
ValueError
If the solution has no DOF descriptions, ``mode_index`` is out of range, or
``top_n`` is not positive.
"""
radius, magnitude, phase_deg, labels = _phasor_data(solution, mode_index, top_n)
figure = go.Figure(_phasor_traces(radius, magnitude, phase_deg, labels))
figure.update_layout(
title=_title(_phasor_title(solution, mode_index), title),
polar=_polar_layout(),
showlegend=False,
template=resolve_template(template),
)
return figure
[docs]
def plot_mode_phasor_static(
solution: ModalSolution,
mode_index: int,
*,
top_n: int = 6,
style: str | dict[str, Any] | Path | None = VANE_STYLE,
size: str | float | FigureSize | None = None,
title: str | None = None,
) -> Figure:
"""Plot a mode's dominant DOFs as phasors on a static polar wheel.
Each degree of freedom is an arrow from the centre, its length the normalized
participation magnitude and its angle the phase relative to the dominant DOF
(counter-clockwise, in degrees). Each arrow has its own colour (the standard line
colours, in order of participation), and the legend names the DOFs with their
magnitudes.
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 (default 6).
style : str, dict, pathlib.Path or None, optional
Matplotlib style in effect while drawing (``"vane"`` by default).
size : str, float, FigureSize or None, optional
Figure size and fonts; ``None`` uses ``"single"``.
title : str or None, optional
Axes title; ``None`` (the default) draws none (the legend title names the
mode and its frequency).
Returns
-------
matplotlib.figure.Figure
The polar figure.
Raises
------
ValueError
If the solution has no DOF descriptions, ``mode_index`` is out of range, or
``top_n`` is not positive.
"""
import matplotlib.pyplot as plt
radius, _, phase_deg, _ = _phasor_data(solution, mode_index, top_n)
names = _short_labels(solution, mode_index, top_n)
colors = series_colors(len(names))
resolved = resolve_size(size, "single", ax=None, style=style, aspect=(0.62, 0.4))
with static_style(style, resolved):
figure = plt.figure(layout="constrained")
axes = figure.add_subplot(projection="polar")
for value, angle, name, color in zip(
radius.tolist(), phase_deg.tolist(), names, colors, strict=True
):
theta = float(np.radians(angle))
axes.annotate(
"",
xy=(theta, value),
xytext=(0.0, 0.0),
arrowprops={
"arrowstyle": "-|>",
"color": color,
"linewidth": 1.5,
"shrinkA": 0.0,
"shrinkB": 0.0,
"mutation_scale": 9.0,
},
)
axes.plot([], [], color=color, linewidth=1.5, label=f"{name} ({value:.2f})")
axes.set_ylim(0.0, 1.05)
axes.set_yticks([0.25, 0.5, 0.75, 1.0])
cast("PolarAxes", axes).set_rlabel_position(_clear_angle(phase_deg.tolist()))
axes.grid(visible=True)
frequency = float(solution.natural_frequencies_hz[mode_index])
axes.legend(
loc="center left",
bbox_to_anchor=(1.12, 0.5),
title=f"Mode {mode_index} ({frequency:.4g} Hz)",
handlelength=1.5,
)
axes.text(
np.radians(270.0),
1.45,
_RADIAL_LABEL,
ha="center",
va="top",
fontsize=plt.rcParams["xtick.labelsize"],
color=AXES_COLOR,
)
if title is not None:
axes.set_title(title)
return figure
[docs]
def plot_mode_phasor_animation(
solution: ModalSolution,
mode_index: int,
*,
top_n: int = 10,
n_frames: int = 24,
template: str | go.layout.Template | None = None,
title: str | None = None,
) -> go.Figure:
"""Animate a mode's phasors through one modal period.
The phasors rotate rigidly (all DOFs share the modal frequency), so the animation
makes the *relative* phasing visible as motion: an in-phase mode sweeps as a rigid
fan, while a whirling mode shows a rotating spread.
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.
n_frames : int, optional
Number of animation frames over one period (must be positive).
template : str, plotly.graph_objects.layout.Template or None, optional
Plotly layout template to apply. ``None`` (the default) applies the VANE
template ``"vane"``.
title : str or None, optional
Figure title. ``None`` (the default) names the mode and its natural
frequency; ``""`` draws none.
Returns
-------
plotly.graph_objects.Figure
The animated polar phasor figure, with a play button.
Raises
------
ValueError
If the solution has no DOF descriptions, ``mode_index`` is out of range, or
``top_n`` or ``n_frames`` is not positive.
"""
if n_frames < 1:
msg = f"n_frames must be at least 1, got {n_frames}"
raise ValueError(msg)
radius, magnitude, phase_deg, labels = _phasor_data(solution, mode_index, top_n)
frames = [
go.Frame(
data=_phasor_traces(
radius, magnitude, phase_deg + 360.0 * k / n_frames, labels
),
name=str(k),
)
for k in range(n_frames)
]
figure = go.Figure(
data=_phasor_traces(radius, magnitude, phase_deg, labels),
frames=frames,
)
figure.update_layout(
title=_title(_phasor_title(solution, mode_index), title),
polar=_polar_layout(),
showlegend=False,
updatemenus=[
{
"type": "buttons",
"showactive": False,
"buttons": [
{
"label": "Play",
"method": "animate",
"args": [
None,
{
"frame": {"duration": 80, "redraw": True},
"fromcurrent": True,
"transition": {"duration": 0},
},
],
}
],
}
],
template=resolve_template(template),
)
return figure
def _blade_component_data(
solution: ModalSolution, mode_index: int
) -> tuple[list[int], npt.NDArray[np.float64]]:
"""Return the indices of the multi-blade DOFs and the mode's magnitudes."""
if not solution.dof_descriptions:
msg = "ModalSolution has no dof_descriptions; cannot plot blade components"
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)
coordinates = solution.dof_mbc_coordinates
blade_indices = [
i for i, coord in enumerate(coordinates) if coord in _BLADE_COMPONENT_COLORS
]
if not blade_indices:
msg = (
"solution carries no multi-blade coordinates; apply the MBC transform "
"before plotting blade components"
)
raise ValueError(msg)
magnitude = np.abs(solution.mode_shapes[:, mode_index]).astype(np.float64)
return blade_indices, magnitude
[docs]
def plot_blade_components(
solution: ModalSolution,
mode_index: int,
*,
template: str | go.layout.Template | None = None,
title: str | None = None,
) -> go.Figure:
"""Plot how a mode's participation divides among the multi-blade coordinates.
After the MBC transform each blade degree of freedom is a collective, cosine, or
sine coordinate. This bar chart shows the participation magnitude of each such DOF,
coloured by coordinate: a collective-dominated bar set marks a collective mode,
while comparable cosine and sine sets mark a cyclic (whirling) mode.
Parameters
----------
solution : ModalSolution
A solution carrying ``dof_mbc_coordinates`` (i.e. produced from an MBC-
transformed model) and ``dof_descriptions``.
mode_index : int
Index of the mode to plot.
template : str, plotly.graph_objects.layout.Template or None, optional
Plotly layout template to apply. ``None`` (the default) applies the VANE
template ``"vane"``.
title : str or None, optional
Figure title. ``None`` (the default) names the mode and its natural
frequency; ``""`` draws none.
Returns
-------
plotly.graph_objects.Figure
One bar per multi-blade DOF, grouped and coloured by coordinate.
Raises
------
ValueError
If the solution has no DOF descriptions, ``mode_index`` is out of range, or the
solution carries no multi-blade coordinates.
"""
blade_indices, magnitude = _blade_component_data(solution, mode_index)
coordinates = solution.dof_mbc_coordinates
positions = list(range(len(blade_indices)))
# The MBC transform reorders the original per-blade descriptions but does not
# rename them, so the row described as "Blade 1 ..." is actually the collective (or
# cosine/sine) coordinate of its triplet. Name the bars by their coordinate.
labels = [
short_dof_name(solution.dof_descriptions[i], coordinates[i])
for i in blade_indices
]
figure = go.Figure()
for component, color in _BLADE_COMPONENT_COLORS.items():
# Bars sit at integer positions (with the DOF names as tick text) so two DOFs
# sharing a description stay distinct.
group = [
(position, float(magnitude[index]), solution.dof_descriptions[index])
for position, index in zip(positions, blade_indices, strict=True)
if coordinates[index] == component
]
if not group:
continue
figure.add_trace(
go.Bar(
x=[item[0] for item in group],
y=[item[1] for item in group],
name=component,
marker_color=color,
customdata=[item[2] for item in group],
hovertemplate=(
"%{customdata}<br>Magnitude: %{y:.3g}"
f"<extra>{component}</extra>"
),
)
)
frequency = solution.natural_frequencies_hz[mode_index]
figure.update_layout(
title=_title(f"Mode {mode_index} ({frequency:.4g} Hz)", title),
yaxis_title="Participation magnitude (-)",
xaxis={
"title": {"text": "Multi-blade degree of freedom"},
"tickmode": "array",
"tickvals": positions,
"ticktext": labels,
},
legend_title="MBC coordinate",
template=resolve_template(template),
)
return figure
[docs]
def plot_blade_components_static(
solution: ModalSolution,
mode_index: int,
*,
style: str | dict[str, Any] | Path | None = VANE_STYLE,
size: str | float | FigureSize | None = None,
title: str | None = None,
) -> Figure:
"""Plot how a mode's participation divides among the multi-blade coordinates.
The static counterpart of :func:`plot_blade_components`: one bar per multi-blade
DOF, coloured by coordinate (collective black, cosine red, sine blue), with the
legend titled by the mode and its natural frequency.
Parameters
----------
solution : ModalSolution
A solution carrying ``dof_mbc_coordinates`` and ``dof_descriptions``.
mode_index : int
Index of the mode to plot.
style : str, dict, pathlib.Path or None, optional
Matplotlib style in effect while drawing (``"vane"`` by default).
size : str, float, FigureSize or None, optional
Figure size and fonts; ``None`` uses ``"single"``.
title : str or None, optional
Axes title; ``None`` (the default) draws none.
Returns
-------
matplotlib.figure.Figure
The bar chart.
Raises
------
ValueError
If the solution has no DOF descriptions, ``mode_index`` is out of range, or the
solution carries no multi-blade coordinates.
"""
import matplotlib.pyplot as plt
blade_indices, magnitude = _blade_component_data(solution, mode_index)
coordinates = solution.dof_mbc_coordinates
resolved = resolve_size(size, "single", ax=None, style=style)
with static_style(style, resolved):
figure, axes = plt.subplots(layout="constrained")
for component, color in _BLADE_COMPONENT_COLORS.items():
group = [
(position, float(magnitude[index]))
for position, index in enumerate(blade_indices)
if coordinates[index] == component
]
if group:
axes.bar(
[item[0] for item in group],
[item[1] for item in group],
color=color,
width=0.7,
label=component.capitalize(),
)
axes.set_xticks(
range(len(blade_indices)),
[
short_dof_name(solution.dof_descriptions[i], coordinates[i])
for i in blade_indices
],
rotation=45,
ha="right",
)
axes.tick_params(axis="x", top=False)
axes.set_ylim(0.0, 1.15 * max(float(magnitude[blade_indices].max()), 1e-12))
axes.set_ylabel("Participation magnitude (-)")
frequency = float(solution.natural_frequencies_hz[mode_index])
axes.legend(
loc="lower center",
bbox_to_anchor=(0.5, 1.02),
borderaxespad=0.0,
title=f"Mode {mode_index} ({frequency:.4g} Hz)",
ncols=3,
)
if title is not None:
axes.set_title(title)
return figure
def _clear_angle(angles_deg: list[float]) -> float:
"""Return the polar angle (deg) farthest from every phasor, for tick labels."""
candidates = [22.5 * k for k in range(16)]
def clearance(candidate: float) -> float:
return min(
(abs((candidate - angle + 180.0) % 360.0 - 180.0) for angle in angles_deg),
default=180.0,
)
return max(candidates, key=clearance)
def _phasor_data(
solution: ModalSolution, mode_index: int, top_n: int
) -> tuple[
npt.NDArray[np.float64],
npt.NDArray[np.float64],
npt.NDArray[np.float64],
list[str],
]:
"""Return ``(radius, magnitude, phase_deg, labels)`` for a mode's top DOFs."""
if not solution.dof_descriptions:
msg = "ModalSolution has no dof_descriptions; cannot plot mode phasors"
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)
# The eigensolver's mode shapes are already phase-normalized (the dominant DOF is
# real and positive), so the phasor angles read as phases relative to that DOF.
shape = solution.mode_shapes[:, mode_index]
magnitude = np.abs(shape)
order = _phasor_order(magnitude, top_n)
selected = magnitude[order]
peak = float(selected.max()) if selected.size and selected.max() > 0 else 1.0
radius = selected / peak
phase_deg = np.degrees(np.angle(shape[order]))
labels = [solution.dof_descriptions[i] for i in order.tolist()]
return radius, selected, phase_deg, labels
def _phasor_order(
magnitude: npt.NDArray[np.float64], top_n: int
) -> npt.NDArray[np.intp]:
"""Return the indices of the ``top_n`` largest magnitudes, largest first."""
return np.argsort(magnitude)[::-1][: min(top_n, magnitude.shape[0])]
def _short_labels(solution: ModalSolution, mode_index: int, top_n: int) -> list[str]:
"""Return the short DOF names of a mode's top DOFs, largest first."""
order = _phasor_order(np.abs(solution.mode_shapes[:, mode_index]), top_n)
coordinates = solution.dof_mbc_coordinates
return [
short_dof_name(
solution.dof_descriptions[i],
coordinates[i] if i < len(coordinates) else "",
)
for i in order.tolist()
]
def _phasor_traces(
radius: npt.NDArray[np.float64],
magnitude: npt.NDArray[np.float64],
phase_deg: npt.NDArray[np.float64],
labels: list[str],
) -> list[go.Scatterpolar]:
"""Build the arrow and marker polar traces for a set of phasors."""
arrow_r: list[float | None] = []
arrow_theta: list[float | None] = []
for value, angle in zip(radius.tolist(), phase_deg.tolist(), strict=True):
arrow_r.extend([0.0, value, None])
arrow_theta.extend([angle, angle, None])
arrows = go.Scatterpolar(
r=arrow_r,
theta=arrow_theta,
mode="lines",
line={"color": _SPOKE_COLOR},
hoverinfo="skip",
showlegend=False,
)
markers = go.Scatterpolar(
r=radius,
theta=phase_deg,
mode="markers",
marker={"size": 9, "color": _MARKER_COLOR},
customdata=np.stack([magnitude, np.array(range(len(labels)))], axis=-1),
text=labels,
hovertemplate=(
"%{text}<br>|participation|: %{customdata[0]:.3g}<br>"
"Phase: %{theta:.0f}°<extra></extra>"
),
showlegend=False,
)
return [arrows, markers]
def _phasor_title(solution: ModalSolution, mode_index: int) -> str:
"""Title naming the mode and its natural frequency."""
frequency = solution.natural_frequencies_hz[mode_index]
return f"Mode {mode_index} phasors ({frequency:.4g} Hz)"