{ "cells": [ { "cell_type": "markdown", "id": "title", "metadata": {}, "source": [ "# pyOMA – Multi-Setup OMA with PoSER merging\n", "\n", "Interactive Jupyter companion to `scripts/multi_setup_analysis.py`.\n", "\n", "Each sub-directory matching `tests/files/measurement_*` is treated as one\n", "measurement setup. Stabilisation diagrams are computed for all setups and\n", "shown in a tabbed widget for interactive pole selection. After selection the\n", "results are merged across setups using PoSER and the mode shapes can be\n", "inspected interactively.\n", "\n", "| Step | Cell | Description |\n", "|------|------|-------------|\n", "| 1 | Geometry | Load nodes, lines, parent–child assignments |\n", "| 2–4 | Per-setup loop | Pre-process signals, identify modal parameters, compute stabilisation masks |\n", "| 5a | StabilGUIWeb | Interactive tabbed stabilisation diagram — select poles manually |\n", "| 5b | Auto-select | Headless alternative when interactive GUI is not needed |\n", "| 6 | Merge | PoSER merge across setups |\n", "| 7 | Results | Print merged frequencies and damping ratios |\n", "| 8 | PlotMSHWeb | Animate merged mode shapes interactively |\n", "\n", "> **Requirements:** `pip install \"pyOMA[jupyter]\"`\n", ">\n", "> Run cells top-to-bottom with **Shift+Enter**. After selecting poles in the\n", "> stabilisation tabs (Step 5a), continue from Step 6 onwards." ] }, { "cell_type": "markdown", "id": "imports-header", "metadata": {}, "source": [ "## 0 Imports and display backend" ] }, { "cell_type": "code", "execution_count": null, "id": "imports", "metadata": {}, "outputs": [], "source": [ "from pathlib import Path\n", "import numpy as np\n", "import ipympl # noqa: F401 – activates the widget backend\n", "%matplotlib widget\n", "import matplotlib.pyplot as plt\n", "\n", "from IPython.display import display\n", "\n", "from pyOMA.core import (\n", " GeometryProcessor,\n", " SignalPlot,\n", " PreProcessSignals,\n", " BRSSICovRef,\n", " SSIData,\n", " PLSCF,\n", " PRCE,\n", " VarSSIRef,\n", " StabilCluster,\n", " StabilPlot,\n", " ModeShapePlot,\n", ")\n", "from pyOMA.core.PostProcessingTools import MergePoSER\n", "from pyOMA.GUI.JupyterGUI import StabilGUIWeb, PlotMSHWeb\n", "\n", "print('pyOMA imported successfully')" ] }, { "cell_type": "markdown", "id": "config-header", "metadata": {}, "source": [ "## 1 Configuration\n", "\n", "Adjust the paths and parameters below to match your project." ] }, { "cell_type": "code", "execution_count": null, "id": "config", "metadata": {}, "outputs": [], "source": [ "import pyOMA\n", "\n", "# Root of the repository — works whether pyOMA is installed or run in-place\n", "REPO_ROOT = Path(pyOMA.__file__).parent.parent\n", "EXAMPLE_DATA = REPO_ROOT / 'tests' / 'files'\n", "\n", "# OMA method – change to SSIData, PLSCF, VarSSIRef, etc.\n", "METHOD = BRSSICovRef\n", "\n", "_CONF_FILES = {\n", " BRSSICovRef: 'ssi_config.txt',\n", " SSIData: 'ssi_config.txt',\n", " PLSCF: 'plscf_config.txt',\n", " PRCE: 'prce_config.txt',\n", " VarSSIRef: 'varssi_config.txt',\n", "}\n", "CONF_FILE = EXAMPLE_DATA / _CONF_FILES[METHOD]\n", "\n", "# Stabilisation thresholds\n", "STABIL_KWARGS = dict(\n", " d_range=(0, 0.5),\n", " df_max=0.01,\n", " dd_max=0.05,\n", " dmac_max=0.05,\n", ")\n", "\n", "SKIP_EXISTING = False # load saved intermediate results when True\n", "SAVE_RESULTS = False # persist intermediate results to disk when True\n", "\n", "PreProcessSignals.load_measurement_file = np.load\n", "\n", "assert EXAMPLE_DATA.exists(), f'Example data not found at {EXAMPLE_DATA}'\n", "print(f'Data root : {EXAMPLE_DATA}')\n", "print(f'Method : {METHOD.__name__}')" ] }, { "cell_type": "markdown", "id": "geometry-header", "metadata": {}, "source": [ "## 2 Structural geometry" ] }, { "cell_type": "code", "execution_count": null, "id": "geometry", "metadata": {}, "outputs": [], "source": [ "geometry_data = GeometryProcessor.load_geometry(\n", " nodes_file=EXAMPLE_DATA / 'grid.txt',\n", " lines_file=EXAMPLE_DATA / 'lines.txt',\n", " parent_childs_file=EXAMPLE_DATA / 'parent_child_assignments.txt',\n", ")\n", "print(f'{len(geometry_data.nodes)} nodes, {len(geometry_data.lines)} lines loaded')" ] }, { "cell_type": "markdown", "id": "loop-header", "metadata": {}, "source": [ "## 3 Per-setup loop: signal processing → modal ID → stabilisation\n", "\n", "All setups are processed here. Results are collected in lists so the\n", "interactive GUI (Step 4) and the merge step (Step 5) can use them." ] }, { "cell_type": "code", "execution_count": null, "id": "loop", "metadata": {}, "outputs": [], "source": [ "setup_dirs = sorted(\n", " EXAMPLE_DATA.glob('measurement_*'),\n", " key=lambda p: int(p.name.split('_')[1]),\n", ")\n", "\n", "prep_signals_list = []\n", "modal_datas = []\n", "stabil_calcs = []\n", "stabil_plots = []\n", "setup_names = []\n", "\n", "for setup_dir in setup_dirs:\n", " meas_name = setup_dir.name\n", " print(f'\\n── {meas_name} ──')\n", "\n", " # ── Signal pre-processing ────────────────────────────────────────────────\n", " _prep_state = setup_dir / 'prep_data.npz'\n", " if _prep_state.exists() and SKIP_EXISTING:\n", " prep_signals = PreProcessSignals.load_state(_prep_state)\n", " else:\n", " prep_signals = PreProcessSignals.init_from_config(\n", " conf_file=setup_dir / 'setup_info.txt',\n", " meas_file=setup_dir / f'{meas_name}.npy',\n", " chan_dofs_file=setup_dir / 'channel_dofs.txt',\n", " )\n", " prep_signals.decimate_signals(3)\n", " prep_signals.decimate_signals(3)\n", " prep_signals.correlation(m_lags=200)\n", " prep_signals.psd()\n", " if SAVE_RESULTS:\n", " prep_signals.save_state(_prep_state)\n", "\n", " # ── System identification ────────────────────────────────────────────────\n", " _modal_state = setup_dir / 'modal_data.npz'\n", " if _modal_state.exists() and SKIP_EXISTING:\n", " modal_data = METHOD.load_state(_modal_state, prep_signals)\n", " else:\n", " modal_data = METHOD.init_from_config(CONF_FILE, prep_signals)\n", " if SAVE_RESULTS:\n", " modal_data.save_state(_modal_state)\n", "\n", " # ── Stabilisation masks ──────────────────────────────────────────────────\n", " _stabil_state = setup_dir / 'stabil_data.npz'\n", " if _stabil_state.exists() and SKIP_EXISTING:\n", " from pyOMA.core import StabilCalc\n", " stabil_calc = StabilCalc.load_state(_stabil_state, modal_data)\n", " else:\n", " stabil_calc = StabilCluster(modal_data)\n", " stabil_calc.calculate_stabilization_masks(**STABIL_KWARGS)\n", " if SAVE_RESULTS:\n", " stabil_calc.save_state(_stabil_state)\n", "\n", " prep_signals_list.append(prep_signals)\n", " modal_datas.append(modal_data)\n", " stabil_calcs.append(stabil_calc)\n", " stabil_plots.append(StabilPlot(stabil_calc))\n", " setup_names.append(meas_name)\n", "\n", "print(f'\\n{len(stabil_calcs)} setup(s) ready.')" ] }, { "cell_type": "markdown", "id": "ms-poser-geo-viz-header", "metadata": {}, "source": [ "### Geometry with sensor positions\n", "\n", "The plot below shows the structural nodes and lines together with channel-DOF arrows for the *last* pre-processed setup. Arrows indicate each sensor's measurement direction." ] }, { "cell_type": "code", "execution_count": null, "id": "ms-poser-geo-viz-code", "metadata": {}, "outputs": [], "source": [ "# Uses prep_signals from the last setup in the loop\n", "geo_plot = ModeShapePlot(geometry_data=geometry_data, prep_signals=prep_signals)\n", "geo_plot.reset_view()\n", "geo_plot.draw_nodes()\n", "geo_plot.draw_lines()\n", "geo_plot.draw_chan_dofs()\n", "geo_plot.refresh_parent_childs(False)\n", "geo_plot.subplot.view_init(elev=20, azim=110)\n", "display(geo_plot.fig)" ] }, { "cell_type": "markdown", "id": "ms-poser-sig-header", "metadata": {}, "source": [ "### Signal inspection\n", "\n", "Inspect the time-series and power spectral densities for the last pre-processed setup before running identification." ] }, { "cell_type": "code", "execution_count": null, "id": "ms-poser-sig-code", "metadata": {}, "outputs": [], "source": [ "sig_plot = SignalPlot(prep_signals)\n", "sig_plot.plot_signals(per_channel_axes=True, psd_scale='db')\n", "plt.suptitle(f'Signals — {prep_signals.setup_name}', fontsize=12)\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "stabil-header", "metadata": {}, "source": [ "## 4a Interactive pole selection\n", "\n", "Each setup has its own tab. Switch the **Cursor** radio button to *Stable*,\n", "hover over a stable pole cluster and **click** to select it. Click again to\n", "deselect. Adjust the soft/hard criteria sliders to refine what counts as\n", "\"stable\" before selecting.\n", "\n", "Once you are satisfied with the selection in **all tabs**, continue to\n", "**Step 5 (Merge)**.\n", "\n", "> **Tip:** If you prefer fully automatic selection, skip this cell and run\n", "> **Step 4b** instead." ] }, { "cell_type": "code", "execution_count": null, "id": "stabil-gui", "metadata": {}, "outputs": [], "source": [ "stabil_widget, cursors = StabilGUIWeb(stabil_plots, setup_names=setup_names)\n", "display(stabil_widget)" ] }, { "cell_type": "markdown", "id": "auto-select-header", "metadata": {}, "source": [ "## 4b Automatic pole selection *(alternative to 4a)*\n", "\n", "Run this cell instead of 4a for a fully headless workflow. Poles that were\n", "already selected interactively are left unchanged." ] }, { "cell_type": "code", "execution_count": null, "id": "auto-select", "metadata": {}, "outputs": [], "source": [ "for sc, name in zip(stabil_calcs, setup_names):\n", " if not sc.select_modes:\n", " sc.automatic_selection()\n", " print(f'{name}: {len(sc.select_modes)} mode(s) selected')" ] }, { "cell_type": "markdown", "id": "merge-header", "metadata": {}, "source": [ "## 5 Merge setups (PoSER)" ] }, { "cell_type": "code", "execution_count": null, "id": "merge", "metadata": {}, "outputs": [], "source": [ "merger = MergePoSER()\n", "for prep_signals, modal_data, stabil_calc in zip(prep_signals_list, modal_datas, stabil_calcs):\n", " merger.add_setup(prep_signals, modal_data, stabil_calc)\n", "\n", "merger.merge()\n", "\n", "if SAVE_RESULTS:\n", " _merged_state = EXAMPLE_DATA / 'measurement_15' / 'merged_setups.npz'\n", " merger.save_state(_merged_state)\n", "\n", "print('Merge complete.')" ] }, { "cell_type": "markdown", "id": "results-header", "metadata": {}, "source": [ "## 6 Merged results" ] }, { "cell_type": "code", "execution_count": null, "id": "results", "metadata": {}, "outputs": [], "source": [ "freqs = merger.mean_frequencies[:, 0]\n", "damps = merger.mean_damping[:, 0]\n", "std_f = merger.std_frequencies[:, 0]\n", "std_d = merger.std_damping[:, 0]\n", "n_modes = len(freqs)\n", "n_setups = len(merger.setups)\n", "\n", "print(f'\\nMerged PoSER results ({n_setups} setup(s), {n_modes} mode(s))')\n", "print('\\u2500' * 56)\n", "print(f' {\"#\":>3} {\"Freq [Hz]\":>10} {\"Damp [%]\":>10} {\"\\u03c3_f [Hz]\":>10} {\"\\u03c3_d [%]\":>10}')\n", "print('\\u2500' * 56)\n", "for i, (f, d, sf, sd) in enumerate(zip(freqs, damps, std_f, std_d), 1):\n", " print(f' {i:>3} {f:>10.3f} {d:>10.3f} {sf:>10.4f} {sd:>10.4f}')\n", "print('\\u2500' * 56)" ] }, { "cell_type": "markdown", "id": "msh-header", "metadata": {}, "source": [ "## 7 Mode shape visualisation\n", "\n", "Displays the merged mode shapes on the 3-D geometry. Use the **Mode**\n", "dropdown to switch between modes and the **play** button to animate." ] }, { "cell_type": "code", "execution_count": null, "id": "msh-gui", "metadata": {}, "outputs": [], "source": [ "msp = ModeShapePlot(\n", " geometry_data=geometry_data,\n", " merged_data=merger,\n", " amplitude=20,\n", ")\n", "msh_widget = PlotMSHWeb(msp)\n", "display(msh_widget)" ] }, { "cell_type": "code", "execution_count": null, "id": "d43aee71-bac7-4473-8f88-60cb7c71a22d", "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "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.14.6" } }, "nbformat": 4, "nbformat_minor": 5 }