Source code for pybmodes.plots.mode_shapes

# Copyright 2024-2026 Jae Hoon Seo
# Marine Structural Mechanics and Integrity Lab (SMI Lab), Inha University
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Professional plotting functions for mode shapes and polynomial fit quality.

All public functions require *matplotlib* (optional dependency).  They return
:class:`~matplotlib.figure.Figure` objects; the caller decides whether to
display or save them.

Typical usage::

    from pybmodes.models import RotatingBlade
    from pybmodes.elastodyn import compute_blade_params
    from pybmodes.plots import plot_mode_shapes, plot_fit_quality, blade_fit_pairs

    result = RotatingBlade("blade.bmi").run(n_modes=10)
    fig1 = plot_mode_shapes(result, n_modes=6)
    fig1.savefig("mode_shapes.png", dpi=150)

    params = compute_blade_params(result)
    fig2 = plot_fit_quality(blade_fit_pairs(result, params))
    fig2.savefig("fit_quality.png", dpi=150)
"""

from __future__ import annotations

import math
from typing import TYPE_CHECKING

import numpy as np

if TYPE_CHECKING:
    from matplotlib.figure import Figure

    from pybmodes.elastodyn.params import BladeElastoDynParams, TowerElastoDynParams
    from pybmodes.fitting.poly_fit import PolyFitResult
    from pybmodes.models.result import ModalResult


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

def _require_matplotlib():
    try:
        import matplotlib.pyplot as plt  # noqa: F401
    except ImportError as exc:
        raise ImportError(
            "matplotlib is required for plotting.  "
            'Install it with: pip install "pybmodes[plots]"'
        ) from exc


def _apply_style(ax, xlabel: str, ylabel: str, title: str | None = None) -> None:
    ax.set_xlabel(xlabel, fontsize=10)
    ax.set_ylabel(ylabel, fontsize=10)
    if title:
        ax.set_title(title, fontsize=11, pad=8)
    ax.tick_params(labelsize=9)
    ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.6)
    ax.spines[["top", "right"]].set_visible(False)


def _mode_colors(n: int, override: list | str | None = None):
    """Pick *n* visually distinct line colours.

    ``override`` lets the caller take full control: a colormap name
    (e.g. ``"turbo"``, ``"viridis"``) sampled into *n* colours, or an
    explicit list of matplotlib colours (used verbatim, cycled if
    shorter than *n*).

    Otherwise the active rcParams ``prop_cycle`` is used (honouring
    :func:`pybmodes.plots.apply_style`'s engineering-paper palette) —
    but **only while it has at least *n* distinct entries**. The
    styled palette is 7 colours; with ``apply_style()`` active and 8+
    modes, wrapping the cycle made mode 8 reuse mode 1's colour (the
    issue #47 "first and last mode are the same colour" collision).
    Once *n* exceeds the palette, a perceptually-ordered continuous
    colormap is sampled instead so every mode gets a unique hue. Falls
    back to ``tab10`` if no cycle is configured.
    """
    import matplotlib as mpl
    import matplotlib.pyplot as plt

    if override is not None:
        if isinstance(override, str):
            cmap = plt.get_cmap(override)
            return [cmap(i / max(n - 1, 1)) for i in range(n)]
        ov = list(override)
        if not ov:
            raise ValueError(
                "colors override must be a non-empty list of colours "
                "or a matplotlib colormap name"
            )
        return [ov[i % len(ov)] for i in range(n)]

    cycle = mpl.rcParams.get("axes.prop_cycle")
    palette: list = []
    if cycle is not None:
        palette = [entry.get("color") for entry in cycle if entry.get("color")]
    if not palette:
        cmap = plt.get_cmap("tab10")
        palette = [cmap(i) for i in range(10)]
    if n <= len(palette):
        return [palette[i] for i in range(n)]
    # More modes than distinct palette colours — wrapping the cycle
    # would repeat a hue. Sample a perceptually-ordered continuous map
    # so all n modes stay distinguishable (issue #47).
    cmap = plt.get_cmap("turbo")
    return [cmap(i / max(n - 1, 1)) for i in range(n)]


def _smooth_curve(
    y_nodes: np.ndarray,
    x_nodes: np.ndarray,
    n_dense: int = 400,
) -> tuple[np.ndarray, np.ndarray]:
    """Cubic-spline-interpolate (x_nodes, y_nodes) onto an evenly-spaced
    grid of length *n_dense* in *y_nodes*, returning (y_dense, x_dense).

    Used by the Bir-style mode-shape plots so the mass-normalised
    eigenvector samples (50-60 nodes for offshore decks) render as a
    smooth curve rather than piecewise-linear segments. Falls back to
    the raw nodal arrays if scipy is unavailable.
    """
    if y_nodes.size < 4:
        return y_nodes, x_nodes
    try:
        from scipy.interpolate import CubicSpline
    except ImportError:
        return y_nodes, x_nodes
    # The natural BC matches a free-end / pinned-end mode shape well at
    # the extremes (zero curvature) and avoids overshoot.
    cs = CubicSpline(y_nodes, x_nodes, bc_type="natural")
    y_dense = np.linspace(y_nodes[0], y_nodes[-1], n_dense)
    return y_dense, cs(y_dense)


# ---------------------------------------------------------------------------
# plot_mode_shapes
# ---------------------------------------------------------------------------

[docs] def plot_mode_shapes( result: ModalResult, n_modes: int = 6, component: str = "both", title: str | None = None, figsize: tuple[float, float] | None = None, *, normalize: str = "mode", colors: list | str | None = None, ) -> Figure: """Plot normalised mode shape displacements vs normalised span. Parameters ---------- result : Output from :meth:`RotatingBlade.run` or :meth:`Tower.run`. n_modes : Number of modes to overlay (at most ``len(result.shapes)``). component : ``"flap"`` — fore-aft (w) panel only. ``"lag"`` — side-side (v) panel only. ``"both"`` — two side-by-side panels (default). title : Overall figure title. Uses a sensible default when *None*. figsize : Matplotlib figure size ``(width_in, height_in)``. Defaults to ``(9, 4)`` for one panel and ``(14, 4)`` for two panels. normalize : How each mode's two curves are scaled (issue #47): - ``"mode"`` (default) — both panels share **one** scale per mode, the peak ``|displacement|`` across flap *and* lag. The dominant direction then reaches ±1 and the minor direction stays proportionally small, so a fore-aft mode reads as a full-amplitude curve in the flap panel and a flat near-zero line in the lag panel. This is what makes FA vs SS (and a rigid platform DOF vs a real bending mode) visually distinguishable. - ``"component"`` — the legacy behaviour: each panel is normalised independently to its own peak, so even a 1 % cross-coupling component is blown up to full height and a predominantly-flap mode looks identical in both panels. Kept for reproducing pre-1.5 figures. colors : Optional colour control passed to the internal palette picker: a matplotlib colormap name (sampled into ``n_modes`` distinct colours) or an explicit list of colours. ``None`` (default) uses the styled ``prop_cycle``, automatically switching to a continuous colormap when there are more modes than distinct palette colours so no two modes collide on the same hue. Returns ------- matplotlib.figure.Figure """ _require_matplotlib() import matplotlib.pyplot as plt component = component.lower() if component not in ("flap", "lag", "both"): raise ValueError(f"component must be 'flap', 'lag', or 'both'; got {component!r}") if normalize not in ("mode", "component"): raise ValueError( f"normalize must be 'mode' or 'component'; got {normalize!r}" ) shapes = result.shapes[: min(n_modes, len(result.shapes))] n = len(shapes) colors = _mode_colors(n, override=colors) # One shared scale per mode for the "mode" convention: the peak # |displacement| over both transverse components. Falls back to 1 # for a null shape so the division is safe. def _shared_scale(shape) -> float: peak = max( float(np.max(np.abs(shape.flap_disp))) if shape.flap_disp.size else 0.0, float(np.max(np.abs(shape.lag_disp))) if shape.lag_disp.size else 0.0, ) return peak if peak > 0.0 else 1.0 mode_scales = [_shared_scale(s) for s in shapes] two_panels = component == "both" if figsize is None: figsize = (14.0, 4.5) if two_panels else (7.0, 4.5) fig, axes = plt.subplots( 1, 2 if two_panels else 1, figsize=figsize, constrained_layout=True, ) if not two_panels: axes = [axes] def _normalise(arr: np.ndarray) -> np.ndarray: peak = np.max(np.abs(arr)) return arr / peak if peak > 0 else arr def _disp(shape, comp: str, i: int) -> np.ndarray: raw = shape.flap_disp if comp == "flap" else shape.lag_disp if normalize == "mode": return raw / mode_scales[i] return _normalise(raw) panel_specs = [] if component in ("flap", "both"): panel_specs.append(("flap", "Flap (fore-aft) displacement")) if component in ("lag", "both"): panel_specs.append(("lag", "Lag (side-side) displacement")) # Floating models carry per-mode platform rigid-body names; append # them to the legend (e.g. "Mode 1 (0.0079 Hz) — surge"). None for # an unattributed mode, and the whole list is None for a # non-floating model — both leave the legend as it was. mode_labels = result.mode_labels for ax, (comp, panel_title) in zip(axes, panel_specs): for i, shape in enumerate(shapes): disp = _disp(shape, comp, i) label = f"Mode {shape.mode_number} ({shape.freq_hz:.4f} Hz)" if mode_labels is not None and i < len(mode_labels): dof = mode_labels[i] if dof is not None: label += f" — {dof}" ax.plot(shape.span_loc, disp, color=colors[i], linewidth=1.8, label=label) ax.plot(shape.span_loc, disp, "o", color=colors[i], markersize=3, markeredgewidth=0) ax.axhline(0, color="gray", linewidth=0.6, linestyle="-") ax.axvline(0, color="gray", linewidth=0.6, linestyle="-") _apply_style(ax, "Normalised span [−]", "Normalised displacement [−]", panel_title) ax.set_xlim(-0.02, 1.02) ax.legend(fontsize=8, loc="upper left", framealpha=0.9, edgecolor="0.8", handlelength=1.5) default_title = title or f"Mode shapes — {n} modes" fig.suptitle(default_title, fontsize=12, fontweight="bold", y=1.02) return fig
# --------------------------------------------------------------------------- # plot_fit_quality # --------------------------------------------------------------------------- FitEntry = tuple[str, np.ndarray, np.ndarray, "PolyFitResult"]
[docs] def plot_fit_quality( fits: list[FitEntry], title: str | None = None, figsize: tuple[float, float] | None = None, ) -> Figure: """Plot polynomial fit vs FEM data with residuals for each mode. Each subplot shows: * FEM nodal values (circles) * Fitted polynomial (solid line over a fine grid) * Residual band (shaded region between FEM and fit) * RMS residual and tip-slope annotation Parameters ---------- fits : List of ``(label, span_loc, fem_disp, fit)`` tuples as returned by :func:`blade_fit_pairs` or :func:`tower_fit_pairs`. title : Overall figure title. figsize : Matplotlib figure size. Defaults to ``(4.5 * n_cols, 4.0 * n_rows)``. Returns ------- matplotlib.figure.Figure """ _require_matplotlib() import matplotlib.pyplot as plt from matplotlib.patches import Patch n = len(fits) if n == 0: raise ValueError("fits list is empty") n_cols = min(n, 3) n_rows = math.ceil(n / n_cols) if figsize is None: figsize = (4.8 * n_cols, 4.4 * n_rows) fig, axes = plt.subplots(n_rows, n_cols, figsize=figsize, constrained_layout=True) # Flatten to 1-D list regardless of grid shape if n == 1: axes_flat = [axes] elif n_rows == 1 or n_cols == 1: axes_flat = list(np.asarray(axes).ravel()) else: axes_flat = [ax for row in axes for ax in row] x_fine = np.linspace(0.0, 1.0, 300) for idx, (label, span_loc, fem_disp, fit) in enumerate(fits): ax = axes_flat[idx] # Normalise FEM data so tip = 1 tip = fem_disp[-1] if abs(tip) < 1e-30: tip = np.max(np.abs(fem_disp)) or 1.0 y_fem = fem_disp / tip # Polynomial on fine grid y_poly_fine = fit.evaluate(x_fine) # Polynomial at FEM stations y_poly_fem = fit.evaluate(np.asarray(span_loc, dtype=float)) # Residual fill between FEM scatter and polynomial (at FEM stations) ax.fill_between(span_loc, y_fem, y_poly_fem, alpha=0.25, color="#d80000", label="Residual") # Polynomial line ax.plot(x_fine, y_poly_fine, color="#0000d8", linewidth=2.0, label="Polynomial fit") # FEM data ax.scatter(span_loc, y_fem, s=28, color="#009900", zorder=5, label="FEM data") # Reference lines ax.axhline(0, color="gray", linewidth=0.5) ax.axhline(1, color="gray", linewidth=0.5, linestyle="--") # Coefficient table inset coeffs = fit.coefficients() coeff_text = "\n".join( f"C{k+2} = {c:+.4f}" for k, c in enumerate(coeffs) ) ax.text(0.03, 0.97, coeff_text, transform=ax.transAxes, verticalalignment="top", fontsize=7.5, fontfamily="monospace", bbox=dict(boxstyle="round,pad=0.3", facecolor="white", edgecolor="0.75", alpha=0.9)) # RMS and tip-slope annotation (bottom-right) metrics_text = ( f"RMS = {fit.rms_residual:.4f}\n" f"tip slope = {fit.tip_slope:.3f}" ) ax.text(0.97, 0.04, metrics_text, transform=ax.transAxes, ha="right", va="bottom", fontsize=8, color="#d80000", bbox=dict(boxstyle="round,pad=0.3", facecolor="white", edgecolor="0.75", alpha=0.9)) _apply_style(ax, "Normalised span [−]", "Normalised displacement [−]", label) ax.set_xlim(-0.02, 1.02) if idx == 0: handles = [ plt.Line2D([0], [0], color="#0000d8", linewidth=2), plt.scatter([], [], s=28, color="#009900"), Patch(facecolor="#d80000", alpha=0.35), ] labels_ = ["Polynomial fit", "FEM data", "Residual"] ax.legend(handles, labels_, fontsize=8, loc="upper right", framealpha=0.9, edgecolor="0.8") # Hide unused subplots for ax in axes_flat[n:]: ax.set_visible(False) fig.suptitle(title or "Polynomial fit quality", fontsize=12, fontweight="bold", y=1.01) return fig
# --------------------------------------------------------------------------- # bir_mode_shape_plot — Bir 2010 figure convention # --------------------------------------------------------------------------- # # Bir's figures (Bir 2010, NREL/CP-500-47953, Figs 4, 5a, 5b, 6a-6c, 8) plot # *modal displacement* on the x-axis (mass-normalised, i.e. straight from the # eigenvector — NOT scaled to unit tip) and *normalised height* (z / H) on the # y-axis, with 0 at the tower base and 1 at the tower top. # # Each mode is drawn as a single curve with a vertical zero line representing # the undeformed tower position. Optional horizontal annotation lines mark # physical interfaces (Mean Sea Level, Mud Line) for offshore configurations. # A coupled-mode overlay (e.g. the small twist component plotted alongside # the dominant S-S component in Fig 5b / 6b) is supported via the dashed # ``coupling_overlay`` argument. ModeSpec = tuple[int, str, str] # (mode_index_1based, component, label)
[docs] def bir_mode_shape_plot( result: ModalResult, mode_specs: list[ModeSpec], *, title: str | None = None, height_label: str = "Tower section height / H", x_label: str = "Modal displacement", annotations: dict[str, float] | None = None, coupling_overlay: list[ModeSpec] | None = None, figsize: tuple[float, float] = (5.5, 6.5), xlim: tuple[float, float] | None = None, colors: list | str | None = None, ) -> Figure: """Plot mode shapes in the Bir 2010 figure convention. Parameters ---------- result : ``ModalResult`` from ``Tower.run()`` or ``RotatingBlade.run()``. mode_specs : List of ``(mode_index_1based, component, label)`` tuples. ``component`` is one of ``"flap"`` (fore-aft / F-A), ``"lag"`` (side-side / S-S), ``"twist"``, or ``"axial"``. ``label`` appears in the legend. title : Optional figure title. height_label : Y-axis label. Default matches Bir's notation; pass ``"Span fraction"`` for blade plots. x_label : X-axis label. Default ``"Modal displacement"`` matches the paper. annotations : Optional ``{label: y_fraction}`` dict drawing horizontal markers at the given normalised heights (e.g. ``{"Mean Sea Level": 0.40, "Mud Line": 0.25}`` for Bir Fig 8). coupling_overlay : Optional list of ``(mode_index, component, label)`` plotted as dashed lines on the same axes; used to show e.g. the twist component of an S-S mode (Bir Fig 5b, 6b). figsize : Matplotlib figure size in inches. xlim : Optional ``(xmin, xmax)``; auto-fits with a small pad if omitted. colors : Optional colour control (issue #47): a matplotlib colormap name or an explicit list of colours. ``None`` (default) uses the styled palette, switching to a continuous colormap when there are more curves than distinct palette colours so no two modes share a hue. Returns ------- matplotlib.figure.Figure """ _require_matplotlib() import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=figsize, constrained_layout=True) n_solid = len(mode_specs) colors = _mode_colors(max(n_solid, 1), override=colors) def _component(shape, comp: str) -> np.ndarray: if comp == "flap": return shape.flap_disp if comp == "lag": return shape.lag_disp if comp == "twist": return shape.twist if comp == "axial": # Axial DOFs aren't surfaced in NodeModeShape; warn-and-skip. raise ValueError( "Axial component is not exposed by NodeModeShape; pass " "'flap' / 'lag' / 'twist' instead." ) raise ValueError( f"component must be 'flap', 'lag', or 'twist'; got {comp!r}" ) def _resolve_mode(idx_1b: int): for shape in result.shapes: if shape.mode_number == idx_1b: return shape raise IndexError( f"Mode {idx_1b} not in result (have modes " f"{[s.mode_number for s in result.shapes]})." ) all_x: list[np.ndarray] = [] for i, (mode_idx, comp, label) in enumerate(mode_specs): shape = _resolve_mode(mode_idx) y_nodes = shape.span_loc x_nodes = _component(shape, comp) y_smooth, x_smooth = _smooth_curve(y_nodes, x_nodes) full_label = f"{label} ({shape.freq_hz:.4f} Hz)" ax.plot(x_smooth, y_smooth, color=colors[i % len(colors)], linewidth=1.8, label=full_label) all_x.append(x_nodes) if coupling_overlay: for i, (mode_idx, comp, label) in enumerate(coupling_overlay): shape = _resolve_mode(mode_idx) y_nodes = shape.span_loc x_nodes = _component(shape, comp) y_smooth, x_smooth = _smooth_curve(y_nodes, x_nodes) color = colors[i % len(colors)] ax.plot(x_smooth, y_smooth, color=color, linewidth=1.4, linestyle="--", alpha=0.85, label=label) all_x.append(x_nodes) # Vertical "undeformed" line — slightly thicker than the grid. ax.axvline(0.0, color="black", linewidth=0.7, zorder=1) # Horizontal annotation lines (MSL / Mud Line for monopile cases). if annotations: for ann_label, y_frac in annotations.items(): ax.axhline(y_frac, color="0.45", linewidth=0.8, linestyle=":", zorder=1) ax.text(0.98, y_frac + 0.012, ann_label, transform=ax.get_yaxis_transform(), ha="right", va="bottom", fontsize=8, color="0.30") ax.set_ylim(0.0, 1.0) if xlim is None and all_x: xs = np.concatenate(all_x) xmax = float(np.max(np.abs(xs))) pad = 0.10 * xmax if xmax > 0 else 0.05 ax.set_xlim(-xmax - pad, xmax + pad) elif xlim is not None: ax.set_xlim(*xlim) _apply_style(ax, x_label, height_label, title) ax.legend(fontsize=8, loc="best", framealpha=0.9, edgecolor="0.8", handlelength=2.0) return fig
[docs] def bir_mode_shape_subplot( result: ModalResult, panels: list[tuple[str, list[ModeSpec]]], *, suptitle: str | None = None, height_label: str = "Tower section height / H", x_label: str = "Modal displacement", annotations: dict[str, float] | None = None, figsize: tuple[float, float] | None = None, colors: list | str | None = None, ) -> Figure: """Multi-panel Bir-convention plot (matches Bir Fig 8 layout). Parameters ---------- panels : List of ``(panel_title, mode_specs)`` tuples; one subplot per entry. annotations : Drawn on every panel (e.g. MSL + Mud Line). colors : Optional colour control (issue #47): a matplotlib colormap name or an explicit list of colours, applied per panel. ``None`` (default) uses the styled palette, switching to a continuous colormap when a panel has more curves than distinct palette colours. Returns ------- matplotlib.figure.Figure """ _require_matplotlib() import matplotlib.pyplot as plt n = len(panels) if n == 0: raise ValueError("panels must contain at least one entry") if figsize is None: figsize = (4.2 * n, 6.5) fig, axes = plt.subplots(1, n, figsize=figsize, constrained_layout=True, sharey=True) if n == 1: axes = [axes] for ax, (panel_title, mode_specs) in zip(axes, panels): panel_colors = _mode_colors(max(len(mode_specs), 1), override=colors) all_x: list[np.ndarray] = [] for i, (mode_idx, comp, label) in enumerate(mode_specs): shape = next( s for s in result.shapes if s.mode_number == mode_idx ) y_nodes = shape.span_loc if comp == "flap": x_nodes = shape.flap_disp elif comp == "lag": x_nodes = shape.lag_disp elif comp == "twist": x_nodes = shape.twist else: raise ValueError(f"unsupported component {comp!r}") y_smooth, x_smooth = _smooth_curve(y_nodes, x_nodes) ax.plot(x_smooth, y_smooth, color=panel_colors[i % len(panel_colors)], linewidth=1.8, label=f"{label} ({shape.freq_hz:.4f} Hz)") all_x.append(x_nodes) ax.axvline(0.0, color="black", linewidth=0.7, zorder=1) if annotations: for ann_label, y_frac in annotations.items(): ax.axhline(y_frac, color="0.45", linewidth=0.8, linestyle=":", zorder=1) ax.text(0.98, y_frac + 0.012, ann_label, transform=ax.get_yaxis_transform(), ha="right", va="bottom", fontsize=7, color="0.30") ax.set_ylim(0.0, 1.0) if all_x: xs = np.concatenate(all_x) xmax = float(np.max(np.abs(xs))) pad = 0.10 * xmax if xmax > 0 else 0.05 ax.set_xlim(-xmax - pad, xmax + pad) _apply_style(ax, x_label, height_label, panel_title) ax.legend(fontsize=8, loc="best", framealpha=0.9, edgecolor="0.8", handlelength=2.0) if suptitle: fig.suptitle(suptitle, fontsize=12, fontweight="bold") return fig
# --------------------------------------------------------------------------- # blade_fit_pairs / tower_fit_pairs # ---------------------------------------------------------------------------
[docs] def blade_fit_pairs( result: ModalResult, params: BladeElastoDynParams, ) -> list[FitEntry]: """Build ``(label, span_loc, fem_disp, fit)`` entries for blade modes. Returns entries for the 1st flap, 2nd flap, and 1st edge modes, matching the order in *params*. Parameters ---------- result : ``ModalResult`` from ``RotatingBlade.run()``. params : ``BladeElastoDynParams`` from ``compute_blade_params(result)``. """ from pybmodes.elastodyn.params import _is_fa_dominated flap_shapes = [s for s in result.shapes if _is_fa_dominated(s)] edge_shapes = [s for s in result.shapes if not _is_fa_dominated(s)] entries: list[FitEntry] = [] if len(flap_shapes) >= 1: s = flap_shapes[0] entries.append(( f"1st flap ({s.freq_hz:.4f} Hz)", s.span_loc, s.flap_disp, params.BldFl1Sh, )) if len(flap_shapes) >= 2: s = flap_shapes[1] entries.append(( f"2nd flap ({s.freq_hz:.4f} Hz)", s.span_loc, s.flap_disp, params.BldFl2Sh, )) if len(edge_shapes) >= 1: s = edge_shapes[0] entries.append(( f"1st edge ({s.freq_hz:.4f} Hz)", s.span_loc, s.lag_disp, params.BldEdgSh, )) return entries
[docs] def tower_fit_pairs( result: ModalResult, params: TowerElastoDynParams, ) -> list[FitEntry]: """Build ``(label, span_loc, fem_disp, fit)`` entries for tower modes. Returns entries for FA1, FA2, SS1, SS2, matching the order in *params*. The rigid-body root component is removed from displacements before fitting (same as :func:`~pybmodes.elastodyn.params.compute_tower_params`). Parameters ---------- result : ``ModalResult`` from ``Tower.run()``. params : ``TowerElastoDynParams`` from ``compute_tower_params(result)``. """ from pybmodes.elastodyn.params import ( _remove_root_rigid_motion, compute_tower_params_report, ) _, report = compute_tower_params_report(result) by_mode = {shape.mode_number: shape for shape in result.shapes} fa1 = by_mode[report.selected_fa_modes[0]] fa2 = by_mode[report.selected_fa_modes[1]] ss1 = by_mode[report.selected_ss_modes[0]] ss2 = by_mode[report.selected_ss_modes[1]] return [ ( f"FA mode 1 ({fa1.freq_hz:.4f} Hz)", fa1.span_loc, _remove_root_rigid_motion(fa1.span_loc, fa1.flap_disp, fa1.flap_slope), params.TwFAM1Sh, ), ( f"FA mode 2 ({fa2.freq_hz:.4f} Hz)", fa2.span_loc, _remove_root_rigid_motion(fa2.span_loc, fa2.flap_disp, fa2.flap_slope), params.TwFAM2Sh, ), ( f"SS mode 1 ({ss1.freq_hz:.4f} Hz)", ss1.span_loc, _remove_root_rigid_motion(ss1.span_loc, ss1.lag_disp, ss1.lag_slope), params.TwSSM1Sh, ), ( f"SS mode 2 ({ss2.freq_hz:.4f} Hz)", ss2.span_loc, _remove_root_rigid_motion(ss2.span_loc, ss2.lag_disp, ss2.lag_slope), params.TwSSM2Sh, ), ]