Coverage for / opt / hostedtoolcache / Python / 3.11.14 / x64 / lib / python3.11 / site-packages / rattlesnake / components / ui_utilities.py: 10%
1196 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-27 18:22 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-27 18:22 +0000
1# -*- coding: utf-8 -*-
2"""
3User interface-specific utilities that might be used in multiple environments
5Rattlesnake Vibration Control Software
6Copyright (C) 2021 National Technology & Engineering Solutions of Sandia, LLC
7(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
25import socket
27import numpy as np
28import openpyxl
29import pyqtgraph
30import requests
31from qtpy import QtCore, QtGui, QtWidgets, uic
32from qtpy.QtCore import Qt, QTimer # pylint: disable=no-name-in-module
33from scipy.interpolate import interp1d
34from scipy.io import loadmat
36from .environments import (
37 ControlTypes,
38 combined_environments_capable,
39 control_select_ui_path,
40 environment_long_names,
41 environment_select_ui_path,
42 environment_UIs,
43 modal_mdi_ui_path,
44 transformation_matrices_ui_path,
45)
46from .utilities import (
47 DataAcquisitionParameters,
48 coherence,
49 error_message_qt,
50 load_csv_matrix,
51 save_csv_matrix,
52 trac,
53)
55ACQUISITION_FRAMES_TO_DISPLAY = 4
58class ProfileTimer(QTimer):
59 """A timer class that allows storage of controller instruction information"""
61 def __init__(self, environment: str, operation: str, data: str):
62 """
63 A timer class that allows storage of controller instruction information
65 When the timer times out, the environment, operation, and any data can
66 be collected by the callback by accessing the self.sender().environment,
67 .operation, or .data attributes.
69 Parameters
70 ----------
71 environment : str
72 The name of the environment (or 'Global') that the instruction will
73 be sent to
74 operation : str
75 The operation that the environment will be instructed to perform
76 data : str
77 Any data corresponding to that operation that is required
80 """
81 super().__init__()
82 self.environment = environment
83 self.operation = operation
84 self.data = data
87def get_table_strings(tablewidget: QtWidgets.QTableWidget):
88 """Collect a table of strings from a QTableWidget
90 Parameters
91 ----------
92 tablewidget : QtWidgets.QTableWidget
93 A table widget to pull the strings from
95 Returns
96 -------
97 string_array : list[list[str]]
98 A nested list of strings from the table items
100 """
101 string_array = []
102 for row_idx in range(tablewidget.rowCount()):
103 string_array.append([])
104 for col_idx in range(tablewidget.columnCount()):
105 value = tablewidget.item(row_idx, col_idx).text()
106 string_array[-1].append(value)
107 return string_array
110def get_table_bools(tablewidget: QtWidgets.QTableWidget):
111 """Collect a table of booleans from a QTableWidget full of QCheckBoxes
113 Parameters
114 ----------
115 tablewidget : QtWidgets.QTableWidget
116 A table widget to pull the strings from
118 Returns
119 -------
120 bool_array : list[list[bool]]
121 A nested list of booleans from the table widgets
123 """
124 bool_array = []
125 for row_idx in range(tablewidget.rowCount()):
126 bool_array.append([])
127 for col_idx in range(tablewidget.columnCount()):
128 value = tablewidget.cellWidget(row_idx, col_idx).isChecked()
129 bool_array[-1].append(value)
130 return bool_array
133def load_time_history(signal_path, sample_rate):
134 """Loads a time history from a given file
136 The signal can be loaded from numpy files (.npz, .npy) or matlab files (.mat).
137 For .mat and .npz files, the time data can be included in the file in the
138 't' field, or it can be excluded and the sample_rate input argument will
139 be used. If time data is specified, it will be linearly interpolated to the
140 sample rate of the controller.
141 For these file types, the signal should be stored in the 'signal'
142 field. For .npy files, only one array is stored, so it is treated as the
143 signal, and the sample_rate input argument is used to construct the time
144 data.
146 Parameters
147 ----------
148 signal_path : str:
149 Path to the file from which to load the time history
151 sample_rate : str:
152 The sample rate of the loaded signal.
154 Returns
155 -------
156 signal : np.ndarray:
157 A signal loaded from the file
159 """
160 _, extension = os.path.splitext(signal_path)
161 if extension.lower() == ".npy":
162 signal = np.load(signal_path)
163 elif extension.lower() == ".npz":
164 data = np.load(signal_path)
165 signal = data["signal"]
166 try:
167 times = data["t"].squeeze()
168 fn = interp1d(times, signal)
169 abscissa = np.arange(0, max(times) + 1 / sample_rate - 1e-10, 1 / sample_rate)
170 abscissa = abscissa[abscissa <= max(times)]
171 signal = fn(abscissa)
172 except KeyError:
173 pass
174 elif extension.lower() == ".mat":
175 data = loadmat(signal_path)
176 signal = data["signal"]
177 try:
178 times = data["t"].squeeze()
179 fn = interp1d(times, signal)
180 abscissa = np.arange(0, max(times) + 1 / sample_rate - 1e-10, 1 / sample_rate)
181 abscissa = abscissa[abscissa <= max(times)]
182 signal = fn(abscissa)
183 except KeyError:
184 pass
185 else:
186 raise ValueError(
187 f"Could Not Determine the file type from the filename {signal_path}: {extension}"
188 )
189 if signal.shape[-1] % 2 == 1:
190 signal = signal[..., :-1]
191 return signal
194colororder = [
195 "#1f77b4",
196 "#ff7f0e",
197 "#2ca02c",
198 "#d62728",
199 "#9467bd",
200 "#8c564b",
201 "#e377c2",
202 "#7f7f7f",
203 "#bcbd22",
204 "#17becf",
205]
208def multiline_plotter(
209 x,
210 y,
211 widget=None,
212 curve_list=None,
213 names=None,
214 other_pen_options=None,
215 legend=False,
216 downsample=None,
217 clip_to_view=False,
218):
219 """Helper function for PyQtGraph to deal with plots with multiple curves
221 Parameters
222 ----------
223 x : np.ndarray
224 Abscissa for the data that will be plotted, 1D array with shape n_samples
225 y : np.ndarray
226 Ordinates for the data that will be plotted. 2D array with shape
227 n_curves x n_samples
228 widget :
229 The plot widget on which the curves will be drawn. (Default value = None)
230 curve_list :
231 Alternatively to specifying the widget, a curve list can be specified
232 directly. (Default value = None)
233 names :
234 Names of the curves that will appear in the legend. (Default value = None)
235 other_pen_options : dict
236 Additional options besides color that will be applied to the curves.
237 (Default value = {'width':1})
238 legend :
239 Whether or not to draw a legend (Default value = False)
241 Returns
242 -------
244 """
245 if other_pen_options is None:
246 other_pen_options = {"width": 1}
247 if downsample is None:
248 downsample = {"ds": 1, "auto": False, "mode": "peak"}
249 if widget is not None:
250 plot_item = widget.getPlotItem()
251 plot_item.setDownsampling(**downsample)
252 plot_item.setClipToView(clip_to_view)
253 if legend:
254 plot_item.addLegend(colCount=len(y) // 10)
255 handles = []
256 for i, this_y in enumerate(y):
257 pen = {"color": colororder[i % len(colororder)]}
258 pen.update(other_pen_options)
259 handles.append(
260 plot_item.plot(x, this_y, pen=pen, name=None if names is None else names[i])
261 )
262 return handles
263 elif curve_list is not None:
264 for this_y, curve in zip(y, curve_list):
265 curve.setData(x, y)
266 return curve_list
267 else:
268 raise ValueError("Either Widget or list of curves must be specified")
271def blended_scatter_plot(xy, widget=None, curve_list=None, names=None, symbol="o"):
272 """Creates a scatter plot with the specified symbols"""
273 if widget is not None:
274 plot_item = widget.getPlotItem()
275 handles = []
276 for index, (x, y) in enumerate(xy):
277 c = (1 - (index + 1) / len(xy)) * 255
278 handles.append(
279 plot_item.plot(
280 [x],
281 [y],
282 symbolBrush=(c, c, c),
283 name=None if names is None else names[index],
284 symbol=symbol,
285 )
286 )
287 return handles
288 elif curve_list is not None:
289 for (x, y), curve in zip(xy, curve_list):
290 curve.setData([x], [y])
291 return curve_list
292 else:
293 raise ValueError("Either Widget or list of curves must be specified")
296def save_combined_environments_profile_template(filename, environment_data):
297 """Creates a spreadsheet template that can be completed and loaded to define a test"""
298 # Create the header
299 workbook = openpyxl.Workbook()
300 worksheet = workbook.active
301 worksheet.title = "Channel Table"
302 hardware_worksheet = workbook.create_sheet("Hardware")
303 # Create the header
304 worksheet.cell(row=1, column=2, value="Test Article Definition")
305 worksheet.merge_cells(start_row=1, start_column=2, end_row=1, end_column=4)
306 worksheet.cell(row=1, column=5, value="Instrument Definition")
307 worksheet.merge_cells(start_row=1, start_column=5, end_row=1, end_column=11)
308 worksheet.cell(row=1, column=12, value="Channel Definition")
309 worksheet.merge_cells(start_row=1, start_column=12, end_row=1, end_column=19)
310 worksheet.cell(row=1, column=20, value="Output Feedback")
311 worksheet.merge_cells(start_row=1, start_column=20, end_row=1, end_column=21)
312 worksheet.cell(row=1, column=22, value="Limits")
313 worksheet.merge_cells(start_row=1, start_column=22, end_row=1, end_column=23)
314 for col_idx, val in enumerate(
315 [
316 "Channel Index",
317 "Node Number",
318 "Node Direction",
319 "Comment",
320 "Serial Number",
321 "Triax DoF",
322 "Sensitivity (mV/EU)",
323 "Engineering Unit",
324 "Make",
325 "Model",
326 "Calibration Exp Date",
327 "Physical Device",
328 "Physical Channel",
329 "Type",
330 "Minimum Value (V)",
331 "Maximum Value (V)",
332 "Coupling",
333 "Current Excitation Source",
334 "Current Excitation Value",
335 "Physical Device",
336 "Physical Channel",
337 "Warning Level (EU)",
338 "Abort Level (EU)",
339 ]
340 ):
341 worksheet.cell(row=2, column=1 + col_idx, value=val)
342 # Fill out the hardware worksheet
343 hardware_worksheet.cell(1, 1, "Hardware Type")
344 hardware_worksheet.cell(1, 2, "# Enter hardware index here")
345 hardware_worksheet.cell(
346 1,
347 3,
348 "Hardware Indices: 0 - NI DAQmx; 1 - LAN XI; 2 - Data Physics Quattro; "
349 "3 - Data Physics 900 Series; 4 - Exodus Modal Solution; 5 - State Space Integration; "
350 "6 - SDynPy System Integration",
351 )
352 hardware_worksheet.cell(2, 1, "Hardware File")
353 hardware_worksheet.cell(
354 2,
355 2,
356 "# Path to Hardware File (Depending on Hardware Device: 0 - Not Used; 1 - Not Used; "
357 "2 - Path to DpQuattro.dll library file; 3 - Not Used; 4 - Path to Exodus Eigensolution; "
358 "5 - Path to State Space File; 6 - Path to SDynPy system file)",
359 )
360 hardware_worksheet.cell(3, 1, "Sample Rate")
361 hardware_worksheet.cell(3, 2, "# Sample Rate of Data Acquisition System")
362 hardware_worksheet.cell(4, 1, "Time Per Read")
363 hardware_worksheet.cell(4, 2, "# Number of seconds per Read from the Data Acquisition System")
364 hardware_worksheet.cell(5, 1, "Time Per Write")
365 hardware_worksheet.cell(5, 2, "# Number of seconds per Write to the Data Acquisition System")
366 hardware_worksheet.cell(6, 1, "Maximum Acquisition Processes")
367 hardware_worksheet.cell(
368 6,
369 2,
370 "# Maximum Number of Acquisition Processes to start to pull data from hardware",
371 )
372 hardware_worksheet.cell(
373 6,
374 3,
375 "Only Used by LAN-XI Hardware. This row can be deleted if LAN-XI is not used",
376 )
377 hardware_worksheet.cell(7, 1, "Integration Oversampling")
378 hardware_worksheet.cell(
379 7, 2, "# For virtual control, an integration oversampling can be specified"
380 )
381 hardware_worksheet.cell(
382 7,
383 3,
384 "Only used for virtual control (Exodus, State Space, or SDynPy). "
385 "This row can be deleted if these are not used.",
386 )
387 hardware_worksheet.cell(8, 1, "Task Trigger")
388 hardware_worksheet.cell(8, 2, "# Start trigger type")
389 hardware_worksheet.cell(
390 8,
391 3,
392 "Task Triggers: 0 - Internal, 1 - PFI0 with external trigger, 2 - PFI0 with Analog Output "
393 "trigger. Only used for NI hardware. This row can be deleted if NI is not used.",
394 )
395 hardware_worksheet.cell(9, 1, "Task Trigger Output Channel")
396 hardware_worksheet.cell(9, 2, "# Physical device and channel that generates a trigger signal")
397 hardware_worksheet.cell(
398 9,
399 3,
400 "Only used if Task Triggers is 2. Only used for NI hardware. "
401 "This row can be deleted if it is not used.",
402 )
404 # Now do the environment
405 worksheet.cell(row=1, column=24, value="Environments")
406 for row, (value, name) in enumerate(environment_data):
407 environment_UIs[value].create_environment_template(name, workbook)
408 worksheet.cell(row=2, column=24 + row, value=name)
409 # Now create a profile page
410 profile_sheet = workbook.create_sheet("Test Profile")
411 profile_sheet.cell(1, 1, "Time (s)")
412 profile_sheet.cell(1, 2, "Environment")
413 profile_sheet.cell(1, 3, "Operation")
414 profile_sheet.cell(1, 4, "Data")
416 workbook.save(filename)
419class EnvironmentSelect(QtWidgets.QDialog):
420 """QDialog for selecting the environments in a combined environments run"""
422 def __init__(self, parent=None):
423 """
424 Constructor for the EnvironmentSelect dialog box.
426 Parameters
427 ----------
428 parent : QWidget, optional
429 Parent widget to the dialog. The default is None.
431 """
432 super(QtWidgets.QDialog, self).__init__(parent)
433 uic.loadUi(environment_select_ui_path, self)
434 self.setWindowIcon(QtGui.QIcon("logo/Rattlesnake_Icon.png"))
436 self.add_environment_button.clicked.connect(self.add_environment)
437 self.remove_environment_button.clicked.connect(self.remove_environment)
438 self.load_profile_button.clicked.connect(self.load_profile)
439 self.save_profile_template_button.clicked.connect(self.save_profile_template)
440 self.loaded_profile = None
442 def add_environment(self):
443 """Adds a row to the environment table"""
444 selected_row = self.environment_display_table.rowCount()
445 self.environment_display_table.insertRow(selected_row)
446 combobox = QtWidgets.QComboBox()
447 for control_type in combined_environments_capable:
448 combobox.addItem(control_type.name.title(), control_type.value)
449 self.environment_display_table.setCellWidget(selected_row, 0, combobox)
451 def remove_environment(self):
452 """Removes a row from the environment table"""
453 selected_row = self.environment_display_table.currentRow()
454 if selected_row >= 0:
455 self.environment_display_table.removeRow(selected_row)
457 def save_profile_template(self):
458 """Saves a template for the given environments table
460 This template can be filled out by a user and then loaded."""
461 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
462 self, "Save Combined Environments Template", filter="Excel File (*.xlsx)"
463 )
464 if filename == "":
465 return
466 # Now do the environments
467 environment_data = []
468 for row in range(self.environment_display_table.rowCount()):
469 combobox = self.environment_display_table.cellWidget(row, 0)
470 value = ControlTypes(combobox.currentData())
471 name = self.environment_display_table.item(row, 1).text()
472 environment_data.append((value, name))
473 save_combined_environments_profile_template(filename, environment_data)
475 def load_profile(self):
476 """Loads a profile from an excel spreadsheet."""
477 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
478 self, "Load Combined Environments Profile", filter="Excel File (*.xlsx)"
479 )
480 if filename == "":
481 return
482 else:
483 self.loaded_profile = filename
484 self.accept()
486 @staticmethod
487 def select_environment(parent=None):
488 """Creates the dialog box and then parses the output.
490 Note that there are variable numbers of outputs for this function
492 Parameters
493 ----------
494 parent : QWidget
495 Parent to the dialog box (Default value = None)
497 Returns
498 -------
499 result : int
500 A flag specifying the outcome of the dialog box. Will be 1 if the
501 dialog was accepted, zero if cancelled, and -1 if a profile was
502 loaded instead.
503 environment_table : list of lists
504 A list of environment type, environment name pairs that will be
505 used to specify the environments in a test.
506 loaded_profile : str
507 File name to the profile file that needs to be loaded. Only
508 output if result == -1
509 """
510 dialog = EnvironmentSelect(parent)
511 result = 1 if (dialog.exec_() == QtWidgets.QDialog.Accepted) else 0
512 if dialog.loaded_profile is None:
513 environment_table = []
514 if result:
515 for row in range(dialog.environment_display_table.rowCount()):
516 combobox = dialog.environment_display_table.cellWidget(row, 0)
517 value = ControlTypes(combobox.currentData())
518 name = dialog.environment_display_table.item(row, 1).text()
519 environment_table.append([value, name])
520 # print(environment_table)
521 return result, environment_table
522 else:
523 result = -1
524 workbook = openpyxl.load_workbook(dialog.loaded_profile)
525 environment_sheets = [
526 sheet
527 for sheet in workbook
528 if (sheet.title not in ["Channel Table", "Hardware", "Test Profile"])
529 and sheet.cell(1, 1).value == "Control Type"
530 ]
531 environment_table = [
532 (ControlTypes[sheet.cell(1, 2).value.upper()], sheet.title)
533 for sheet in environment_sheets
534 ]
535 workbook.close()
536 return result, environment_table, dialog.loaded_profile
539class ControlSelect(QtWidgets.QDialog):
540 """Environment selector dialog box to select the control type for the test"""
542 def __init__(self, parent=None):
543 """
544 Selects the environment type that gets used for the test.
546 This function reads from the environment control types to populate the
547 radiobuttons on the dialog.
549 Parameters
550 ----------
551 parent : QWidget, optional
552 Parent of the dialog box. The default is None.
554 """
555 super(QtWidgets.QDialog, self).__init__(parent)
556 uic.loadUi(control_select_ui_path, self)
557 self.setWindowIcon(QtGui.QIcon("logo/Rattlesnake_Icon.png"))
559 self.buttonBox.accepted.connect(self.accept)
560 self.buttonBox.rejected.connect(self.reject)
561 self.control_select_buttongroup = QtWidgets.QButtonGroup()
563 # Go through and create radiobuttons for each control type
564 control_types_sorted = sorted(
565 [(control_type.value, control_type) for control_type in ControlTypes]
566 )
568 for value, control_type in control_types_sorted[1:] + control_types_sorted[:1]:
569 radiobutton = QtWidgets.QRadioButton(environment_long_names[control_type])
570 self.control_select_buttongroup.addButton(radiobutton, value)
571 if value == ControlTypes.RANDOM.value:
572 radiobutton.setChecked(True)
573 self.environment_radiobutton_layout.addWidget(radiobutton)
575 @staticmethod
576 def select_control(parent=None):
577 """Create the dialog box and parse the output
579 Parameters
580 ----------
581 parent : QWidget
582 Parent of the dialog box (Default value = None)
584 Returns
585 -------
586 button_id : int
587 The index of the button that was pressed
588 result : bool
589 True if dialog was accepted, otherwise false if cancelled.
590 """
591 dialog = ControlSelect(parent)
592 result = dialog.exec_() == QtWidgets.QDialog.Accepted
593 index = dialog.control_select_buttongroup.checkedId()
594 button_id = ControlTypes(index)
595 # print(button_id)
596 return (button_id, result)
599class PlotWindow(QtWidgets.QDialog):
600 """Class defining a subwindow that displays specific channel information"""
602 WARNING_COLOR = (225, 190, 0)
603 WARNING_LINEWIDTH = 0.3
604 WARNING_LINESTYLE = Qt.SolidLine
605 ABORT_COLOR = (200, 0, 0)
606 ABORT_LINEWIDTH = 0.3
607 ABORT_LINESTYLE = Qt.SolidLine
609 def __init__(
610 self,
611 parent,
612 row,
613 column,
614 datatype,
615 specification,
616 row_name,
617 column_name,
618 datatype_name,
619 warning_matrix=None,
620 abort_matrix=None,
621 ):
622 """
623 Creates a window showing CPSD matrix information for a single channel.
625 Parameters
626 ----------
627 parent : QWidget
628 Parent of the window.
629 row : int
630 Row of the CPSD matrix to plot.
631 column : int
632 Column of the CPSD matrix to plot.
633 datatype : int
634 Type of data to plot: 0 - Magnitude, 1 - Coherence, 2 - Phase, 3 -
635 Real, 4 - Imaginary.
636 specification : np.ndarray
637 The specification against which data will be compared.
638 row_name : str
639 Channel name for the row.
640 column_name : str
641 Channel name for the column.
642 datatype_name : str
643 Name for the datatype.
646 """
647 super(QtWidgets.QDialog, self).__init__(parent)
648 self.setWindowFlags(self.windowFlags() & Qt.Tool)
649 self.row = row
650 self.column = column
651 self.datatype = datatype
652 self.frequencies = specification[0]
653 self.spec_data = self.reduce_matrix(specification[1])
654 self.data = np.zeros(self.spec_data.shape)
655 # Now plot the data
656 layout = QtWidgets.QVBoxLayout()
657 plotwidget = pyqtgraph.PlotWidget()
658 layout.addWidget(plotwidget)
659 self.setLayout(layout)
660 plot_item = plotwidget.getPlotItem()
661 plot_item.showGrid(True, True, 0.25)
662 plot_item.enableAutoRange()
663 plot_item.getViewBox().enableAutoRange(enable=True)
664 if self.datatype == 0:
665 plot_item.setLogMode(False, True)
666 plot_item.plot(self.frequencies, self.spec_data, pen={"color": "b", "width": 1})
667 if warning_matrix is not None:
668 plot_item.plot(
669 self.frequencies,
670 warning_matrix[0, :, row],
671 pen={
672 "color": PlotWindow.WARNING_COLOR,
673 "width": PlotWindow.WARNING_LINEWIDTH,
674 "style": PlotWindow.WARNING_LINESTYLE,
675 },
676 )
677 plot_item.plot(
678 self.frequencies,
679 warning_matrix[1, :, row],
680 pen={
681 "color": PlotWindow.WARNING_COLOR,
682 "width": PlotWindow.WARNING_LINEWIDTH,
683 "style": PlotWindow.WARNING_LINESTYLE,
684 },
685 )
686 if abort_matrix is not None:
687 plot_item.plot(
688 self.frequencies,
689 abort_matrix[0, :, row],
690 pen={
691 "color": PlotWindow.ABORT_COLOR,
692 "width": PlotWindow.ABORT_LINEWIDTH,
693 "style": PlotWindow.ABORT_LINESTYLE,
694 },
695 )
696 plot_item.plot(
697 self.frequencies,
698 abort_matrix[1, :, row],
699 pen={
700 "color": PlotWindow.ABORT_COLOR,
701 "width": PlotWindow.ABORT_LINEWIDTH,
702 "style": PlotWindow.ABORT_LINESTYLE,
703 },
704 )
705 self.curve = plot_item.plot(self.frequencies, self.data, pen={"color": "r", "width": 1})
706 self.setWindowTitle(f"{datatype_name} {row_name} / {column_name}")
707 self.show()
709 def reduce_matrix(self, matrix):
710 """Collects the data specific to the row and column and datatype
712 Parameters
713 ----------
714 matrix : np.ndarray
715 The 3D CPSD data that will be reduced
717 Returns
718 -------
719 plot_data : np.ndarray
720 The data that will be plotted
722 """
723 if self.datatype == 0: # Magnitude
724 return np.abs(matrix[..., self.row, self.column])
725 elif self.datatype == 1: # Coherence
726 return coherence(matrix, (self.row, self.column))
727 elif self.datatype == 2: # Phase
728 return np.angle(matrix[..., self.row, self.column])
729 elif self.datatype == 3: # Real
730 return np.real(matrix[..., self.row, self.column])
731 elif self.datatype == 4: # Imag
732 return np.imag(matrix[..., self.row, self.column])
733 else:
734 raise ValueError(f"{self.datatype} is not a valid datatype!")
736 def update_plot(self, cpsd_matrix):
737 """Updates the plot with the given CPSD matrix data
739 Parameters
740 ----------
741 cpsd_matrix : np.ndarray
742 3D CPSD matrix that will be reduced for plotting
744 """
745 self.curve.setData(self.frequencies, self.reduce_matrix(cpsd_matrix))
748class PlotTimeWindow(QtWidgets.QDialog):
749 """Class defining a subwindow that displays specific channel information"""
751 def __init__(self, parent, index, specification, sample_rate, index_name):
752 """
753 Creates a window showing time history information for a single channel.
755 Parameters
756 ----------
757 parent : QWidget
758 Parent of the window.
759 index : int
760 Row of the time history matrix to plot
761 specification : np.ndarray
762 The specification against which data will be compared.
763 sample_rate : int
764 The sample rate of the time signal
765 index_name : str
766 Channel name for the row.
767 """
768 super(QtWidgets.QDialog, self).__init__(parent)
769 self.setWindowFlags(self.windowFlags() & Qt.Tool)
770 self.index = index
771 self.times = np.arange(specification.shape[-1]) / sample_rate
772 self.spec_data = self.reduce_matrix(specification)
773 self.data = np.zeros(self.spec_data.shape)
774 # Now plot the data
775 layout = QtWidgets.QVBoxLayout()
776 plotwidget = pyqtgraph.PlotWidget()
777 layout.addWidget(plotwidget)
778 self.setLayout(layout)
779 plot_item = plotwidget.getPlotItem()
780 plot_item.showGrid(True, True, 0.25)
781 plot_item.enableAutoRange()
782 plot_item.getViewBox().enableAutoRange(enable=True)
783 plot_item.plot(self.times, self.spec_data, pen={"color": "b", "width": 1})
784 plot_item.setLabel("left", "TRAC: 0.0")
785 self.plot_item = plot_item
786 self.curve = plot_item.plot(self.times, self.data, pen={"color": "r", "width": 1})
787 self.setWindowTitle(f"{index_name}")
788 self.show()
790 def reduce_matrix(self, matrix):
791 """Collects the data specific to the row and column and datatype
793 Parameters
794 ----------
795 matrix : np.ndarray
796 The 3D CPSD data that will be reduced
798 Returns
799 -------
800 plot_data : np.ndarray
801 The data that will be plotted
803 """
804 return matrix[self.index]
806 def update_plot(self, data):
807 """Updates the plot with the given CPSD matrix data
809 Parameters
810 ----------
811 cpsd_matrix : np.ndarray
812 3D CPSD matrix that will be reduced for plotting
814 """
815 data = self.reduce_matrix(data)
816 self.curve.setData(self.times, data)
817 self.plot_item.setLabel("left", f"TRAC: {trac(data, self.spec_data).squeeze():0.2f}")
820class TransformationMatrixWindow(QtWidgets.QDialog):
821 """Dialog box for specifying transformation matrices"""
823 def __init__(
824 self,
825 parent,
826 current_response_transformation_matrix,
827 num_responses,
828 current_output_transformation_matrix,
829 num_outputs,
830 ):
831 """
832 Creates a dialog box for specifying response and output transformations
834 Parameters
835 ----------
836 parent : QWidget
837 Parent to the dialog box.
838 current_response_transformation_matrix : np.ndarray
839 The current value of the transformation matrix that will be used to
840 populate the entries in the table.
841 num_responses : int
842 Number of physical responses in the transformation.
843 current_output_transformation_matrix : np.ndarray
844 The current value of the transformation matrix that will be used to
845 populate the entries in the table.
846 num_outputs : int
847 Number of physical outputs in the transformation.
849 """
850 super().__init__(parent)
851 uic.loadUi(transformation_matrices_ui_path, self)
852 self.setWindowTitle("Transformation Matrix Definition")
854 self.response_transformation_matrix.setColumnCount(num_responses)
855 self.output_transformation_matrix.setColumnCount(num_outputs)
857 if current_response_transformation_matrix is None:
858 self.set_response_transformation_identity()
859 else:
860 self.response_transformation_matrix.setRowCount(
861 current_response_transformation_matrix.shape[0]
862 )
863 for row_idx, row in enumerate(current_response_transformation_matrix):
864 for col_idx, col in enumerate(row):
865 try:
866 self.response_transformation_matrix.item(row_idx, col_idx).setText(str(col))
867 except AttributeError:
868 item = QtWidgets.QTableWidgetItem(str(col))
869 self.response_transformation_matrix.setItem(row_idx, col_idx, item)
870 if current_output_transformation_matrix is None:
871 self.set_output_transformation_identity()
872 else:
873 self.output_transformation_matrix.setRowCount(
874 current_output_transformation_matrix.shape[0]
875 )
876 for row_idx, row in enumerate(current_output_transformation_matrix):
877 for col_idx, col in enumerate(row):
878 try:
879 self.output_transformation_matrix.item(row_idx, col_idx).setText(str(col))
880 except AttributeError:
881 item = QtWidgets.QTableWidgetItem(str(col))
882 self.output_transformation_matrix.setItem(row_idx, col_idx, item)
884 # Callbacks
885 self.ok_button.clicked.connect(self.accept)
886 self.cancel_button.clicked.connect(self.reject)
888 self.response_transformation_add_row_button.clicked.connect(
889 self.response_transformation_add_row
890 )
891 self.response_transformation_remove_row_button.clicked.connect(
892 self.response_transformation_remove_row
893 )
894 self.response_transformation_save_matrix_button.clicked.connect(
895 self.save_response_transformation_matrix
896 )
897 self.response_transformation_load_matrix_button.clicked.connect(
898 self.load_response_transformation_matrix
899 )
900 self.response_transformation_identity_button.clicked.connect(
901 self.set_response_transformation_identity
902 )
903 self.response_transformation_6dof_kinematic_button.clicked.connect(
904 self.set_response_transformation_6dof
905 )
906 self.response_transformation_reversed_6dof_kinematic_button.clicked.connect(
907 self.set_response_transformation_6dof_reversed
908 )
910 self.output_transformation_add_row_button.clicked.connect(
911 self.output_transformation_add_row
912 )
913 self.output_transformation_remove_row_button.clicked.connect(
914 self.output_transformation_remove_row
915 )
916 self.output_transformation_save_matrix_button.clicked.connect(
917 self.save_output_transformation_matrix
918 )
919 self.output_transformation_load_matrix_button.clicked.connect(
920 self.load_output_transformation_matrix
921 )
922 self.output_transformation_identity_button.clicked.connect(
923 self.set_output_transformation_identity
924 )
925 self.output_transformation_6dof_kinematic_button.clicked.connect(
926 self.set_output_transformation_6dof
927 )
928 self.output_transformation_reversed_6dof_kinematic_button.clicked.connect(
929 self.set_output_transformation_6dof_reversed
930 )
932 @staticmethod
933 def define_transformation_matrices(
934 current_response_transformation_matrix,
935 num_responses,
936 current_output_transformation_matrix,
937 num_outputs,
938 parent=None,
939 ):
940 """
941 Shows the dialog and returns the transformation matrices
943 Parameters
944 ----------
945 current_response_transformation_matrix : np.ndarray
946 The current value of the transformation matrix that will be used to
947 populate the entries in the table.
948 num_responses : int
949 Number of physical responses in the transformation.
950 current_output_transformation_matrix : np.ndarray
951 The current value of the transformation matrix that will be used to
952 populate the entries in the table.
953 num_outputs : int
954 Number of physical outputs in the transformation.
955 parent : QWidget
956 Parent to the dialog box. (Default value = None)
958 Returns
959 -------
960 response_transformation : np.ndarray
961 Response transformation (or None if Identity)
962 output_transformation : np.ndarray
963 Output transformation (or None if Identity)
964 result : bool
965 True if dialog was accepted, false if cancelled.
966 """
967 dialog = TransformationMatrixWindow(
968 parent,
969 current_response_transformation_matrix,
970 num_responses,
971 current_output_transformation_matrix,
972 num_outputs,
973 )
974 result = dialog.exec_() == QtWidgets.QDialog.Accepted
975 response_transformation = np.array(
976 [
977 [float(val) for val in row]
978 for row in get_table_strings(dialog.response_transformation_matrix)
979 ]
980 )
981 if all(
982 val == response_transformation.shape[0] for val in response_transformation.shape
983 ) and np.allclose(response_transformation, np.eye(response_transformation.shape[0])):
984 response_transformation = None
985 output_transformation = np.array(
986 [
987 [float(val) for val in row]
988 for row in get_table_strings(dialog.output_transformation_matrix)
989 ]
990 )
991 if all(
992 val == output_transformation.shape[0] for val in output_transformation.shape
993 ) and np.allclose(output_transformation, np.eye(output_transformation.shape[0])):
994 output_transformation = None
995 return (response_transformation, output_transformation, result)
997 def response_transformation_add_row(self):
998 """Adds a row to the response transformation"""
999 num_rows = self.response_transformation_matrix.rowCount()
1000 self.response_transformation_matrix.insertRow(num_rows)
1001 for col_idx in range(self.response_transformation_matrix.columnCount()):
1002 item = QtWidgets.QTableWidgetItem("0.0")
1003 self.response_transformation_matrix.setItem(num_rows, col_idx, item)
1005 def response_transformation_remove_row(self):
1006 """Removes a row from the response transformation"""
1007 num_rows = self.response_transformation_matrix.rowCount()
1008 self.response_transformation_matrix.removeRow(num_rows - 1)
1010 def save_response_transformation_matrix(self):
1011 """Saves the response transformation matrix to a csv file"""
1012 string_array = self.get_table_strings(self.response_transformation_matrix)
1013 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
1014 self,
1015 "Save Response Transformation",
1016 filter="Comma-separated Values (*.csv)",
1017 )
1018 if filename == "":
1019 return
1020 save_csv_matrix(string_array, filename)
1022 def load_response_transformation_matrix(self):
1023 """Loads the response transformation from a csv file"""
1024 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
1025 self,
1026 "Load Response Transformation",
1027 filter="Comma-separated values (*.csv *.txt);;"
1028 "Numpy Files (*.npy *.npz);;Matlab Files (*.mat)",
1029 )
1030 if filename == "":
1031 return
1032 _, extension = os.path.splitext(filename)
1033 string_array = None
1034 if extension.lower() == ".npy":
1035 string_array = np.load(filename).astype("U")
1036 elif extension.lower() == ".npz":
1037 data = np.load(filename)
1038 for key, array in data.items():
1039 string_array = array.astype("U")
1040 break
1041 elif extension.lower() == ".mat":
1042 data = loadmat(filename)
1043 for key, array in data.items():
1044 if "__" in key:
1045 continue
1046 string_array = array.astype("U")
1047 break
1048 else:
1049 string_array = load_csv_matrix(filename)
1050 if string_array is None:
1051 return
1052 # Set the number of rows
1053 self.response_transformation_matrix.setRowCount(len(string_array))
1054 num_rows = self.response_transformation_matrix.rowCount()
1055 num_cols = self.response_transformation_matrix.columnCount()
1056 for row_idx, row in enumerate(string_array):
1057 if row_idx == num_rows:
1058 break
1059 for col_idx, value in enumerate(row):
1060 if col_idx == num_cols:
1061 break
1062 try:
1063 self.response_transformation_matrix.item(row_idx, col_idx).setText(value)
1064 except AttributeError:
1065 item = QtWidgets.QTableWidgetItem(value)
1066 self.response_transformation_matrix.setItem(row_idx, col_idx, item)
1068 def set_response_transformation_identity(self):
1069 """Sets the response transformation to identity matrix (no transform)"""
1070 num_columns = self.response_transformation_matrix.columnCount()
1071 self.response_transformation_matrix.setRowCount(num_columns)
1072 for row_idx in range(num_columns):
1073 for col_idx in range(num_columns):
1074 if row_idx == col_idx:
1075 value = 1.0
1076 else:
1077 value = 0.0
1078 try:
1079 self.response_transformation_matrix.item(row_idx, col_idx).setText(str(value))
1080 except AttributeError:
1081 item = QtWidgets.QTableWidgetItem(str(value))
1082 self.response_transformation_matrix.setItem(row_idx, col_idx, item)
1084 def set_response_transformation_6dof(self):
1085 """Sets the response transformation matrix to the 6DoF table"""
1086 num_columns = self.response_transformation_matrix.columnCount()
1087 if num_columns != 12:
1088 error_message_qt(
1089 "Invalid Number of Control Channels.",
1090 "Invalid Number of Control Channels. "
1091 "6DoF Transform assumes 12 control accelerometer channels.",
1092 )
1093 return
1094 self.response_transformation_matrix.setRowCount(6)
1095 matrix = [
1096 [0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0],
1097 [0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0],
1098 [0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25],
1099 [0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, -0.25, 0.0, 0.0, -0.25],
1100 [0.0, 0.0, -0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, -0.25],
1101 [
1102 -0.125,
1103 0.125,
1104 0.0,
1105 -0.125,
1106 -0.125,
1107 0.0,
1108 0.125,
1109 -0.125,
1110 0.0,
1111 0.125,
1112 0.125,
1113 0.0,
1114 ],
1115 ]
1116 for row_idx, row in enumerate(matrix):
1117 for col_idx, value in enumerate(row):
1118 try:
1119 self.response_transformation_matrix.item(row_idx, col_idx).setText(str(value))
1120 except AttributeError:
1121 item = QtWidgets.QTableWidgetItem(str(value))
1122 self.response_transformation_matrix.setItem(row_idx, col_idx, item)
1124 def set_response_transformation_6dof_reversed(self):
1125 """Sets the response transformation matrix to the 6DoF table"""
1126 num_columns = self.response_transformation_matrix.columnCount()
1127 if num_columns != 12:
1128 error_message_qt(
1129 "Invalid Number of Control Channels.",
1130 "Invalid Number of Control Channels. "
1131 "6DoF Transform assumes 12 control accelerometer channels.",
1132 )
1133 return
1134 self.response_transformation_matrix.setRowCount(6)
1135 matrix = [
1136 [0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0],
1137 [0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0],
1138 [0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25],
1139 [0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, -0.25, 0.0, 0.0, -0.25],
1140 [0.0, 0.0, -0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, -0.25],
1141 [
1142 -0.125,
1143 0.125,
1144 0.0,
1145 -0.125,
1146 -0.125,
1147 0.0,
1148 0.125,
1149 -0.125,
1150 0.0,
1151 0.125,
1152 0.125,
1153 0.0,
1154 ],
1155 ]
1156 for row_idx, row in enumerate(matrix):
1157 for col_idx, value in enumerate(row):
1158 try:
1159 self.response_transformation_matrix.item(row_idx, col_idx).setText(str(value))
1160 except AttributeError:
1161 item = QtWidgets.QTableWidgetItem(str(value))
1162 self.response_transformation_matrix.setItem(row_idx, col_idx, item)
1164 def output_transformation_add_row(self):
1165 """Adds a row to the output transformation"""
1166 num_rows = self.output_transformation_matrix.rowCount()
1167 self.output_transformation_matrix.insertRow(num_rows)
1168 for col_idx in range(self.output_transformation_matrix.columnCount()):
1169 item = QtWidgets.QTableWidgetItem("0.0")
1170 self.output_transformation_matrix.setItem(num_rows, col_idx, item)
1172 def output_transformation_remove_row(self):
1173 """Removes a row from the output tranformation"""
1174 num_rows = self.output_transformation_matrix.rowCount()
1175 self.output_transformation_matrix.removeRow(num_rows - 1)
1177 def save_output_transformation_matrix(self):
1178 """Saves output transformation matrix to a CSV file"""
1179 string_array = self.get_table_strings(self.output_transformation_matrix)
1180 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
1181 self, "Save Output Transformation", filter="Comma-separated Values (*.csv)"
1182 )
1183 if filename == "":
1184 return
1185 save_csv_matrix(string_array, filename)
1187 def load_output_transformation_matrix(self):
1188 """Loads the output transformation from a CSV file"""
1189 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
1190 self,
1191 "Load Output Transformation",
1192 filter="Comma-separated values (*.csv *.txt);;"
1193 "Numpy Files (*.npy *.npz);;Matlab Files (*.mat)",
1194 )
1195 if filename == "":
1196 return
1197 _, extension = os.path.splitext(filename)
1198 string_array = None
1199 if extension.lower() == ".npy":
1200 string_array = np.load(filename).astype("U")
1201 elif extension.lower() == ".npz":
1202 data = np.load(filename)
1203 for key, array in data.items():
1204 string_array = array.astype("U")
1205 break
1206 elif extension.lower() == ".mat":
1207 data = loadmat(filename)
1208 for key, array in data.items():
1209 if "__" in key:
1210 continue
1211 string_array = array.astype("U")
1212 break
1213 else:
1214 string_array = load_csv_matrix(filename)
1215 if string_array is None:
1216 return
1217 # Set the number of rows
1218 self.output_transformation_matrix.setRowCount(len(string_array))
1219 num_rows = self.output_transformation_matrix.rowCount()
1220 num_cols = self.output_transformation_matrix.columnCount()
1221 for row_idx, row in enumerate(string_array):
1222 if row_idx == num_rows:
1223 break
1224 for col_idx, value in enumerate(row):
1225 if col_idx == num_cols:
1226 break
1227 try:
1228 self.output_transformation_matrix.item(row_idx, col_idx).setText(value)
1229 except AttributeError:
1230 item = QtWidgets.QTableWidgetItem(value)
1231 self.output_transformation_matrix.setItem(row_idx, col_idx, item)
1233 def set_output_transformation_identity(self):
1234 """Sets the output transformation to identity (no transform)"""
1235 num_columns = self.output_transformation_matrix.columnCount()
1236 self.output_transformation_matrix.setRowCount(num_columns)
1237 for row_idx in range(num_columns):
1238 for col_idx in range(num_columns):
1239 if row_idx == col_idx:
1240 value = 1.0
1241 else:
1242 value = 0.0
1243 try:
1244 self.output_transformation_matrix.item(row_idx, col_idx).setText(str(value))
1245 except AttributeError:
1246 item = QtWidgets.QTableWidgetItem(str(value))
1247 self.output_transformation_matrix.setItem(row_idx, col_idx, item)
1249 def set_output_transformation_6dof(self):
1250 """Sets the output transformation matrix to the 6DoF table"""
1251 num_columns = self.output_transformation_matrix.columnCount()
1252 if num_columns != 12:
1253 error_message_qt(
1254 "Invalid Number of Output Signals.",
1255 "Invalid Number of Output Signals. 6DoF Transform assumes 12 drive channels.",
1256 )
1257 return
1258 self.output_transformation_matrix.setRowCount(6)
1259 matrix = [
1260 [0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0],
1261 [0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0],
1262 [0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25],
1263 [0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, -0.25, 0.0, 0.0, -0.25],
1264 [0.0, 0.0, -0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, -0.25],
1265 [
1266 -0.125,
1267 0.125,
1268 0.0,
1269 -0.125,
1270 -0.125,
1271 0.0,
1272 0.125,
1273 -0.125,
1274 0.0,
1275 0.125,
1276 0.125,
1277 0.0,
1278 ],
1279 ]
1280 for row_idx, row in enumerate(matrix):
1281 for col_idx, value in enumerate(row):
1282 try:
1283 self.output_transformation_matrix.item(row_idx, col_idx).setText(str(value))
1284 except AttributeError:
1285 item = QtWidgets.QTableWidgetItem(str(value))
1286 self.output_transformation_matrix.setItem(row_idx, col_idx, item)
1288 def set_output_transformation_6dof_reversed(self):
1289 """Sets the output transformation matrix to the 6DoF table"""
1290 num_columns = self.output_transformation_matrix.columnCount()
1291 if num_columns != 12:
1292 error_message_qt(
1293 "Invalid Number of Output Signals.",
1294 "Invalid Number of Output Signals. 6DoF Transform assumes 12 drive channels.",
1295 )
1296 return
1297 self.output_transformation_matrix.setRowCount(6)
1298 matrix = [
1299 [0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0],
1300 [0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0],
1301 [0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25],
1302 [0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, -0.25, 0.0, 0.0, -0.25],
1303 [0.0, 0.0, -0.25, 0.0, 0.0, 0.25, 0.0, 0.0, 0.25, 0.0, 0.0, -0.25],
1304 [
1305 -0.125,
1306 0.125,
1307 0.0,
1308 -0.125,
1309 -0.125,
1310 0.0,
1311 0.125,
1312 -0.125,
1313 0.0,
1314 0.125,
1315 0.125,
1316 0.0,
1317 ],
1318 ]
1319 for row_idx, row in enumerate(matrix):
1320 for col_idx, value in enumerate(row):
1321 try:
1322 self.output_transformation_matrix.item(row_idx, col_idx).setText(str(value))
1323 except AttributeError:
1324 item = QtWidgets.QTableWidgetItem(str(value))
1325 self.output_transformation_matrix.setItem(row_idx, col_idx, item)
1328class ModalMDISubWindow(QtWidgets.QWidget):
1329 """A window that shows modal data"""
1331 def __init__(self, parent):
1332 super().__init__(parent)
1333 uic.loadUi(modal_mdi_ui_path, self)
1335 self.parent = parent
1336 self.channel_names = self.parent.channel_names
1337 self.reference_names = np.array(
1338 [self.parent.channel_names[i] for i in self.parent.reference_channel_indices]
1339 )
1340 self.response_names = np.array(
1341 [self.parent.channel_names[i] for i in self.parent.response_channel_indices]
1342 )
1343 self.reciprocal_responses = self.parent.reciprocal_responses
1345 self.signal_selector.currentIndexChanged.connect(self.update_ui)
1346 self.data_type_selector.currentIndexChanged.connect(self.update_ui_no_clear)
1347 self.response_coordinate_selector.currentIndexChanged.connect(self.update_data)
1348 self.reference_coordinate_selector.currentIndexChanged.connect(self.update_data)
1350 self.primary_plotitem = self.primary_plot.getPlotItem()
1351 self.secondary_plotitem = self.secondary_plot.getPlotItem()
1352 self.primary_viewbox = self.primary_plotitem.getViewBox()
1353 self.secondary_viewbox = self.secondary_plotitem.getViewBox()
1354 self.primary_axis = self.primary_plotitem.getAxis("left")
1355 self.secondary_axis = self.secondary_plotitem.getAxis("left")
1357 self.secondary_plotitem.setXLink(self.primary_plotitem)
1359 self.primary_plotdataitem = pyqtgraph.PlotDataItem(
1360 np.arange(2), np.zeros(2), pen={"color": "r", "width": 1}
1361 )
1362 self.secondary_plotdataitem = pyqtgraph.PlotDataItem(
1363 np.arange(2), np.zeros(2), pen={"color": "r", "width": 1}
1364 )
1366 self.primary_viewbox.addItem(self.primary_plotdataitem)
1367 self.secondary_viewbox.addItem(self.secondary_plotdataitem)
1369 self.twinx_viewbox = None
1370 self.twinx_axis = None
1371 self.twinx_original_plotitem = None
1372 self.twinx_plotdataitem = None
1374 self.is_comparing = False
1375 self.primary_plotdataitem_compare = pyqtgraph.PlotDataItem(
1376 np.arange(2), np.zeros(2), pen={"color": "b", "width": 1}
1377 )
1378 self.secondary_plotdataitem_compare = pyqtgraph.PlotDataItem(
1379 np.arange(2), np.zeros(2), pen={"color": "b", "width": 1}
1380 )
1382 self.update_ui()
1384 def remove_twinx(self):
1385 """Removes the overlaid plot"""
1386 if self.twinx_viewbox is None:
1387 return
1388 self.twinx_original_plotitem.layout.removeItem(self.twinx_axis)
1389 self.twinx_original_plotitem.scene().removeItem(self.twinx_viewbox)
1390 self.twinx_original_plotitem.scene().removeItem(self.twinx_axis)
1391 self.twinx_viewbox = None
1392 self.twinx_axis = None
1393 self.twinx_original_plotitem = None
1395 def add_twinx(self, existing_plot_item: pyqtgraph.PlotItem):
1396 """Adds an overlaid plot"""
1397 # Create a viewbox
1398 self.twinx_original_plotitem = existing_plot_item
1399 self.twinx_viewbox = pyqtgraph.ViewBox()
1400 self.twinx_original_plotitem.scene().addItem(self.twinx_viewbox)
1401 self.twinx_axis = pyqtgraph.AxisItem("right")
1402 self.twinx_axis.setLogMode(False)
1403 self.twinx_axis.linkToView(self.twinx_viewbox)
1404 self.twinx_original_plotitem.layout.addItem(self.twinx_axis, 2, 3)
1405 self.updateTwinXViews()
1406 self.twinx_viewbox.setXLink(self.twinx_original_plotitem)
1407 self.twinx_original_plotitem.vb.sigResized.connect(self.updateTwinXViews)
1408 self.twinx_plotdataitem = pyqtgraph.PlotDataItem(
1409 np.arange(2), np.zeros(2), pen={"color": "b", "width": 1}
1410 )
1411 self.twinx_viewbox.addItem(self.twinx_plotdataitem)
1413 def add_compare(self):
1414 """Adds a second function for comparison for reciprocal plots"""
1415 self.is_comparing = True
1416 self.primary_viewbox.addItem(self.primary_plotdataitem_compare)
1417 self.secondary_viewbox.addItem(self.secondary_plotdataitem_compare)
1419 def remove_compare(self):
1420 """Removes the second function that was used for comparison"""
1421 if self.is_comparing:
1422 self.primary_viewbox.removeItem(self.primary_plotdataitem_compare)
1423 self.secondary_viewbox.removeItem(self.secondary_plotdataitem_compare)
1424 self.is_comparing = False
1426 def updateTwinXViews(self): # pylint: disable=invalid-name
1427 """Updates the second view box based on the view from the first box"""
1428 if self.twinx_viewbox is None:
1429 return
1430 self.twinx_viewbox.setGeometry(self.twinx_original_plotitem.vb.sceneBoundingRect())
1431 # self.twinx_viewbox.linkedViewChanged(
1432 # self.twinx_original_plotitem.vb, self.twinx_viewbox.XAxis)
1434 def update_ui_no_clear(self):
1435 """Updates the UI without clearing the data"""
1436 self.update_ui(False)
1438 def update_ui(self, clear_channels=True):
1439 """Updates the UI based on which function type is selected"""
1440 self.response_coordinate_selector.blockSignals(True)
1441 self.reference_coordinate_selector.blockSignals(True)
1442 self.remove_twinx()
1443 self.remove_compare()
1444 if self.signal_selector.currentIndex() in [
1445 0,
1446 1,
1447 2,
1448 3,
1449 ]: # Time or Windowed Time or Spectrum or Autospectrum
1450 self.reference_coordinate_selector.hide()
1451 self.data_type_selector.hide()
1452 self.secondary_plot.hide()
1453 if clear_channels:
1454 self.response_coordinate_selector.clear()
1455 self.reference_coordinate_selector.clear()
1456 for channel_name in self.channel_names:
1457 self.response_coordinate_selector.addItem(channel_name)
1458 if self.signal_selector.currentIndex() in [0, 1]:
1459 self.primary_axis.setLogMode(False)
1460 self.primary_plotdataitem.setLogMode(False, False)
1461 else:
1462 self.primary_axis.setLogMode(True)
1463 self.primary_plotdataitem.setLogMode(False, True)
1464 elif self.signal_selector.currentIndex() in [
1465 4,
1466 6,
1467 7,
1468 ]: # FRF or FRF Coherence or Reciprocity
1469 self.reference_coordinate_selector.show()
1470 self.data_type_selector.show()
1471 if self.data_type_selector.currentIndex() in [1, 4]:
1472 self.secondary_plot.show()
1473 if self.signal_selector.currentIndex() == 6:
1474 self.add_twinx(self.secondary_plotitem)
1475 else:
1476 self.secondary_plot.hide()
1477 if self.signal_selector.currentIndex() == 6:
1478 self.add_twinx(self.primary_plotitem)
1479 if self.signal_selector.currentIndex() == 7:
1480 if any([val is None for val in self.reciprocal_responses]):
1481 error_message_qt(
1482 "Invalid Reciprocal Channels",
1483 "Could not deterimine reciprocal channels for this test",
1484 )
1485 self.signal_selector.setCurrentIndex(4)
1486 return
1487 self.add_compare()
1488 if clear_channels:
1489 self.response_coordinate_selector.clear()
1490 self.reference_coordinate_selector.clear()
1491 if self.signal_selector.currentIndex() == 7:
1492 for channel_name in self.response_names[self.reciprocal_responses]:
1493 self.response_coordinate_selector.addItem(channel_name)
1494 else:
1495 for channel_name in self.response_names:
1496 self.response_coordinate_selector.addItem(channel_name)
1497 for channel_name in self.reference_names:
1498 self.reference_coordinate_selector.addItem(channel_name)
1499 if self.data_type_selector.currentIndex() == 0:
1500 self.primary_axis.setLogMode(True)
1501 self.primary_plotdataitem.setLogMode(False, True)
1502 self.primary_plotdataitem_compare.setLogMode(False, True)
1503 elif self.data_type_selector.currentIndex() == 1:
1504 self.primary_axis.setLogMode(False)
1505 self.primary_plotdataitem.setLogMode(False, False)
1506 self.primary_plotdataitem_compare.setLogMode(False, False)
1507 self.secondary_axis.setLogMode(True)
1508 self.secondary_plotdataitem.setLogMode(False, True)
1509 self.secondary_plotdataitem_compare.setLogMode(False, True)
1510 elif self.data_type_selector.currentIndex() in [2, 3]:
1511 self.primary_axis.setLogMode(False)
1512 self.primary_plotdataitem.setLogMode(False, False)
1513 self.primary_plotdataitem_compare.setLogMode(False, False)
1514 elif self.data_type_selector.currentIndex() == 4:
1515 self.primary_axis.setLogMode(False)
1516 self.primary_plotdataitem.setLogMode(False, False)
1517 self.primary_plotdataitem_compare.setLogMode(False, False)
1518 self.secondary_axis.setLogMode(False)
1519 self.secondary_plotdataitem.setLogMode(False, False)
1520 self.secondary_plotdataitem_compare.setLogMode(False, False)
1521 if self.signal_selector.currentIndex() == 6:
1522 self.twinx_axis.setLogMode(False)
1523 self.twinx_plotdataitem.setLogMode(False, False)
1524 elif self.signal_selector.currentIndex() in [5]: # Coherence
1525 self.reference_coordinate_selector.hide()
1526 self.data_type_selector.hide()
1527 self.secondary_plot.hide()
1528 if clear_channels:
1529 self.response_coordinate_selector.clear()
1530 self.reference_coordinate_selector.clear()
1531 for channel_name in self.response_names:
1532 self.response_coordinate_selector.addItem(channel_name)
1533 self.primary_axis.setLogMode(False)
1534 self.primary_plotdataitem.setLogMode(False, False)
1535 self.update_data()
1536 self.response_coordinate_selector.blockSignals(False)
1537 self.reference_coordinate_selector.blockSignals(False)
1539 def set_window_title(self):
1540 """Sets the window title"""
1541 signal_name = self.signal_selector.itemText(self.signal_selector.currentIndex())
1542 response_name = self.response_coordinate_selector.itemText(
1543 self.response_coordinate_selector.currentIndex()
1544 )
1545 reference_name = (
1546 self.reference_coordinate_selector.itemText(
1547 self.reference_coordinate_selector.currentIndex()
1548 )
1549 if self.signal_selector.currentIndex() == 4
1550 else ""
1551 )
1552 self.setWindowTitle(f"{signal_name} {response_name} {reference_name}")
1554 def update_data(self):
1555 """Updates the data in the plot"""
1556 self.set_window_title()
1557 current_index = self.signal_selector.currentIndex()
1558 if current_index in [0, 1]: # Time history
1559 if self.parent.last_frame is None:
1560 return
1561 data = self.parent.last_frame[self.response_coordinate_selector.currentIndex()]
1562 if current_index == 1:
1563 data = data * self.parent.window_function
1564 self.primary_plotdataitem.setData(self.parent.time_abscissa, data)
1565 elif current_index == 2: # Spectrum
1566 if self.parent.last_spectrum is None:
1567 return
1568 data = self.parent.last_spectrum[self.response_coordinate_selector.currentIndex()]
1569 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, data)
1570 elif current_index == 3: # Autospectrum
1571 if self.parent.last_autospectrum is None:
1572 return
1573 data = self.parent.last_autospectrum[self.response_coordinate_selector.currentIndex()]
1574 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, data)
1575 elif current_index == 4 or current_index == 6: # FRF or FRF Coherence
1576 if self.parent.last_frf is None:
1577 return
1578 data = self.parent.last_frf[
1579 :,
1580 self.response_coordinate_selector.currentIndex(),
1581 self.reference_coordinate_selector.currentIndex(),
1582 ]
1583 if self.data_type_selector.currentIndex() == 0: # Magnitude
1584 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, np.abs(data))
1585 elif self.data_type_selector.currentIndex() == 1: # Magnitude/Phase
1586 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, np.angle(data))
1587 self.secondary_plotdataitem.setData(self.parent.frequency_abscissa, np.abs(data))
1588 elif self.data_type_selector.currentIndex() == 2: # Real
1589 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, np.real(data))
1590 elif self.data_type_selector.currentIndex() == 3: # Imag
1591 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, np.imag(data))
1592 elif self.data_type_selector.currentIndex() == 4: # Real/Imag
1593 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, np.real(data))
1594 self.secondary_plotdataitem.setData(self.parent.frequency_abscissa, np.imag(data))
1595 if current_index == 6:
1596 data = self.parent.last_coh[self.response_coordinate_selector.currentIndex()]
1597 self.twinx_plotdataitem.setData(self.parent.frequency_abscissa, data)
1598 elif current_index == 5: # Coherence
1599 if self.parent.last_coh is None:
1600 return
1601 data = self.parent.last_coh[self.response_coordinate_selector.currentIndex()]
1602 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, data)
1603 elif current_index == 7: # FRF or FRF Coherence
1604 if self.parent.last_frf is None:
1605 return
1606 resp_ind = self.response_coordinate_selector.currentIndex()
1607 ref_ind = self.reference_coordinate_selector.currentIndex()
1608 data = self.parent.last_frf[:, self.reciprocal_responses[resp_ind], ref_ind]
1609 compare_data = self.parent.last_frf[:, self.reciprocal_responses[ref_ind], resp_ind]
1610 if self.data_type_selector.currentIndex() == 0: # Magnitude
1611 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, np.abs(data))
1612 self.primary_plotdataitem_compare.setData(
1613 self.parent.frequency_abscissa, np.abs(compare_data)
1614 )
1615 elif self.data_type_selector.currentIndex() == 1: # Magnitude/Phase
1616 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, np.angle(data))
1617 self.secondary_plotdataitem.setData(self.parent.frequency_abscissa, np.abs(data))
1618 self.primary_plotdataitem_compare.setData(
1619 self.parent.frequency_abscissa, np.angle(compare_data)
1620 )
1621 self.secondary_plotdataitem_compare.setData(
1622 self.parent.frequency_abscissa, np.abs(compare_data)
1623 )
1624 elif self.data_type_selector.currentIndex() == 2: # Real
1625 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, np.real(data))
1626 self.primary_plotdataitem_compare.setData(
1627 self.parent.frequency_abscissa, np.real(compare_data)
1628 )
1629 elif self.data_type_selector.currentIndex() == 3: # Imag
1630 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, np.imag(data))
1631 self.primary_plotdataitem_compare.setData(
1632 self.parent.frequency_abscissa, np.imag(compare_data)
1633 )
1634 elif self.data_type_selector.currentIndex() == 4: # Real/Imag
1635 self.primary_plotdataitem.setData(self.parent.frequency_abscissa, np.real(data))
1636 self.secondary_plotdataitem.setData(self.parent.frequency_abscissa, np.imag(data))
1637 self.primary_plotdataitem_compare.setData(
1638 self.parent.frequency_abscissa, np.real(compare_data)
1639 )
1640 self.secondary_plotdataitem_compare.setData(
1641 self.parent.frequency_abscissa, np.imag(compare_data)
1642 )
1644 def increment_channel(self, increment=1):
1645 """Increments the channel number by the specified amount"""
1646 if not self.lock_response_checkbox.isChecked():
1647 num_channels = self.response_coordinate_selector.count()
1648 current_index = self.response_coordinate_selector.currentIndex()
1649 new_index = (current_index + increment) % num_channels
1650 self.response_coordinate_selector.setCurrentIndex(new_index)
1653class ChannelMonitor(QtWidgets.QDialog):
1654 """Class defining a subwindow that displays specific channel information"""
1656 def __init__(self, parent, daq_settings: DataAcquisitionParameters):
1657 """
1658 Creates a window showing CPSD matrix information for a single channel.
1660 Parameters
1661 ----------
1662 parent : QWidget
1663 Parent of the window.
1664 """
1665 super(QtWidgets.QDialog, self).__init__(parent)
1666 self.setWindowFlags(self.windowFlags() & Qt.Tool)
1667 self.channels = daq_settings.channel_list
1668 # Set up the window
1669 self.graphics_layout_widget = pyqtgraph.GraphicsLayoutWidget(self)
1670 self.push_button = QtWidgets.QPushButton("Clear Alerts", self)
1671 self.channels_per_row_label = QtWidgets.QLabel("Channels per Row: ", self)
1672 self.channels_per_row_selector = QtWidgets.QSpinBox(self)
1673 self.channels_per_row_selector.setMinimum(2)
1674 self.channels_per_row_selector.setMaximum(100)
1675 self.channels_per_row_selector.setValue(20)
1676 self.channels_per_row_selector.setKeyboardTracking(False)
1677 layout = QtWidgets.QVBoxLayout()
1678 control_layout = QtWidgets.QHBoxLayout()
1679 layout.addWidget(self.graphics_layout_widget)
1680 control_layout.addWidget(self.channels_per_row_label)
1681 control_layout.addWidget(self.channels_per_row_selector)
1682 control_layout.addStretch()
1683 control_layout.addWidget(self.push_button)
1684 layout.addLayout(control_layout)
1685 self.setLayout(layout)
1686 # Set up defaults for the channel ranges
1687 self.channel_ranges = None
1688 self.channel_warning_limits = None
1689 self.channel_abort_limits = None
1690 self.background_bars = None
1691 self.history_bars = None
1692 self.level_bars = None
1693 self.history_last_update = None
1694 self.history_hold_frames = int(
1695 np.ceil(10 * daq_settings.sample_rate / daq_settings.samples_per_read)
1696 )
1697 self.aborted_channels = None
1698 # Set up defaults for the plot
1699 self.plots = None
1700 self.bar_channel_indices = None
1701 self.pen = pyqtgraph.mkPen(color=(0, 0, 0, 255), width=1)
1702 self.background_brush = pyqtgraph.mkBrush((255, 255, 255))
1703 self.history_brush = pyqtgraph.mkBrush((124, 124, 255))
1704 self.current_brush = pyqtgraph.mkBrush((34, 139, 34))
1705 self.limit_brush = pyqtgraph.mkBrush((145, 197, 17))
1706 self.abort_brush = pyqtgraph.mkBrush((145, 70, 17))
1707 self.limit_background_brush = pyqtgraph.mkBrush(
1708 (
1709 255,
1710 255,
1711 0,
1712 )
1713 )
1714 self.abort_background_brush = pyqtgraph.mkBrush((255, 0, 0))
1715 self.limit_history_brush = pyqtgraph.mkBrush((190, 190, 128))
1716 self.abort_history_brush = pyqtgraph.mkBrush((190, 62, 128))
1717 # Connect everything and do final builds
1718 self.connect_callbacks()
1719 self.build_plot()
1720 self.setWindowTitle("Channel Monitor")
1721 self.resize(400, 300)
1722 self.show()
1724 def connect_callbacks(self):
1725 """Connects callback functions to the respective widgets"""
1726 self.channels_per_row_selector.valueChanged.connect(self.build_plot)
1727 self.push_button.clicked.connect(self.clear_alerts)
1729 def update_channel_list(self, daq_settings):
1730 """Updates the channel list in the test"""
1731 self.channels = daq_settings.channel_list
1732 self.history_hold_frames = int(
1733 np.ceil(10 * daq_settings.sample_rate / daq_settings.samples_per_read)
1734 )
1735 self.build_plot()
1737 def clear_alerts(self):
1738 """Clears any alerts that have been triggered by high values"""
1739 self.aborted_channels = [False for val in self.aborted_channels]
1740 for current_bar in self.level_bars:
1741 current_bar.setOpts(brushes=[self.current_brush])
1742 for history_bar in self.history_bars:
1743 history_bar.setOpts(brushes=[self.history_brush])
1744 for background_bar in self.background_bars:
1745 background_bar.setOpts(brushes=[self.background_brush])
1747 def build_plot(self):
1748 """Builds the channel monitor window and plots"""
1749 # TODO Need to get the values from the bars before deleting them so we
1750 # can maintain the levels from before the value was changed
1751 self.graphics_layout_widget.clear()
1752 num_channels = len(self.channels)
1753 num_bars = int(np.ceil(num_channels / self.channels_per_row_selector.value()))
1754 # Compute number of channels per bar
1755 channels_per_bar = [0 for i in range(num_bars)]
1756 for i in range(num_channels):
1757 channels_per_bar[i % num_bars] += 1
1759 # print('Channels per Bar {:}'.format(channels_per_bar))
1760 # Now let's actually make the plots
1761 self.plots = [self.graphics_layout_widget.addPlot(i, 0) for i in range(num_bars)]
1763 # Now parse the channel ranges
1764 self.channel_ranges = []
1765 self.channel_warning_limits = []
1766 self.channel_abort_limits = []
1767 for channel in self.channels:
1768 try:
1769 max_abs_volt = np.min(
1770 np.abs([float(channel.maximum_value), float(channel.minimum_value)])
1771 )
1772 except (ValueError, TypeError):
1773 max_abs_volt = 10 # Assume 10 V range on DAQ
1774 try:
1775 sensitivity = float(channel.sensitivity) / 1000 # mV -> V
1776 except (ValueError, TypeError):
1777 sensitivity = 0.01 # Assume 10 mV/EU
1778 max_abs_eu = max_abs_volt / sensitivity
1779 try:
1780 warning_limit = float(channel.warning_level)
1781 except (ValueError, TypeError):
1782 warning_limit = max_abs_eu * 0.9 # Put out warning at 90% the max range
1783 try:
1784 abort_limit = float(channel.abort_level)
1785 except (ValueError, TypeError):
1786 abort_limit = max_abs_eu # Never abort on this channel if not specified
1787 self.channel_ranges.append(max_abs_eu)
1788 self.channel_warning_limits.append(warning_limit)
1789 self.channel_abort_limits.append(abort_limit)
1790 self.channel_ranges = np.array(self.channel_ranges)
1791 self.channel_warning_limits = np.array(self.channel_warning_limits)
1792 self.channel_abort_limits = np.array(self.channel_abort_limits)
1793 # Display abort limit as range rather than channel if it is lower
1794 abort_lower = self.channel_ranges > self.channel_abort_limits
1795 self.channel_ranges[abort_lower] = self.channel_abort_limits[abort_lower]
1797 # Now build the plots
1798 self.bar_channel_indices = []
1799 for i, num_channels in enumerate(channels_per_bar):
1800 try:
1801 next_starting_index = self.bar_channel_indices[-1][-1] + 1
1802 except IndexError:
1803 next_starting_index = 0
1804 self.bar_channel_indices.append(next_starting_index + np.arange(num_channels))
1805 # print(self.bar_channel_indices)
1806 self.background_bars = []
1807 self.history_bars = []
1808 self.level_bars = []
1809 self.history_last_update = []
1810 self.aborted_channels = []
1811 for indices, plot in zip(self.bar_channel_indices, self.plots):
1812 plot.hideAxis("left")
1813 for _, index in enumerate(indices):
1814 background_bar = pyqtgraph.BarGraphItem(
1815 x=[index + 1],
1816 height=1.0,
1817 width=0.9,
1818 pen=self.pen,
1819 brush=self.background_brush,
1820 )
1821 plot.addItem(background_bar)
1822 self.background_bars.append(background_bar)
1823 history_bar = pyqtgraph.BarGraphItem(
1824 x=[index + 1],
1825 height=0,
1826 width=0.9,
1827 pen=self.pen,
1828 brush=self.history_brush,
1829 )
1830 plot.addItem(history_bar)
1831 self.history_bars.append(history_bar)
1832 current_bar = pyqtgraph.BarGraphItem(
1833 x=[index + 1],
1834 height=0,
1835 width=0.9,
1836 pen=self.pen,
1837 brush=self.current_brush,
1838 )
1839 plot.addItem(current_bar)
1840 self.level_bars.append(current_bar)
1841 self.history_last_update.append(0)
1842 self.aborted_channels.append(False)
1844 def update(self, channel_levels):
1845 """Updates the level data in each bar"""
1846 # print('Data {:}'.format(channel_levels.shape))
1847 # print(channel_levels)
1848 for index, (
1849 level,
1850 current_bar,
1851 history_bar,
1852 background_bar,
1853 history_last_update,
1854 warning,
1855 abort,
1856 value_range,
1857 aborted,
1858 ) in enumerate(
1859 zip(
1860 channel_levels,
1861 self.level_bars,
1862 self.history_bars,
1863 self.background_bars,
1864 self.history_last_update,
1865 self.channel_warning_limits,
1866 self.channel_abort_limits,
1867 self.channel_ranges,
1868 self.aborted_channels,
1869 )
1870 ):
1871 # Set the current bar height
1872 current_height = level / value_range
1873 current_bar.setOpts(height=current_height if current_height < 1 else 1)
1874 # Now look at the history bar
1875 last_history_height = history_bar.opts.get("height")
1876 # print(last_history_height)
1877 if history_last_update > self.history_hold_frames:
1878 desired_history_height = last_history_height - 1 / self.history_hold_frames
1879 else:
1880 desired_history_height = last_history_height
1881 if desired_history_height < current_height:
1882 desired_history_height = current_height
1883 self.history_last_update[index] = 0
1884 else:
1885 self.history_last_update[index] += 1
1886 history_bar.setOpts(height=1 if desired_history_height > 1 else desired_history_height)
1887 # Now look at the pen color
1888 if level > abort or aborted:
1889 current_bar.setOpts(brushes=[self.abort_brush])
1890 background_bar.setOpts(brushes=[self.abort_background_brush])
1891 history_bar.setOpts(brushes=[self.abort_history_brush])
1892 self.aborted_channels[index] = True
1893 elif level > warning:
1894 current_bar.setOpts(brushes=[self.limit_brush])
1895 background_bar.setOpts(brushes=[self.limit_background_brush])
1896 history_bar.setOpts(brushes=[self.limit_history_brush])
1899class VaryingNumberOfLinePlot:
1900 """A plot that can have a dynamic number of lines assigned,
1901 adding or removing lines as necessary"""
1903 def __init__(self, plot_item, initial_abscissa=None, initial_ordinate=None):
1904 self.plot_item = plot_item
1905 self.lines = []
1906 if initial_abscissa is not None and initial_ordinate is not None:
1907 self.set_data(initial_abscissa, initial_ordinate)
1909 def set_data(self, abscissa, ordinate):
1910 """Sets the data of the plot
1912 Parameters
1913 ----------
1914 abscissa : np.ndarray
1915 A 2D dataset where each row is a different plot and the columns are the abscissa values
1916 of each curve
1917 ordinate : np.ndarray
1918 A 2D dataset where each row is a different plot and the columns are the ordinate values
1919 of each curve
1920 """
1921 for i, (this_ordinate, this_abscissa) in enumerate(zip(ordinate, abscissa)):
1922 try:
1923 self.lines[i].setData(this_abscissa, this_ordinate)
1924 except IndexError:
1925 pen = {"color": colororder[i % len(colororder)]}
1926 self.lines.append(self.plot_item.plot(this_abscissa, this_ordinate, pen=pen))
1928 # Remove extra lines
1929 extra_lines = len(self.lines) - len(ordinate)
1930 for i in range(extra_lines):
1931 line = self.lines.pop()
1932 self.plot_item.removeItem(line)
1934 def clear(self):
1935 """Clears all data from the plots"""
1936 self.lines = []
1937 self.plot_item.clear()
1940class IPAddress:
1941 """Container for information about IPAddress, mainly used to make
1942 sure each address has a values for relevant information"""
1944 def __init__(self, host_name=None, ipv4_address=None, ipv6_address=None, valid_ip=False):
1945 self.host_name = host_name
1946 self.ipv4_address = ipv4_address
1947 self.ipv6_address = ipv6_address
1948 self.valid_ip = valid_ip
1951this_path = os.path.split(__file__)[0]
1952ip_manager_ui_path = os.path.join(this_path, "ip_manager.ui")
1955class IPAddressManager(QtWidgets.QDialog):
1956 """A class to manage IP addresses"""
1958 def __init__(self, ip_addresses: list[IPAddress] = None, parent=None):
1959 if ip_addresses is None:
1960 ip_addresses = []
1961 super().__init__(parent)
1962 uic.loadUi(ip_manager_ui_path, self)
1964 self.ip_address_table.setColumnWidth(0, 200)
1965 self.ip_address_table.setColumnWidth(1, 200)
1966 self.ip_address_table.setColumnWidth(2, 250)
1968 self.ip_addresses = []
1969 self.unique_indices = []
1970 for ind, address in enumerate(ip_addresses):
1971 self.add_ip_address()
1972 self.ip_addresses[ind] = address
1974 self.validation_timeout = 0.5
1975 self.selected_index = -1
1977 self.refresh_ip_table()
1978 self.loading_bar.hide()
1980 self.connect_callbacks()
1982 self.setWindowIcon(QtGui.QIcon("logo/Rattlesnake_Icon.png"))
1984 def connect_callbacks(self):
1985 """Connects callbacks to the widgets"""
1986 self.add_ip_address_button.clicked.connect(self.add_ip_address)
1987 self.remove_ip_address_button.clicked.connect(self.remove_ip_address)
1988 self.validate_ip_address_button.clicked.connect(self.validate_button_pressed)
1990 self.button_box.accepted.disconnect()
1991 self.button_box.accepted.connect(self.accept)
1992 self.button_box.rejected.connect(self.reject)
1994 def set_row_count(self, row_count):
1995 """Sets the number of rows in the table"""
1996 while self.ip_address_table.rowCount() < row_count:
1997 clicked = False
1998 self.add_ip_address(clicked)
2000 def add_ip_address(self, clicked=None, append_list=True): # pylint: disable=unused-argument
2001 """Adds a new IP address to the manager"""
2002 if append_list:
2003 new_ip = IPAddress()
2004 self.ip_addresses.append(new_ip)
2006 unique_index = 0
2007 while unique_index in self.unique_indices:
2008 unique_index += 1
2009 self.unique_indices.append(unique_index)
2011 # Add new row to list
2012 current_row = self.ip_address_table.rowCount()
2013 self.ip_address_table.setRowCount(len(self.unique_indices))
2015 # Add a host name line edit with move up and move down buttons
2016 host_name_widget = QtWidgets.QWidget()
2017 host_name_layout = QtWidgets.QHBoxLayout(host_name_widget)
2018 host_name_layout.setContentsMargins(0, 0, 0, 0)
2019 host_name_layout.setSpacing(0)
2021 move_button_layout = QtWidgets.QVBoxLayout()
2022 move_button_layout.setContentsMargins(0, 0, 0, 0)
2023 move_button_layout.setSpacing(0)
2025 up_button = QtWidgets.QToolButton()
2026 up_button.setArrowType(QtCore.Qt.UpArrow)
2027 up_button.setFixedSize(30, 15)
2028 up_button.clicked.connect(lambda: self.move_address_up(unique_index))
2030 down_button = QtWidgets.QToolButton()
2031 down_button.setArrowType(QtCore.Qt.DownArrow)
2032 down_button.setFixedSize(30, 15)
2033 down_button.clicked.connect(lambda: self.move_address_down(unique_index))
2035 move_button_layout.addWidget(up_button)
2036 move_button_layout.addWidget(down_button)
2038 host_name_input = QtWidgets.QLineEdit()
2039 host_name_input.setFixedHeight(45)
2040 host_name_input.setPlaceholderText("BK<Type>-<Serial>")
2041 host_name_input.textChanged.connect(lambda text: self.host_name_changed(text, unique_index))
2042 host_name_input.focusInEvent = lambda event: self.ip_input_focused(event, unique_index)
2044 host_name_layout.addLayout(move_button_layout)
2045 host_name_layout.addWidget(host_name_input)
2047 self.ip_address_table.setCellWidget(current_row, 0, host_name_widget)
2049 ipv4_input = QtWidgets.QLineEdit()
2050 ipv4_input.setFixedHeight(45)
2051 ipv4_input.setPlaceholderText("169.254.001.001")
2052 ipv4_input.textChanged.connect(lambda text: self.ipv4_address_changed(text, unique_index))
2053 ipv4_input.focusInEvent = lambda event: self.ip_input_focused(event, unique_index)
2055 self.ip_address_table.setCellWidget(current_row, 1, ipv4_input)
2057 ipv6_input = QtWidgets.QLineEdit()
2058 ipv6_input.setFixedHeight(45)
2059 ipv6_input.setPlaceholderText("[<Unicast>%<Network>]")
2060 ipv6_input.textChanged.connect(lambda text: self.ipv6_address_changed(text, unique_index))
2061 ipv6_input.focusInEvent = lambda event: self.ip_input_focused(event, unique_index)
2063 self.ip_address_table.setCellWidget(current_row, 2, ipv6_input)
2065 def host_name_changed(self, text: str, unique_index: int):
2066 """Updates the host name"""
2067 try:
2068 current_row = self.unique_indices.index(unique_index)
2069 except ValueError:
2070 return
2071 self.ip_addresses[current_row].host_name = text
2072 self.ip_addresses[current_row].valid_ip = False
2074 def ipv4_address_changed(self, text: str, unique_index: int):
2075 """Updates the IPv4 Address"""
2076 try:
2077 current_row = self.unique_indices.index(unique_index)
2078 except ValueError:
2079 return
2080 self.ip_addresses[current_row].ipv4_address = text
2081 self.ip_addresses[current_row].valid_ip = False
2083 def ipv6_address_changed(self, text: str, unique_index: int):
2084 """Updates the IPv6 Address"""
2085 try:
2086 current_row = self.unique_indices.index(unique_index)
2087 except ValueError:
2088 return
2089 self.ip_addresses[current_row].ipv6_address = text
2090 self.ip_addresses[current_row].valid_ip = False
2092 def ip_input_focused(self, event, unique_index: int): # pylint: disable=unused-argument
2093 """Updates the selected index based on the window focus"""
2094 self.selected_index = unique_index
2096 def remove_ip_address(self):
2097 """Removes the currently selected IP Address"""
2098 try:
2099 current_row = self.unique_indices.index(self.selected_index)
2100 except ValueError:
2101 return
2102 if 0 <= current_row < len(self.unique_indices):
2103 self.ip_address_table.removeRow(
2104 current_row,
2105 )
2106 self.ip_addresses.pop(current_row)
2107 self.unique_indices.pop(current_row)
2108 self.selected_index = -1
2110 def move_address_up(self, ip_index):
2111 """Shifts values up one line edit"""
2112 # Just shifts text values up one LineEdit, unique_indices correspond
2113 # to LineEdit objects which dont shift therefore the unique_indices dont change
2114 try:
2115 current_row = self.unique_indices.index(ip_index)
2116 except ValueError:
2117 return
2118 if current_row > 0:
2119 move_ip = self.ip_addresses.pop(current_row)
2120 self.ip_addresses.insert(current_row - 1, move_ip)
2121 self.refresh_ip_table([current_row - 1, current_row])
2123 def move_address_down(self, ip_index):
2124 """Shifts the addresses down one line edit"""
2125 # Just shifts text values down one LineEdit, unique_indices correspond
2126 # to LineEdit objects which dont shift therefore the unique_indices dont change
2127 try:
2128 current_row = self.unique_indices.index(ip_index)
2129 except ValueError:
2130 return
2131 if current_row < len(self.unique_indices) - 1:
2132 move_ip = self.ip_addresses.pop(current_row)
2133 self.ip_addresses.insert(current_row + 1, move_ip)
2134 self.refresh_ip_table([current_row, current_row + 1])
2136 def refresh_ip_table(self, rows: list[int] = None):
2137 """Refreshes the IP address table"""
2138 if rows is None:
2139 rows = range(len(self.unique_indices))
2141 # This is slower than just deleting widgets and refreshing them but I do
2142 # this to keep a consistent unique indice corresponding to rows
2143 for row_idx in rows:
2144 if row_idx >= self.ip_address_table.rowCount():
2145 return
2147 host_name = (
2148 str(self.ip_addresses[row_idx].host_name)
2149 if self.ip_addresses[row_idx].host_name is not None
2150 else ""
2151 )
2152 host_name_widget = self.ip_address_table.cellWidget(row_idx, 0)
2153 host_name_input = host_name_widget.findChild(QtWidgets.QLineEdit)
2154 host_name_input.blockSignals(True)
2155 host_name_input.setText(host_name)
2156 host_name_input.blockSignals(False)
2158 ipv4_address = (
2159 str(self.ip_addresses[row_idx].ipv4_address)
2160 if self.ip_addresses[row_idx].ipv4_address is not None
2161 else ""
2162 )
2163 ipv4_input = self.ip_address_table.cellWidget(row_idx, 1)
2164 ipv4_input.blockSignals(True)
2165 ipv4_input.setText(ipv4_address)
2166 ipv4_input.blockSignals(False)
2168 ipv6_address = (
2169 str(self.ip_addresses[row_idx].ipv6_address)
2170 if self.ip_addresses[row_idx].ipv6_address is not None
2171 else ""
2172 )
2173 ipv6_input = self.ip_address_table.cellWidget(row_idx, 2)
2174 ipv6_input.blockSignals(True)
2175 ipv6_input.setText(ipv6_address)
2176 ipv6_input.blockSignals(False)
2178 def get_ip_addresses(self, host_name: str = None):
2179 """Gets valid IP Addresses given the host name"""
2180 valid_host_name = False
2181 ipv4_address = None
2182 ipv6_address = None
2183 try:
2184 # Get the address info for the hostname
2185 ipv4_info = socket.getaddrinfo(host_name, None, socket.AF_INET)
2186 ipv6_info = socket.getaddrinfo(host_name, None, socket.AF_INET6)
2187 ipv4 = ipv4_info[0]
2188 ipv4_address = ipv4[4][0]
2189 ipv6 = ipv6_info[0]
2190 ipv6_address = f"[{ipv6[4][0]}%{ipv6[4][3]}]"
2192 valid_host_name = self.validate_ip_address(ipv6_address)
2193 except (socket.gaierror, IndexError):
2194 # print(f'Error retrieving info')
2195 pass
2197 return (valid_host_name, ipv4_address, ipv6_address)
2199 def get_host_name(self, ip_address: str = None):
2200 """Gets the host name from an IP address"""
2201 host_name = None
2202 host = "http://" + ip_address
2203 valid_ip = self.validate_ip_address(ip_address)
2204 if valid_ip:
2205 try:
2206 response = requests.get(host + "/rest/rec/module/info", timeout=1)
2207 info = response.json()
2208 host_name = f"BK{info['module']['type']['number']}-{info['module']['serial']}"
2209 except Exception:
2210 valid_ip = False
2211 host_name = None
2213 return (valid_ip, host_name)
2215 def validate_ip_address(self, ip_address: str = None):
2216 """Checks if IP addresses are valid"""
2217 valid_ip = False
2218 host = "http://" + ip_address
2219 try:
2220 response = requests.put(host + "/rest/rec/open", timeout=self.validation_timeout)
2221 if response.status_code == 200:
2222 valid_ip = True
2223 except requests.exceptions.Timeout:
2224 pass
2225 except requests.exceptions.ConnectionError:
2226 pass
2227 except requests.exceptions.RequestException:
2228 pass
2230 return valid_ip
2232 def autofill_ip_addresses(self):
2233 """This function validates the ip address and autofills the other values.
2234 If multiple inputs are valid but correspond to different devices, the
2235 priority is host_name > ipv4 > ipv6
2236 Note: Having 2 of the same host names may not validate correctly due to weird
2237 socket waiting requirements
2238 """
2239 self.loading_bar.setValue(0)
2240 self.loading_bar.show()
2241 num_rows = len(self.unique_indices)
2242 for row_idx in range(num_rows):
2243 valid_row = self.ip_addresses[row_idx].valid_ip
2244 percent_complete = round((row_idx + 1) / num_rows * 100)
2245 self.loading_bar.setValue(percent_complete)
2247 # Check if you can pull information from hostname
2248 host_name = (
2249 str(self.ip_addresses[row_idx].host_name)
2250 if self.ip_addresses[row_idx].host_name is not None
2251 else ""
2252 )
2253 if not valid_row and host_name != "":
2254 valid_row, ipv4_address, ipv6_address = self.get_ip_addresses(host_name)
2256 if valid_row:
2257 self.ip_addresses[row_idx].ipv4_address = ipv4_address
2258 self.ip_addresses[row_idx].ipv6_address = ipv6_address
2259 self.ip_addresses[row_idx].valid_ip = valid_row
2260 continue
2262 ipv4_address = (
2263 str(self.ip_addresses[row_idx].ipv4_address)
2264 if self.ip_addresses[row_idx].ipv4_address is not None
2265 else ""
2266 )
2267 if not valid_row and ipv4_address is not None:
2268 valid_row, host_name = self.get_host_name(ipv4_address)
2270 if valid_row:
2271 self.ip_addresses[row_idx].host_name = host_name
2272 (valid_row, _, ipv6_address) = self.get_ip_addresses(host_name)
2273 self.ip_addresses[row_idx].ipv6_address = ipv6_address
2274 self.ip_addresses[row_idx].valid_ip = valid_row
2275 continue
2277 ipv6_address = (
2278 str(self.ip_addresses[row_idx].ipv6_address)
2279 if self.ip_addresses[row_idx].ipv6_address is not None
2280 else ""
2281 )
2282 if not valid_row and ipv6_address is not None:
2283 valid_row, host_name = self.get_host_name(ipv6_address)
2285 if valid_row:
2286 self.ip_addresses[row_idx].host_name = host_name
2287 (valid_row, ipv4_address, _) = self.get_ip_addresses(host_name)
2288 self.ip_addresses[row_idx].ipv4_address = ipv4_address
2289 self.ip_addresses[row_idx].valid_ip = valid_row
2290 continue
2292 self.loading_bar.hide()
2294 def validate_button_pressed(self):
2295 """Validates the IP Addresses"""
2296 self.autofill_ip_addresses()
2297 self.refresh_ip_table()
2299 valid_ip_list = [ip.valid_ip for ip in self.ip_addresses]
2300 if not all(valid_ip_list):
2301 invalid_ip_rows = [
2302 row for row, valid_bool in enumerate(valid_ip_list) if not valid_bool
2303 ]
2304 message = (
2305 f"Invalid IP address at rows: {invalid_ip_rows}.\n\n "
2306 f"If IPv4 connection is unstable, try inputting host name."
2307 )
2308 reply = QtWidgets.QMessageBox.question(
2309 self,
2310 "Invalid IP Addresses",
2311 message,
2312 QtWidgets.QMessageBox.Ok,
2313 QtWidgets.QMessageBox.Ok,
2314 )
2316 def closeEvent(self, a0): # pylint: disable=unused-argument,invalid-name
2317 """Returns the IP addresses"""
2318 return self.ip_addresses