{
  "cells": [
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "import pyvista\npyvista.OFF_SCREEN = True\npyvista.set_jupyter_backend('static')"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "\n# Mode-shape animation \u2014 cantilever bending modes\n\nA mode shape $\\phi_{i}$ is the spatial pattern of a structure's\n$i$-th free-vibration response.  When the structure is excited at\nthe corresponding natural frequency $f_{i}$, every point moves\nin phase with amplitude proportional to $\\phi_{i}$ and time\nprofile\n\n\\begin{align}:label: mode-time-history\n\n    \\mathbf{u}(\\mathbf{x}, t)\n    \\;=\\;\n    A\\,\\phi_{i}(\\mathbf{x})\\,\\cos\\!\\bigl(2\\pi f_{i} t + \\theta\\bigr).\\end{align}\n\nSo an \"animation\" of mode $i$ is just a parametric sweep over\nphase $\\omega t \\in [0, 2\\pi]$, with the displacement field\nscaled by $\\cos(\\omega t)$ at each frame.  The static plot\n:doc:`example_mode_shape_plot` shows one snapshot per mode; this\nexample shows the *time evolution* of a single mode across one full\nperiod \u2014 the key visual that makes mode shapes click for new users.\n\nThe standard recipe:\n\n1. ``modal_result_to_grid(model, result)`` scatters every mode onto\n   the mesh as a ``mode_{k}_disp`` point-data vector.\n2. For each animation frame, ``grid.warp_by_vector(\"mode_k_disp\",\n   factor=A\u00b7cos(\u03c9t))`` deforms the geometry by the phase-scaled\n   shape.\n3. Render either as a filmstrip (sphinx-gallery friendly) or as a\n   true GIF/MP4 with :meth:`pyvista.Plotter.open_gif` /\n   :meth:`pyvista.Plotter.write_frame`.\n\nStep 3 is the only one that branches: the gallery shows a filmstrip\nbecause the doc build is offscreen, but the script writes a GIF too \u2014\nrunning this file locally produces ``mode_1_animation.gif`` next to\nthe source.\n\n## Implementation\n\nSlender HEX8 cantilever (clamped at $x = 0$).  Solve six modes,\npick mode 1 (first transverse bending), filmstrip eight evenly-spaced\nphases over one period.  Mode-shape amplitude is normalised so the\npeak displacement reaches 8 % of the beam length \u2014 a visually\nreadable scale that is also physically meaningless (mode shapes carry\nno absolute magnitude; the norm choice is arbitrary).\n\n## References\n\n* Bathe, K.-J. (2014) *Finite Element Procedures*, 2nd ed.,\n  Prentice Hall, \u00a710.2 \u2014 undamped free vibration.\n* Chopra, A. K. (2017) *Dynamics of Structures*, 5th ed., Pearson,\n  \u00a710.2 \u2014 free-vibration response of MDOF systems.\n* Cook, R. D., Malkus, D. S., Plesha, M. E., Witt, R. J. (2002)\n  *Concepts and Applications of Finite Element Analysis*, 4th ed.,\n  Wiley, \u00a711.3 \u2014 eigenproblems of structural dynamics.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "from __future__ import annotations\n\nfrom pathlib import Path\n\nimport numpy as np\nimport pyvista as pv\n\nimport femorph_solver\nfrom femorph_solver import ELEMENTS\nfrom femorph_solver.io import modal_result_to_grid"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Build a slender cantilever beam (HEX8 EAS)\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "E = 2.0e11\nNU = 0.30\nRHO = 7850.0\nL = 4.0\nWIDTH = 0.05\nHEIGHT = 0.05\n\nNX, NY, NZ = 60, 3, 3\nxs = np.linspace(0.0, L, NX + 1)\nys = np.linspace(0.0, WIDTH, NY + 1)\nzs = np.linspace(0.0, HEIGHT, NZ + 1)\ngrid = pv.StructuredGrid(*np.meshgrid(xs, ys, zs, indexing=\"ij\")).cast_to_unstructured_grid()\n\nm = femorph_solver.Model.from_grid(grid)\nm.assign(\n    ELEMENTS.HEX8(integration=\"enhanced_strain\"),\n    material={\"EX\": E, \"PRXY\": NU, \"DENS\": RHO},\n)\n\npts = np.asarray(m.grid.points)\nclamped = np.where(pts[:, 0] < 1e-9)[0]\nm.fix(nodes=(clamped + 1).tolist(), dof=\"ALL\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Modal solve + scatter onto the grid\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "N_MODES = 6\nres = m.solve_modal(n_modes=N_MODES)\nfreqs = np.asarray(res.frequency, dtype=np.float64)\nprint(\"Cantilever beam \u2014 first six natural frequencies\")\nfor i, f in enumerate(freqs):\n    print(f\"  mode {i + 1}:  f = {f:7.3f} Hz\")\n\n# ``modal_result_to_grid`` attaches one ``mode_{k}_disp`` vector and\n# one ``mode_{k}_magnitude`` scalar per mode.  ``scale`` is uniform \u2014\n# we'll choose a per-mode amplitude later when we render.\ngrid_modes = modal_result_to_grid(m, res, scale=1.0)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Pick the visualisation amplitude\n\nMode shapes are mass-normalised, so the magnitudes look tiny in\nabsolute units.  Pick a render amplitude such that the peak\ndisplacement equals 8 % of the beam length \u2014 independent of the\neigenvector norm, this gives a consistent visual.\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "mode_index = 1  # animate mode 1 (first transverse bending)\ndisp = np.asarray(grid_modes.point_data[f\"mode_{mode_index}_disp\"])\npeak = float(np.linalg.norm(disp, axis=1).max())\ntarget_peak = 0.08 * L  # 8 % of beam length\namp = target_peak / peak if peak > 0 else 1.0\nprint(\n    f\"\\n  Animating mode {mode_index} at f = {freqs[mode_index - 1]:.3f} Hz, \"\n    f\"amplitude scaled so peak displacement = {target_peak:.3f} m \"\n    f\"({100 * target_peak / L:.1f}% of L)\"\n)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Filmstrip: eight phase snapshots over one period\n\nSphinx-gallery captures static images, so the rendered output is a\n2 x 4 filmstrip of warps at ``\u03c9t = 0, \u03c0/4, \u03c0/2, \u2026, 7\u03c0/4``.  Running\nthis script locally also writes a real GIF \u2014 see the next cell.\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "n_frames = 8\nphases = np.linspace(0.0, 2.0 * np.pi, n_frames, endpoint=False)\n\nplotter = pv.Plotter(shape=(2, 4), off_screen=True, window_size=(1200, 540), border=False)\nfor k, phase in enumerate(phases):\n    row, col = divmod(k, 4)\n    plotter.subplot(row, col)\n    factor = amp * float(np.cos(phase))\n    warped = grid_modes.warp_by_vector(f\"mode_{mode_index}_disp\", factor=factor)\n    plotter.add_mesh(\n        warped,\n        scalars=f\"mode_{mode_index}_magnitude\",\n        cmap=\"viridis\",\n        clim=(0.0, peak),\n        show_edges=False,\n        show_scalar_bar=False,\n    )\n    plotter.add_text(f\"\u03c9t = {phase / np.pi:.2f}\u03c0\", position=\"upper_left\", font_size=10)\n    plotter.view_xy()\n    plotter.camera.zoom(1.4)\nplotter.link_views()\nplotter.show()"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Write a real GIF for users running this script directly\n\nThe block below produces a 24-frame GIF that loops over one full period \u2014\nthis is the artefact you actually want to embed in a report or a slide.\nSphinx-gallery captures only the static filmstrip above, so the GIF lands\nin :func:`tempfile.gettempdir` rather than the source tree (the printed\npath tells you exactly where).  GIF writing uses pyvista's\n:meth:`~pyvista.Plotter.open_gif` / :meth:`~pyvista.Plotter.write_frame`\npair and requires ``imageio`` (already pulled in by pyvista).\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "import importlib.util  # noqa: E402\nimport tempfile  # noqa: E402\n\nif importlib.util.find_spec(\"imageio\") is None:\n    print(\n        \"\\n  imageio not installed \u2014 skipping GIF write.  \"\n        \"Install with `pip install imageio` to enable Plotter.open_gif.\"\n    )\nelse:\n    out_path = Path(tempfile.gettempdir()) / f\"mode_{mode_index}_animation.gif\"\n    n_gif_frames = 24\n    gif_phases = np.linspace(0.0, 2.0 * np.pi, n_gif_frames, endpoint=False)\n\n    p2 = pv.Plotter(off_screen=True, window_size=(720, 480))\n    p2.view_xy()\n    p2.camera.zoom(1.3)\n    p2.open_gif(str(out_path))\n    for phase in gif_phases:\n        p2.clear_actors()\n        factor = amp * float(np.cos(phase))\n        warped = grid_modes.warp_by_vector(f\"mode_{mode_index}_disp\", factor=factor)\n        p2.add_mesh(\n            warped,\n            scalars=f\"mode_{mode_index}_magnitude\",\n            cmap=\"viridis\",\n            clim=(0.0, peak),\n            show_edges=False,\n            show_scalar_bar=False,\n        )\n        p2.add_text(\n            f\"mode {mode_index}  \u2014  f = {freqs[mode_index - 1]:.2f} Hz\",\n            position=\"upper_left\",\n            font_size=11,\n        )\n        p2.write_frame()\n    p2.close()\n    print(f\"\\n  GIF written to {out_path}\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Take-aways\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "print()\nprint(\"Take-aways:\")\nprint(\n    \"  \u2022 A mode shape is a spatial pattern; its time evolution at the natural \"\n    \"frequency is u(x,t) = A\u00b7\u03c6(x)\u00b7cos(2\u03c0 f t).  Animation = phase sweep over \"\n    \"\u03c9t \u2208 [0, 2\u03c0].\"\n)\nprint(\n    \"  \u2022 modal_result_to_grid(m, res) attaches per-mode vector arrays \"\n    \"(mode_k_disp); pyvista's warp_by_vector(factor=A\u00b7cos(\u03c9t)) deforms the \"\n    \"mesh by the phase-scaled shape.\"\n)\nprint(\n    \"  \u2022 Mode-shape magnitudes are arbitrary (mass-normalised eigenvectors).  \"\n    \"Pick the render amplitude to be a fraction of the structure's bounding \"\n    \"box \u2014 never raw eigenvector units.\"\n)\nprint(\n    \"  \u2022 For a true animation, use Plotter.open_gif(path) + write_frame() in \"\n    \"an off-screen plotter; the same loop that fills the filmstrip produces \"\n    \"the GIF frames.\"\n)"
      ]
    }
  ],
  "metadata": {
    "kernelspec": {
      "display_name": "Python 3",
      "language": "python",
      "name": "python3"
    },
    "language_info": {
      "codemirror_mode": {
        "name": "ipython",
        "version": 3
      },
      "file_extension": ".py",
      "mimetype": "text/x-python",
      "name": "python",
      "nbconvert_exporter": "python",
      "pygments_lexer": "ipython3",
      "version": "3.12.3"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 0
}