{
  "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# Full-rotor mode-shape slides via CyclicModel\n\nDrives the bundled bladed-rotor sector through the\n:class:`~femorph_solver.CyclicModel` API end-to-end:\n\n#. Wrap the sector as a :class:`~femorph_solver.CyclicModel`.\n#. Call :meth:`~femorph_solver.CyclicModel.solve_modal` once \u2014\n   one :class:`~femorph_solver.solvers.cyclic.CyclicModalResult`\n   per harmonic index $k = 0 \\ldots N/2$.\n#. **Expand each base-sector mode shape to the full rotor** via\n   :func:`femorph_solver.result._cyclic_expand.expand_mesh` and\n   :func:`~femorph_solver.result._cyclic_expand.expand_mode_shape`\n   \u2014 turn one sector's complex eigenvector into ``N`` rotated\n   real-valued snapshots that tile the full 360\u00b0 rotor.\n#. Lay the lowest non-rigid mode of every harmonic out as a\n   subplot grid \u2014 one panel per $k$, all rendered on the\n   same full-rotor mesh so the wave structure is immediate.\n\nThe \"slide-show\" framing comes from the spatial replication: every\npanel is the same instant in time, but each harmonic's mode shape\nhas a different number of nodal diameters around the\ncircumference.  Engine-order excitation analysis picks the\nharmonic whose nodal-diameter count matches the forcing pattern.\n\n## References\n\n* Thomas, D. L. (1979) \"Dynamics of rotationally periodic\n  structures,\" *J. Sound Vib.* 66 (4), 585\u2013597.\n* Wildheim, S. J. (1979) \"Excitation of rotationally periodic\n  structures,\" *J. Appl. Mech.* 46, 891\u2013893.\n* Bathe, K.-J. (2014) *Finite Element Procedures*, 2nd ed.,\n  \u00a710.3.4 (cyclic-symmetry modal).\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "from __future__ import annotations\n\nimport numpy as np\nimport pyvista as pv\n\nimport femorph_solver as fs\nfrom femorph_solver.result._cyclic_expand import (\n    expand_mesh,\n    expand_mode_shape,\n)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Load the bundled bladed-rotor sector\n``cyclic_bladed_rotor_sector_path()`` ships a 230-node, 101-cell\nHEX8 sector with the element-type / material / unit-system\nbookkeeping already stamped on the grid \u2014 ready for an immediate\ncyclic modal solve.\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "cm = fs.CyclicModel.from_pv(\n    fs.examples.cyclic_bladed_rotor_sector_path(),\n    n_sectors=15,\n)\nprint(f\"sector mesh: {cm.grid.n_points} nodes, {cm.grid.n_cells} cells\")\nprint(f\"full rotor : {cm.n_sectors * cm.grid.n_points} nodes\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Solve every harmonic index\nA single :meth:`CyclicModel.solve_modal` call returns one result\nper harmonic ``k = 0, 1, \u2026, N // 2``; for N = 15 that's 8 indices\n(0 plus 1\u20137 \u2014 odd N has no Nyquist counter-rotating partner).\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "results = cm.solve_modal(n_modes=4)\nprint()\nprint(f\"{'k':>3}  {'f_min(elastic) [Hz]':>22}\")\nfor r in results:\n    f = np.asarray(r.frequency, dtype=np.float64)\n    elastic = f[f > 1.0]\n    f_min = float(elastic[0]) if elastic.size else float(\"nan\")\n    print(f\"{r.harmonic_index:>3}  {f_min:22.2f}\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Expand each sector to the full rotor mesh\nThe cyclic axis comes from the model itself (``cm.axis``); the\naxis-pivot point is the origin for this sector.\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "axis_dir = cm.axis\naxis_point = np.zeros(3, dtype=np.float64)\nfull_grid = expand_mesh(cm.grid, n_sectors=cm.n_sectors, axis_point=axis_point, axis_dir=axis_dir)\nprint(f\"expanded full-rotor mesh: {full_grid.n_points} nodes, {full_grid.n_cells} cells\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Render the mode-shape slide grid\nOne subplot per harmonic ``k`` \u2014 each panel shows that harmonic's\n**lowest non-rigid mode** warped onto the full rotor.  The\ntravelling-wave pair at harmonic ``k`` produces a pattern with\n``k`` nodal diameters around the circumference; the rendering\nbelow makes that count visible without reading off frequencies.\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "def lowest_elastic_index(freq: np.ndarray, floor_hz: float = 1.0) -> int:\n    \"\"\"Index of the first non-rigid mode in a frequency vector.\"\"\"\n    elastic = np.where(np.asarray(freq, dtype=np.float64) > floor_hz)[0]\n    return int(elastic[0]) if elastic.size else 0\n\n\nnrows = 2\nncols = (len(results) + nrows - 1) // nrows\nplotter = pv.Plotter(shape=(nrows, ncols), off_screen=True, window_size=(960, 480))\n\nfor panel_idx, r in enumerate(results):\n    row, col = divmod(panel_idx, ncols)\n    plotter.subplot(row, col)\n\n    j = lowest_elastic_index(r.frequency)\n    base_phi = np.asarray(r.mode_shapes)[:, j].reshape(-1, 3)\n\n    # Expand the (complex) base sector across all N sectors at the\n    # selected harmonic index \u2014 produces (N \u00b7 n_base, 3) real-valued.\n    full_phi = expand_mode_shape(\n        base_phi,\n        k=int(r.harmonic_index),\n        n_sectors=cm.n_sectors,\n        axis_dir=axis_dir,\n    )\n    amp = float(np.max(np.abs(full_phi))) or 1.0\n    warp_factor = 0.05 / amp\n    warped = full_grid.copy()\n    warped.points = full_grid.points + warp_factor * full_phi\n    warped[\"uz\"] = full_phi[:, 2]\n\n    plotter.add_text(\n        f\"k = {r.harmonic_index}, f = {float(np.asarray(r.frequency)[j]):.0f} Hz\",\n        font_size=9,\n    )\n    plotter.add_mesh(full_grid, style=\"wireframe\", color=\"grey\", opacity=0.3)\n    plotter.add_mesh(warped, scalars=\"uz\", cmap=\"coolwarm\", show_edges=False, show_scalar_bar=False)\n\nplotter.link_views()\nplotter.view_isometric()\nplotter.show()"
      ]
    }
  ],
  "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
}