Mode-shape animation — cantilever bending modes#

A mode shape \(\phi_{i}\) is the spatial pattern of a structure’s \(i\)-th free-vibration response. When the structure is excited at the corresponding natural frequency \(f_{i}\), every point moves in phase with amplitude proportional to \(\phi_{i}\) and time profile

(1)#\[\mathbf{u}(\mathbf{x}, t) \;=\; A\,\phi_{i}(\mathbf{x})\,\cos\!\bigl(2\pi f_{i} t + \theta\bigr).\]

So an “animation” of mode \(i\) is just a parametric sweep over phase \(\omega t \in [0, 2\pi]\), with the displacement field scaled by \(\cos(\omega t)\) at each frame. The static plot Plotting mode shapes shows one snapshot per mode; this example shows the time evolution of a single mode across one full period — the key visual that makes mode shapes click for new users.

The standard recipe:

  1. modal_result_to_grid(model, result) scatters every mode onto the mesh as a mode_{k}_disp point-data vector.

  2. For each animation frame, grid.warp_by_vector("mode_k_disp", factor=A·cos(ωt)) deforms the geometry by the phase-scaled shape.

  3. Render either as a filmstrip (sphinx-gallery friendly) or as a true GIF/MP4 with pyvista.Plotter.open_gif() / pyvista.Plotter.write_frame().

Step 3 is the only one that branches: the gallery shows a filmstrip because the doc build is offscreen, but the script writes a GIF too — running this file locally produces mode_1_animation.gif next to the source.

Implementation#

Slender HEX8 cantilever (clamped at \(x = 0\)). Solve six modes, pick mode 1 (first transverse bending), filmstrip eight evenly-spaced phases over one period. Mode-shape amplitude is normalised so the peak displacement reaches 8 % of the beam length — a visually readable scale that is also physically meaningless (mode shapes carry no absolute magnitude; the norm choice is arbitrary).

References#

  • Bathe, K.-J. (2014) Finite Element Procedures, 2nd ed., Prentice Hall, §10.2 — undamped free vibration.

  • Chopra, A. K. (2017) Dynamics of Structures, 5th ed., Pearson, §10.2 — free-vibration response of MDOF systems.

  • Cook, R. D., Malkus, D. S., Plesha, M. E., Witt, R. J. (2002) Concepts and Applications of Finite Element Analysis, 4th ed., Wiley, §11.3 — eigenproblems of structural dynamics.

from __future__ import annotations

from pathlib import Path

import numpy as np
import pyvista as pv

import femorph_solver
from femorph_solver import ELEMENTS
from femorph_solver.io import modal_result_to_grid

Build a slender cantilever beam (HEX8 EAS)#

E = 2.0e11
NU = 0.30
RHO = 7850.0
L = 4.0
WIDTH = 0.05
HEIGHT = 0.05

NX, NY, NZ = 60, 3, 3
xs = np.linspace(0.0, L, NX + 1)
ys = np.linspace(0.0, WIDTH, NY + 1)
zs = np.linspace(0.0, HEIGHT, NZ + 1)
grid = pv.StructuredGrid(*np.meshgrid(xs, ys, zs, indexing="ij")).cast_to_unstructured_grid()

m = femorph_solver.Model.from_grid(grid)
m.assign(
    ELEMENTS.HEX8(integration="enhanced_strain"),
    material={"EX": E, "PRXY": NU, "DENS": RHO},
)

pts = np.asarray(m.grid.points)
clamped = np.where(pts[:, 0] < 1e-9)[0]
m.fix(nodes=(clamped + 1).tolist(), dof="ALL")

Pick the visualisation amplitude#

Mode shapes are mass-normalised, so the magnitudes look tiny in absolute units. Pick a render amplitude such that the peak displacement equals 8 % of the beam length — independent of the eigenvector norm, this gives a consistent visual.

mode_index = 1  # animate mode 1 (first transverse bending)
disp = np.asarray(grid_modes.point_data[f"mode_{mode_index}_disp"])
peak = float(np.linalg.norm(disp, axis=1).max())
target_peak = 0.08 * L  # 8 % of beam length
amp = target_peak / peak if peak > 0 else 1.0
print(
    f"\n  Animating mode {mode_index} at f = {freqs[mode_index - 1]:.3f} Hz, "
    f"amplitude scaled so peak displacement = {target_peak:.3f} m "
    f"({100 * target_peak / L:.1f}% of L)"
)
Animating mode 1 at f = 2.554 Hz, amplitude scaled so peak displacement = 0.320 m (8.0% of L)

Filmstrip: eight phase snapshots over one period#

Sphinx-gallery captures static images, so the rendered output is a 2 x 4 filmstrip of warps at ωt = 0, π/4, π/2, …, 7π/4. Running this script locally also writes a real GIF — see the next cell.

n_frames = 8
phases = np.linspace(0.0, 2.0 * np.pi, n_frames, endpoint=False)

plotter = pv.Plotter(shape=(2, 4), off_screen=True, window_size=(1200, 540), border=False)
for k, phase in enumerate(phases):
    row, col = divmod(k, 4)
    plotter.subplot(row, col)
    factor = amp * float(np.cos(phase))
    warped = grid_modes.warp_by_vector(f"mode_{mode_index}_disp", factor=factor)
    plotter.add_mesh(
        warped,
        scalars=f"mode_{mode_index}_magnitude",
        cmap="viridis",
        clim=(0.0, peak),
        show_edges=False,
        show_scalar_bar=False,
    )
    plotter.add_text(f"ωt = {phase / np.pi:.2f}π", position="upper_left", font_size=10)
    plotter.view_xy()
    plotter.camera.zoom(1.4)
plotter.link_views()
plotter.show()
example mode shape animation

Write a real GIF for users running this script directly#

The block below produces a 24-frame GIF that loops over one full period — this is the artefact you actually want to embed in a report or a slide. Sphinx-gallery captures only the static filmstrip above, so the GIF lands in tempfile.gettempdir() rather than the source tree (the printed path tells you exactly where). GIF writing uses pyvista’s open_gif() / write_frame() pair and requires imageio (already pulled in by pyvista).

import importlib.util  # noqa: E402
import tempfile  # noqa: E402

if importlib.util.find_spec("imageio") is None:
    print(
        "\n  imageio not installed — skipping GIF write.  "
        "Install with `pip install imageio` to enable Plotter.open_gif."
    )
else:
    out_path = Path(tempfile.gettempdir()) / f"mode_{mode_index}_animation.gif"
    n_gif_frames = 24
    gif_phases = np.linspace(0.0, 2.0 * np.pi, n_gif_frames, endpoint=False)

    p2 = pv.Plotter(off_screen=True, window_size=(720, 480))
    p2.view_xy()
    p2.camera.zoom(1.3)
    p2.open_gif(str(out_path))
    for phase in gif_phases:
        p2.clear_actors()
        factor = amp * float(np.cos(phase))
        warped = grid_modes.warp_by_vector(f"mode_{mode_index}_disp", factor=factor)
        p2.add_mesh(
            warped,
            scalars=f"mode_{mode_index}_magnitude",
            cmap="viridis",
            clim=(0.0, peak),
            show_edges=False,
            show_scalar_bar=False,
        )
        p2.add_text(
            f"mode {mode_index}  —  f = {freqs[mode_index - 1]:.2f} Hz",
            position="upper_left",
            font_size=11,
        )
        p2.write_frame()
    p2.close()
    print(f"\n  GIF written to {out_path}")
imageio not installed — skipping GIF write.  Install with `pip install imageio` to enable Plotter.open_gif.

Take-aways#

print()
print("Take-aways:")
print(
    "  • A mode shape is a spatial pattern; its time evolution at the natural "
    "frequency is u(x,t) = A·φ(x)·cos(2π f t).  Animation = phase sweep over "
    "ωt ∈ [0, 2π]."
)
print(
    "  • modal_result_to_grid(m, res) attaches per-mode vector arrays "
    "(mode_k_disp); pyvista's warp_by_vector(factor=A·cos(ωt)) deforms the "
    "mesh by the phase-scaled shape."
)
print(
    "  • Mode-shape magnitudes are arbitrary (mass-normalised eigenvectors).  "
    "Pick the render amplitude to be a fraction of the structure's bounding "
    "box — never raw eigenvector units."
)
print(
    "  • For a true animation, use Plotter.open_gif(path) + write_frame() in "
    "an off-screen plotter; the same loop that fills the filmstrip produces "
    "the GIF frames."
)
Take-aways:
  • A mode shape is a spatial pattern; its time evolution at the natural frequency is u(x,t) = A·φ(x)·cos(2π f t).  Animation = phase sweep over ωt ∈ [0, 2π].
  • modal_result_to_grid(m, res) attaches per-mode vector arrays (mode_k_disp); pyvista's warp_by_vector(factor=A·cos(ωt)) deforms the mesh by the phase-scaled shape.
  • Mode-shape magnitudes are arbitrary (mass-normalised eigenvectors).  Pick the render amplitude to be a fraction of the structure's bounding box — never raw eigenvector units.
  • For a true animation, use Plotter.open_gif(path) + write_frame() in an off-screen plotter; the same loop that fills the filmstrip produces the GIF frames.

Total running time of the script: (0 minutes 0.754 seconds)

Gallery generated by Sphinx-Gallery