# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University
r"""Physical full-system mode-shape visualization of a wind-turbine.
The other mode-shape views in :mod:`vane.viz` are deliberately abstract — phasor
wheels and participation bars — because an OpenFAST mode shape is expressed over
*modal* degrees of freedom, not spatial coordinates. This module closes the gap to
a picture an engineer can read at a glance: it draws the whole structure — floating
platform, mooring lines, tower, nacelle, and the three blades — and deforms it with
the actual complex mode shape, then animates it through one modal cycle.
How the modal coordinates become motion
----------------------------------------
The reference geometry (:mod:`vane.config.system_config`) supplies undeformed node
positions and, for the tower and blades, a normalized arc length per node. Each
modal degree of freedom is then mapped to a physical displacement field:
* **Platform** surge/sway/heave/roll/pitch/yaw move the *entire* structure as a
rigid body about the platform reference point.
* **Tower** fore-aft and side-side bending modes add a lateral deflection along the
tower via clamped-free (cantilever) bending shape functions; the resulting
tower-top translation and slope carry the rotor-nacelle assembly.
* **Blade** flap and edge modes deflect each blade along its flapwise and edgewise
directions. After the multi-blade-coordinate transform the blade modes are
collective/cosine/sine; the inverse transform recovers the per-blade amplitude at
each blade's azimuth.
* **Mooring** lines are dragged kinematically by the fairlead motion, fading to the
fixed anchor.
Honesty
-------
The hull and mooring outlines are a *schematic* (see
:mod:`vane.config.system_config`); the bending shape functions are the standard
Euler-Bernoulli cantilever modes, not the turbine's exact polynomial shapes. What
is faithful is the *relative* motion across the structure, which is set by the
computed complex mode shape. The deflection magnitude is exaggerated by a scale
factor for visibility (universal practice for mode-shape plots) — it is not a
physical amplitude.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal
import numpy as np
import numpy.typing as npt
import plotly.graph_objects as go
from matplotlib.ticker import MaxNLocator
from vane.config.dof_map import DofCategory, classify_dof
from vane.config.system_config import (
FowtGeometry,
Member,
SystemType,
default_geometry,
)
from vane.viz._style import (
FAMILY_COLORS,
STANDARD_COLORS,
VANE_STYLE,
ModeFamily,
resolve_size,
resolve_template,
static_style,
)
if TYPE_CHECKING:
from pathlib import Path
from matplotlib.figure import Figure
from vane.modal.eigensolver import ModalSolution
from vane.viz.publication import FigureSize
__all__ = [
"FowtModeDeformation",
"deform_mode",
"infer_geometry",
"plot_mode_fowt",
"plot_mode_fowt_animation",
"plot_mode_fowt_static",
]
# First two clamped-free (cantilever) Euler-Bernoulli eigenvalues beta*L.
_CANTILEVER_BETA = (1.8751040687119611, 4.694091132974175)
_PLATFORM_TRANSLATION = {
DofCategory.PLATFORM_SURGE: 0,
DofCategory.PLATFORM_SWAY: 1,
DofCategory.PLATFORM_HEAVE: 2,
}
_PLATFORM_ROTATION = {
DofCategory.PLATFORM_ROLL: 0,
DofCategory.PLATFORM_PITCH: 1,
DofCategory.PLATFORM_YAW: 2,
}
_TOWER_FORE_AFT = (DofCategory.TOWER_FORE_AFT_1, DofCategory.TOWER_FORE_AFT_2)
_TOWER_SIDE_SIDE = (DofCategory.TOWER_SIDE_SIDE_1, DofCategory.TOWER_SIDE_SIDE_2)
_BLADE_FLAP = (DofCategory.BLADE_FLAP_1, DofCategory.BLADE_FLAP_2)
_BLADE_EDGE = DofCategory.BLADE_EDGE_1
# Colour per member kind for the deformed structure; the undeformed ghost is grey.
# The structural members take the colours of their mode families in the Campbell
# diagram (tower black, blades green, platform red); hub, nacelle and mooring are
# neutral greys.
_KIND_COLORS = {
"tower": FAMILY_COLORS[ModeFamily.TOWER],
"nacelle": "#555555",
"hub": "#555555",
"blade": FAMILY_COLORS[ModeFamily.BLADE],
"platform": FAMILY_COLORS[ModeFamily.PLATFORM],
"mooring": "#7f7f7f",
}
_GHOST_COLOR = "#cfcfcf"
_WATER_COLOR = STANDARD_COLORS[6]
@dataclass
class _Amplitudes:
"""Complex modal amplitudes of a single mode, grouped by physical role."""
platform: dict[DofCategory, complex] = field(default_factory=dict)
tower: dict[DofCategory, complex] = field(default_factory=dict)
blade_mbc: dict[DofCategory, dict[str, complex]] = field(default_factory=dict)
blade_indexed: dict[tuple[DofCategory, int], complex] = field(default_factory=dict)
has_mbc: bool = False
def _cantilever_shape(
mode: int, xi: npt.NDArray[np.float64]
) -> npt.NDArray[np.float64]:
r"""Return the clamped-free bending shape ``mode`` evaluated at ``xi``.
The Euler-Bernoulli cantilever mode shape, normalized to unit deflection at the
free tip (``xi = 1``):
.. math::
\psi_n(\xi) = \cosh(\beta_n \xi) - \cos(\beta_n \xi)
- \sigma_n[\sinh(\beta_n \xi) - \sin(\beta_n \xi)],
with :math:`\sigma_n = (\cosh\beta_n + \cos\beta_n)/(\sinh\beta_n + \sin\beta_n)`,
the clamped-free (cantilever) constant that enforces a zero-moment, zero-shear
free end.
Parameters
----------
mode : int
Bending mode number, 1 or 2.
xi : npt.NDArray[np.float64]
Normalized arc length in ``[0, 1]``.
Returns
-------
npt.NDArray[np.float64]
The shape value at each ``xi``, normalized so the tip value is 1.
"""
beta = _CANTILEVER_BETA[mode - 1]
sigma = (np.cosh(beta) + np.cos(beta)) / (np.sinh(beta) + np.sin(beta))
def raw(s: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
return np.asarray(
np.cosh(beta * s)
- np.cos(beta * s)
- sigma * (np.sinh(beta * s) - np.sin(beta * s)),
dtype=np.float64,
)
tip = raw(np.array([1.0]))[0]
return np.asarray(raw(xi) / tip, dtype=np.float64)
def _cantilever_tip_slope(mode: int) -> float:
r"""Return the tip slope :math:`\mathrm{d}\psi_n/\mathrm{d}\xi` at ``xi = 1``.
Parameters
----------
mode : int
Bending mode number, 1 or 2.
Returns
-------
float
The slope of the unit-tip-normalized shape at the free end.
"""
beta = _CANTILEVER_BETA[mode - 1]
sigma = (np.cosh(beta) + np.cos(beta)) / (np.sinh(beta) + np.sin(beta))
tip = np.cosh(beta) - np.cos(beta) - sigma * (np.sinh(beta) - np.sin(beta))
slope = beta * (np.sinh(beta) + np.sin(beta)) - sigma * beta * (
np.cosh(beta) - np.cos(beta)
)
return float(slope / tip)
def _extract_amplitudes(solution: ModalSolution, mode_index: int) -> _Amplitudes:
"""Group a mode's complex DOF amplitudes by physical role.
Parameters
----------
solution : ModalSolution
A solution carrying ``dof_descriptions`` (and ``dof_mbc_coordinates`` when
the model has been MBC-transformed).
mode_index : int
The mode column to read.
Returns
-------
_Amplitudes
Platform, tower, and blade amplitudes ready for the deformation map.
"""
shape = solution.mode_shapes[:, mode_index]
coordinates = solution.dof_mbc_coordinates
amplitudes = _Amplitudes(has_mbc=bool(coordinates))
for row, description in enumerate(solution.dof_descriptions):
info = classify_dof(description)
if info.is_velocity or info.category is DofCategory.UNKNOWN:
continue
value = complex(shape[row])
category = info.category
if category in _PLATFORM_TRANSLATION or category in _PLATFORM_ROTATION:
amplitudes.platform[category] = value
elif category in _TOWER_FORE_AFT or category in _TOWER_SIDE_SIDE:
amplitudes.tower[category] = value
elif category in _BLADE_FLAP or category == _BLADE_EDGE:
mbc = coordinates[row] if row < len(coordinates) else ""
if mbc in ("collective", "cosine", "sine"):
amplitudes.blade_mbc.setdefault(category, {})[mbc] = value
elif info.blade is not None:
amplitudes.blade_indexed[(category, info.blade)] = value
return amplitudes
def _platform_motion(
amplitudes: _Amplitudes,
) -> tuple[npt.NDArray[np.complex128], npt.NDArray[np.complex128]]:
"""Return the rigid-body ``(translation, rotation)`` complex vectors."""
translation = np.zeros(3, dtype=np.complex128)
rotation = np.zeros(3, dtype=np.complex128)
for category, axis in _PLATFORM_TRANSLATION.items():
translation[axis] = amplitudes.platform.get(category, 0.0)
for category, axis in _PLATFORM_ROTATION.items():
rotation[axis] = amplitudes.platform.get(category, 0.0)
return translation, rotation
def _rigid_body(
nodes: npt.NDArray[np.float64],
translation: npt.NDArray[np.complex128],
rotation: npt.NDArray[np.complex128],
reference: npt.NDArray[np.float64],
) -> npt.NDArray[np.complex128]:
"""Return the small-displacement rigid-body field at ``nodes``.
Parameters
----------
nodes : npt.NDArray[np.float64]
Node positions, shape ``(n, 3)``.
translation, rotation : npt.NDArray[np.complex128]
Complex translation and small-angle rotation vectors, shape ``(3,)``.
reference : npt.NDArray[np.float64]
The point the rotation acts about, shape ``(3,)``.
Returns
-------
npt.NDArray[np.complex128]
The complex displacement at each node, shape ``(n, 3)``.
"""
arm = nodes - reference[np.newaxis, :]
spin = np.cross(rotation[np.newaxis, :], arm)
return (translation[np.newaxis, :] + spin).astype(np.complex128)
def _tower_top_motion(
amplitudes: _Amplitudes, tower_length: float
) -> tuple[npt.NDArray[np.complex128], npt.NDArray[np.complex128]]:
"""Return the tower-top bending ``(translation, rotation)`` complex vectors.
The shapes are normalized to unit tip deflection, so the tip translation is the
sum of the fore-aft (and side-side) modal amplitudes; the rotation follows from
the bending slopes at the tip.
"""
fa1 = amplitudes.tower.get(DofCategory.TOWER_FORE_AFT_1, 0.0)
fa2 = amplitudes.tower.get(DofCategory.TOWER_FORE_AFT_2, 0.0)
ss1 = amplitudes.tower.get(DofCategory.TOWER_SIDE_SIDE_1, 0.0)
ss2 = amplitudes.tower.get(DofCategory.TOWER_SIDE_SIDE_2, 0.0)
translation = np.array([fa1 + fa2, ss1 + ss2, 0.0], dtype=np.complex128)
slope_scale = 1.0 / tower_length if tower_length > 0.0 else 0.0
slope_x = (fa1 * _cantilever_tip_slope(1) + fa2 * _cantilever_tip_slope(2)) * (
slope_scale
)
slope_y = (ss1 * _cantilever_tip_slope(1) + ss2 * _cantilever_tip_slope(2)) * (
slope_scale
)
# A fore-aft slope rotates about +y; a side-side slope rotates about -x.
rotation = np.array([-slope_y, slope_x, 0.0], dtype=np.complex128)
return translation, rotation
def _tower_bending(
member: Member, amplitudes: _Amplitudes
) -> npt.NDArray[np.complex128]:
"""Return the tower's flexible (bending) displacement along its span."""
xi = member.span_fraction
assert xi is not None # tower members always carry span fractions
fa1 = amplitudes.tower.get(DofCategory.TOWER_FORE_AFT_1, 0.0)
fa2 = amplitudes.tower.get(DofCategory.TOWER_FORE_AFT_2, 0.0)
ss1 = amplitudes.tower.get(DofCategory.TOWER_SIDE_SIDE_1, 0.0)
ss2 = amplitudes.tower.get(DofCategory.TOWER_SIDE_SIDE_2, 0.0)
phi1, phi2 = _cantilever_shape(1, xi), _cantilever_shape(2, xi)
bending = np.zeros((xi.shape[0], 3), dtype=np.complex128)
bending[:, 0] = fa1 * phi1 + fa2 * phi2
bending[:, 1] = ss1 * phi1 + ss2 * phi2
return bending
def _blade_amplitude(
amplitudes: _Amplitudes, category: DofCategory, blade: int, n_blades: int
) -> complex:
"""Return a blade's complex amplitude for a DOF category via inverse MBC."""
if amplitudes.has_mbc and category in amplitudes.blade_mbc:
components = amplitudes.blade_mbc[category]
azimuth = 2.0 * np.pi * (blade - 1) / n_blades
return complex(
components.get("collective", 0.0)
+ components.get("cosine", 0.0) * np.cos(azimuth)
+ components.get("sine", 0.0) * np.sin(azimuth)
)
return amplitudes.blade_indexed.get((category, blade), 0.0)
def _blade_bending(
member: Member, amplitudes: _Amplitudes, n_blades: int
) -> npt.NDArray[np.complex128]:
"""Return a blade's flap+edge bending displacement along its span."""
xi = member.span_fraction
blade = member.blade_index
flap_dir, edge_dir = member.flap_dir, member.edge_dir
assert xi is not None and blade is not None
assert flap_dir is not None and edge_dir is not None
flap1 = _blade_amplitude(amplitudes, DofCategory.BLADE_FLAP_1, blade, n_blades)
flap2 = _blade_amplitude(amplitudes, DofCategory.BLADE_FLAP_2, blade, n_blades)
edge1 = _blade_amplitude(amplitudes, DofCategory.BLADE_EDGE_1, blade, n_blades)
phi1, phi2 = _cantilever_shape(1, xi), _cantilever_shape(2, xi)
flap = flap1 * phi1 + flap2 * phi2
edge = edge1 * phi1
return (
flap[:, np.newaxis] * flap_dir[np.newaxis, :]
+ edge[:, np.newaxis] * edge_dir[np.newaxis, :]
).astype(np.complex128)
def _member_displacement(
member: Member,
amplitudes: _Amplitudes,
geometry: FowtGeometry,
rigid: tuple[npt.NDArray[np.complex128], npt.NDArray[np.complex128]],
top: tuple[npt.NDArray[np.complex128], npt.NDArray[np.complex128]],
) -> npt.NDArray[np.complex128]:
"""Return the unscaled complex displacement field of one member."""
translation, rotation = rigid
top_translation, top_rotation = top
nodes = member.nodes
base = _rigid_body(nodes, translation, rotation, geometry.reference_point)
if member.kind == "mooring":
# The fairlead (node 0) rides the platform; fade to the fixed anchor.
weight = np.linspace(1.0, 0.0, nodes.shape[0])[:, np.newaxis]
fairlead = _rigid_body(
nodes[:1], translation, rotation, geometry.reference_point
)
return (weight * fairlead).astype(np.complex128)
if member.kind == "tower":
return (base + _tower_bending(member, amplitudes)).astype(np.complex128)
if member.rides_tower_top:
arm = nodes - geometry.tower_top[np.newaxis, :]
carry = top_translation[np.newaxis, :] + np.cross(
top_rotation[np.newaxis, :], arm
)
displacement = base + carry
if member.kind == "blade":
displacement = displacement + _blade_bending(
member, amplitudes, geometry.n_blades
)
return displacement.astype(np.complex128)
return base
[docs]
def infer_geometry(solution: ModalSolution) -> FowtGeometry:
"""Infer a schematic reference geometry from a solution's degrees of freedom.
A floating (semi-submersible) schematic is chosen when platform degrees of
freedom are present, otherwise a bottom-fixed monopile. The number of blades is
taken from the highest blade index seen, defaulting to three. Principal
dimensions use the preset defaults — pass an explicit
:class:`~vane.config.system_config.FowtGeometry` to match a specific turbine.
Parameters
----------
solution : ModalSolution
The solution whose ``dof_descriptions`` are inspected.
Returns
-------
FowtGeometry
A default geometry matching the detected support structure.
"""
has_platform = False
max_blade = 0
for description in solution.dof_descriptions:
info = classify_dof(description)
if info.category in _PLATFORM_TRANSLATION or info.category in (
_PLATFORM_ROTATION
):
has_platform = True
if info.blade is not None:
max_blade = max(max_blade, info.blade)
geometry = default_geometry(SystemType.SEMI if has_platform else SystemType.FIXED)
if max_blade and max_blade != geometry.n_blades:
# Fall through with the preset blade count; presets are 3-bladed and the
# MBC check enforces three blades elsewhere, so this is informational only.
pass
return geometry
def _ghost_trace(member: Member) -> go.Scatter3d:
"""Return the faint undeformed outline trace for a member."""
nodes = member.nodes
return go.Scatter3d(
x=nodes[:, 0],
y=nodes[:, 1],
z=nodes[:, 2],
mode="lines",
line={"color": _GHOST_COLOR, "width": 2},
hoverinfo="skip",
showlegend=False,
)
def _deformed_trace(
member: Member,
nodes: npt.NDArray[np.float64],
*,
show_legend: bool,
) -> go.Scatter3d:
"""Return the coloured deformed trace for a member."""
color = _KIND_COLORS.get(member.kind, "#333333")
return go.Scatter3d(
x=nodes[:, 0],
y=nodes[:, 1],
z=nodes[:, 2],
mode="lines",
line={"color": color, "width": 6},
name=member.kind,
legendgroup=member.kind,
showlegend=show_legend,
text=member.name,
hovertemplate="%{text}<extra></extra>",
)
_OFFSHORE = frozenset(
{
SystemType.SEMI,
SystemType.SPAR,
SystemType.TLP,
SystemType.MONOPILE,
SystemType.JACKET,
}
)
def _water_trace(geometry: FowtGeometry) -> go.Mesh3d | None:
"""Return a translucent still-water plane for offshore systems, else ``None``."""
if geometry.system_type not in _OFFSHORE:
return None
extent = max(
(float(np.abs(member.nodes[:, :2]).max()) for member in geometry.members),
default=geometry.characteristic_length,
)
span = 1.05 * extent
corners = np.array(
[[-span, -span], [span, -span], [span, span], [-span, span]], dtype=np.float64
)
return go.Mesh3d(
x=corners[:, 0],
y=corners[:, 1],
z=np.zeros(4),
i=[0, 0],
j=[1, 2],
k=[2, 3],
color=_WATER_COLOR,
opacity=0.12,
hoverinfo="skip",
showlegend=False,
name="Still water",
)
def _first_of_kind(members: list[Member]) -> set[int]:
"""Return the indices that are the first occurrence of each member kind."""
seen: set[str] = set()
firsts: set[int] = set()
for index, member in enumerate(members):
if member.kind not in seen:
seen.add(member.kind)
firsts.add(index)
return firsts
def _scene_layout(title: str) -> dict[str, object]:
"""Return the 3-D scene layout dict shared by the static and animated figures."""
return {
"title": {"text": title},
"scene": {
"xaxis_title": "x, downwind (m)",
"yaxis_title": "y, lateral (m)",
"zaxis_title": "z, up (m)",
"aspectmode": "data",
"camera": {"eye": {"x": 1.6, "y": 1.6, "z": 0.6}},
},
"margin": {"l": 0, "r": 0, "t": 40, "b": 0},
}
def _mode_title(deformation: FowtModeDeformation) -> str:
"""Return a one-line title naming the mode, frequency, and damping."""
return (
f"Mode {deformation.mode_index} - "
f"{deformation.frequency_hz:.3f} Hz, "
f"damping {deformation.damping_ratio:.3f} "
f"(schematic; deflection exaggerated {deformation.scale:.2g}x for visibility)"
)
[docs]
def plot_mode_fowt(
solution: ModalSolution,
mode_index: int,
geometry: FowtGeometry | None = None,
*,
deflection_ratio: float = 0.12,
scale: float = 1.0,
phase_deg: float | None = None,
template: str | go.layout.Template | None = None,
title: str | None = None,
) -> go.Figure:
"""Plot a mode on the full wind-turbine structure at a single phase.
The undeformed structure is drawn as a faint grey outline; the deformed
structure — platform, mooring lines, tower, nacelle, and blades — is drawn in
colour at the requested modal phase.
Parameters
----------
solution : ModalSolution
A solution whose ``dof_descriptions`` are populated.
mode_index : int
Index of the mode to plot.
geometry : FowtGeometry or None, optional
Reference geometry; inferred from the solution when ``None``.
deflection_ratio : float, optional
Peak deflection as a fraction of the characteristic length.
scale : float, optional
Extra exaggeration multiplier.
phase_deg : float or None, optional
Modal phase in degrees at which to freeze the deformation. When ``None``
(the default) the phase of maximum total deflection is used, which is the
displacement extreme for a real mode and the most legible snapshot for a
complex one.
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, its frequency and
damping and the deflection scale; ``""`` draws none.
Returns
-------
plotly.graph_objects.Figure
The 3-D mode-shape figure.
Raises
------
ValueError
Propagated from :func:`deform_mode` for an empty, out-of-range, or
non-positively-scaled request.
"""
deformation = deform_mode(
solution,
mode_index,
geometry,
deflection_ratio=deflection_ratio,
scale=scale,
)
geometry = deformation.geometry
firsts = _first_of_kind(geometry.members)
phase = (
deformation.peak_phase_rad()
if phase_deg is None
else float(np.radians(phase_deg))
)
traces: list[go.Scatter3d | go.Mesh3d] = []
water = _water_trace(geometry)
if water is not None:
traces.append(water)
traces.extend(_ghost_trace(member) for member in geometry.members)
for index, member in enumerate(geometry.members):
nodes = deformation.deformed_nodes(index, phase)
traces.append(_deformed_trace(member, nodes, show_legend=index in firsts))
figure = go.Figure(data=traces)
figure.update_layout(
**_scene_layout(_mode_title(deformation) if title is None else title)
)
figure.update_layout(template=resolve_template(template))
return figure
[docs]
def plot_mode_fowt_animation(
solution: ModalSolution,
mode_index: int,
geometry: FowtGeometry | None = None,
*,
motion: Literal["cycle", "swing"] = "cycle",
deflection_ratio: float = 0.12,
scale: float = 1.0,
n_frames: int = 36,
template: str | go.layout.Template | None = None,
title: str | None = None,
) -> go.Figure:
"""Animate a mode on the full wind-turbine structure (Play, scrub, swing/orbit).
The faint undeformed outline and the still-water plane stay fixed while the coloured
structure oscillates, so the relative phasing across platform, tower, and blades
reads as physical motion. The figure carries a Play/Pause control and a **frame
slider** to scrub or replay any instant; the deformation **magnification** is set
manually with ``deflection_ratio`` x ``scale`` (an engineering deformation-scale
factor, as in an FE post-processor).
Two motions are offered. ``"cycle"`` sweeps the full modal phase, so a whirling
(complex) mode traces its true orbit. ``"swing"`` oscillates the real peak shape
symmetrically about the undeformed state (``cos`` amplitude from ``+peak`` through
``0`` to ``-peak``) — the standing-wave animation a modal analyst expects for a
single mode.
Parameters
----------
solution : ModalSolution
A solution whose ``dof_descriptions`` are populated.
mode_index : int
Index of the mode to animate.
geometry : FowtGeometry or None, optional
Reference geometry; inferred from the solution when ``None``.
motion : {"cycle", "swing"}, optional
``"cycle"`` sweeps the full complex phase (orbit); ``"swing"`` oscillates the
real peak shape about the undeformed state. Default ``"cycle"``.
deflection_ratio : float, optional
Peak deflection as a fraction of the characteristic length.
scale : float, optional
Manual magnification multiplier on top of ``deflection_ratio``.
n_frames : int, optional
Number of 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, its frequency and
damping and the deflection scale; ``""`` draws none.
Returns
-------
plotly.graph_objects.Figure
The animated 3-D mode-shape figure with a Play/Pause control and a scrub slider.
Raises
------
ValueError
If ``n_frames`` is not positive, or as propagated from :func:`deform_mode`.
"""
if n_frames < 1:
msg = f"n_frames must be at least 1, got {n_frames}"
raise ValueError(msg)
if motion not in ("cycle", "swing"):
msg = f"motion must be 'cycle' or 'swing', got {motion!r}"
raise ValueError(msg)
deformation = deform_mode(
solution,
mode_index,
geometry,
deflection_ratio=deflection_ratio,
scale=scale,
)
geometry = deformation.geometry
firsts = _first_of_kind(geometry.members)
n_members = len(geometry.members)
static: list[go.Scatter3d | go.Mesh3d] = []
water = _water_trace(geometry)
if water is not None:
static.append(water)
static.extend(_ghost_trace(member) for member in geometry.members)
# The real peak-deflection field; "swing" scales this by a cosine, "cycle" sweeps
# the complex phase to show the true orbit.
peak_phase = deformation.peak_phase_rad()
peak_offsets = [
np.asarray((vec * np.exp(1j * peak_phase)).real, dtype=np.float64)
for vec in deformation.displacements
]
def member_nodes(index: int, fraction: float) -> npt.NDArray[np.float64]:
"""Return a member's node positions at a cycle fraction in ``[0, 1)``."""
if motion == "swing":
amplitude = float(np.cos(2.0 * np.pi * fraction))
nodes = geometry.members[index].nodes + amplitude * peak_offsets[index]
return np.asarray(nodes, dtype=np.float64)
return deformation.deformed_nodes(index, 2.0 * np.pi * fraction)
def deformed_traces(fraction: float) -> list[go.Scatter3d]:
"""Return the coloured deformed traces at one cycle fraction."""
return [
_deformed_trace(
geometry.members[index],
member_nodes(index, fraction),
show_legend=index in firsts,
)
for index in range(n_members)
]
deformed_indices = list(range(len(static), len(static) + n_members))
# The loaded figure must show frame 0 so it agrees with the slider's active
# step (0) and its "0°" label; both motions therefore start at fraction 0
# (swing: cos 0 = +1 peak; cycle: the start of the orbit sweep).
base = static + deformed_traces(0.0)
frames = [
go.Frame(
data=deformed_traces(k / n_frames),
traces=deformed_indices,
name=str(k),
)
for k in range(n_frames)
]
figure = go.Figure(data=base, frames=frames)
figure.update_layout(
**_scene_layout(_mode_title(deformation) if title is None else title)
)
figure.update_layout(
updatemenus=[
{
"type": "buttons",
"showactive": False,
"x": 0.02,
"y": 0.05,
"xanchor": "left",
"buttons": [
{
"label": "▶ Play",
"method": "animate",
"args": [
None,
{
"frame": {"duration": 70, "redraw": True},
"fromcurrent": True,
"transition": {"duration": 0},
},
],
},
{
"label": "❚❚ Pause",
"method": "animate",
"args": [
[None],
{
"frame": {"duration": 0, "redraw": False},
"mode": "immediate",
},
],
},
],
}
],
sliders=[_frame_slider(n_frames)],
)
figure.update_layout(template=resolve_template(template))
return figure
def _frame_slider(n_frames: int) -> dict[str, object]:
"""Return a Plotly slider that scrubs/replays the animation frame by frame."""
return {
"active": 0,
"x": 0.12,
"len": 0.84,
"xanchor": "left",
"y": 0.02,
"currentvalue": {"prefix": "phase: ", "visible": True, "xanchor": "right"},
"pad": {"t": 0, "b": 8},
"steps": [
{
"label": f"{round(360 * k / n_frames):d}°",
"method": "animate",
"args": [
[str(k)],
{
"frame": {"duration": 0, "redraw": True},
"mode": "immediate",
"transition": {"duration": 0},
},
],
}
for k in range(n_frames)
],
}
[docs]
def plot_mode_fowt_static(
solution: ModalSolution,
mode_index: int,
geometry: FowtGeometry | None = None,
*,
deflection_ratio: float = 0.12,
scale: float = 1.0,
phase_deg: float | None = None,
elevation_deg: float = 15.0,
azimuth_deg: float = -50.0,
style: str | dict[str, Any] | Path | None = VANE_STYLE,
size: str | float | FigureSize | None = None,
title: str | None = None,
) -> Figure:
"""Plot a mode on the full wind-turbine structure as a static 3-D figure.
The static counterpart of :func:`plot_mode_fowt`: the undeformed structure as a
light grey outline and the deformed one coloured by member (tower black, blades
green, platform red, as the mode families of the Campbell diagram; nacelle, hub
and mooring grey), on equal axes, with a translucent still-water plane for
offshore systems. The legend title notes that the deflection is exaggerated.
Parameters
----------
solution : ModalSolution
A solution whose ``dof_descriptions`` are populated.
mode_index : int
Index of the mode to plot.
geometry : FowtGeometry or None, optional
Reference geometry; inferred from the solution when ``None``.
deflection_ratio : float, optional
Peak deflection as a fraction of the characteristic length.
scale : float, optional
Extra exaggeration multiplier.
phase_deg : float or None, optional
Modal phase in degrees; ``None`` uses the phase of maximum total deflection.
elevation_deg, azimuth_deg : float, optional
Viewing angles of the 3-D axes, in degrees.
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"``, nearly square.
title : str or None, optional
Axes title. ``None`` (the default) draws none; the legend title names the mode
and its natural frequency.
Returns
-------
matplotlib.figure.Figure
The 3-D mode-shape figure.
Raises
------
ValueError
Propagated from :func:`deform_mode` for an empty, out-of-range, or
non-positively-scaled request.
"""
import matplotlib.pyplot as plt
deformation = deform_mode(
solution,
mode_index,
geometry,
deflection_ratio=deflection_ratio,
scale=scale,
)
geometry = deformation.geometry
phase = (
deformation.peak_phase_rad()
if phase_deg is None
else float(np.radians(phase_deg))
)
firsts = _first_of_kind(geometry.members)
resolved = resolve_size(size, "single", ax=None, style=style, aspect=(0.95, 0.6))
with static_style(style, resolved):
figure = plt.figure(layout="constrained")
axes = figure.add_subplot(projection="3d")
deformed = [
deformation.deformed_nodes(index, phase)
for index in range(len(geometry.members))
]
extent = _view_extent(geometry, deformed)
if geometry.system_type in _OFFSHORE:
grid = np.array([-extent, extent])
plane_x, plane_y = np.meshgrid(grid, grid)
axes.plot_surface(
plane_x,
plane_y,
np.zeros_like(plane_x),
color=_WATER_COLOR,
alpha=0.12,
linewidth=0,
shade=False,
)
for member in geometry.members:
nodes = _within(member.nodes, extent)
axes.plot(
nodes[:, 0], nodes[:, 1], nodes[:, 2], color=_GHOST_COLOR, linewidth=0.8
)
for index, (member, moved) in enumerate(
zip(geometry.members, deformed, strict=True)
):
shown = _within(moved, extent)
axes.plot(
shown[:, 0],
shown[:, 1],
shown[:, 2],
color=_KIND_COLORS.get(member.kind, "#333333"),
linewidth=1.8,
label=member.kind.capitalize() if index in firsts else None,
)
points = np.vstack(
[_within(nodes, extent) for nodes in deformed]
+ [_within(m.nodes, extent) for m in geometry.members]
)
z_low, z_high = float(points[:, 2].min()), float(points[:, 2].max())
axes.set_xlim(-extent, extent)
axes.set_ylim(-extent, extent)
axes.set_zlim(z_low, z_high)
axes.set_box_aspect(
(2.0 * extent, 2.0 * extent, max(z_high - z_low, 0.1 * extent)),
zoom=0.78,
)
for axis in (axes.xaxis, axes.yaxis):
axis.set_major_locator(MaxNLocator(4))
axes.set_xlabel("x (m)")
axes.set_ylabel("y (m)")
axes.set_zlabel("z (m)")
axes.view_init(elev=elevation_deg, azim=azimuth_deg)
figure.legend(
loc="outside upper center",
ncols=3,
title=(
f"Mode {deformation.mode_index} ({deformation.frequency_hz:.4g} Hz); "
"schematic, deflection exaggerated"
),
)
if title is not None:
axes.set_title(title)
return figure
def _view_extent(
geometry: FowtGeometry, deformed: list[npt.NDArray[np.float64]]
) -> float:
"""Return the half-width of the static view: the structure without its mooring.
The mooring spreads far wider than the turbine, so the view is sized on the other
members (with a 25 % margin) and the mooring lines are cut at its edge.
"""
members = [
nodes
for member, nodes in zip(geometry.members, deformed, strict=True)
if member.kind != "mooring"
] or deformed
reach = max(float(np.abs(nodes[:, :2]).max()) for nodes in members)
height = max(float(np.ptp(nodes[:, 2])) for nodes in members)
return 1.25 * max(reach, 0.25 * height, 1.0)
def _within(nodes: npt.NDArray[np.float64], extent: float) -> npt.NDArray[np.float64]:
"""Return the leading nodes of a polyline that lie within the horizontal view."""
inside = np.all(np.abs(nodes[:, :2]) <= extent, axis=1)
if bool(inside.all()):
return nodes
outside = np.flatnonzero(~inside)
first = int(outside[0])
if first == 0:
keep = np.flatnonzero(inside)
return nodes[keep] if keep.size else nodes[:0]
return nodes[:first]