# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2025 Simon Marwitz, Volkmar Zabel, Andrei Udrea et al.
"""Interactive PyQt6 GUI orchestrating multi-setup OMA (PoSER, PoGER, PreGER).
Each measurement setup gets its own tab (:class:`SetupTabWidget`), which
pops open the existing single-setup dialogs
(:func:`~pyOMA.GUI.PreProcessSignalsGUI.start_preprocess_gui`,
:func:`~pyOMA.GUI.ModalAnalysisGUI.start_modal_analysis_gui`,
:func:`~pyOMA.GUI.StabilGUI.start_stabil_gui`) rather than re-implementing
those steps - this module only orchestrates, mirroring what
``scripts/multi_setup_analysis.py``, ``scripts/multi_setup_analysis_poger.py``
and ``scripts/multi_setup_analysis_preger.py`` already do as a script loop,
made interactive.
PoSER (:class:`~pyOMA.core.PostProcessingTools.MergePoSER`) identifies and
pole-selects each setup independently, then merges; every setup tab needs
its own modal-ID + pole-selection pass, so :class:`SetupTabWidget` shows
those rows. PoGER (:class:`~pyOMA.core.SSICovRef.PogerSSICovRef`) and PreGER
(:class:`~pyOMA.core.MultiSetupSSI.PreGERSSI`, plus its variance-propagating
subclass :class:`~pyOMA.core.MultiSetupSSI.VarPreGERSSI` selected via the
"Compute variances" checkbox) both pool the pre-processed setups *before*
identification and identify/pole-select the joint data once -
:class:`SetupTabWidget` hides its modal-ID/pole rows in these modes, and
:meth:`MultiSetupGUI._merge_poger`/:meth:`MultiSetupGUI._merge_preger` run the
single shared identification + pole-selection pass instead.
Widget layout lives in ``ui/multi_setup.ui`` and ``ui/setup_tab.ui``
(compiled to ``generated/ui_multi_setup.py`` / ``generated/ui_setup_tab.py``
by ``scripts/build_ui.py``); this module only wires signals/slots.
"""
import sys
import logging
from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget, QMessageBox, QFileDialog
from PyQt6.QtCore import QEventLoop, pyqtSignal
from .generated.ui_multi_setup import Ui_MultiSetupGUI
from .generated.ui_setup_tab import Ui_SetupTabWidget
from .HelpersGUI import UnsavedChangesMixin
from .PreProcessSignalsGUI import start_preprocess_gui
from .ModalAnalysisGUI import start_modal_analysis_gui
from .StabilGUI import start_stabil_gui
from .PlotMSHGUI import start_msh_gui
from .GeometryProcessorGUI import start_geometry_processor_gui
from ..core.PreProcessingTools import GeometryProcessor
from ..core.SSICovRef import PogerSSICovRef
from ..core.MultiSetupSSI import PreGERSSI, VarPreGERSSI
from ..core.StabilDiagram import StabilCluster, StabilPlot
from ..core.PostProcessingTools import MergePoSER
from ..core.PlotMSH import ModeShapePlot
logger = logging.getLogger(__name__)
app = None
_STATE_FILE_FILTER = "NumPy archive (*.npz);;All files (*)"
_MODES = ['Single Setup', 'PoSER', 'PoGER', 'PreGER', 'Var-PreGER']
# mode -> class used by _on_load to pick the right load_state(); PoSER/Single
# Setup are handled separately in _on_load (MergePoSER / no load support).
_MERGE_CLASSES = {
'PoGER': PogerSSICovRef,
'PreGER': PreGERSSI,
'Var-PreGER': VarPreGERSSI,
}
[docs]
class MultiSetupGUI(UnsavedChangesMixin, QMainWindow, Ui_MultiSetupGUI):
"""Interactive GUI orchestrating multi-setup OMA merging via PoSER, PoGER or PreGER.
Setups are added as tabs; "Merge Setups" then combines them via
:class:`~pyOMA.core.PostProcessingTools.MergePoSER` (PoSER),
:class:`~pyOMA.core.SSICovRef.PogerSSICovRef` (PoGER) or
:class:`~pyOMA.core.MultiSetupSSI.PreGERSSI`/
:class:`~pyOMA.core.MultiSetupSSI.VarPreGERSSI` (PreGER/Var-PreGER),
mirroring ``scripts/multi_setup_analysis.py`` /
``scripts/multi_setup_analysis_poger.py`` /
``scripts/multi_setup_analysis_preger.py`` interactively.
Parameters
----------
geometry_data : GeometryProcessor, optional
Shared geometry object. Created (and mutated in place) via the
geometry-loading dialog if not given.
parent : QWidget, optional
"""
[docs]
def __init__(self, geometry_data=None, parent=None):
super().__init__(parent)
self.geometry_data = geometry_data
self.merged_data = None
self._pooled_stabil_calc = None
self._single_setup_tab = None
self._dirty = False
self.setupUi(self)
self.combo_mode.addItems(_MODES)
self._wire_buttons()
self._refresh_geometry_status()
self._on_mode_changed(0)
self.show()
# ------------------------------------------------------------------
# Wiring
# ------------------------------------------------------------------
def _wire_buttons(self):
self.combo_mode.currentIndexChanged.connect(self._on_mode_changed)
self.btn_load_geometry.clicked.connect(self._on_load_geometry)
self.btn_add_setup.clicked.connect(self._on_add_setup)
self.btn_remove_setup.clicked.connect(self._on_remove_setup)
self.btn_merge.clicked.connect(self._on_merge)
self.btn_show_mode_shapes.clicked.connect(self._on_show_mode_shapes)
self.actionSave_State.triggered.connect(self._on_save)
self.actionLoad_State.triggered.connect(self._on_load)
self.actionQuit.triggered.connect(self.close)
@property
def mode(self):
return self.combo_mode.currentText()
@property
def _tabs(self):
return [self.tabs_setups.widget(i) for i in range(self.tabs_setups.count())]
# ------------------------------------------------------------------
# Mode switch
# ------------------------------------------------------------------
def _on_mode_changed(self, _index):
mode = self.mode
if mode == 'PoGER':
settings_page = 1
elif mode in ('PreGER', 'Var-PreGER'):
settings_page = 2
else:
settings_page = 0
self.stack_settings.setCurrentIndex(settings_page)
self.btn_merge.setText("Continue" if mode == 'Single Setup' else "Merge Setups")
for tab in self._tabs:
tab.set_mode(mode)
self.merged_data = None
self._single_setup_tab = None
self._dirty = False
self.btn_show_mode_shapes.setEnabled(False)
self._refresh_add_setup_enabled()
self._refresh_merge_status()
# ------------------------------------------------------------------
# Geometry
# ------------------------------------------------------------------
def _on_load_geometry(self):
if self.geometry_data is None:
self.geometry_data = GeometryProcessor()
start_geometry_processor_gui(self.geometry_data)
for tab in self._tabs:
tab.geometry_data = self.geometry_data
self._refresh_geometry_status()
def _refresh_geometry_status(self):
if self.geometry_data is None:
self.lbl_geometry_status.setText("Geometry: not loaded")
else:
self.lbl_geometry_status.setText(
f"Geometry: {len(self.geometry_data.nodes)} node(s)")
# ------------------------------------------------------------------
# Setup tabs
# ------------------------------------------------------------------
def _on_add_setup(self):
tab = SetupTabWidget(self.mode, self.geometry_data)
tab.status_changed.connect(self._refresh_merge_status)
index = self.tabs_setups.addTab(tab, f"Setup {self.tabs_setups.count() + 1}")
self.tabs_setups.setCurrentIndex(index)
self._refresh_add_setup_enabled()
self._refresh_merge_status()
def _on_remove_setup(self):
index = self.tabs_setups.currentIndex()
if index < 0:
return
answer = QMessageBox.question(
self, "Remove Setup",
"Remove the current setup tab? Unsaved progress on this tab will be lost.")
if answer != QMessageBox.StandardButton.Yes:
return
widget = self.tabs_setups.widget(index)
self.tabs_setups.removeTab(index)
widget.deleteLater()
self._refresh_add_setup_enabled()
self._refresh_merge_status()
def _refresh_add_setup_enabled(self):
# Single Setup mode is capped at one tab - there is nothing to merge.
self.btn_add_setup.setEnabled(not (self.mode == 'Single Setup' and self._tabs))
# ------------------------------------------------------------------
# Merge
# ------------------------------------------------------------------
def _refresh_merge_status(self):
tabs = self._tabs
if not tabs:
self.btn_merge.setEnabled(False)
self.lbl_merge_status.setText("Add at least one setup.")
return
if self.mode == 'Single Setup':
ready = tabs[0].is_ready_poser
hint = "poles selected" if ready else "select poles first"
self.btn_merge.setEnabled(ready)
self.lbl_merge_status.setText(f"1 setup, {hint}.")
return
if self.mode == 'PoSER':
ready = all(tab.is_ready_poser for tab in tabs)
hint = "poles selected" if ready else "select poles on every setup first"
else:
ready = all(tab.is_ready_pooled for tab in tabs)
hint = "setups loaded" if ready else "load every setup first"
self.btn_merge.setEnabled(ready and len(tabs) >= 2)
if len(tabs) < 2:
self.lbl_merge_status.setText("Add at least two setups to merge.")
else:
self.lbl_merge_status.setText(f"{len(tabs)} setup(s), {hint}.")
def _on_merge(self):
try:
if self.mode == 'Single Setup':
self._single_setup_tab = self._tabs[0]
self.merged_data = None # nothing to merge - the tab's own results are shown
elif self.mode == 'PoSER':
self._merge_poser()
elif self.mode == 'PoGER':
self._merge_poger()
else:
self._merge_preger(use_variance=(self.mode == 'Var-PreGER'))
except Exception as exc:
logger.exception("Merge failed")
QMessageBox.warning(self, "Merge failed", str(exc))
return
self._dirty = True
self.btn_show_mode_shapes.setEnabled(True)
self.lbl_merge_status.setText(
"Ready." if self.mode == 'Single Setup' else "Merged successfully.")
def _merge_poser(self):
merger = MergePoSER()
for tab in self._tabs:
merger.add_setup(tab.prep_signals, tab.modal_data, tab.stabil_calc)
merger.merge()
self.merged_data = merger
def _merge_poger(self):
poger = PogerSSICovRef()
for tab in self._tabs:
if tab.prep_signals.m_lags is None:
name = tab.prep_signals.setup_name or 'A setup'
raise ValueError(
f"{name} has no correlation function computed yet. Open "
"\"Pre-process Signals...\" for that setup and compute "
"correlation (e.g. via the Correlation time-domain diagram) "
"before merging with PoGER.")
poger.add_setup(tab.prep_signals)
poger.pair_channels()
num_block_columns = self.spin_num_block_columns.value() or None
max_model_order = self.spin_max_model_order.value() or None
poger.build_merged_subspace_matrix(num_block_columns)
poger.compute_modal_params(max_model_order)
stabil_calc = StabilCluster(poger, poger.prep_signals)
stabil_calc.calculate_stabilization_masks()
stabil_plot = StabilPlot(stabil_calc)
start_stabil_gui(stabil_plot, poger, self.geometry_data, poger.prep_signals)
self.merged_data = poger
self._pooled_stabil_calc = stabil_calc
def _merge_preger(self, use_variance):
"""PreGER (or, with *use_variance*, Var-PreGER) merge - mirrors
:meth:`_merge_poger`, but through the classes' own ``add_setup``
(Ns) -> ``pair_channels`` -> ``build_subspace_matrices`` ->
``compute_modal_params`` sequence rather than PoGER's
``build_merged_subspace_matrix``.
"""
cls = VarPreGERSSI if use_variance else PreGERSSI
preger = cls()
for tab in self._tabs:
if tab.prep_signals.m_lags is None:
name = tab.prep_signals.setup_name or 'A setup'
raise ValueError(
f"{name} has no correlation function computed yet. Open "
"\"Pre-process Signals...\" for that setup and compute "
"correlation (e.g. via the Correlation time-domain diagram) "
"before merging with PreGER.")
if use_variance and (tab.prep_signals.n_segments is None
or tab.prep_signals.n_segments < 2):
name = tab.prep_signals.setup_name or 'A setup'
raise ValueError(
f"{name} has no block-wise correlation function computed yet. "
"Recompute correlation with n_segments >= 2 (e.g. "
"corr_blackman_tukey(m_lags, n_segments=...)) before merging "
"with Var-PreGER.")
preger.add_setup(tab.prep_signals)
preger.pair_channels()
num_block_columns = self.spin_preger_num_block_columns.value() or None
num_block_rows = self.spin_preger_num_block_rows.value() or None
subspace_method = self.combo_preger_subspace_method.currentText()
num_blocks = self.spin_preger_num_blocks.value() or None
max_model_order = self.spin_preger_max_model_order.value() or None
preger.build_subspace_matrices(
num_block_columns, num_block_rows=num_block_rows,
subspace_method=subspace_method, num_blocks=num_blocks)
preger.compute_modal_params(max_model_order)
stabil_calc = StabilCluster(preger, preger.prep_signals)
stabil_calc.calculate_stabilization_masks()
stabil_plot = StabilPlot(stabil_calc)
start_stabil_gui(stabil_plot, preger, self.geometry_data, preger.prep_signals)
self.merged_data = preger
self._pooled_stabil_calc = stabil_calc
# ------------------------------------------------------------------
# Mode shapes
# ------------------------------------------------------------------
def _on_show_mode_shapes(self):
if self.geometry_data is None:
return
if self.mode == 'Single Setup':
if self._single_setup_tab is None:
return
tab = self._single_setup_tab
mode_shape_plot = ModeShapePlot(
geometry_data=self.geometry_data,
stabil_calc=tab.stabil_calc,
modal_data=tab.modal_data,
prep_signals=tab.prep_signals)
elif self.merged_data is None:
return
elif isinstance(self.merged_data, MergePoSER):
mode_shape_plot = ModeShapePlot(
geometry_data=self.geometry_data, merged_data=self.merged_data)
else:
mode_shape_plot = ModeShapePlot(
geometry_data=self.geometry_data,
stabil_calc=self._pooled_stabil_calc,
modal_data=self.merged_data,
prep_signals=self.merged_data.prep_signals)
start_msh_gui(mode_shape_plot)
# ------------------------------------------------------------------
# Save / Load
# ------------------------------------------------------------------
def _on_save(self):
if self.merged_data is None:
QMessageBox.warning(self, "Nothing to save", "Merge setups first.")
return
fname, _filter = QFileDialog.getSaveFileName(
self, "Save Merged State", "", _STATE_FILE_FILTER)
if not fname:
return
try:
self.merged_data.save_state(fname)
except Exception as exc:
logger.exception("save_state failed")
QMessageBox.warning(self, "Save failed", str(exc))
return
self._dirty = False
def _do_save(self):
self._on_save()
def _on_load(self):
fname, _filter = QFileDialog.getOpenFileName(
self, "Load Merged State", "", _STATE_FILE_FILTER)
if not fname:
return
cls = MergePoSER if self.mode == 'PoSER' else _MERGE_CLASSES.get(self.mode, PogerSSICovRef)
try:
loaded = cls.load_state(fname)
except Exception as exc:
logger.exception("load_state failed")
QMessageBox.warning(self, "Load failed", str(exc))
return
self.merged_data = loaded
self._pooled_stabil_calc = None
self._dirty = False # freshly loaded from disk - nothing unsaved yet
self.btn_show_mode_shapes.setEnabled(True)
self.lbl_merge_status.setText("Loaded merged state.")
[docs]
def closeEvent(self, event):
if not self._prompt_save_on_close(event):
return
# Snapshot into a plain attribute *before* deleteLater(): once the
# underlying Qt object is destroyed, reaching into child widgets is
# no longer safe, but a plain Python attribute stays readable.
self._merged_data_at_close = self.merged_data
self.deleteLater()
return QMainWindow.closeEvent(self, event)
[docs]
def start_multi_setup_gui(geometry_data=None):
global app
app = QApplication.instance() or QApplication(sys.argv)
form = MultiSetupGUI(geometry_data)
form.resize(760, 860)
loop = QEventLoop()
form.destroyed.connect(loop.quit)
loop.exec()
return form._merged_data_at_close
[docs]
def main():
start_multi_setup_gui()
if __name__ == '__main__':
main()