Source code for pyOMA.GUI.StabilGUI

# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2025  Simon Marwitz, Volkmar Zabel, Andrei Udrea et al.
"""PyQt6 interactive stabilization diagram and mode-selection GUI."""
import logging
logger = logging.getLogger(__name__)
logger.setLevel(level=logging.DEBUG)

app = None

from .HelpersGUI import my_excepthook, UnsavedChangesMixin
from .PlotMSHGUI import ModeShapeGUI
from .generated.ui_stabil_gui import Ui_StabilGUI
from .generated.ui_complex_plot import Ui_ComplexPlot
from .generated.ui_histo_plot import Ui_HistoPlot
from pyOMA.core.StabilDiagram import StabilPlot, StabilCluster, StabilCalc
from pyOMA.core.PlotMSH import ModeShapePlot
from PyQt6.QtCore import Qt, pyqtSlot, QEventLoop
from PyQt6.QtGui import QPalette
from PyQt6.QtWidgets import QMainWindow, QFileDialog, QApplication, QMessageBox
import numpy as np
import sys
import os

from matplotlib import rcParams
from matplotlib import ticker

from matplotlib.backend_bases import FigureCanvasBase
from matplotlib.figure import Figure
import matplotlib.pyplot as plot

plot.rc('figure', figsize=[8.5039399474194, 5.255723925793184], dpi=100,)
plot.rc('font', size=10)
plot.rc('legend', fontsize=10, labelspacing=0.1)
plot.rc('axes', linewidth=0.2)
plot.rc('xtick.major', width=0.2)
plot.rc('ytick.major', width=0.2)
plot.ioff()

sys.excepthook = my_excepthook


[docs] class StabilGUI(UnsavedChangesMixin, QMainWindow, Ui_StabilGUI): """PyQt6 main window for interactive stabilisation diagram and mode selection. Displays a :class:`~pyOMA.core.StabilDiagram.StabilPlot` with interactive pole-picking, PSD overlay, adjustable stabilisation criteria, and optionally a linked :class:`~pyOMA.GUI.PlotMSHGUI.ModeShapeGUI` for immediate mode- shape feedback. Parameters ---------- stabil_plot : StabilPlot Populated stabilisation-diagram plot object. cmpl_plot : StabilPlot or similar Complementary plot shown beside the stabilisation diagram (e.g. a PSD or correlation plot). msh_plot : ModeShapeGUI, optional When provided, mode-shape is updated whenever the user selects a pole. .. TODO:: * scale markers right on every platform * frequency range as argument or from ssi params, sampling freq * add switch to choose between "unstable only in ..." or "stable in ..." * distinguish between stabilization criteria and filtering criteria * add zoom and sliders (horizontal/vertical) for the main figure """
[docs] def __init__(self, stabil_plot, cmpl_plot, msh_plot=None): """ Parameters ---------- stabil_plot : StabilPlot Populated stabilisation-diagram plot object. cmpl_plot : StabilPlot or similar Complementary plot shown beside the stabilisation diagram. msh_plot : ModeShapeGUI, optional When provided, mode-shape is updated on pole selection. """ QMainWindow.__init__(self) self.setupUi(self) self.setWindowTitle( 'Stabilization Diagram: {} - {}'.format( stabil_plot.stabil_calc.setup_name, stabil_plot.stabil_calc.start_time)) self.stabil_plot = stabil_plot self.stabil_calc = stabil_plot.stabil_calc if self.stabil_calc.state < 2: self.stabil_calc.calculate_stabilization_masks() self.cmpl_plot = cmpl_plot self.msh_plot = msh_plot self.current_mode = (0, 0) self.histo_plot_f = None self.histo_plot_sf = None self.histo_plot_d = None self.histo_plot_sd = None self.histo_plot_dr = None self.histo_plot_mac = None self.histo_plot_mpc = None self.histo_plot_mpd = None self._wire_menu() self._wire_canvas() self._wire_mode_display_widgets(cmpl_plot) self._wire_stab_val_widget() self._wire_diag_val_widget() self._wire_buttons() self._wire_panel_toggles() self.stabil_calc.add_callback('add_mode', self.mode_selector_add) self.stabil_calc.add_callback('remove_mode', self.mode_selector_take) for index, mode in enumerate(self.stabil_calc.select_modes): self.mode_selector_add(mode, index) self._dirty = False # reflects modes already selected before this GUI opened self.show() for widg in [self.mode_val_view_text, self.current_value_view_text]: widg.setText('\n \n \n \n \n \n \n') height = widg.document().size().toSize().height() + 3 widg.setFixedHeight(height) self.update_stabil_view()
def _wire_menu(self): """Connect the .ui-declared menu actions to their slots. action_save_plot has never been wired to anything (pre-existing, left as-is - not something this migration should fix). """ self.action_quit.triggered.connect(self.close) self.actionSave_State.triggered.connect(self.save_state) self.actionLoad_State.triggered.connect(self.load_state) def _wire_canvas(self): """Attach stabil_plot's figure to the canvas and set up the pole cursor.""" self.fig = self.stabil_plot.fig self.canvas.set_figure(self.fig) self.stabil_plot.fig.set_facecolor('none') self.init_cursor() def _wire_mode_display_widgets(self, cmpl_plot): """Wire the mode selector, mode-shape-viewer toggle, and mode display views.""" palette = QPalette() palette.setColor(QPalette.ColorRole.Base, Qt.GlobalColor.transparent) self.mode_val_view_text.setPalette(palette) self.current_value_view_text.setPalette(palette) self.mode_selector.currentIndexChanged[int].connect(self.update_mode_val_view) self.msh_toggle_button.toggled.connect(self.toggle_msh_plot) self.msh_toggle_button.setEnabled(self.msh_plot is not None) cmpl_plot.plot_diagram() self._embed_cmpl_plot(cmpl_plot) def _embed_cmpl_plot(self, cmpl_plot): """Embed the ComplexPlot window as a permanent widget in the right pane.""" cmpl_plot.setParent(self.right_pane_widget) cmpl_plot.setWindowFlags(Qt.WindowType.Widget) self.right_pane_layout.insertWidget(1, cmpl_plot, 1) cmpl_plot.show() def _wire_stab_val_widget(self): """Populate and wire the 'Stabilization Criteria' frame's widgets.""" caps = self.stabil_calc.capabilities self.df_edit.setText(str(self.stabil_calc.df_max * 100)) self.df_histo_button.released.connect(self.create_histo_plot_f) self.stdf_histo_button.released.connect(self.create_histo_plot_sf) self.dd_edit.setText(str(self.stabil_calc.dd_max * 100)) self.dd_histo_button.released.connect(self.create_histo_plot_d) self.stdd_histo_button.released.connect(self.create_histo_plot_sd) d_range = self.stabil_calc.d_range self.d_min_edit.setText(str(d_range[0])) self.d_max_edit.setText(str(d_range[1])) self.dr_histo_button.released.connect(self.create_histo_plot_dr) self.mac_edit.setText(str(self.stabil_calc.dmac_max * 100)) self.mac_histo_button.released.connect(self.create_histo_plot_mac) self.mpc_edit.setText(str(self.stabil_calc.mpc_min)) self.mpc_histo_button.released.connect(self.create_histo_plot_mpc) self.mpd_edit.setText(str(self.stabil_calc.mpd_max)) self.mpd_histo_button.released.connect(self.create_histo_plot_mpd) self.show_mc_button.released.connect(self.show_MC_plot) if caps['auto']: self.num_iter_edit.setText(str(self.stabil_calc.num_iter)) self.threshold_edit.setPlaceholderText(str(self.stabil_calc.threshold)) self.clear_auto_button.released.connect(self.prepare_auto_clearing) self.classify_auto_button.released.connect(self.prepare_auto_classification) self.select_auto_button.released.connect(self.prepare_auto_selection) self._apply_stab_val_visibility(caps) def _apply_stab_val_visibility(self, caps): """Show/hide capability-gated rows of the 'Stabilization Criteria' frame. A static form can't express the original code's conditional row construction, so every row is always built and toggled instead. """ self._set_visible(caps['f'], self.freq_label, self.df_edit, self.df_histo_button) self._set_visible( caps['std'], self.cov_freq_label, self.stdf_edit, self.stdf_histo_button, self.cov_damping_label, self.stdd_edit, self.stdd_histo_button) self._set_visible( caps['d'], self.damping_label, self.dd_edit, self.dd_histo_button, self.damping_range_label, self.d_min_edit, self.damping_range_to_label, self.d_max_edit, self.dr_histo_button) self._set_visible( caps['msh'], self.mac_label, self.mac_edit, self.mac_histo_button, self.mpc_label, self.mpc_edit, self.mpc_histo_button, self.mpd_label, self.mpd_edit, self.mpd_histo_button) # MTN was never finished in pyOMA/core (capabilities['mtn'] is # hardcoded off there) and this button has no method to call - # keep it permanently hidden rather than wiring a dangling signal. self._set_visible(False, self.mtn_label, self.mtn_edit, self.mtn_histo_button) self._set_visible(caps['MC'], self.mc_label, self.MC_edit, self.show_mc_button) self._set_visible( caps['auto'], self.clear_auto_button, self.num_iter_edit, self.classify_auto_button, self.use_stabil_checkbox, self.threshold_edit, self.select_auto_button, self.num_modes_edit) def _wire_diag_val_widget(self): """Populate and wire the 'View Settings' frame's widgets.""" caps = self.stabil_calc.capabilities self.stabil_plot.toggle_stable(True) self.stable_pole_checkbox.stateChanged.connect(self.stabil_plot.toggle_stable) self.snap_stable_radio.toggled.connect(self.snap_stable) self.stabil_plot.toggle_all(True) self.all_poles_checkbox.stateChanged.connect(self.stabil_plot.toggle_all) self.snap_all_radio.toggled.connect(self.snap_all) if caps['auto']: show_clear = self.stabil_calc.state >= 3 self.autoclear_checkbox.setChecked(show_clear) self.stabil_plot.toggle_clear(show_clear) show_select = self.stabil_calc.state >= 4 self.autoselect_checkbox.setChecked(show_select) self.stabil_plot.toggle_select(show_select) self.autoclear_checkbox.stateChanged.connect(self.stabil_plot.toggle_clear) self.snap_clear_radio.toggled.connect(self.snap_clear) self.autoselect_checkbox.stateChanged.connect(self.stabil_plot.toggle_select) self.snap_select_radio.toggled.connect(self.snap_select) if caps['data']: self.stabil_plot.plot_sv_psd(False) self.show_psd_checkbox.stateChanged.connect(self.stabil_plot.plot_sv_psd) if caps['std']: self.show_stdf_checkbox.stateChanged.connect(self.stabil_plot.toggle_stdf) f_range = (0, self.stabil_calc.get_max_f()) self.freq_low_edit.setText('{:2.3f}'.format(f_range[0])) self.freq_high_edit.setText('{:2.3f}'.format(f_range[1] * 1.05)) n_range = (0, 1, self.stabil_calc.modal_data.max_model_order) self.n_low_edit.setText('{:2d}'.format(n_range[0])) self.n_step_edit.setText('{:2d}'.format(n_range[1])) self.n_high_edit.setText('{:2d}'.format(n_range[2])) self._apply_diag_val_visibility(caps) def _apply_diag_val_visibility(self, caps): """Show/hide capability-gated rows of the 'View Settings' frame.""" self._set_visible( caps['auto'], self.autoclear_checkbox, self.snap_clear_radio, self.autoselect_checkbox, self.snap_select_radio) self._set_visible(caps['data'], self.show_psd_checkbox) self._set_visible(caps['std'], self.show_stdf_checkbox) @staticmethod def _set_visible(visible, *widgets): for widget in widgets: widget.setVisible(visible) def _wire_buttons(self): """Connect the bottom button row to their slots.""" self.save_figure_button.released.connect(self.save_figure) self.export_results_button.released.connect(self.save_results) self.ok_close_button.released.connect(self.close) def _wire_panel_toggles(self): """Wire the left/right pane collapse toggle buttons.""" self.left_toggle_btn.toggled.connect( self._make_toggle_handler(self.left_toggle_btn, self.left_pane_widget)) self.right_toggle_btn.toggled.connect( self._make_toggle_handler(self.right_toggle_btn, self.right_pane_widget)) def _make_toggle_handler(self, button, panel): def handler(checked): panel.setVisible(checked) button.setArrowType(Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow) return handler
[docs] def create_histo_plot_f(self): ''' create show/hide update stable ''' # print('here') array = self.stabil_calc.freq_diffs self.histo_plot_f = self.create_histo_plot( array, self.histo_plot_f, title='Frequency Differences (percent)', ranges=( 0, 1), select_ranges=[ float( self.df_edit.text()) / 100], select_callback=[ lambda x: [ self.df_edit.setText( str( x * 100)), self.update_stabil_view()]])
[docs] def create_histo_plot_sf(self): ''' create show/hide update stable ''' array = np.ma.array( self.stabil_calc.modal_data.std_frequencies / self.stabil_calc.modal_data.modal_frequencies) self.histo_plot_sf = self.create_histo_plot( array, self.histo_plot_sf, title='CoV Frequencies (percent)', ranges=( 0, 1), select_ranges=[ float( self.stdf_edit.text()) / 100], select_callback=[ lambda x: [ self.stdf_edit.setText( str( x * 100)), self.update_stabil_view()]])
[docs] def create_histo_plot_d(self): ''' create show/hide update stable ''' array = self.stabil_calc.damp_diffs self.histo_plot_d = self.create_histo_plot( array, self.histo_plot_d, title='Damping Differences (percent)', ranges=( 0, 1), select_ranges=[ float( self.dd_edit.text()) / 100], select_callback=[ lambda x: [ self.dd_edit.setText( str( x * 100)), self.update_stabil_view()]])
[docs] def create_histo_plot_sd(self): ''' create show/hide update stable ''' array = np.ma.array( self.stabil_calc.modal_data.std_damping / self.stabil_calc.modal_data.modal_damping) self.histo_plot_sd = self.create_histo_plot( array, self.histo_plot_sd, title='CoV Damping (percent)', ranges=( 0, 1), select_ranges=[ float( self.stdd_edit.text()) / 100], select_callback=[ lambda x: [ self.stdd_edit.setText( str( x * 100)), self.update_stabil_view()]])
[docs] def create_histo_plot_dr(self): ''' create show/hide update stable ''' array = np.ma.array(self.stabil_calc.modal_data.modal_damping) self.histo_plot_dr = self.create_histo_plot( array, self.histo_plot_dr, title='Damping range ', ranges=( 0, 10), select_ranges=[ float( self.d_min_edit.text()), float( self.d_max_edit.text())], select_callback=[ lambda x: [ self.d_min_edit.setText( str(x)), self.update_stabil_view()], lambda x: [ self.d_max_edit.setText( str(x)), self.update_stabil_view()]])
[docs] def create_histo_plot_mac(self): ''' create show/hide update stable ''' array = self.stabil_calc.MAC_diffs self.histo_plot_mac = self.create_histo_plot( array, self.histo_plot_mac, title='MAC Diffs (percent)', ranges=( 0, 1), select_ranges=[ float( self.mac_edit.text()) / 100], select_callback=[ lambda x: [ self.mac_edit.setText( str( x * 100)), self.update_stabil_view()]])
[docs] def create_histo_plot_mpc(self): ''' create show/hide update stable ''' array = self.stabil_calc.MPC_matrix self.histo_plot_mpc = self.create_histo_plot( array, self.histo_plot_mpc, title='MPC', ranges=( 0, 1), select_ranges=[ float( self.mpc_edit.text()), None], select_callback=[ lambda x: [ self.mpc_edit.setText( str(x)), self.update_stabil_view()], str])
[docs] def create_histo_plot_mpd(self): ''' create show/hide update stable ''' array = self.stabil_calc.MPD_matrix self.histo_plot_mpd = self.create_histo_plot( array, self.histo_plot_mpd, title='MPD', ranges=( 0, 90), select_ranges=[ float( self.mpd_edit.text())], select_callback=[ lambda x: [ self.mpd_edit.setText( str(x)), self.update_stabil_view()]])
def show_MC_plot(self): b = self.sender().isChecked() self.stabil_plot.show_MC(b)
[docs] def create_histo_plot( self, array, plot_obj, title='', ranges=None, select_ranges=None, select_callback=None): ''' should work like following:: button press if None → visible = True, create if visible → visible = False, hide if not visible → visible = True, show update stabil if None → skip if visible → visible = visible, update if not visible → visible = visible, update close button → visible = False, hide but doesn't, since the function can not distinguish between "button press" and "update stabil" ''' if select_ranges is None: select_ranges = [None] if select_callback is None: select_callback = [None] old_mask = np.copy(array.mask) array.mask = np.ma.nomask mask_stable = self.stabil_calc.get_stabilization_mask('mask_stable') if len(array.shape) == 3: mask = array == 0 array.mask = mask a = np.min(array, axis=2) a.mask = mask_stable stable_data = a.compressed() else: array.mask = mask_stable stable_data = array.compressed() if plot_obj is None: # create # print('here again') mask_pre = self.stabil_calc.get_stabilization_mask('mask_pre') if len(array.shape) == 3: mask = array == 0 array.mask = mask a = np.min(array, axis=2) a.mask = mask_pre all_data = a.compressed() else: array.mask = mask_pre all_data = array.compressed() plot_obj = HistoPlot( all_data, stable_data, title, ranges, select_ranges=select_ranges, select_callback=select_callback) else: # update plot_obj.update_histo(stable_data, select_ranges) array.mask = old_mask # show (hide is accomplished by just closing the window closeEvent was overridden to self.hide() ) # print('show') if plot_obj.visible: plot_obj.show() return plot_obj
def update_mode_val_view(self, index): # display information about currently selected mode i = self.stabil_calc.select_modes[index] n, f, stdf, d, stdd, mpc, mp, mpd, dmp, _dmpd, mtn, MC, ex_1, ex_2 = self.stabil_calc.get_modal_values( i) if self.stabil_calc.capabilities['std']: import scipy.stats num_blocks = self.stabil_calc.modal_data.num_blocks stdf = scipy.stats.t.ppf( 0.975, num_blocks) * stdf / np.sqrt(num_blocks) stdd = scipy.stats.t.ppf( 0.975, num_blocks) * stdd / np.sqrt(num_blocks) self.current_mode = i s = '' for text, val in [('Frequency=%1.3fHz, \n' % (f), f), ('CI Frequency ± %1.3e, \n' % (stdf), stdf), ('Order=%1.0f, \n' % (n), n), ('Damping=%1.3f%%, \n' % (d), d), ('CI Damping ± %1.3e, \n' % (stdd), stdd), ('MPC=%1.5f, \n' % (mpc), mpc), ('MP=%1.3f°, \n' % (mp), mp), ('MPD=%1.5f°, \n' % (mpd), mpd), ('dMP=%1.3f°, \n' % (dmp), dmp), # ('dMPD=%1.5f°, \n' % (dmpd), dmpd), ('MTN=%1.5f, \n' % (mtn), mtn), ('MC=%1.5f, \n' % (MC), MC), ('Ext=%1.5f°, \n' % (ex_1), ex_1), ('Ext=%1.3f°, \n' % (ex_2), ex_2) ]: if val is not np.nan: s += text self.mode_val_view_text.setText(s) height = self.mode_val_view_text.document().size().toSize().height() + 3 self.mode_val_view_text.setFixedHeight(height) self.update_mode_plot(i, mp) @pyqtSlot(tuple) def mode_selector_add(self, i, index): # add mode tomode_selector and select it self._dirty = True _, f, *_ = self.stabil_calc.get_modal_values(i) # index = self.stabil_calc.select_modes.index(i) # print(n,f,d,mpc, mp, mpd) # print(index) text = '{} - {:2.3f}'.format(index, f) self.mode_selector.currentIndexChanged[int].disconnect(self.update_mode_val_view) self.mode_selector.addItem(text) self.mode_selector.setCurrentIndex(index) self.update_mode_val_view(index) self.mode_selector.currentIndexChanged[ int].connect(self.update_mode_val_view) @pyqtSlot(tuple) def mode_selector_take(self, i_, index): self._dirty = True if self.current_mode == i_: if self.stabil_calc.select_modes: self.current_mode = self.stabil_calc.select_modes[0] else: self.current_mode = (0, 0) self.mode_selector.currentIndexChanged[ int].disconnect(self.update_mode_val_view) self.mode_selector.clear() for index, i in enumerate(self.stabil_calc.select_modes): _, f, *_ = self.stabil_calc.get_modal_values(i) text = '{} - {:2.3f}'.format(index, f) self.mode_selector.addItem(text) if self.current_mode == i: for ind in range(self.mode_selector.count()): if text == self.mode_selector.itemText(ind): break else: ind = 0 if self.mode_selector.count(): self.mode_selector.setCurrentIndex(ind) self.update_mode_val_view(ind) self.mode_selector.currentIndexChanged[ int].connect(self.update_mode_val_view) def update_mode_plot(self, i, mpd=None): # update the plot of the currently selected mode msh = self.stabil_calc.get_mode_shape(i) self.cmpl_plot.scatter_this(msh, mpd) # time.sleep(1) if self.msh_plot is not None: self.msh_plot.plot_this(i) def toggle_msh_plot(self, b): # change the type of mode plot # print('msh',b) if b: self.msh_plot.show() else: self.msh_plot.hide() def init_cursor(self): self.cursor = self.stabil_plot.init_cursor() self.cursor.add_callback('show_current_info', self.update_value_view) # @pyqtSlot(bool) def snap_frequency(self, b=True): if b: mask = self.stabil_calc.get_stabilization_mask('mask_df') self.cursor.set_mask(mask, 'mask_df') # @pyqtSlot(bool) def snap_damping(self, b=True): if b: mask = self.stabil_calc.get_stabilization_mask('mask_dd') self.cursor.set_mask(mask, 'mask_dd') # @pyqtSlot(bool) def snap_vector(self, b=True): if b: mask = self.stabil_calc.get_stabilization_mask('mask_dmac') self.cursor.set_mask(mask, 'mask_dmac') # @pyqtSlot(bool) def snap_stable(self, b=True): if b: mask = self.stabil_calc.get_stabilization_mask('mask_stable') self.cursor.set_mask(mask, 'mask_stable') # @pyqtSlot(bool) def snap_all(self, b=True): if b: mask = self.stabil_calc.get_stabilization_mask('mask_pre') self.cursor.set_mask(mask, 'mask_pre') # @pyqtSlot(bool) def snap_clear(self, b=True): if b: mask = self.stabil_calc.get_stabilization_mask('mask_autoclear') self.cursor.set_mask(mask, 'mask_autoclear') # @pyqtSlot(bool) def snap_select(self, b=True): if b: mask = self.stabil_calc.get_stabilization_mask('mask_autoselect') self.cursor.set_mask(mask, 'mask_autoselect') def update_value_view(self, i): n, f, stdf, d, stdd, mpc, mp, mpd, dmp, _dmpd, mtn, MC, ex_1, ex_2 = self.stabil_calc.get_modal_values( i) if self.stabil_calc.capabilities['std']: import scipy.stats num_blocks = self.stabil_calc.modal_data.num_blocks stdf = scipy.stats.t.ppf( 0.975, num_blocks) * stdf / np.sqrt(num_blocks) stdd = scipy.stats.t.ppf( 0.975, num_blocks) * stdd / np.sqrt(num_blocks) self.current_mode = i s = '' for text, val in [('Frequency=%1.3fHz, \n' % (f), f), ('CI Frequency ± %1.3e, \n' % (stdf), stdf), ('Order=%1.0f, \n' % (n), n), ('Damping=%1.3f%%, \n' % (d), d), ('CI Damping ± %1.3e, \n' % (stdd), stdd), ('MPC=%1.5f, \n' % (mpc), mpc), ('MP=%1.3f°, \n' % (mp), mp), ('MPD=%1.5f°, \n' % (mpd), mpd), ('dMP=%1.3f°, \n' % (dmp), dmp), # ('dMPD=%1.5f°, \n' % (dmpd), dmpd), ('MTN=%1.5f, \n' % (mtn), mtn), ('MC=%1.5f, \n' % (MC), MC), ('Ext=%1.5f°, \n' % (ex_1), ex_1), ('Ext=%1.3f°, \n' % (ex_2), ex_2) ]: if val is not np.nan: s += text self.current_value_view_text.setText(s) height = self.current_value_view_text.document( ).size().toSize().height() + 3 self.current_value_view_text.setFixedHeight(height) def _collect_stabil_params(self): """Read stabilization-criterion values from the UI edit boxes.""" caps = self.stabil_calc.capabilities stdf_max = float(self.stdf_edit.text()) / 100 if caps['std'] else None stdd_max = float(self.stdd_edit.text()) / 100 if caps['std'] else None dmac_max = float(self.mac_edit.text()) / 100 if caps['msh'] else None mpc_min = float(self.mpc_edit.text()) if caps['msh'] else None mpd_max = float(self.mpd_edit.text()) if caps['msh'] else None MC_min = float(self.MC_edit.text()) if caps['MC'] else None return dict( df_max=float(self.df_edit.text()) / 100, stdf_max=stdf_max, dd_max=float(self.dd_edit.text()) / 100, stdd_max=stdd_max, dmac_max=dmac_max, d_range=(float(self.d_min_edit.text()), float(self.d_max_edit.text())), mpc_min=mpc_min, mpd_max=mpd_max, MC_min=MC_min, order_range=( int(self.n_low_edit.text()), int(self.n_step_edit.text()), int(self.n_high_edit.text())), ) def _refresh_histos(self): """Redraw any open histogram windows.""" for attr, creator in [ ('histo_plot_f', self.create_histo_plot_f), ('histo_plot_sf', self.create_histo_plot_sf), ('histo_plot_d', self.create_histo_plot_d), ('histo_plot_sd', self.create_histo_plot_sd), ('histo_plot_dr', self.create_histo_plot_dr), ('histo_plot_mac', self.create_histo_plot_mac), ('histo_plot_mpc', self.create_histo_plot_mpc), ('histo_plot_mpd', self.create_histo_plot_mpd), ]: if getattr(self, attr) is not None: creator() def update_stabil_view(self): params = self._collect_stabil_params() f_range = (float(self.freq_low_edit.text()), float(self.freq_high_edit.text())) order_range = params['order_range'] self.stabil_plot.update_stabilization(**params) self.stabil_plot.update_xlim(f_range) self.stabil_plot.update_ylim((order_range[0], order_range[2])) self._refresh_histos() def prepare_auto_clearing(self): if not self.stabil_calc.capabilities['auto']: raise RuntimeError("Automatic clearing requires a StabilCalc with 'auto' capabilities.") num_iter = int(self.num_iter_edit.text()) if isinstance(self.stabil_calc, StabilCluster): self.stabil_calc.automatic_clearing(num_iter) self.threshold_edit.setPlaceholderText( str(self.stabil_calc.threshold)) self.stabil_plot.update_stabilization() self.autoclear_checkbox.setChecked(True) def prepare_auto_classification(self): if not self.stabil_calc.capabilities['auto']: raise RuntimeError("Automatic classification requires a StabilCalc with 'auto' capabilities.") use_stabil = self.use_stabil_checkbox.isChecked() threshold = self.threshold_edit.text() # print(threshold, type(threshold), threshold.isnumeric()) if threshold.isnumeric(): threshold = float(threshold) else: threshold = None if isinstance(self.stabil_calc, StabilCluster): self.stabil_calc.automatic_classification(threshold, use_stabil) self.autoselect_checkbox.setChecked(True) def prepare_auto_selection(self): if not self.stabil_calc.capabilities['auto']: raise RuntimeError("Automatic selection requires a StabilCalc with 'auto' capabilities.") num_modes = self.num_modes_edit.text() if num_modes.isnumeric(): num_modes = int(num_modes) else: num_modes = 0 for datapoint in reversed(self.stabil_calc.select_modes): self.stabil_calc.remove_mode(datapoint) print(self.stabil_calc.select_modes) if isinstance(self.stabil_calc, StabilCluster): self.stabil_calc.automatic_selection(num_modes) # self.stabil_plot.update_stabilization() # self.stabil_calc.plot_selection() def save_figure(self, fname=None): # copied and modified from # matplotlib.backends.backend_qt4.NavigationToolbar2QT canvas = self.stabil_plot.ax.figure.canvas filetypes = canvas.get_supported_filetypes_grouped() sorted_filetypes = sorted(filetypes.items()) # default_filetype = canvas.get_default_filetype() startpath = rcParams.get('savefig.directory', '') startpath = os.path.expanduser(startpath) start = os.path.join(startpath, self.canvas.get_default_filename()) filters = [] for name, exts in sorted_filetypes: exts_list = " ".join(['*.%s' % ext for ext in exts]) filter_ = '%s (%s)' % (name, exts_list) filters.append(filter_) filters = ';;'.join(filters) if fname is None: fname, ext = QFileDialog.getSaveFileName( self, caption="Choose a filename to save to", directory=start, filter=filters) # print(fname) self.stabil_plot.save_figure(fname) def save_results(self): fname, fext = QFileDialog.getSaveFileName(self, caption="Choose a directory to save to", directory=os.getcwd(), filter='Text File (*.txt)') # fname, fext = os.path.splitext(fname) if fext != 'txt': fname += '.txt' self.stabil_calc.export_results(fname) def _save_to_file(self): """Prompt for a filename and save stabil_calc state; return True if saved.""" fname, _ = QFileDialog.getSaveFileName(self, caption="Choose a directory to save to", directory=os.getcwd(), filter='Numpy Archive File (*.npz)') if fname == '': return False fname, fext = os.path.splitext(fname) if fext != 'npz': fname += '.npz' self.stabil_calc.save_state(fname) self._dirty = False return True def save_state(self): if self._save_to_file(): self.close() def _do_save(self): self._save_to_file() def load_state(self): fname, _ = QFileDialog.getOpenFileName(self, caption="Choose a state file to load", directory=os.getcwd(), filter='Numpy Archive File (*.npz)') if fname == '': return try: stabil_calc = StabilCalc.load_state(fname, self.stabil_calc.modal_data) except Exception as exc: logger.exception("load_state failed") QMessageBox.warning(self, "Load failed", str(exc)) return if stabil_calc is None: QMessageBox.warning(self, "Load failed", f"No stabilization state found in {fname!r}.") return self.stabil_calc = stabil_calc self.stabil_plot.stabil_calc = stabil_calc self.mode_selector.clear() self.stabil_calc.add_callback('add_mode', self.mode_selector_add) self.stabil_calc.add_callback('remove_mode', self.mode_selector_take) for index, mode in enumerate(self.stabil_calc.select_modes): self.mode_selector_add(mode, index) self._dirty = False # freshly loaded from disk - nothing unsaved yet self.update_stabil_view()
[docs] def closeEvent(self, event): if not self._prompt_save_on_close(event): return if self.msh_plot is not None: self.msh_plot.mode_shape_plot.stop_ani() # self.stabil_calc.select_modes = self.stabil_calc.select_modes self.deleteLater() return QMainWindow.closeEvent(self, event)
[docs] def keyPressEvent(self, e): # print(e.key()) if e.key() == Qt.Key.Key_Return or e.key() == Qt.Key.Key_Enter: self.update_stabil_view() # print('2') super().keyPressEvent(e)
[docs] class ComplexPlot(QMainWindow, Ui_ComplexPlot):
[docs] def __init__(self): QMainWindow.__init__(self) self.setupUi(self) self.fig = Figure(facecolor='white', dpi=100, figsize=(4, 4)) self.canvas.set_figure(self.fig)
@staticmethod def _normalize_mp_angle(mp): """Normalize mp from degrees to radians in [0, 2π].""" while mp < 0: mp += 180 while mp > 360: mp -= 360 return mp * np.pi / 180 @staticmethod def _compute_mp_endpoints(mp): """Return (x1, x2, y1, y2) for a line at angle mp (radians) inside [-1, 1]².""" xmin, xmax, ymin, ymax = -1, 1, -1, 1 t = np.tan(mp) if mp <= np.pi / 2: x1 = max(xmin, ymin / t) x2 = min(xmax, ymax / t) y1 = max(ymin, xmin * t) y2 = min(ymax, xmax * t) elif mp <= np.pi: x1 = max(xmin, ymax / t) x2 = min(xmax, ymin / t) y2 = max(ymin, xmax * t) y1 = min(ymax, xmin * t) elif mp <= 3 * np.pi / 2: x1 = max(xmin, ymin / t) x2 = min(xmax, ymax / t) y1 = max(ymin, xmin * t) y2 = min(ymax, xmax * t) else: x1 = max(xmin, ymax / t) x2 = min(xmax, ymin / t) y2 = max(ymin, xmax * t) y1 = min(ymax, xmin * t) return x1, x2, y1, y2 def scatter_this(self, msh, mp=None): self.ax.cla() self.ax.scatter(msh.real, msh.imag) if mp is not None: mp = self._normalize_mp_angle(mp) x1, x2, y1, y2 = self._compute_mp_endpoints(mp) self.ax.plot([x1, x2], [y1, y2]) lim = max(max(abs(msh.real)) * 1.1, max(abs(msh.imag)) * 1.1) self.ax.set_xlim((-lim, lim)) self.ax.set_ylim((-lim, lim)) self.ax.spines['left'].set_position(('data', 0)) self.ax.spines['bottom'].set_position(('data', 0)) self.ax.spines['right'].set_position(('data', 0 - 1)) self.ax.spines['top'].set_position(('data', 0 - 1)) for side in ['right', 'top']: self.ax.spines[side].set_color('none') for axis, _ in zip([self.ax.xaxis, self.ax.yaxis], [0, 0]): axis.set_minor_locator(ticker.NullLocator()) axis.set_major_formatter(ticker.NullFormatter()) self.fig.canvas.draw_idle() def plot_diagram(self): self.fig.set_tight_layout(True) self.ax = self.fig.add_subplot(111) self.ax.autoscale_view(tight=True) # Set the axis's spines to be centered at the given point # (Setting all 4 spines so that the tick marks go in both directions) self.ax.spines['left'].set_position(('data', 0)) self.ax.spines['bottom'].set_position(('data', 0)) self.ax.spines['right'].set_position(('data', 0 - 1)) self.ax.spines['top'].set_position(('data', 0 - 1)) self.ax.xaxis.set_label_text('Re') self.ax.yaxis.set_label_text('Im') # Hide the line (but not ticks) for "extra" spines for side in ['right', 'top']: self.ax.spines[side].set_color('none') # On both the x and y axes... for axis, _ in zip([self.ax.xaxis, self.ax.yaxis], [0, 0]): axis.set_minor_locator(ticker.NullLocator()) axis.set_major_formatter(ticker.NullFormatter()) self.fig.canvas.draw_idle()
[docs] class HistoPlot(QMainWindow, Ui_HistoPlot):
[docs] def __init__( self, all_data, stabil_data, title='', ranges=None, select_ranges=None, select_callback=None): if select_ranges is None: select_ranges = [None] if select_callback is None: select_callback = [None] QMainWindow.__init__(self) self.setupUi(self) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.setWindowTitle(title) if ranges is None: ranges = (all_data.min(), all_data.max()) step = (ranges[1] - ranges[0]) / 50 self.lrange_box.setSingleStep(step) self.lrange_box.setValue(ranges[0]) self.lrange_box.valueChangedDelayed.connect(self.update_range) self.urange_box.setSingleStep(step) self.urange_box.setValue(ranges[1]) self.urange_box.valueChangedDelayed.connect(self.update_range) self.axes = self.canvas.axes self.main_widget.setFocus() self.all_data = np.copy(all_data) self.stabil_data = np.copy(stabil_data) self.all_patches = None self.stabil_patches = None self.ranges = None self.visible = True self.select_ranges = select_ranges self.select_callback = select_callback self.selector_lines = [] # print('here in hist') self.update_range()
def _should_setup_selector_lines(self): """Return True when draggable selector lines should be created.""" has_callback = self.select_callback[0] is not None has_range = (self.select_ranges[0] is not None or self.select_ranges[1] is not None) return has_callback and has_range and not self.selector_lines def _setup_selector_lines(self): """Create draggable vertical lines and wire mouse-event handlers.""" for val in self.select_ranges: if val is None: self.selector_lines.append(None) else: line = self.axes.axvline(val, picker=5, color='red') self.selector_lines.append(line) self.axes.figure.canvas.mpl_connect('pick_event', self.on_pick_event) self.axes.figure.canvas.mpl_connect( "button_release_event", self.on_release_event) self.axes.figure.canvas.mpl_connect( "motion_notify_event", self.on_move_event) self.dragged = None def update_range(self, *args): self.ranges = (self.lrange_box.value(), self.urange_box.value()) if self.ranges[0] >= self.ranges[1]: return if self.all_patches: for patch in self.all_patches: patch.remove() n, self.bins, self.all_patches = self.axes.hist( self.all_data, bins=50, color='blue', range=self.ranges) self.axes.set_xlim(self.ranges) self.axes.set_ylim((0, max(n) * 1.1)) self.axes.set_yticks([]) if self._should_setup_selector_lines(): self._setup_selector_lines() self.update_histo(self.stabil_data) def update_histo(self, stabil_data, select_ranges=None): self.stabil_data = np.copy(stabil_data) if self.stabil_patches: for patch in self.stabil_patches: patch.remove() _, _, self.stabil_patches = self.axes.hist( stabil_data, bins=self.bins, color='orange') if self.selector_lines and select_ranges is not None: # self.axes.figure.canvas.mpl_disconnect(self.connect_cid) for val, line in zip(select_ranges, self.selector_lines): if line is None: continue line.set_xdata(val) self.axes.figure.canvas.draw_idle()
[docs] def closeEvent(self, e): self.visible = False e.ignore() self.hide()
def on_pick_event(self, event): self.dragged = event.artist self.pick_pos = (event.mouseevent.xdata, event.mouseevent.ydata) return True
[docs] def on_release_event(self, event): " Update text position and redraw" if self.dragged is not None: xdata = event.xdata if not xdata: return False # old_pos = self.dragged.get_xdata() # new_pos = old_pos[0] + event.xdata - self.pick_pos[0] self.dragged.set_xdata(xdata) # print(self.dragged.get_xdata(), event.xdata) ind = self.selector_lines.index(self.dragged) self.select_callback[ind](xdata) if len(self.selector_lines) == 1: self.urange_box.setValue(xdata * 2) self.urange_box.delayed_emit() elif len(self.selector_lines) == 2: delta_x = (self.ranges[0 if ind == 1 else 1] - xdata) / 2 [self.lrange_box, self.urange_box][ind].setValue(xdata - delta_x) [self.lrange_box, self.urange_box][ind].delayed_emit() self.dragged = None self.axes.figure.canvas.draw_idle() return True
[docs] def on_move_event(self, event): " Update text position and redraw" if self.dragged is not None: # old_pos = self.dragged.get_xdata() # new_pos = old_pos[0] + event.xdata - self.pick_pos[0] self.dragged.set_xdata(event.xdata) # print(self.dragged.get_xdata(), event.xdata) # self.dragged = None self.axes.figure.canvas.draw_idle() return True
[docs] def nearly_equal(a, b, sig_fig=5): return (a == b or int(a * 10 ** sig_fig) == int(b * 10 ** sig_fig) )
[docs] def start_stabil_gui( stabil_plot, modal_data, geometry_data=None, prep_signals=None, select_modes=None, **kwargs): # print(kwargs) if select_modes is None: select_modes = [] def _handler(msg_type, msg_string): pass global app app = QApplication.instance() or QApplication(sys.argv) if not isinstance(stabil_plot, StabilPlot): raise TypeError( f"stabil_plot must be StabilPlot, got {type(stabil_plot).__name__!r}") cmpl_plot = ComplexPlot() if geometry_data is not None: # and prep_signals is not None: mode_shape_plot = ModeShapePlot(stabil_calc=stabil_plot.stabil_calc, modal_data=modal_data, geometry_data=geometry_data, prep_signals=prep_signals, **kwargs) msh_plot = ModeShapeGUI(mode_shape_plot, reduced_gui=True) msh_plot.setGeometry(1000, 0, 800, 600) msh_plot.reset_view() msh_plot.hide() else: msh_plot = None # qInstallMessageHandler(handler) #suppress unimportant error msg stabil_gui = StabilGUI(stabil_plot, cmpl_plot, msh_plot) # stabil_gui.cursor.add_datapoints(select_modes) loop = QEventLoop() stabil_gui.destroyed.connect(loop.quit) loop.exec() FigureCanvasBase(stabil_plot.fig) return
if __name__ == '__main__': pass