Coverage for src / sdynpy / modal / sdynpy_polypy.py: 10%
662 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-11 16:22 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-11 16:22 +0000
1# -*- coding: utf-8 -*-
2"""
3Implementation of a multi-reference polynomial curve fitter for Python
4"""
5"""
6Copyright 2022 National Technology & Engineering Solutions of Sandia,
7LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the U.S.
8Government retains certain rights in this software.
10This program is free software: you can redistribute it and/or modify
11it under the terms of the GNU General Public License as published by
12the Free Software Foundation, either version 3 of the License, or
13(at your option) any later version.
15This program is distributed in the hope that it will be useful,
16but WITHOUT ANY WARRANTY; without even the implied warranty of
17MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18GNU General Public License for more details.
20You should have received a copy of the GNU General Public License
21along with this program. If not, see <https://www.gnu.org/licenses/>.
22"""
24import os
26import numpy as np
27from ..core.sdynpy_data import TransferFunctionArray, GUIPlot
28from ..signal_processing.sdynpy_complex import collapse_complex_to_real
29from ..core.sdynpy_geometry import Geometry
30from ..core.sdynpy_shape import ShapeArray
31from .sdynpy_modeshape import compute_shapes_multireference
32import matplotlib.pyplot as plt
33import matplotlib.cm as cm
34import traceback
36from qtpy import QtWidgets, uic
37from qtpy.QtWidgets import QMainWindow, QTableWidgetItem, QWidget
38import pyqtgraph
39pyqtgraph.setConfigOption('background', 'w')
40pyqtgraph.setConfigOption('foreground', 'k')
43class PolyPy:
45 def __init__(self, frfs: TransferFunctionArray, min_frequency=None,
46 max_frequency=None, displacement_derivative=2):
47 self.frfs = frfs.reshape_to_matrix()
48 self.min_frequency = min_frequency
49 self.max_frequency = max_frequency
50 self.displacement_derivative = displacement_derivative
51 abscissa_indices = np.ones(self.frequencies.shape, dtype=bool)
52 if min_frequency is not None:
53 abscissa_indices &= (self.frequencies >= min_frequency)
54 if max_frequency is not None:
55 abscissa_indices &= (self.frequencies <= max_frequency)
56 abscissa = self.frequencies[abscissa_indices]
57 freq_range = np.array((np.min(abscissa), np.max(abscissa)))
58 index_range = np.argmin(np.abs(self.frequencies - freq_range[:, np.newaxis]), axis=1)
59 self.frequency_slice = slice(index_range[0], index_range[1] + 1)
61 def compute_poles(self, polynomial_orders, weighting=None,
62 frequency_stability_threshold=0.01,
63 damping_stability_threshold=0.1,
64 modal_participation_threshold=0.15):
65 H = self.frfs.ordinate[..., self.frequency_slice]
66 num_output, num_input, num_freq = H.shape
67 num_poles = int(polynomial_orders[-1])
68 omegas = self.angular_frequencies[self.frequency_slice]
70 # Convert frf to acceleration for pole fitting
71 H *= (1j * omegas)**(2 - self.displacement_derivative)
73 omega0 = omegas[0]
74 sample_time = np.pi / (omegas[-1] - omega0)
75 omegas = omegas - omega0
76 if weighting is None:
77 weighting = np.ones((num_freq, num_output))
78 else:
79 weighting = weighting[self.frequency_slice]
81 if isinstance(polynomial_orders, (int, np.integer)):
82 polynomial_orders = (np.arange(np.floor(polynomial_orders /
83 num_input).astype(int)) + 1)[1:] * num_input
85 self.polynomial_orders = np.flip(polynomial_orders)
86 self.weighting = weighting
87 self.frequency_stability_threshold = frequency_stability_threshold
88 self.damping_stability_threshold = damping_stability_threshold
89 self.modal_participation_threshold = modal_participation_threshold
91 pole_dtype = np.dtype([('omega', float), ('zeta', float), ('Lr_real', float, (num_input,)),
92 ('Lr_complex', complex, (num_input,)), ('freq_stable', bool), ('damp_stable', bool),
93 ('part_stable', bool)])
95 self.pole_list = []
97 Omega = np.exp(-1j * omegas[:, np.newaxis] * sample_time * np.arange(num_poles + 1))
99 RTot = np.zeros([num_poles + 1, num_poles + 1, num_output])
100 STot = np.zeros([num_poles + 1, num_input * (num_poles + 1), num_output])
101 TTot = np.zeros([num_input * (num_poles + 1), num_input * (num_poles + 1), num_output])
103 for j_output in range(num_output):
104 print('Accumulating Data: Progress = {:0.2f}%'.format(j_output / num_output * 100.0))
105 Xj = weighting[:, j_output][..., np.newaxis] * Omega
106 Hj = H[j_output, ...]
107 Yj = np.array([-np.kron(Xk, Hk) for Xk, Hk in zip(Xj, Hj.transpose())])
108 Rj = np.real(Xj.conjugate().transpose() @ Xj)
109 Sj = np.real(Xj.conjugate().transpose() @ Yj)
110 Tj = np.real(Yj.conjugate().transpose() @ Yj)
111 RTot[:, :, j_output] = Rj
112 STot[:, :, j_output] = Sj
113 TTot[:, :, j_output] = Tj
115 for i_order, order in enumerate(self.polynomial_orders):
117 print('Solving for {:} roots ({:} of {:})'.format(
118 order, i_order + 1, len(polynomial_orders)))
120 RTot = RTot[0:order + 1, 0:order + 1, :]
121 STot = STot[0:order + 1, 0:num_input * (order + 1), :]
122 TTot = TTot[0:num_input * (order + 1), 0:num_input * (order + 1), :]
124 M = np.zeros(np.shape(TTot)[0:2])
125 for Rj, Sj, Tj in zip(RTot.transpose([2, 0, 1]), STot.transpose([2, 0, 1]), TTot.transpose([2, 0, 1])):
126 M = M + Tj - Sj.transpose() @ np.linalg.solve(Rj, Sj)
128 A = M[:order * num_input, :order * num_input]
129 B = -M[:order * num_input, order * num_input:]
131 alpha = np.linalg.solve(A, B)
132 Ac_top_left = np.zeros([(order - 1) * num_input, num_input])
133 Ac_top_right = np.eye((order - 1) * num_input)
134 Ac_bottom = -alpha.transpose()
136 Ac = np.concatenate((np.concatenate((Ac_top_left, Ac_top_right), axis=1),
137 Ac_bottom), axis=0)
139 zpoles, V = np.linalg.eig(Ac)
140 spoles = -1 / sample_time * np.log(zpoles)
141 Lr = V[-num_input:]
142 keep_poles = (np.imag(spoles) > 0) & (np.real(spoles) < 0)
143 poles = spoles[keep_poles]
144 Lr = Lr[:, keep_poles]
145 omgr = abs(poles) + omega0
146 zetar = -np.real(poles) / omgr
147 isort = np.argsort(omgr)
148 omgr = omgr[isort]
149 zetar = zetar[isort]
150 Lr = Lr[:, isort]
151 Lr_real = collapse_complex_to_real(Lr, axis=0, preserve_magnitude=True)
153 # Now check stability
154 pole_list_i = np.zeros(omgr.size, pole_dtype)
155 pole_list_i['omega'] = omgr
156 pole_list_i['zeta'] = zetar
157 pole_list_i['Lr_complex'] = (Lr.T)
158 pole_list_i['Lr_real'] = (Lr_real.T)
160 self.pole_list.append(pole_list_i)
162 # Now analyze stability
163 last_poles = None
164 for i_order, (order, poles) in enumerate(zip(self.polynomial_orders[::-1], self.pole_list[::-1])):
165 if i_order > 0:
166 for i_pole, pole in enumerate(poles):
167 previous_omegas = last_poles['omega']
168 if previous_omegas.size == 0:
169 continue
170 # Check frequency
171 frequency_differences = np.abs(
172 pole['omega'] - previous_omegas) / previous_omegas
173 closest_freq_index = np.argmin(frequency_differences)
174 if frequency_differences[closest_freq_index] < frequency_stability_threshold:
175 poles['freq_stable'][i_pole] = True
176 # Now check damping
177 previous_zeta = last_poles['zeta'][closest_freq_index]
178 if abs(pole['zeta'] - previous_zeta) / previous_zeta < damping_stability_threshold:
179 poles['damp_stable'][i_pole] = True
180 # Now check participation factor
181 previous_Lr = last_poles['Lr_complex'][closest_freq_index]
182 if np.all(
183 (np.abs(np.abs(pole['Lr_complex']) - np.abs(previous_Lr)) / np.abs(previous_Lr) < modal_participation_threshold) |
184 (np.abs(pole['Lr_complex']) < np.abs(previous_Lr).max()*1e-4)): # Added this to remove divide by zero issues
185 poles['part_stable'][i_pole] = True
186 last_poles = poles.copy()
188 def plot_stability(self, no_converge_marger='rx',
189 freq_converge_marker='b^',
190 damp_converge_marker='bs',
191 full_converge_marker='go',
192 label_poles=False, order_range=None,
193 mif_type='cmif', *mif_args, **mif_kwargs):
194 cmif = self.frfs.compute_mif(mif_type, *mif_args, **mif_kwargs)
196 ax = cmif.plot()
198 ax.set_yscale('log')
199 ax_poles = ax.twinx()
201 for j, (order, poles) in enumerate(zip(self.polynomial_orders, self.pole_list)):
202 if order_range is not None:
203 if order < order_range[0]:
204 continue
205 if order > order_range[1]:
206 continue
207 frequencies = poles['omega'] / (2 * np.pi)
208 freq_convergence = poles['freq_stable']
209 damp_convergence = poles['damp_stable']
210 part_convergence = poles['part_stable']
211 for i, (freq, fc, dc, pc) in enumerate(zip(frequencies, freq_convergence, damp_convergence, part_convergence)):
212 if pc:
213 marker = full_converge_marker
214 elif dc:
215 marker = damp_converge_marker
216 elif fc:
217 marker = freq_converge_marker
218 else:
219 marker = no_converge_marger
220 ax_poles.plot(freq, order, marker, markerfacecolor='none')
221 if label_poles:
222 ax_poles.text(freq, order, '{:},{:}'.format(j, i))
223 freq = self.frequencies[self.frequency_slice]
224 ax.set_xlim(freq.min(), freq.max())
225 return ax, ax_poles
227 def pole_list_from_indices(self, indices):
228 index_list = [self.pole_list[order_index][pole_index, np.newaxis]
229 for order_index, pole_index in indices]
230 if len(index_list) == 0:
231 return np.zeros((0,), dtype=self.pole_list[0].dtype)
232 return np.concatenate(index_list)
234 def analyze_pole_convergence(self, pole_or_index,
235 frequency_stability_threshold=0.01,
236 damping_stability_threshold=0.1,
237 modal_participation_threshold=0.15,
238 subplots_kwargs={},
239 label_order=True,
240 no_converge_marger='rx',
241 freq_converge_marker='b^',
242 damp_converge_marker='bs',
243 full_converge_marker='go'):
244 try:
245 omega = pole_or_index['omega']
246 zeta = pole_or_index['zeta']
247 part = pole_or_index['Lr_complex']
248 except (IndexError, TypeError):
249 pole_or_index = self.pole_list[pole_or_index[0]][pole_or_index[1]]
250 omega = pole_or_index['omega']
251 zeta = pole_or_index['zeta']
252 part = pole_or_index['Lr_complex']
253 fig, ax = plt.subplots(2, 2, **subplots_kwargs)
254 ax = ax.flatten()
255 for poles, order in zip(self.pole_list, self.polynomial_orders):
256 if len(poles) < 1:
257 continue
258 frequency_differences = np.abs(poles['omega'] - omega) / omega
259 closest_freq_index = np.argmin(frequency_differences)
260 pole = poles[closest_freq_index]
261 if pole['part_stable']:
262 marker = full_converge_marker
263 elif pole['damp_stable']:
264 marker = damp_converge_marker
265 elif pole['freq_stable']:
266 marker = freq_converge_marker
267 else:
268 marker = no_converge_marger
269 for a, index in zip(ax[:3], ['omega', 'zeta', 'Lr_real']):
270 a.plot(order * np.ones(pole[index].shape),
271 pole[index], marker, markerfacecolor='none')
272 if label_order:
273 try:
274 for val in pole[index]:
275 a.text(order, val, '{:}'.format(order))
276 except TypeError:
277 a.text(order, pole[index], '{:}'.format(order))
278 ax[-1].plot(pole['Lr_complex'].real, pole['Lr_complex'].imag,
279 marker, markerfacecolor='none')
280 if label_order:
281 for val in pole['Lr_complex']:
282 ax[-1].text(val.real, val.imag, '{:}'.format(order))
283 for a, title in zip(ax, ['Angular Frequency', 'Damping', 'Participation Factor (real)', 'Participation Factor (complex)']):
284 a.set_title(title)
286 def compute_shapes(self, poles,
287 low_residuals=True, high_residuals = True, real_modes=False,
288 frequency_lines_at_resonance: int = None,
289 frequency_lines_for_residuals: int = None
290 ):
291 self.natural_frequencies = poles['omega'] / (2 * np.pi)
292 self.damping_ratios = poles['zeta']
293 self.participation_factors = poles['Lr_complex']
294 self.shapes, self.frfs_synth, self.frfs_residuals, self.frfs_kernel = (
295 compute_shapes_multireference(
296 self.frfs, self.natural_frequencies, self.damping_ratios,
297 self.participation_factors, real_modes,
298 low_residuals, high_residuals,
299 self.min_frequency, self.max_frequency,
300 self.displacement_derivative,
301 frequency_lines_at_resonance,
302 frequency_lines_for_residuals))
304 @property
305 def frequencies(self):
306 return self.frfs[0, 0].abscissa
308 @property
309 def angular_frequencies(self):
310 return 2 * np.pi * self.frequencies
312 @property
313 def frequency_spacing(self):
314 return np.mean(np.diff(self.frequencies))
317class PolyPy_Stability(QWidget):
318 def __init__(self, polypy_gui, polypy_tabwidget,
319 frfs, frequency_region, poles, displacement_derivative):
320 # Load in the GUI
321 super(PolyPy_Stability, self).__init__(polypy_tabwidget)
322 uic.loadUi(os.path.join(os.path.abspath(os.path.dirname(
323 os.path.abspath(__file__))), 'polypy_stability_diagram.ui'), self)
324 self.polypy_gui = polypy_gui
325 self.stability_tabwidget = polypy_tabwidget
326 self.tabwidget_index = self.stability_tabwidget.count()
327 self.stability_tabwidget.addTab(self, "{:0.2f}--{:0.2f} Hz".format(*frequency_region))
329 # Set up colormaps
330 self.cm = cm.Dark2
331 self.cm_mod = 8
332 self.compare_cm = cm.Paired
333 self.compare_cm_mod = 12
335 self.frfs = frfs
336 self.pole_markers = []
337 self.pole_positions = np.zeros((0, 2), dtype=float)
338 self.pole_indices = np.zeros((0, 2), dtype=int)
339 self.previous_closest_marker_index = None
340 self.selected_poles = set([])
342 # Set up the second axis for the stabilizaton plot
343 self.pole_plot = self.stabilization_diagram.getPlotItem()
344 self.pole_plot.setLabels(left='Polynomial Order')
345 self.stabilization_plot = pyqtgraph.ViewBox()
346 self.pole_plot.setAxisItems(
347 {'right': pyqtgraph.AxisItem(orientation='right', showValues=False)})
348 self.pole_plot.showAxis('right')
349 self.pole_plot.scene().addItem(self.stabilization_plot)
350 self.pole_plot.getAxis('right').linkToView(self.stabilization_plot)
351 self.stabilization_plot.setXLink(self.pole_plot)
352 self.pole_plot.getAxis('right').setLabel('Mode Indicator Function')
353 self.update_stability_plot_views()
354 self.update_stabilization_plot()
356 self.connect_callbacks()
358 self.polypy = PolyPy(frfs, *frequency_region, displacement_derivative)
359 self.polypy.compute_poles(poles)
361 self.plot_poles()
363 def connect_callbacks(self):
364 self.stabilization_cmif_selection.clicked.connect(self.update_stabilization_plot)
365 self.stabilization_qmif_selection.clicked.connect(self.update_stabilization_plot)
366 self.stabilization_mmif_selection.clicked.connect(self.update_stabilization_plot)
367 self.stabilization_nmif_selection.clicked.connect(self.update_stabilization_plot)
368 self.pole_plot.vb.sigResized.connect(self.update_stability_plot_views)
369 self.pole_plot.scene().sigMouseMoved.connect(self.mouseMoved)
370 self.pole_plot.scene().sigMouseClicked.connect(self.mouseClicked)
371 self.discard_button.clicked.connect(self.discard)
373 def discard(self):
374 self.polypy_gui.stability_diagrams.pop(self.tabwidget_index)
375 for i in range(len(self.polypy_gui.stability_diagrams)):
376 self.polypy_gui.stability_diagrams[i].tabwidget_index = i
377 self.stability_tabwidget.removeTab(self.tabwidget_index)
378 self.polypy_gui.pole_selection_changed()
380 def plot_poles(self):
381 self.pole_markers = []
382 self.pole_positions = []
383 self.pole_indices = []
384 for j, (order, poles) in enumerate(zip(self.polypy.polynomial_orders, self.polypy.pole_list)):
385 frequencies = poles['omega'] / (2 * np.pi)
386 freq_convergence = poles['freq_stable']
387 damp_convergence = poles['damp_stable']
388 part_convergence = poles['part_stable']
389 for i, (freq, fc, dc, pc) in enumerate(zip(frequencies, freq_convergence, damp_convergence, part_convergence)):
390 if pc:
391 pen = pyqtgraph.mkPen(color=(0, 128, 0))
392 symbol = 'o'
393 elif dc:
394 pen = pyqtgraph.mkPen(color='b')
395 symbol = 's'
396 elif fc:
397 pen = pyqtgraph.mkPen(color='b')
398 symbol = 't1'
399 else:
400 pen = pyqtgraph.mkPen(color='r')
401 symbol = 'x'
402 item = pyqtgraph.ScatterPlotItem(
403 [freq], [order], pen=pen, symbol=symbol, symbolPen=pen, brush=(0, 0, 0, 0))
404 self.pole_markers.append(item)
405 self.pole_indices.append((j, i))
406 self.pole_positions.append((freq, order))
407 self.pole_plot.addItem(item)
408 freq = self.polypy.frequencies[self.polypy.frequency_slice]
409 self.pole_plot.setRange(xRange=(freq.min(), freq.max()),
410 yRange=(self.polypy.polynomial_orders.min(),
411 self.polypy.polynomial_orders.max()), padding=0.1)
412 self.pole_indices = np.array(self.pole_indices)
413 self.pole_positions = np.array(self.pole_positions)
415 def update_stabilization_plot(self):
416 self.stabilization_plot.clear()
417 if self.stabilization_cmif_selection.isChecked():
418 cmif = self.frfs.compute_cmif()
419 for i, curve in enumerate(cmif):
420 pen = pyqtgraph.mkPen(color=[int(255 * v) for v in self.cm(i % self.cm_mod)])
421 self.stabilization_plot.addItem(pyqtgraph.PlotCurveItem(
422 curve.abscissa, np.log10(curve.ordinate), pen=pen))
423 elif self.stabilization_qmif_selection.isChecked():
424 qmif = self.frfs.compute_cmif(
425 part='real' if self.polypy_gui.datatype_selector.currentIndex() == 1 else 'imag')
426 for i, curve in enumerate(qmif):
427 pen = pyqtgraph.mkPen(color=[int(255 * v) for v in self.cm(i % self.cm_mod)])
428 self.stabilization_plot.addItem(pyqtgraph.PlotCurveItem(
429 curve.abscissa, np.log10(curve.ordinate), pen=pen))
430 elif self.stabilization_mmif_selection.isChecked():
431 mmif = self.frfs.compute_mmif()
432 for i, curve in enumerate(mmif):
433 pen = pyqtgraph.mkPen(color=[int(255 * v) for v in self.cm(i % self.cm_mod)])
434 self.stabilization_plot.addItem(pyqtgraph.PlotCurveItem(
435 curve.abscissa, curve.ordinate, pen=pen))
436 elif self.stabilization_nmif_selection.isChecked():
437 nmif = self.frfs.compute_nmif()
438 pen = pyqtgraph.mkPen(color=[int(255 * v) for v in self.cm(0 % self.cm_mod)])
439 self.stabilization_plot.addItem(pyqtgraph.PlotCurveItem(
440 nmif.abscissa, nmif.ordinate), pen=pen)
442 def update_stability_plot_views(self):
443 self.stabilization_plot.setGeometry(self.pole_plot.vb.sceneBoundingRect())
444 self.stabilization_plot.linkedViewChanged(self.pole_plot.vb, self.stabilization_plot.XAxis)
446 def mouseMoved(self, position):
447 if self.pole_plot.vb.sceneBoundingRect().contains(position):
448 # Get aspect ratio of the box
449 ar = np.array([self.pole_plot.vb.getAspectRatio(), 1])
450 # Get the point in scene coordinates
451 mouse_point = self.pole_plot.vb.mapSceneToView(position)
452 # print('Mouse Point: {:}'.format((mouse_point.x(), mouse_point.y())))
453 # Find closest point to the mouse point
454 try:
455 closest_marker = np.argmin(np.linalg.norm(
456 (self.pole_positions - np.array([mouse_point.x(), mouse_point.y()])) * ar, axis=-1))
457 # print('Closest Marker {:}'.format(self.pole_positions[closest_marker]))
458 except ValueError:
459 return
460 if self.previous_closest_marker_index is not None:
461 if self.previous_closest_marker_index in self.selected_poles:
462 order_index, pole_index = self.pole_indices[self.previous_closest_marker_index]
463 pole = self.polypy.pole_list[order_index][pole_index]
464 if pole['part_stable']:
465 brush = (0, 128, 0)
466 elif pole['damp_stable'] or pole['freq_stable']:
467 brush = 'b'
468 else:
469 brush = 'r'
470 self.pole_markers[self.previous_closest_marker_index].setBrush(brush)
471 else:
472 self.pole_markers[self.previous_closest_marker_index].setBrush((0, 0, 0, 0))
473 self.previous_closest_marker_index = closest_marker
474 order_index, pole_index = self.pole_indices[self.previous_closest_marker_index]
475 pole = self.polypy.pole_list[order_index][pole_index]
476 self.selected_pole_display.setText('Highlighted Pole: {:0.2f} Hz, {:0.3f}% Damping'.format(
477 pole['omega'] / (2 * np.pi), pole['zeta'] * 100))
478 self.pole_markers[self.previous_closest_marker_index].setBrush((0, 0, 0, 255))
480 def mouseClicked(self, event):
481 view_position = self.pole_plot.vb.mapSceneToView(event.scenePos())
482 clickx = view_position.x()
483 clicky = view_position.y()
484 [[xmin, xmax], [ymin, ymax]] = self.pole_plot.vb.viewRange()
485 if (clickx < xmin) or (clickx > xmax) or (clicky > ymax) or (clicky < ymin):
486 return
487 if self.previous_closest_marker_index is None:
488 return
489 if self.previous_closest_marker_index in self.selected_poles:
490 self.selected_poles.remove(self.previous_closest_marker_index)
491 self.pole_markers[self.previous_closest_marker_index].setBrush((0, 0, 0, 0))
492 else:
493 order_index, pole_index = self.pole_indices[self.previous_closest_marker_index]
494 pole = self.polypy.pole_list[order_index][pole_index]
495 if pole['part_stable']:
496 brush = (0, 128, 0)
497 elif pole['damp_stable'] or pole['freq_stable']:
498 brush = 'b'
499 else:
500 brush = 'r'
501 self.selected_poles.add(self.previous_closest_marker_index)
502 self.pole_markers[self.previous_closest_marker_index].setBrush(brush)
503 self.previous_closest_marker_index = None
504 self.selected_pole_display.setText('Highlighted Pole:')
505 # Now update the pole list
506 self.pole_table.clearContents()
507 self.pole_table.setRowCount(len(self.selected_poles))
508 selected_pole_list = []
509 for index in self.selected_poles:
510 order_index, pole_index = self.pole_indices[index]
511 pole = self.polypy.pole_list[order_index][pole_index]
512 if pole['part_stable']:
513 stable = 'All'
514 elif pole['damp_stable']:
515 stable = 'Damp'
516 elif pole['freq_stable']:
517 stable = 'Freq'
518 else:
519 stable = 'None'
520 selected_pole_list.append((pole['omega'] / (2 * np.pi),
521 pole['zeta'], stable))
522 for i, (frequency, damping, stable) in enumerate(sorted(selected_pole_list)):
523 freq_label = QTableWidgetItem('{:0.2f}'.format(frequency))
524 damp_label = QTableWidgetItem('{:0.2f}%'.format(100 * damping))
525 stab_label = QTableWidgetItem(stable)
526 self.pole_table.setItem(i, 0, freq_label)
527 self.pole_table.setItem(i, 1, damp_label)
528 self.pole_table.setItem(i, 2, stab_label)
529 # Send a notification to the main gui that the pole selection has been
530 # updated
531 self.polypy_gui.pole_selection_changed()
534class PolyPy_GUI(QMainWindow):
536 def __init__(self, frf_data: TransferFunctionArray):
537 super(PolyPy_GUI, self).__init__()
538 uic.loadUi(os.path.join(os.path.abspath(os.path.dirname(
539 os.path.abspath(__file__))), 'polypy.ui'), self)
540 # Store the FRF
541 # TODO Make it so we can load data if none is passed in
542 self.frfs = frf_data.reshape_to_matrix()
543 self.resynthesis_plots = []
544 self.resynthesized_frfs = self.frfs.copy()
545 self.resynthesized_frfs.ordinate = 0
546 self.frfs_synth_residual = self.resynthesized_frfs.copy()
547 self.frfs_synth_kernel = self.resynthesized_frfs.copy()
548 self.shapes = ShapeArray((0,), self.frfs.shape[0])
549 self.first_stability = True
551 # Set up colormaps
552 self.cm = cm.Dark2
553 self.cm_mod = 8
554 self.compare_cm = cm.Paired
555 self.compare_cm_mod = 12
557 # Initialize the plots
558 self.data_plot = self.data_display.getPlotItem()
560 # Create the frequency selector
561 freq_range = (self.frfs.abscissa.min(),
562 self.frfs.abscissa.max())
563 self.frequency_selector = pyqtgraph.LinearRegionItem(values=freq_range,
564 orientation='vertical',
565 bounds=freq_range,
566 )
567 self.update_data_plot()
569 # Update max and mins of selectors
570 self.min_frequency_selector.setRange(*freq_range)
571 self.min_frequency_selector.setValue(freq_range[0])
572 self.max_frequency_selector.setRange(*freq_range)
573 self.max_frequency_selector.setValue(freq_range[1])
575 self.lines_at_resonance_spinbox.setMaximum(self.frfs.num_elements)
576 self.lines_at_residuals_spinbox.setMaximum(self.frfs.num_elements)
577 self.lines_at_residuals_spinbox.setValue(self.frfs.num_elements // 10)
579 self.connect_callbacks()
580 self.num_responses_label.setText('{:} Responses'.format(self.frfs.shape[0]))
581 self.num_references_label.setText('{:} References'.format(self.frfs.shape[1]))
582 self.num_frequencies_label.setText('{:} Frequencies'.format(self.frfs.num_elements))
583 self.by_order_spinbox.setValue(self.frfs.shape[1])
584 self.stability_diagrams = []
585 self.geometry = None
586 self.shapes = None
587 self.setWindowTitle('PolyPy -- Multi-reference Polynomial Curve Fitter')
588 self.show_line_selectors()
589 self.show()
591 @property
592 def min_frequency(self):
593 min_freq = np.min([sd.polypy.min_frequency for sd in self.stability_diagrams])
594 if min_freq == 0:
595 min_freq = self.frequencies[1]
596 return min_freq
598 @property
599 def max_frequency(self):
600 return np.max([sd.polypy.max_frequency for sd in self.stability_diagrams])
602 @property
603 def frequencies(self):
604 return self.frfs[0, 0].abscissa
606 @property
607 def frequency_slice(self):
608 abscissa_indices = np.ones(self.frequencies.shape, dtype=bool)
609 if self.min_frequency is not None:
610 abscissa_indices &= (self.frequencies >= self.min_frequency)
611 if self.max_frequency is not None:
612 abscissa_indices &= (self.frequencies <= self.max_frequency)
613 abscissa = self.frequencies[abscissa_indices]
614 if abscissa[0] == 0:
615 abscissa = abscissa[1:]
616 freq_range = np.array((np.min(abscissa), np.max(abscissa)))
617 index_range = np.argmin(np.abs(self.frequencies - freq_range[:, np.newaxis]), axis=1)
618 return slice(index_range[0], index_range[1] + 1)
620 def connect_callbacks(self):
621 self.setup_cmif_selection.clicked.connect(self.update_data_plot)
622 self.setup_qmif_selection.clicked.connect(self.update_data_plot)
623 self.setup_mmif_selection.clicked.connect(self.update_data_plot)
624 self.setup_nmif_selection.clicked.connect(self.update_data_plot)
625 self.frequency_selector.sigRegionChanged.connect(self.update_frequency_from_region)
626 self.min_frequency_selector.valueChanged.connect(self.update_min_frequency)
627 self.max_frequency_selector.valueChanged.connect(self.update_max_frequency)
628 self.compute_stabilization_button.clicked.connect(self.compute_stabilization)
629 self.compute_shapes_button.clicked.connect(self.compute_shapes)
630 self.export_pole_list_button.clicked.connect(self.export_pole_list)
631 self.load_geometry_button.clicked.connect(self.load_geometry)
632 self.plot_shapes_button.clicked.connect(self.plot_shapes)
633 self.export_shapes_button.clicked.connect(self.save_shapes)
634 self.create_frf_window_button.clicked.connect(self.create_frf_window)
635 self.create_cmif_window_button.clicked.connect(self.create_cmif_window)
636 self.create_qmif_window_button.clicked.connect(self.create_qmif_window)
637 self.create_mmif_window_button.clicked.connect(self.create_mmif_window)
638 self.create_nmif_window_button.clicked.connect(self.create_nmif_window)
639 self.complex_modes_checkbox.stateChanged.connect(self.pole_selection_changed)
640 self.autoresynthesize_checkbox.stateChanged.connect(self.pole_selection_changed)
641 self.all_frequency_lines_checkbox.stateChanged.connect(self.pole_selection_changed)
642 self.lines_at_resonance_spinbox.valueChanged.connect(self.pole_selection_changed)
643 self.lines_at_residuals_spinbox.valueChanged.connect(self.pole_selection_changed)
644 self.use_low_residuals_checkbox.stateChanged.connect(self.pole_selection_changed)
645 self.use_high_residuals_checkbox.stateChanged.connect(self.pole_selection_changed)
646 self.mode_display_selector.currentIndexChanged.connect(self.update_resynthesis)
647 self.reconstruction_mode_selector.currentIndexChanged.connect(self.update_resynthesis)
648 self.export_fit_data_button.clicked.connect(self.export_fit_data)
649 self.all_frequency_lines_checkbox.stateChanged.connect(self.show_line_selectors)
651 def show_line_selectors(self):
652 if self.all_frequency_lines_checkbox.isChecked():
653 for widget in [self.lines_at_resonance_label, self.lines_at_resonance_spinbox,
654 self.lines_at_residuals_label, self.lines_at_residuals_spinbox]:
655 widget.hide()
656 else:
657 for widget in [self.lines_at_resonance_label, self.lines_at_resonance_spinbox,
658 self.lines_at_residuals_label, self.lines_at_residuals_spinbox]:
659 widget.show()
661 def load_geometry(self):
662 filename, file_filter = QtWidgets.QFileDialog.getOpenFileName(
663 self, 'Open Geometry', filter='Numpy Files (*.npz);;Universal Files (*.unv *.uff)')
664 if filename == '':
665 return
666 self.geometry = Geometry.load(filename)
668 def set_geometry(self, geometry):
669 self.geometry = geometry
671 def save_shapes(self):
672 if self.shapes is None:
673 QtWidgets.QMessageBox.warning(self, 'PyPoly', 'Shapes not created!')
674 return
675 filename, file_filter = QtWidgets.QFileDialog.getSaveFileName(
676 self, 'Save Shapes', filter='Numpy Files (*.npy)')
677 if filename == '':
678 return
679 np.sort(self.shapes).save(filename)
681 def export_fit_data(self):
682 if self.shapes is None:
683 QtWidgets.QMessageBox.warning(self, 'PyPoly', 'Shapes not created!')
684 return
685 filename, file_filter = QtWidgets.QFileDialog.getSaveFileName(
686 self, 'Save Fit Data', filter='Numpy Files (*.npz)')
687 if filename == '':
688 return
689 shapes = np.sort(self.shapes)
690 np.savez(filename, shapes=shapes.view(np.ndarray),
691 frfs=self.frfs.view(np.ndarray),
692 frfs_resynth=self.resynthesized_frfs.view(np.ndarray),
693 frfs_residual=self.frfs_synth_residual.view(np.ndarray))
695 def export_pole_list(self):
696 filename, file_filter = QtWidgets.QFileDialog.getSaveFileName(
697 self, "Save Pole List Data", filter="Numpy Files (*.npy)"
698 )
699 if filename == "":
700 return
701 poles = []
702 for sd in self.stability_diagrams:
703 pole_indices = [sd.pole_indices[val] for val in sd.selected_poles]
704 poles.append(sd.polypy.pole_list_from_indices(pole_indices))
705 poles = np.concatenate(poles)
706 np.save(filename, poles)
708 def plot_shapes(self):
709 if self.geometry is None:
710 self.load_geometry()
711 if self.geometry is None:
712 QtWidgets.QMessageBox.warning(self, 'PyPoly', 'No Geometry Loaded!')
713 return
714 if self.shapes is None:
715 QtWidgets.QMessageBox.warning(self, 'PyPoly', 'Shapes not created!')
716 return
717 self.geometry.plot_shape(np.sort(self.shapes))
719 def pole_selection_changed(self):
720 if self.autoresynthesize_checkbox.isChecked():
721 self.compute_shapes()
723 def create_frf_window(self):
724 self.resynthesis_plots.append(
725 ('frf', GUIPlot(Data=self.frfs, Fit=self.resynthesized_frfs)))
726 self.update_resynthesis()
728 def create_cmif_window(self):
729 self.resynthesis_plots.append(
730 ('cmif', GUIPlot(Data=self.frfs.compute_cmif(), Fit=self.resynthesized_frfs.compute_cmif())))
731 self.resynthesis_plots[-1][-1].ordinate_log = True
732 self.resynthesis_plots[-1][-1].actionOrdinate_Log.setChecked(True)
733 self.update_resynthesis()
735 def create_qmif_window(self):
736 self.resynthesis_plots.append(
737 ('qmif', GUIPlot(Data=self.frfs.compute_cmif(part='real' if self.datatype_selector.currentIndex() == 1 else 'imag'),
738 Fit=self.resynthesized_frfs.compute_cmif(part='imag'))))
739 self.resynthesis_plots[-1][-1].ordinate_log = True
740 self.resynthesis_plots[-1][-1].actionOrdinate_Log.setChecked(True)
741 self.update_resynthesis()
743 def create_mmif_window(self):
744 test_mmif = self.frfs.compute_mmif()
745 try:
746 mmif = self.resynthesized_frfs.compute_mmif()
747 except np.linalg.LinAlgError:
748 mmif = test_mmif.copy()
749 mmif.ordinate = 1
750 self.resynthesis_plots.append(
751 ('mmif', GUIPlot(Data=test_mmif, Fit=mmif)))
752 self.update_resynthesis()
754 def create_nmif_window(self):
755 self.resynthesis_plots.append(
756 ('nmif', GUIPlot(Data=self.frfs.compute_nmif(), Fit=self.resynthesized_frfs.compute_nmif())))
757 self.update_resynthesis()
759 def update_resynthesis(self):
760 # First go through and remove any closed windows
761 self.resynthesis_plots = [(function_type, window) for function_type,
762 window in self.resynthesis_plots if window.isVisible()]
763 # Now go and update the resynthesized FRFs
764 if self.reconstruction_mode_selector.currentIndex() == 0:
765 data_computed = {'frf': self.resynthesized_frfs.flatten()}
766 elif self.reconstruction_mode_selector.currentIndex() == 1:
767 data_computed = {'frf': self.frfs_synth_kernel.flatten()}
768 elif self.reconstruction_mode_selector.currentIndex() == 2:
769 data_computed = {'frf': self.frfs_synth_residual.flatten()}
770 for function_type, window in self.resynthesis_plots:
771 if function_type not in data_computed:
772 if function_type == 'cmif':
773 data_computed[function_type] = data_computed["frf"].compute_cmif().flatten()
774 elif function_type == 'qmif':
775 data_computed[function_type] = data_computed['frf'].compute_cmif(
776 part='real' if self.datatype_selector.currentIndex() == 1 else 'imag').flatten()
777 elif function_type == 'mmif':
778 try:
779 data_computed[function_type] = data_computed['frf'].compute_mmif().flatten()
780 except np.linalg.LinAlgError:
781 test_mmif = self.frfs.compute_mmif().copy()
782 test_mmif.ordinate = 1
783 data_computed[function_type] = test_mmif
784 elif function_type == 'nmif':
785 data_computed[function_type] = data_computed['frf'].compute_nmif().flatten()
786 window.data_dictionary['Fit'] = data_computed[function_type]
787 if self.mode_display_selector.currentIndex() > 0:
788 symbol = [None,'vline','o','x','+','star'][self.mode_display_selector.currentIndex()]
789 window.marker_data['Fit'] = [self.shapes.frequency, '{index:}: {abscissa:0.2f}', symbol]
790 else:
791 window.marker_data['Fit'] = [None, None, None]
792 window.update()
794 def compute_shapes(self):
795 try:
796 # Assemble pole list
797 poles = []
798 for sd in self.stability_diagrams:
799 pole_indices = [sd.pole_indices[val] for val in sd.selected_poles]
800 poles.append(sd.polypy.pole_list_from_indices(pole_indices))
801 poles = np.concatenate(poles)
802 if poles.size == 0:
803 self.resynthesized_frfs = self.frfs.copy()
804 self.resynthesized_frfs.ordinate = 0
805 self.frfs_synth_residual = self.resynthesized_frfs.copy()
806 self.frfs_synth_kernel = self.resynthesized_frfs.copy()
807 self.shapes = ShapeArray((0,), self.frfs.shape[0])
808 self.negative_drive_points = np.zeros((0,), dtype=int)
809 return
810 natural_frequencies = poles['omega'] / (2 * np.pi)
811 damping_ratios = poles['zeta']
812 participation_factors = poles['Lr_complex']
813 H = self.frfs.ordinate[..., self.frequency_slice]
814 num_outputs, num_inputs, num_elements = H.shape
815 self.shapes, self.resynthesized_frfs, self.frfs_synth_residual, self.frfs_synth_kernel = (
816 compute_shapes_multireference(
817 self.frfs, natural_frequencies, damping_ratios,
818 participation_factors,
819 not self.complex_modes_checkbox.isChecked(),
820 self.use_low_residuals_checkbox.isChecked(),
821 self.use_high_residuals_checkbox.isChecked(),
822 self.min_frequency, self.max_frequency,
823 self.datatype_selector.currentIndex(),
824 None if self.all_frequency_lines_checkbox.isChecked() else self.lines_at_resonance_spinbox.value(),
825 self.lines_at_residuals_spinbox.value())
826 )
827 self.update_resynthesis()
828 except Exception:
829 print(traceback.format_exc())
831 def compute_stabilization(self):
832 self.compute_stabilization_button.setEnabled(False)
833 poles = np.arange(self.min_order_spinbox.value(),
834 self.max_order_spinbox.value() + 1,
835 self.by_order_spinbox.value())
836 self.stability_diagrams.append(
837 PolyPy_Stability(self, self.stability_tab_widget,
838 self.frfs, self.frequency_selector.getRegion(), poles, self.datatype_selector.currentIndex()))
839 self.compute_stabilization_button.setEnabled(True)
840 if self.first_stability:
841 self.first_stability = False
842 num_lines = np.sum((self.frequencies >= self.min_frequency) &
843 (self.frequencies <= self.max_frequency))
844 self.lines_at_residuals_spinbox.setValue(num_lines // 10)
845 self.stability_tab_widget.setCurrentIndex(len(self.stability_diagrams) - 1)
846 self.polypy_tab_widget.setCurrentIndex(1)
848 def update_frequency_from_region(self):
849 self.min_frequency_selector.blockSignals(True)
850 self.max_frequency_selector.blockSignals(True)
851 region = self.frequency_selector.getRegion()
852 self.min_frequency_selector.setValue(region[0])
853 self.max_frequency_selector.setValue(region[1])
854 self.min_frequency_selector.blockSignals(False)
855 self.max_frequency_selector.blockSignals(False)
857 def update_min_frequency(self):
858 self.frequency_selector.blockSignals(True)
859 self.max_frequency_selector.blockSignals(True)
860 if self.min_frequency_selector.value() > self.max_frequency_selector.value():
861 self.max_frequency_selector.setValue(self.min_frequency_selector.value())
862 self.frequency_selector.setRegion((self.min_frequency_selector.value(),
863 self.max_frequency_selector.value()))
864 self.frequency_selector.blockSignals(False)
865 self.max_frequency_selector.blockSignals(False)
867 def update_max_frequency(self):
868 self.frequency_selector.blockSignals(True)
869 self.min_frequency_selector.blockSignals(True)
870 if self.min_frequency_selector.value() > self.max_frequency_selector.value():
871 self.min_frequency_selector.setValue(self.max_frequency_selector.value())
872 self.frequency_selector.setRegion((self.min_frequency_selector.value(),
873 self.max_frequency_selector.value()))
874 self.frequency_selector.blockSignals(False)
875 self.min_frequency_selector.blockSignals(False)
877 def update_data_plot(self):
878 self.data_plot.clear()
879 self.data_plot.addItem(self.frequency_selector)
880 if self.setup_cmif_selection.isChecked():
881 self.data_plot.setLogMode(False, True)
882 cmif = self.frfs.compute_cmif()
883 for i, curve in enumerate(cmif):
884 pen = pyqtgraph.mkPen(color=[int(255 * v) for v in self.cm(i % self.cm_mod)])
885 self.data_plot.plot(curve.abscissa, curve.ordinate, pen=pen)
886 elif self.setup_qmif_selection.isChecked():
887 self.data_plot.setLogMode(False, True)
888 qmif = self.frfs.compute_cmif(
889 part='real' if self.datatype_selector.currentIndex() == 1 else 'imag')
890 for i, curve in enumerate(qmif):
891 pen = pyqtgraph.mkPen(color=[int(255 * v) for v in self.cm(i % self.cm_mod)])
892 self.data_plot.plot(curve.abscissa, curve.ordinate, pen=pen)
893 elif self.setup_mmif_selection.isChecked():
894 self.data_plot.setLogMode(False, False)
895 mmif = self.frfs.compute_mmif()
896 for i, curve in enumerate(mmif):
897 pen = pyqtgraph.mkPen(color=[int(255 * v) for v in self.cm(i % self.cm_mod)])
898 self.data_plot.plot(curve.abscissa, curve.ordinate, pen=pen)
899 elif self.setup_nmif_selection.isChecked():
900 self.data_plot.setLogMode(False, False)
901 nmif = self.frfs.compute_nmif()
902 pen = pyqtgraph.mkPen(color=[int(255 * v) for v in self.cm(0 % self.cm_mod)])
903 self.data_plot.plot(nmif.abscissa, nmif.ordinate)