{
  "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# Tutorial 6 \u2014 End-to-end deliverable workflow\n\nThe earlier tutorials all built their meshes natively in\npyvista.  Real engineering workflows rarely start that way:\nthe geometry comes out of a CAD tool through a meshing\npre-processor (HyperMesh, ANSA, Gmsh) and lands as a\ndeck \u2014 almost always an MAPDL CDB or a NASTRAN BDF \u2014 that\nthe analyst hands off to the solver, plus a snapshot of the\nsolver-ready model that downstream consumers re-load.\n\nThis tutorial walks the *complete* deliverable workflow from a\npre-built model snapshot through a modal-plus-static analysis\nto a saved ``.pv`` result file the analyst can hand the next\nperson on the team.  Six steps:\n\n* **Step 1** \u2014 load a model snapshot and inspect what landed.\n  Counts, element types, materials, units, and BCs.\n* **Step 2** \u2014 fill in the gaps.  Pre-built snapshots\n  sometimes ship without the BC / load setup the next\n  analysis needs; we register them natively.\n* **Step 3** \u2014 run a modal solve and read the spectrum.\n* **Step 4** \u2014 set up a static load case from scratch on top\n  of the snapshot's geometry, solve it, and recover the\n  stress field.\n* **Step 5** \u2014 save the static result to a self-contained\n  ``.pv`` file and re-load it through the disk-backed\n  :class:`~femorph_solver.result.static.StaticResultDisk` handle.\n* **Step 6** \u2014 render a stress-distribution histogram for\n  the design-review packet.\n\nThis is the canonical end-to-end workflow the docs missed\nbefore \u2014 every other gallery example focuses on one slice\n(reader, solver, recovery, plotting).  Tutorial 6 stitches\nthem all together.\n\n## Note on the source format\n\nThe tutorial uses the bundled\n:func:`femorph_solver.examples.cyclic_bladed_rotor_sector_path`\n``.pv`` fixture as the input.  The same workflow applies when\nthe source is an MAPDL CDB / NASTRAN BDF / Abaqus INP \u2014 call\n:func:`femorph_solver.interop.mapdl.from_cdb` /\n:func:`~femorph_solver.interop.nastran.from_bdf` /\n:func:`~femorph_solver.interop.abaqus.from_inp` instead of\n:meth:`Model.from_pv` in Step 1, and the rest is identical.\n\n## Companion deeper-dive resources\n\n* :doc:`/getting-started/mapdl-interop` \u2014 full MAPDL\n  compatibility deep-dive (KEYOPT parity, binary-file\n  coverage).\n* :doc:`/interop/nastran`, :doc:`/interop/abaqus` \u2014\n  equivalent migration deep-dives for the other two\n  vendor decks.\n* :doc:`/user-guide/post-processing/result-objects` \u2014 how\n  the disk-backed result handles work.\n* :doc:`/user-guide/solving/static`,\n  :doc:`/user-guide/solving/modal` \u2014 the analysis-type\n  pages each step exercises.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "from __future__ import annotations\n\nimport tempfile\nfrom pathlib import Path\n\nimport matplotlib\n\nmatplotlib.use(\"Agg\")  # headless: no GUI window pop in gallery / CI\n\nimport matplotlib.pyplot as plt\nimport numpy as np\n\nimport femorph_solver\nfrom femorph_solver.recover import compute_nodal_stress, stress_invariants\nfrom femorph_solver.result.static import StaticResultDisk"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Step 1 \u2014 load the model snapshot\n\nThe bundled ``cyclic_bladed_rotor_sector_path`` returns a\n``.pv`` file that is a complete :meth:`Model.save` snapshot\nof one sector of a bladed-rotor disk meshed with HEX8 cells.\nReal workflows would point :meth:`Model.from_pv` at the\nengineer's own ``.pv`` (or call the relevant vendor-deck\nreader from :mod:`femorph_solver.interop`); the API call is\nthe same.\n\n:meth:`Model.from_pv <femorph_solver.Model.from_pv>` returns\na native :class:`~femorph_solver.Model` carrying everything\nstamped on the snapshot: geometry, connectivity, element-type\nregistry, materials, real-constant table, the\n:class:`UnitSystem` stamp, and any BCs / loads the snapshot\nauthor registered.  No re-parsing \u2014 the ``.pv`` is the\ncanonical Model on-disk format.\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "source_path = femorph_solver.examples.cyclic_bladed_rotor_sector_path()\nprint(f\"Loading model snapshot:\\n  {source_path}\\n\")\n\nmodel = femorph_solver.Model.from_pv(source_path)\n\nprint(\"After load:\")\nprint(f\"  Nodes:                  {model.n_nodes}\")\nprint(f\"  Elements:               {model.n_elements}\")\nprint(f\"  Element-type registry:  {model.etypes}\")\nprint(f\"  Materials:              {list(model.materials.keys())}\")\nprint(f\"  Unit-system stamp:      {model.unit_system}\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Step 2 \u2014 fill in the gaps\n\nLooking at the load-time inspection: the snapshot declared\none element type and one material.  That's typical \u2014 many\npre-built models ship with the geometry + materials but\nwithout the BC setup the next analysis needs (because BCs\nare analysis-specific, not model-specific).  We register\nthem natively.\n\nThis step uses the same :meth:`Model.fix\n<femorph_solver.Model.fix>` and :meth:`Model.apply_force\n<femorph_solver.Model.apply_force>` calls every other\ntutorial uses \u2014 the foreign-deck-loader contract is \"after\nthe loader returns, the resulting Model is indistinguishable\nfrom one built natively\".\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "pts = np.asarray(model.grid.points)\nnode_nums = np.asarray(model.grid.point_data[\"ansys_node_num\"], dtype=np.int64)\n\nz_min = pts[:, 2].min()\nhub_mask = pts[:, 2] < z_min + 1e-6\nprint(f\"\\n  Hub face at z={z_min:.4f} carries {int(hub_mask.sum())} nodes.\")\n\nmodel.fix(nodes=node_nums[hub_mask].tolist(), dof=\"ALL\")\nprint(\"  Clamped the hub face (full-fix).\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Step 3 \u2014 modal solve\n\nA clamped-hub modal solve is the standard \"is the rotor\nbehaving\" first check.  Six modes is the default analyst\nsize for a quick survey; commercial codes typically default\nto 10-20 modes for the same purpose.\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "modal = model.solve_modal(n_modes=6)\nprint(\"\\n  Lowest six modes (Hz):\")\nfor i, f in enumerate(modal.frequency, start=1):\n    print(f\"    mode {i}: {f:8.2f} Hz\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Step 4 \u2014 static load case + stress recovery\n\nStatic analysis on the same model.  We push a 1000 N axial\nload distributed across the tip face, solve, recover the\nstress field, and read out the peak von Mises.\n\nNote that the solve reuses the BCs already on the model from\nStep 2 \u2014 the cyclic faces of the sector are *not* fixed\n(that's appropriate for a fully-tip-loaded static demo).\nReal cyclic-symmetry analysis would use\n:class:`~femorph_solver.CyclicModel` and its\n:meth:`~femorph_solver.CyclicModel.solve_modal`; this tutorial keeps\nthe static case simple to focus on the workflow.\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "z_max = pts[:, 2].max()\ntip_mask = pts[:, 2] > z_max - 1e-6\ntip_nodes = node_nums[tip_mask]\nTOTAL_LOAD = 1000.0  # arbitrary, in the snapshot's unit system\n\nper_node = TOTAL_LOAD / tip_nodes.size\nfor n in tip_nodes:\n    model.apply_force(int(n), fz=per_node)\nprint(f\"\\n  Applied {TOTAL_LOAD} units total across {tip_nodes.size} tip nodes.\")\n\nstatic = model.solve_static()\nprint(f\"  Static solve done.  displacement.shape = {static.displacement.shape}\")\n\n# Stress recovery via the public free-function helper.  Same\n# math as `StaticResultDisk.stress(model=model)` after the result\n# is loaded from disk (Step 5); we use the in-memory path here\n# because the model is already in scope.\ndisplacement = static.displacement.reshape(-1, 3)  # 3 DOFs / node\nprint(f\"  Peak displacement magnitude: {np.linalg.norm(displacement, axis=1).max():.4e}\")\n\nstress_field = compute_nodal_stress(model, static.displacement)\ninvariants = stress_invariants(stress_field)\nsigma_vm = invariants[\"von_mises\"]\npeak_vm = float(sigma_vm.max())\nprint(f\"  Peak von Mises stress: {peak_vm:.3e}\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Step 5 \u2014 save / reload through the disk-backed StaticResultDisk\n\nThe in-memory result lives in RAM only; the disk-backed\n:class:`~femorph_solver.result.static.StaticResultDisk` is the\nformat you hand to the next person on the team.  ``.save``\nwrites a single self-contained ``.pv`` (zstd-compressed\npyvista) file; ``StaticResultDisk(path)`` re-loads it lazily.\n\nWe write to ``tempfile.TemporaryDirectory`` so the gallery\nbuild doesn't litter the source tree, but real workflows\nwould write to a project-results directory and check the\n``.pv`` into the deliverables folder.\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "with tempfile.TemporaryDirectory() as tmp:\n    out = static.save(Path(tmp) / \"rotor_sector_static.pv\", model)\n    print(f\"\\n  Wrote: {out.name}  ({out.stat().st_size / 1024:.1f} KiB)\")\n\n    # Re-load through the disk-backed handle.  Lazy IO \u2014 the\n    # full grid only inflates on first .grid access.\n    handle = StaticResultDisk(out)\n    print(f\"  Re-loaded: {type(handle).__name__} from {out.name}\")\n    print(f\"  Disk-backed n_points: {handle.n_points}\")\n\n    # Stress recovery on the disk-backed handle uses the same\n    # call shape as the in-memory path; the model has to be in\n    # scope because stress isn't stored on disk (see\n    # /reference/theory/stress_recovery).\n    stress_handle = handle.stress(model=model)\n    np.testing.assert_allclose(stress_handle, stress_field)\n    print(\"  Round-tripped stress field matches the in-memory recovery.\")\n\n    # The lazy disk-backed handle is the canonical way to keep\n    # several large analyses in scope without burning RAM.\n    print(f\"  Available point arrays: {sorted(handle.available_point_arrays())}\")"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Step 6 \u2014 render the stress histogram\n\nA simple histogram of the von-Mises field plus a max-line\nannotation \u2014 enough to drop into a design-review slide and\nread off \"where's the peak and how broad is the\ndistribution?\".  Real packets would also include the rendered\nmesh from\n`sphx_glr_gallery_post-processing_example_principal_stress.py`,\nbut this tutorial is about the workflow rather than the final\nplot polish.\n\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "fig, ax = plt.subplots(figsize=(7.5, 4.5))\nax.hist(sigma_vm, bins=40, color=\"C0\", edgecolor=\"k\", alpha=0.85)\nax.axvline(peak_vm, color=\"C3\", lw=2.0, label=f\"peak = {peak_vm:.3e}\")\nax.set_xlabel(r\"von Mises stress $\\sigma_\\mathrm{vm}$\")\nax.set_ylabel(\"Number of nodes\")\nax.set_title(\"Rotor sector \u2014 static stress distribution\")\nax.legend(loc=\"upper right\")\nax.grid(True, ls=\":\", alpha=0.5)\nfig.tight_layout()\nplt.show()"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Engineering takeaway\n\nThree things to read off the workflow before the\ndeliverable:\n\n1. **Snapshot-side gaps are routine.**  The ``.pv``\n   snapshot came in without analysis-specific BCs / loads \u2014\n   the analyst's first move was to register them.  Any\n   pre-built-model workflow includes a \"what did the\n   snapshot declare vs what do I still need to assign\"\n   pass; the inspection in Step 1 surfaces it explicitly.\n2. **The solver doesn't care where the mesh came from.**\n   Steps 3 and 4 use the same APIs as the from-scratch\n   tutorials.  This is the foreign-deck-reader contract:\n   after :func:`from_cdb` / :func:`from_bdf` /\n   :func:`from_inp` / :meth:`Model.from_pv` returns, the\n   resulting :class:`~femorph_solver.Model` is\n   indistinguishable from one built natively.\n3. **The .pv result file is the deliverable, not the script.**\n   Step 5 round-trips through ``.pv`` because that's how\n   the next person on the team picks the result up.  The\n   file carries the displacement + every metadata field,\n   and stress is recovered on demand from the model the\n   consumer has in scope.  See\n   :doc:`/reference/theory/stress_recovery` for why stress\n   is *not* stored on disk.\n\nWhat's missing (and where to find it):\n\n* A composite cross-check against an originating MAPDL run \u2014\n  that's the verification-corpus pattern in\n  :doc:`/verification/index`, not what a typical analyst\n  needs in their day-to-day workflow.\n* A cyclic-symmetry expansion \u2014 :doc:`tutorial_05_cyclic_rotor`\n  shows it; the modal solve in Step 3 is the static\n  single-sector form.\n* A NASTRAN-deck variant \u2014 same pattern with\n  :func:`femorph_solver.interop.nastran.from_bdf` instead.\n\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
}