1# -*- coding: utf-8 -*-
2"""
3This file defines a transient environment that utilizes system
4identification.
5
6Rattlesnake Vibration Control Software
7Copyright (C) 2021 National Technology & Engineering Solutions of Sandia, LLC
8(NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the U.S.
9Government retains certain rights in this software.
10
11This program is free software: you can redistribute it and/or modify
12it under the terms of the GNU General Public License as published by
13the Free Software Foundation, either version 3 of the License, or
14(at your option) any later version.
15
16This program is distributed in the hope that it will be useful,
17but WITHOUT ANY WARRANTY; without even the implied warranty of
18MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19GNU General Public License for more details.
20
21You should have received a copy of the GNU General Public License
22along with this program. If not, see <https://www.gnu.org/licenses/>.
23"""
24
25import importlib
26import inspect
27import multiprocessing as mp
28import multiprocessing.sharedctypes # pylint: disable=unused-import
29import os
30import traceback
31from enum import Enum
32from multiprocessing.queues import Queue
33
34import netCDF4 as nc4
35import numpy as np
36import scipy.signal as sig
37from qtpy import QtCore, QtWidgets, uic
38from qtpy.QtCore import Qt
39
40from .abstract_sysid_environment import (
41 AbstractSysIdEnvironment,
42 AbstractSysIdMetadata,
43 AbstractSysIdUI,
44)
45from .environments import (
46 ControlTypes,
47 environment_definition_ui_paths,
48 environment_prediction_ui_paths,
49 environment_run_ui_paths,
50)
51from .ui_utilities import (
52 PlotTimeWindow,
53 TransformationMatrixWindow,
54 colororder,
55 load_time_history,
56 multiline_plotter,
57)
58from .utilities import (
59 DataAcquisitionParameters,
60 GlobalCommands,
61 VerboseMessageQueue,
62 align_signals,
63 db2scale,
64 load_python_module,
65 rms_time,
66 shift_signal,
67 trac,
68)
69
70# %% Global Variables
71CONTROL_TYPE = ControlTypes.TRANSIENT
72MAXIMUM_NAME_LENGTH = 50
73BUFFER_SIZE_SAMPLES_PER_READ_MULTIPLIER = 2
74
75
76# %% Commands
77class TransientCommands(Enum):
78 """Valid commands for the transient environment"""
79
80 START_CONTROL = 0
81 STOP_CONTROL = 1
82 PERFORM_CONTROL_PREDICTION = 3
83 # UPDATE_INTERACTIVE_CONTROL_PARAMETERS = 4
84
85
86# %% Queues
87
88
89class TransientQueues:
90 """A container class for the queues that this environment will manage."""
91
92 def __init__(
93 self,
94 environment_name: str,
95 environment_command_queue: VerboseMessageQueue,
96 gui_update_queue: Queue,
97 controller_communication_queue: VerboseMessageQueue,
98 data_in_queue: Queue,
99 data_out_queue: Queue,
100 log_file_queue: VerboseMessageQueue,
101 ):
102 """A container class for the queues that transient will manage.
103
104 The environment uses many queues to pass data between the various pieces.
105 This class organizes those queues into one common namespace.
106
107
108 Parameters
109 ----------
110 environment_name : str
111 Name of the environment
112 environment_command_queue : VerboseMessageQueue
113 Queue that is read by the environment for environment commands
114 gui_update_queue : mp.queues.Queue
115 Queue where various subtasks put instructions for updating the
116 widgets in the user interface
117 controller_communication_queue : VerboseMessageQueue
118 Queue that is read by the controller for global controller commands
119 data_in_queue : mp.queues.Queue
120 Multiprocessing queue that connects the acquisition subtask to the
121 environment subtask. Each environment will retrieve acquired data
122 from this queue.
123 data_out_queue : mp.queues.Queue
124 Multiprocessing queue that connects the output subtask to the
125 environment subtask. Each environment will put data that it wants
126 the controller to generate in this queue.
127 log_file_queue : VerboseMessageQueue
128 Queue for putting logging messages that will be read by the logging
129 subtask and written to a file.
130 """
131 self.environment_command_queue = environment_command_queue
132 self.gui_update_queue = gui_update_queue
133 self.data_analysis_command_queue = VerboseMessageQueue(
134 log_file_queue, environment_name + " Data Analysis Command Queue"
135 )
136 self.signal_generation_command_queue = VerboseMessageQueue(
137 log_file_queue, environment_name + " Signal Generation Command Queue"
138 )
139 self.spectral_command_queue = VerboseMessageQueue(
140 log_file_queue, environment_name + " Spectral Computation Command Queue"
141 )
142 self.collector_command_queue = VerboseMessageQueue(
143 log_file_queue, environment_name + " Data Collector Command Queue"
144 )
145 self.controller_communication_queue = controller_communication_queue
146 self.data_in_queue = data_in_queue
147 self.data_out_queue = data_out_queue
148 self.data_for_spectral_computation_queue = mp.Queue()
149 self.updated_spectral_quantities_queue = mp.Queue()
150 self.time_history_to_generate_queue = mp.Queue()
151 self.log_file_queue = log_file_queue
152
153
154# %% Metadata
155
156
157class TransientMetadata(AbstractSysIdMetadata):
158 """Metadata required to define a transient control law in rattlesnake."""
159
160 def __init__(
161 self,
162 number_of_channels,
163 sample_rate,
164 control_signal,
165 ramp_time,
166 control_python_script,
167 control_python_function,
168 control_python_function_type,
169 control_python_function_parameters,
170 control_channel_indices,
171 output_channel_indices,
172 response_transformation_matrix,
173 output_transformation_matrix,
174 ):
175 super().__init__()
176 self.number_of_channels = number_of_channels
177 self.sample_rate = sample_rate
178 self.control_signal = control_signal
179 self.test_level_ramp_time = ramp_time
180 self.control_python_script = control_python_script
181 self.control_python_function = control_python_function
182 self.control_python_function_type = control_python_function_type
183 self.control_python_function_parameters = control_python_function_parameters
184 self.control_channel_indices = control_channel_indices
185 self.output_channel_indices = output_channel_indices
186 self.response_transformation_matrix = response_transformation_matrix
187 self.reference_transformation_matrix = output_transformation_matrix
188
189 @property
190 def ramp_samples(self):
191 """Number of samples to ramp down to zero when aborting a test"""
192 return int(self.test_level_ramp_time * self.sample_rate)
193
194 @property
195 def number_of_channels(self):
196 """Total number of channels in the environment"""
197 return self._number_of_channels
198
199 @number_of_channels.setter
200 def number_of_channels(self, value):
201 """Sets the total number of channels in the environment"""
202 self._number_of_channels = value
203
204 @property
205 def response_channel_indices(self):
206 """Indices identifying which channels are control channels"""
207 return self.control_channel_indices
208
209 @property
210 def reference_channel_indices(self):
211 """Indices identifying which channels are reference or excitation channels"""
212 return self.output_channel_indices
213
214 @property
215 def response_transformation_matrix(self):
216 """Transformation matrix applied to the control channels"""
217 return self._response_transformation_matrix
218
219 @response_transformation_matrix.setter
220 def response_transformation_matrix(self, value):
221 """Sets the transformation matrix for the control channels"""
222 self._response_transformation_matrix = value
223
224 @property
225 def reference_transformation_matrix(self):
226 """Transformation matrix applied to the excitation channels"""
227 return self._reference_transformation_matrix
228
229 @reference_transformation_matrix.setter
230 def reference_transformation_matrix(self, value):
231 """Sets the transformation matrix applied to the excitation channels"""
232 self._reference_transformation_matrix = value
233
234 @property
235 def sample_rate(self):
236 """Gets the sample rate of the data acquisition system"""
237 return self._sample_rate
238
239 @sample_rate.setter
240 def sample_rate(self, value):
241 """Sets the sample rate of the data acquisition system"""
242 self._sample_rate = value
243
244 @property
245 def signal_samples(self):
246 """Gets the number of samples in the signal that is being controlled to"""
247 return self.control_signal.shape[-1]
248
249 def store_to_netcdf(
250 self, netcdf_group_handle: nc4._netCDF4.Group # pylint: disable=c-extension-no-member
251 ):
252 """Stores the metadata in a netcdf group
253
254 Parameters
255 ----------
256 netcdf_group_handle : nc4._netCDF4.Group
257 A group in a NetCDF4 group defining the environment's medatadata
258 """
259 super().store_to_netcdf(netcdf_group_handle)
260 netcdf_group_handle.test_level_ramp_time = self.test_level_ramp_time
261 netcdf_group_handle.control_python_script = self.control_python_script
262 netcdf_group_handle.control_python_function = self.control_python_function
263 netcdf_group_handle.control_python_function_type = self.control_python_function_type
264 netcdf_group_handle.control_python_function_parameters = (
265 self.control_python_function_parameters
266 )
267 # Save the output signal
268 netcdf_group_handle.createDimension("control_channels", len(self.control_channel_indices))
269 netcdf_group_handle.createDimension("specification_channels", self.control_signal.shape[0])
270 netcdf_group_handle.createDimension("signal_samples", self.signal_samples)
271 var = netcdf_group_handle.createVariable(
272 "control_signal", "f8", ("specification_channels", "signal_samples")
273 )
274 var[...] = self.control_signal
275 # Control Channels
276 var = netcdf_group_handle.createVariable(
277 "control_channel_indices", "i4", ("control_channels")
278 )
279 var[...] = self.control_channel_indices
280 # Transformation Matrix
281 if self.response_transformation_matrix is not None:
282 netcdf_group_handle.createDimension(
283 "response_transformation_rows",
284 self.response_transformation_matrix.shape[0],
285 )
286 netcdf_group_handle.createDimension(
287 "response_transformation_cols",
288 self.response_transformation_matrix.shape[1],
289 )
290 var = netcdf_group_handle.createVariable(
291 "response_transformation_matrix",
292 "f8",
293 ("response_transformation_rows", "response_transformation_cols"),
294 )
295 var[...] = self.response_transformation_matrix
296 if self.reference_transformation_matrix is not None:
297 netcdf_group_handle.createDimension(
298 "reference_transformation_rows",
299 self.reference_transformation_matrix.shape[0],
300 )
301 netcdf_group_handle.createDimension(
302 "reference_transformation_cols",
303 self.reference_transformation_matrix.shape[1],
304 )
305 var = netcdf_group_handle.createVariable(
306 "reference_transformation_matrix",
307 "f8",
308 ("reference_transformation_rows", "reference_transformation_cols"),
309 )
310 var[...] = self.reference_transformation_matrix
311
312
313# %% UI
314
315from .abstract_interactive_control_law import ( # noqa: E402 pylint: disable=wrong-import-position
316 AbstractControlLawComputation,
317)
318from .abstract_sysid_data_analysis import ( # noqa: E402 pylint: disable=wrong-import-position
319 sysid_data_analysis_process,
320)
321from .data_collector import ( # noqa: E402 pylint: disable=wrong-import-position
322 FrameBuffer,
323 data_collector_process,
324)
325from .signal_generation import ( # noqa: E402 pylint: disable=wrong-import-position
326 TransientSignalGenerator,
327)
328from .signal_generation_process import ( # noqa: E402 pylint: disable=wrong-import-position
329 SignalGenerationCommands,
330 SignalGenerationMetadata,
331 signal_generation_process,
332)
333from .spectral_processing import ( # noqa: E402 pylint: disable=wrong-import-position
334 spectral_processing_process,
335)
336
337
338class TransientUI(AbstractSysIdUI):
339 """Class defining the user interface for the transient environment"""
340
341 def __init__(
342 self,
343 environment_name: str,
344 definition_tabwidget: QtWidgets.QTabWidget,
345 system_id_tabwidget: QtWidgets.QTabWidget,
346 test_predictions_tabwidget: QtWidgets.QTabWidget,
347 run_tabwidget: QtWidgets.QTabWidget,
348 environment_command_queue: VerboseMessageQueue,
349 controller_communication_queue: VerboseMessageQueue,
350 log_file_queue: Queue,
351 ):
352 super().__init__(
353 environment_name,
354 environment_command_queue,
355 controller_communication_queue,
356 log_file_queue,
357 system_id_tabwidget,
358 )
359 # Add the page to the control definition tabwidget
360 self.definition_widget = QtWidgets.QWidget()
361 uic.loadUi(environment_definition_ui_paths[CONTROL_TYPE], self.definition_widget)
362 definition_tabwidget.addTab(self.definition_widget, self.environment_name)
363 # Add the page to the control prediction tabwidget
364 self.prediction_widget = QtWidgets.QWidget()
365 uic.loadUi(environment_prediction_ui_paths[CONTROL_TYPE], self.prediction_widget)
366 test_predictions_tabwidget.addTab(self.prediction_widget, self.environment_name)
367 # Add the page to the run tabwidget
368 self.run_widget = QtWidgets.QWidget()
369 uic.loadUi(environment_run_ui_paths[CONTROL_TYPE], self.run_widget)
370 run_tabwidget.addTab(self.run_widget, self.environment_name)
371
372 self.specification_signal = None
373 self.show_signal_checkboxes = None
374 self.plot_data_items = {}
375 self.plot_windows = []
376 self.response_transformation_matrix = None
377 self.output_transformation_matrix = None
378 self.python_control_module = None
379 self.physical_channel_names = None
380 self.physical_output_indices = None
381 self.excitation_prediction = None
382 self.response_prediction = None
383 self.last_control_data = None
384 self.last_output_data = None
385 self.interactive_control_law_widget = None
386 self.interactive_control_law_window = None
387 self.max_plot_samples = None
388
389 self.control_selector_widgets = [
390 self.prediction_widget.response_selector,
391 self.run_widget.control_channel_selector,
392 ]
393 self.output_selector_widgets = [
394 self.prediction_widget.excitation_selector,
395 ]
396
397 # Set common look and feel for plots
398 plot_widgets = [
399 self.definition_widget.signal_display_plot,
400 self.prediction_widget.excitation_display_plot,
401 self.prediction_widget.response_display_plot,
402 self.run_widget.output_signal_plot,
403 self.run_widget.response_signal_plot,
404 ]
405 for plot_widget in plot_widgets:
406 plot_item = plot_widget.getPlotItem()
407 plot_item.showGrid(True, True, 0.25)
408 plot_item.enableAutoRange()
409 plot_item.getViewBox().enableAutoRange(enable=True)
410
411 self.connect_callbacks()
412
413 # Complete the profile commands
414 self.command_map["Set Test Level"] = self.change_test_level_from_profile
415 self.command_map["Set Repeat"] = self.set_repeat_from_profile
416 self.command_map["Set No Repeat"] = self.set_norepeat_from_profile
417
418 def connect_callbacks(self):
419 """Connects the callbacks to the transient UI widgets"""
420 # Definition
421 self.definition_widget.load_signal_button.clicked.connect(self.load_signal)
422 self.definition_widget.transformation_matrices_button.clicked.connect(
423 self.define_transformation_matrices
424 )
425 self.definition_widget.show_all_button.clicked.connect(self.show_all_signals)
426 self.definition_widget.show_none_button.clicked.connect(self.show_no_signals)
427 self.definition_widget.control_channels_selector.itemChanged.connect(
428 self.update_control_channels
429 )
430 self.definition_widget.control_script_load_file_button.clicked.connect(
431 self.select_python_module
432 )
433 self.definition_widget.control_function_input.currentIndexChanged.connect(
434 self.update_generator_selector
435 )
436 self.definition_widget.check_selected_button.clicked.connect(
437 self.check_selected_control_channels
438 )
439 self.definition_widget.uncheck_selected_button.clicked.connect(
440 self.uncheck_selected_control_channels
441 )
442 # Prediction
443 self.prediction_widget.excitation_selector.currentIndexChanged.connect(
444 self.plot_predictions
445 )
446 self.prediction_widget.response_selector.currentIndexChanged.connect(self.plot_predictions)
447 self.prediction_widget.response_error_list.itemClicked.connect(
448 self.update_response_error_prediction_selector
449 )
450 self.prediction_widget.excitation_voltage_list.itemClicked.connect(
451 self.update_excitation_prediction_selector
452 )
453 self.prediction_widget.maximum_voltage_button.clicked.connect(
454 self.show_max_voltage_prediction
455 )
456 self.prediction_widget.minimum_voltage_button.clicked.connect(
457 self.show_min_voltage_prediction
458 )
459 self.prediction_widget.maximum_error_button.clicked.connect(self.show_max_error_prediction)
460 self.prediction_widget.minimum_error_button.clicked.connect(self.show_min_error_prediction)
461 self.prediction_widget.recompute_predictions_button.clicked.connect(
462 self.recompute_predictions
463 )
464 # Run Test
465 self.run_widget.start_test_button.clicked.connect(self.start_control)
466 self.run_widget.stop_test_button.clicked.connect(self.stop_control)
467 self.run_widget.create_window_button.clicked.connect(self.create_window)
468 self.run_widget.show_all_channels_button.clicked.connect(self.show_all_channels)
469 self.run_widget.tile_windows_button.clicked.connect(self.tile_windows)
470 self.run_widget.close_windows_button.clicked.connect(self.close_windows)
471 self.run_widget.control_response_error_list.itemDoubleClicked.connect(self.show_window)
472 self.run_widget.save_current_control_data_button.clicked.connect(self.save_control_data)
473 self.run_widget.display_duration_spinbox.valueChanged.connect(self.set_display_duration)
474
475 # %% Data Acquisition
476
477 def initialize_data_acquisition(self, data_acquisition_parameters):
478 super().initialize_data_acquisition(data_acquisition_parameters)
479 # Initialize the plots
480 for plot in [
481 self.definition_widget.signal_display_plot,
482 self.prediction_widget.excitation_display_plot,
483 self.prediction_widget.response_display_plot,
484 self.run_widget.output_signal_plot,
485 self.run_widget.response_signal_plot,
486 ]:
487 plot.getPlotItem().clear()
488
489 # Set up channel names
490 self.physical_channel_names = [
491 (
492 f"{'' if channel.channel_type is None else channel.channel_type} "
493 f"{channel.node_number} "
494 f"{'' if channel.node_direction is None else channel.node_direction}"
495 )[:MAXIMUM_NAME_LENGTH]
496 for channel in data_acquisition_parameters.channel_list
497 ]
498 self.physical_output_indices = [
499 i
500 for i, channel in enumerate(data_acquisition_parameters.channel_list)
501 if channel.feedback_device
502 ]
503 # Set up widgets
504 self.definition_widget.sample_rate_display.setValue(data_acquisition_parameters.sample_rate)
505 self.system_id_widget.samplesPerFrameSpinBox.setValue(
506 data_acquisition_parameters.sample_rate
507 )
508 self.definition_widget.control_channels_selector.clear()
509 for channel_name in self.physical_channel_names:
510 item = QtWidgets.QListWidgetItem()
511 item.setText(channel_name)
512 item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
513 item.setCheckState(Qt.Unchecked)
514 self.definition_widget.control_channels_selector.addItem(item)
515 self.response_transformation_matrix = None
516 self.output_transformation_matrix = None
517 self.define_transformation_matrices(None, False)
518 self.definition_widget.input_channels_display.setValue(len(self.physical_channel_names))
519 self.definition_widget.output_channels_display.setValue(len(self.physical_output_indices))
520 self.definition_widget.control_channels_display.setValue(0)
521
522 @property
523 def physical_output_names(self):
524 """Names of the physical drive channels"""
525 return [self.physical_channel_names[i] for i in self.physical_output_indices]
526
527 # %% Environment
528
529 @property
530 def physical_control_indices(self):
531 """Indices of the control channels"""
532 return [
533 i
534 for i in range(self.definition_widget.control_channels_selector.count())
535 if self.definition_widget.control_channels_selector.item(i).checkState() == Qt.Checked
536 ]
537
538 @property
539 def physical_control_names(self):
540 """Names of the selected control channels"""
541 return [self.physical_channel_names[i] for i in self.physical_control_indices]
542
543 @property
544 def initialized_control_names(self):
545 """Names of the control channels that have been initialized"""
546 if self.environment_parameters.response_transformation_matrix is None:
547 return [
548 self.physical_channel_names[i]
549 for i in self.environment_parameters.control_channel_indices
550 ]
551 else:
552 return [
553 f"Transformed Response {i + 1}"
554 for i in range(self.environment_parameters.response_transformation_matrix.shape[0])
555 ]
556
557 @property
558 def initialized_output_names(self):
559 """Names of the drive channels that have been initialized"""
560 if self.environment_parameters.reference_transformation_matrix is None:
561 return self.physical_output_names
562 else:
563 return [
564 f"Transformed Drive {i + 1}"
565 for i in range(self.environment_parameters.reference_transformation_matrix.shape[0])
566 ]
567
568 def update_control_channels(self):
569 """Callback called when control channels are updated in the UI"""
570 self.response_transformation_matrix = None
571 self.output_transformation_matrix = None
572 self.specification_signal = None
573 self.definition_widget.control_channels_display.setValue(len(self.physical_control_indices))
574 self.define_transformation_matrices(None, False)
575 self.show_signal()
576
577 def collect_environment_definition_parameters(self):
578 """Collects the metadata defining the environment from the UI widgets"""
579 if self.python_control_module is None:
580 control_module = None
581 control_function = None
582 control_function_type = None
583 control_function_parameters = None
584 else:
585 control_module = self.definition_widget.control_script_file_path_input.text()
586 control_function = self.definition_widget.control_function_input.itemText(
587 self.definition_widget.control_function_input.currentIndex()
588 )
589 control_function_type = (
590 self.definition_widget.control_function_generator_selector.currentIndex()
591 )
592 control_function_parameters = (
593 self.definition_widget.control_parameters_text_input.toPlainText()
594 )
595 return TransientMetadata(
596 len(self.data_acquisition_parameters.channel_list),
597 self.definition_widget.sample_rate_display.value(),
598 self.specification_signal,
599 self.definition_widget.ramp_selector.value(),
600 control_module,
601 control_function,
602 control_function_type,
603 control_function_parameters,
604 self.physical_control_indices,
605 self.physical_output_indices,
606 self.response_transformation_matrix,
607 self.output_transformation_matrix,
608 )
609
610 def load_signal(self, clicked, filename=None): # pylint: disable=unused-argument
611 """Loads a time signal using a dialog or the specified filename
612
613 Parameters
614 ----------
615 clicked :
616 The clicked event that triggered the callback.
617 filename :
618 File name defining the specification for bypassing the callback when
619 loading from a file (Default value = None).
620
621 """
622 if filename is None:
623 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
624 self.definition_widget,
625 "Select Signal File",
626 filter="Numpy or Mat (*.npy *.npz *.mat)",
627 )
628 if filename == "":
629 return
630 self.definition_widget.signal_file_name_display.setText(filename)
631 self.specification_signal = load_time_history(
632 filename, self.definition_widget.sample_rate_display.value()
633 )
634 self.setup_specification_table()
635 self.show_signal()
636
637 def setup_specification_table(self):
638 """Sets up the specification table for the Transient Environment
639
640 This function computes the RMS and max values for the signals and then
641 creates entries in the table for each signal"""
642 self.definition_widget.signal_samples_display.setValue(self.specification_signal.shape[-1])
643 self.definition_widget.signal_time_display.setValue(
644 self.specification_signal.shape[-1] / self.definition_widget.sample_rate_display.value()
645 )
646 maxs = np.max(np.abs(self.specification_signal), axis=-1)
647 rmss = rms_time(self.specification_signal, axis=-1)
648 # Add rows to the signal table
649 self.definition_widget.signal_information_table.setRowCount(
650 self.specification_signal.shape[0]
651 )
652 self.show_signal_checkboxes = []
653 for i, (name, mx, rms) in enumerate(zip(self.physical_control_names, maxs, rmss)):
654 item = QtWidgets.QTableWidgetItem()
655 item.setText(name)
656 item.setFlags(item.flags() ^ QtCore.Qt.ItemIsEditable)
657 self.definition_widget.signal_information_table.setItem(i, 1, item)
658 checkbox = QtWidgets.QCheckBox()
659 checkbox.setChecked(True)
660 checkbox.stateChanged.connect(self.show_signal)
661 self.show_signal_checkboxes.append(checkbox)
662 self.definition_widget.signal_information_table.setCellWidget(i, 0, checkbox)
663 item = QtWidgets.QTableWidgetItem()
664 item.setText(f"{mx:0.2f}")
665 item.setFlags(item.flags() ^ QtCore.Qt.ItemIsEditable)
666 self.definition_widget.signal_information_table.setItem(i, 2, item)
667 item = QtWidgets.QTableWidgetItem()
668 item.setText(f"{rms:0.2f}")
669 item.setFlags(item.flags() ^ QtCore.Qt.ItemIsEditable)
670 self.definition_widget.signal_information_table.setItem(i, 3, item)
671
672 def show_signal(self):
673 """Shows the signal on the user interface"""
674 pi = self.definition_widget.signal_display_plot.getPlotItem()
675 pi.clear()
676 if self.specification_signal is None:
677 self.definition_widget.signal_information_table.setRowCount(0)
678 return
679 abscissa = (
680 np.arange(self.specification_signal.shape[-1])
681 / self.definition_widget.sample_rate_display.value()
682 )
683 for i, (curve, checkbox) in enumerate(
684 zip(self.specification_signal, self.show_signal_checkboxes)
685 ):
686 pen = {"color": colororder[i % len(colororder)]}
687 if checkbox.isChecked():
688 pi.plot(abscissa, curve, pen=pen)
689 else:
690 pi.plot((0, 0), (0, 0), pen=pen)
691
692 def show_all_signals(self):
693 """Callback to show all signals in the specification"""
694 # print('Showing All Signals')
695 for checkbox in self.show_signal_checkboxes:
696 checkbox.blockSignals(True)
697 checkbox.setChecked(True)
698 checkbox.blockSignals(False)
699 self.show_signal()
700
701 def show_no_signals(self):
702 """Callback to hide all signals in the specification"""
703 # print('Showing No Signals')
704 for checkbox in self.show_signal_checkboxes:
705 checkbox.blockSignals(True)
706 checkbox.setChecked(False)
707 checkbox.blockSignals(False)
708 self.show_signal()
709
710 def define_transformation_matrices( # pylint: disable=unused-argument
711 self, clicked, dialog=True
712 ):
713 """Defines the transformation matrices using the dialog box"""
714 if dialog:
715 (response_transformation, output_transformation, result) = (
716 TransformationMatrixWindow.define_transformation_matrices(
717 self.response_transformation_matrix,
718 self.definition_widget.control_channels_display.value(),
719 self.output_transformation_matrix,
720 self.definition_widget.output_channels_display.value(),
721 self.definition_widget,
722 )
723 )
724 else:
725 response_transformation = self.response_transformation_matrix
726 output_transformation = self.output_transformation_matrix
727 result = True
728 if result:
729 # Update the control names
730 for widget in self.control_selector_widgets:
731 widget.blockSignals(True)
732 widget.clear()
733 if response_transformation is None:
734 for i, control_name in enumerate(self.physical_control_names):
735 for widget in self.control_selector_widgets:
736 widget.addItem(f"{i + 1}: {control_name}")
737 self.definition_widget.transform_channels_display.setValue(
738 len(self.physical_control_names)
739 )
740 else:
741 for i in range(response_transformation.shape[0]):
742 for widget in self.control_selector_widgets:
743 widget.addItem(f"{i + 1}: Virtual Response")
744 self.definition_widget.transform_channels_display.setValue(
745 response_transformation.shape[0]
746 )
747 for widget in self.control_selector_widgets:
748 widget.blockSignals(False)
749 # Update the output names
750 for widget in self.output_selector_widgets:
751 widget.blockSignals(True)
752 widget.clear()
753 if output_transformation is None:
754 for i, drive_name in enumerate(self.physical_output_names):
755 for widget in self.output_selector_widgets:
756 widget.addItem(f"{i + 1}: {drive_name}")
757 self.definition_widget.transform_outputs_display.setValue(
758 len(self.physical_output_names)
759 )
760 else:
761 for i in range(output_transformation.shape[0]):
762 for widget in self.output_selector_widgets:
763 widget.addItem(f"{i + 1}: Virtual Drive")
764 self.definition_widget.transform_outputs_display.setValue(
765 output_transformation.shape[0]
766 )
767 for widget in self.output_selector_widgets:
768 widget.blockSignals(False)
769 # Clear the signals
770 self.definition_widget.signal_information_table.clear()
771 self.definition_widget.signal_display_plot.clear()
772 self.definition_widget.signal_file_name_display.clear()
773 self.definition_widget.signal_information_table.setRowCount(0)
774 self.show_signal_checkboxes = None
775 self.response_transformation_matrix = response_transformation
776 self.output_transformation_matrix = output_transformation
777
778 def select_python_module(self, clicked, filename=None): # pylint: disable=unused-argument
779 """Loads a Python module using a dialog or the specified filename
780
781 Parameters
782 ----------
783 clicked :
784 The clicked event that triggered the callback.
785 filename :
786 File name defining the Python module for bypassing the callback when
787 loading from a file (Default value = None).
788
789 """
790 if filename is None or not os.path.isfile(filename):
791 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
792 self.definition_widget,
793 "Select Python Module",
794 filter="Python Modules (*.py)",
795 )
796 if filename == "":
797 return
798 self.python_control_module = load_python_module(filename)
799 functions = [
800 function
801 for function in inspect.getmembers(self.python_control_module)
802 if (
803 inspect.isfunction(function[1])
804 and len(inspect.signature(function[1]).parameters) >= 6
805 )
806 or inspect.isgeneratorfunction(function[1])
807 or (
808 inspect.isclass(function[1])
809 and all(
810 [
811 (
812 method in function[1].__dict__
813 and not (
814 hasattr(function[1].__dict__[method], "__isabstractmethod__")
815 and function[1].__dict__[method].__isabstractmethod__
816 )
817 )
818 for method in ["system_id_update", "control"]
819 ]
820 )
821 )
822 ]
823 self.log(
824 f"Loaded module {self.python_control_module.__name__} with "
825 f"functions {[function[0] for function in functions]}"
826 )
827 self.definition_widget.control_function_input.clear()
828 self.definition_widget.control_script_file_path_input.setText(filename)
829 for function in functions:
830 self.definition_widget.control_function_input.addItem(function[0])
831
832 def update_generator_selector(self):
833 """Updates the function/generator selector based on the function selected"""
834 if self.python_control_module is None:
835 return
836 try:
837 function = getattr(
838 self.python_control_module,
839 self.definition_widget.control_function_input.itemText(
840 self.definition_widget.control_function_input.currentIndex()
841 ),
842 )
843 except AttributeError:
844 return
845 if inspect.isgeneratorfunction(function):
846 self.definition_widget.control_function_generator_selector.setCurrentIndex(1)
847 elif inspect.isclass(function) and issubclass(function, AbstractControlLawComputation):
848 self.definition_widget.control_function_generator_selector.setCurrentIndex(3)
849 elif inspect.isclass(function):
850 self.definition_widget.control_function_generator_selector.setCurrentIndex(2)
851 else:
852 self.definition_widget.control_function_generator_selector.setCurrentIndex(0)
853
854 def initialize_environment(self):
855 super().initialize_environment()
856 # Make sure everything is defined
857 if self.environment_parameters.control_signal is None:
858 raise ValueError(f"Control Signal is not defined for {self.environment_name}!")
859 if self.environment_parameters.control_python_script is None:
860 raise ValueError(f"Control function has not been loaded for {self.environment_name}")
861 self.system_id_widget.samplesPerFrameSpinBox.setMaximum(self.specification_signal.shape[-1])
862 for widget in [
863 self.prediction_widget.response_selector,
864 self.run_widget.control_channel_selector,
865 ]:
866 widget.blockSignals(True)
867 widget.clear()
868 for i, control_name in enumerate(self.initialized_control_names):
869 widget.addItem(f"{i + 1}: {control_name}")
870 widget.blockSignals(False)
871 for widget in [self.prediction_widget.excitation_selector]:
872 widget.blockSignals(True)
873 widget.clear()
874 for i, drive_name in enumerate(self.initialized_output_names):
875 widget.addItem(f"{i + 1}: {drive_name}")
876 widget.blockSignals(False)
877 # Set up the prediction plots
878 self.prediction_widget.excitation_display_plot.getPlotItem().clear()
879 self.prediction_widget.response_display_plot.getPlotItem().clear()
880 self.plot_data_items["response_prediction"] = multiline_plotter(
881 np.arange(self.environment_parameters.control_signal.shape[-1])
882 / self.environment_parameters.sample_rate,
883 np.zeros((2, self.environment_parameters.control_signal.shape[-1])),
884 widget=self.prediction_widget.response_display_plot,
885 other_pen_options={"width": 1},
886 names=["Prediction", "Spec"],
887 downsample={"auto": True},
888 clip_to_view=True,
889 )
890 self.plot_data_items["excitation_prediction"] = multiline_plotter(
891 np.arange(self.environment_parameters.control_signal.shape[-1])
892 / self.environment_parameters.sample_rate,
893 np.zeros((1, self.environment_parameters.control_signal.shape[-1])),
894 widget=self.prediction_widget.excitation_display_plot,
895 other_pen_options={"width": 1},
896 names=["Prediction"],
897 downsample={"auto": True},
898 clip_to_view=True,
899 )
900 # Set up the run plots
901 self.run_widget.output_signal_plot.getPlotItem().clear()
902 self.run_widget.response_signal_plot.getPlotItem().clear()
903 self.max_plot_samples = (
904 self.data_acquisition_parameters.sample_rate
905 * self.run_widget.display_duration_spinbox.value()
906 )
907 self.plot_data_items["output_signal_measurement"] = multiline_plotter(
908 (np.array([])),
909 np.zeros((len(self.initialized_control_names), 0)),
910 widget=self.run_widget.output_signal_plot,
911 other_pen_options={"width": 1},
912 names=self.initialized_control_names,
913 downsample={"auto": True},
914 clip_to_view=True,
915 )
916 self.plot_data_items[
917 "signal_range"
918 ] = self.run_widget.response_signal_plot.getPlotItem().plot(
919 np.zeros(5),
920 np.zeros(5),
921 pen={"color": "k", "width": 1},
922 name="Signal Lower Bound",
923 )
924 self.plot_data_items["control_signal_measurement"] = multiline_plotter(
925 (np.array([])),
926 np.zeros((len(self.initialized_output_names), 0)),
927 widget=self.run_widget.response_signal_plot,
928 other_pen_options={"width": 1},
929 names=self.initialized_output_names,
930 downsample={"auto": True},
931 clip_to_view=True,
932 )
933 if self.definition_widget.control_function_generator_selector.currentIndex() == 3:
934 control_class = getattr(
935 self.python_control_module,
936 self.definition_widget.control_function_input.itemText(
937 self.definition_widget.control_function_input.currentIndex()
938 ),
939 )
940 self.log(f"Building Interactive UI for class {control_class.__name__}")
941 ui_class = control_class.get_ui_class()
942 if ui_class == self.interactive_control_law_widget.__class__:
943 print("initializing data acquisition and environment parameters")
944 self.interactive_control_law_widget.initialize_parameters(
945 self.data_acquisition_parameters, self.environment_parameters
946 )
947 else:
948 if self.interactive_control_law_widget is not None:
949 self.interactive_control_law_widget.close()
950 self.interactive_control_law_window = QtWidgets.QDialog(self.definition_widget)
951 self.interactive_control_law_widget = ui_class(
952 self.log_name,
953 self.environment_command_queue,
954 self.interactive_control_law_window,
955 self,
956 self.data_acquisition_parameters,
957 self.environment_parameters,
958 )
959 self.interactive_control_law_window.show()
960 return self.environment_parameters
961
962 def check_selected_control_channels(self):
963 """Callback to check control channels that are selected"""
964 for item in self.definition_widget.control_channels_selector.selectedItems():
965 item.setCheckState(Qt.Checked)
966
967 def uncheck_selected_control_channels(self):
968 """Callback to uncheck control channels that are selected"""
969 for item in self.definition_widget.control_channels_selector.selectedItems():
970 item.setCheckState(Qt.Unchecked)
971
972 # %% Predictions
973 def plot_predictions(self):
974 """Plots the control predictions based on the currently selected item"""
975 times = (
976 np.arange(self.specification_signal.shape[-1])
977 / self.data_acquisition_parameters.sample_rate
978 )
979 index = self.prediction_widget.excitation_selector.currentIndex()
980 self.plot_data_items["excitation_prediction"][0].setData(
981 times, self.excitation_prediction[index]
982 )
983 index = self.prediction_widget.response_selector.currentIndex()
984 self.plot_data_items["response_prediction"][0].setData(
985 times, self.response_prediction[index]
986 )
987 self.plot_data_items["response_prediction"][1].setData(
988 times, self.specification_signal[index]
989 )
990
991 def show_max_voltage_prediction(self):
992 """Callback to find and plot the time history showing the maximum drive voltage required"""
993 widget = self.prediction_widget.excitation_voltage_list
994 index = np.argmax([float(widget.item(v).text()) for v in range(widget.count())])
995 self.prediction_widget.excitation_selector.setCurrentIndex(index)
996
997 def show_min_voltage_prediction(self):
998 """Callback to find and plot the time history showing the minimum drive voltage required"""
999 widget = self.prediction_widget.excitation_voltage_list
1000 index = np.argmin([float(widget.item(v).text()) for v in range(widget.count())])
1001 self.prediction_widget.excitation_selector.setCurrentIndex(index)
1002
1003 def show_max_error_prediction(self):
1004 """Callback to find and plot the time history with the largest error compared to spec"""
1005 widget = self.prediction_widget.response_error_list
1006 index = np.argmax([float(widget.item(v).text()) for v in range(widget.count())])
1007 self.prediction_widget.response_selector.setCurrentIndex(index)
1008
1009 def show_min_error_prediction(self):
1010 """Callback to find and plot the time history with the smallest error compared to spec"""
1011 widget = self.prediction_widget.response_error_list
1012 index = np.argmin([float(widget.item(v).text()) for v in range(widget.count())])
1013 self.prediction_widget.response_selector.setCurrentIndex(index)
1014
1015 def update_response_error_prediction_selector(self, item):
1016 """Callback to update the response prediction selector when an item is doubleclicked"""
1017 index = self.prediction_widget.response_error_list.row(item)
1018 self.prediction_widget.response_selector.setCurrentIndex(index)
1019
1020 def update_excitation_prediction_selector(self, item):
1021 """Callback to update the drive predition selector when an item is doubleclicked"""
1022 index = self.prediction_widget.excitation_voltage_list.row(item)
1023 self.prediction_widget.excitation_selector.setCurrentIndex(index)
1024
1025 def recompute_predictions(self):
1026 """Recomputes the control predictions"""
1027 self.environment_command_queue.put(
1028 self.log_name, (TransientCommands.PERFORM_CONTROL_PREDICTION, False)
1029 )
1030
1031 # %% Control
1032
1033 def start_control(self):
1034 """Starts the chain of events to start the environment"""
1035 self.enable_control(False)
1036 self.controller_communication_queue.put(
1037 self.log_name, (GlobalCommands.START_ENVIRONMENT, self.environment_name)
1038 )
1039 self.environment_command_queue.put(
1040 self.log_name,
1041 (
1042 TransientCommands.START_CONTROL,
1043 (
1044 db2scale(self.run_widget.test_level_selector.value()),
1045 self.run_widget.repeat_signal_checkbox.isChecked(),
1046 ),
1047 ),
1048 )
1049 if self.run_widget.test_level_selector.value() >= 0:
1050 self.controller_communication_queue.put(
1051 self.log_name, (GlobalCommands.AT_TARGET_LEVEL, self.environment_name)
1052 )
1053 for item in self.plot_data_items["control_signal_measurement"]:
1054 item.clear()
1055 for item in self.plot_data_items["output_signal_measurement"]:
1056 item.clear()
1057
1058 def stop_control(self):
1059 """Starts the sequence of events to stop the controller prematurely"""
1060 self.environment_command_queue.put(self.log_name, (TransientCommands.STOP_CONTROL, None))
1061
1062 def enable_control(self, enabled):
1063 """Enables or disables the buttons to start control if it's already running"""
1064 for widget in [
1065 self.run_widget.test_level_selector,
1066 self.run_widget.repeat_signal_checkbox,
1067 self.run_widget.start_test_button,
1068 ]:
1069 widget.setEnabled(enabled)
1070 for widget in [self.run_widget.stop_test_button]:
1071 widget.setEnabled(not enabled)
1072
1073 def change_test_level_from_profile(self, test_level):
1074 """Updates the test level based on a profile event"""
1075 self.run_widget.test_level_selector.setValue(int(test_level))
1076
1077 def set_repeat_from_profile(self, data): # pylint: disable=unused-argument
1078 """Sets whether or not to repeat the signal based on profile events"""
1079 self.run_widget.repeat_signal_checkbox.setChecked(True)
1080
1081 def set_norepeat_from_profile(self, data): # pylint: disable=unused-argument
1082 """Sets whether or not to repeat the signal based on profile events"""
1083 self.run_widget.repeat_signal_checkbox.setChecked(False)
1084
1085 def set_display_duration(self, value):
1086 """Updates the display duration in the UI"""
1087 self.max_plot_samples = int(self.data_acquisition_parameters.sample_rate * value)
1088
1089 def create_window(self, event, control_index=None): # pylint: disable=unused-argument
1090 """Creates a subwindow to show a specific channel information
1091
1092 Parameters
1093 ----------
1094 event :
1095
1096 control_index :
1097 Row index in the specification matrix to display (Default value = None)
1098
1099 """
1100 if control_index is None:
1101 control_index = self.run_widget.control_channel_selector.currentIndex()
1102 self.plot_windows.append(
1103 PlotTimeWindow(
1104 None,
1105 control_index,
1106 self.environment_parameters.control_signal,
1107 self.data_acquisition_parameters.sample_rate,
1108 self.run_widget.control_channel_selector.itemText(control_index),
1109 )
1110 )
1111 if self.last_control_data is not None:
1112 self.plot_windows[-1].update_plot(self.last_control_data)
1113
1114 def show_all_channels(self):
1115 """Creates a subwindow for each ASD in the CPSD matrix"""
1116 for i in range(self.environment_parameters.control_signal.shape[0]):
1117 self.create_window(None, i)
1118 self.tile_windows()
1119
1120 def tile_windows(self):
1121 """Tile subwindow equally across the screen"""
1122 screen_rect = QtWidgets.QApplication.desktop().screenGeometry()
1123 # Go through and remove any closed windows
1124 self.plot_windows = [window for window in self.plot_windows if window.isVisible()]
1125 num_windows = len(self.plot_windows)
1126 ncols = int(np.ceil(np.sqrt(num_windows)))
1127 nrows = int(np.ceil(num_windows / ncols))
1128 window_width = int(screen_rect.width() / ncols)
1129 window_height = int(screen_rect.height() / nrows)
1130 for index, window in enumerate(self.plot_windows):
1131 window.resize(window_width, window_height)
1132 row_ind = index // ncols
1133 col_ind = index % ncols
1134 window.move(col_ind * window_width, row_ind * window_height)
1135
1136 def show_window(self, item):
1137 """Shows the currently selected control channel in a new subwindow"""
1138 index = self.run_widget.control_response_error_list.row(item)
1139 self.create_window(None, index)
1140
1141 def close_windows(self):
1142 """Close all subwindows"""
1143 for window in self.plot_windows:
1144 window.close()
1145
1146 def update_control_plots(self):
1147 """Updates plots in all of the existing subwindows"""
1148 # Go through and remove any closed windows
1149 self.plot_windows = [window for window in self.plot_windows if window.isVisible()]
1150 for window in self.plot_windows:
1151 window.update_plot(self.last_control_data)
1152
1153 def save_control_data(self):
1154 """Save Time-aligned Control Data from the Controller"""
1155 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
1156 self.definition_widget,
1157 "Select File to Save Spectral Data",
1158 filter="NetCDF File (*.nc4)",
1159 )
1160 if filename == "":
1161 return
1162 labels = [
1163 ["node_number", str],
1164 ["node_direction", str],
1165 ["comment", str],
1166 ["serial_number", str],
1167 ["triax_dof", str],
1168 ["sensitivity", str],
1169 ["unit", str],
1170 ["make", str],
1171 ["model", str],
1172 ["expiration", str],
1173 ["physical_device", str],
1174 ["physical_channel", str],
1175 ["channel_type", str],
1176 ["minimum_value", str],
1177 ["maximum_value", str],
1178 ["coupling", str],
1179 ["excitation_source", str],
1180 ["excitation", str],
1181 ["feedback_device", str],
1182 ["feedback_channel", str],
1183 ["warning_level", str],
1184 ["abort_level", str],
1185 ]
1186 global_data_parameters: DataAcquisitionParameters
1187 global_data_parameters = self.data_acquisition_parameters
1188 netcdf_handle = nc4.Dataset( # pylint: disable=no-member
1189 filename, "w", format="NETCDF4", clobber=True
1190 )
1191 # Create dimensions
1192 netcdf_handle.createDimension("response_channels", len(global_data_parameters.channel_list))
1193 netcdf_handle.createDimension(
1194 "output_channels",
1195 len(
1196 [
1197 channel
1198 for channel in global_data_parameters.channel_list
1199 if channel.feedback_device is not None
1200 ]
1201 ),
1202 )
1203 netcdf_handle.createDimension("time_samples", None)
1204 netcdf_handle.createDimension(
1205 "num_environments", len(global_data_parameters.environment_names)
1206 )
1207 # Create attributes
1208 netcdf_handle.file_version = "3.0.0"
1209 netcdf_handle.sample_rate = global_data_parameters.sample_rate
1210 netcdf_handle.time_per_write = (
1211 global_data_parameters.samples_per_write / global_data_parameters.output_sample_rate
1212 )
1213 netcdf_handle.time_per_read = (
1214 global_data_parameters.samples_per_read / global_data_parameters.sample_rate
1215 )
1216 netcdf_handle.hardware = global_data_parameters.hardware
1217 netcdf_handle.hardware_file = (
1218 "None"
1219 if global_data_parameters.hardware_file is None
1220 else global_data_parameters.hardware_file
1221 )
1222 netcdf_handle.output_oversample = global_data_parameters.output_oversample
1223 for key, value in global_data_parameters.extra_parameters.items():
1224 setattr(netcdf_handle, key, value)
1225 # Create Variables
1226 var = netcdf_handle.createVariable("environment_names", str, ("num_environments",))
1227 this_environment_index = None
1228 for i, name in enumerate(global_data_parameters.environment_names):
1229 var[i] = name
1230 if name == self.environment_name:
1231 this_environment_index = i
1232 var = netcdf_handle.createVariable(
1233 "environment_active_channels",
1234 "i1",
1235 ("response_channels", "num_environments"),
1236 )
1237 var[...] = global_data_parameters.environment_active_channels.astype("int8")[
1238 global_data_parameters.environment_active_channels[:, this_environment_index],
1239 :,
1240 ]
1241 # Create channel table variables
1242
1243 for label, netcdf_datatype in labels:
1244 var = netcdf_handle.createVariable(
1245 "/channels/" + label, netcdf_datatype, ("response_channels",)
1246 )
1247 channel_data = [
1248 getattr(channel, label) for channel in global_data_parameters.channel_list
1249 ]
1250 if netcdf_datatype == "i1":
1251 channel_data = np.array([1 if val else 0 for val in channel_data])
1252 else:
1253 channel_data = ["" if val is None else val for val in channel_data]
1254 for i, cd in enumerate(channel_data):
1255 var[i] = cd
1256 # Save the environment to the file
1257 group_handle = netcdf_handle.createGroup(self.environment_name)
1258 self.environment_parameters.store_to_netcdf(group_handle)
1259 # Create Variables for Spectral Data
1260 group_handle.createDimension("drive_channels", self.last_transfer_function.shape[2])
1261 group_handle.createDimension(
1262 "fft_lines", self.environment_parameters.sysid_frame_size // 2 + 1
1263 )
1264 var = group_handle.createVariable(
1265 "frf_data_real",
1266 "f8",
1267 ("fft_lines", "specification_channels", "drive_channels"),
1268 )
1269 var[...] = self.last_transfer_function.real
1270 var = group_handle.createVariable(
1271 "frf_data_imag",
1272 "f8",
1273 ("fft_lines", "specification_channels", "drive_channels"),
1274 )
1275 var[...] = self.last_transfer_function.imag
1276 var = group_handle.createVariable(
1277 "frf_coherence", "f8", ("fft_lines", "specification_channels")
1278 )
1279 var[...] = self.last_coherence.real
1280 var = group_handle.createVariable(
1281 "response_cpsd_real",
1282 "f8",
1283 ("fft_lines", "specification_channels", "specification_channels"),
1284 )
1285 var[...] = self.last_response_cpsd.real
1286 var = group_handle.createVariable(
1287 "response_cpsd_imag",
1288 "f8",
1289 ("fft_lines", "specification_channels", "specification_channels"),
1290 )
1291 var[...] = self.last_response_cpsd.imag
1292 var = group_handle.createVariable(
1293 "drive_cpsd_real", "f8", ("fft_lines", "drive_channels", "drive_channels")
1294 )
1295 var[...] = self.last_reference_cpsd.real
1296 var = group_handle.createVariable(
1297 "drive_cpsd_imag", "f8", ("fft_lines", "drive_channels", "drive_channels")
1298 )
1299 var[...] = self.last_reference_cpsd.imag
1300 var = group_handle.createVariable(
1301 "response_noise_cpsd_real",
1302 "f8",
1303 ("fft_lines", "specification_channels", "specification_channels"),
1304 )
1305 var[...] = self.last_response_noise.real
1306 var = group_handle.createVariable(
1307 "response_noise_cpsd_imag",
1308 "f8",
1309 ("fft_lines", "specification_channels", "specification_channels"),
1310 )
1311 var[...] = self.last_response_noise.imag
1312 var = group_handle.createVariable(
1313 "drive_noise_cpsd_real",
1314 "f8",
1315 ("fft_lines", "drive_channels", "drive_channels"),
1316 )
1317 var[...] = self.last_reference_noise.real
1318 var = group_handle.createVariable(
1319 "drive_noise_cpsd_imag",
1320 "f8",
1321 ("fft_lines", "drive_channels", "drive_channels"),
1322 )
1323 var[...] = self.last_reference_noise.imag
1324 var = group_handle.createVariable(
1325 "control_response", "f8", ("specification_channels", "signal_samples")
1326 )
1327 var[...] = self.last_control_data
1328 var = group_handle.createVariable(
1329 "control_drives", "f8", ("drive_channels", "signal_samples")
1330 )
1331 var[...] = self.last_output_data
1332 netcdf_handle.close()
1333
1334 # %% Misc
1335
1336 def retrieve_metadata(self, netcdf_handle=None, environment_name=None):
1337 group = super().retrieve_metadata(netcdf_handle, environment_name)
1338
1339 # Control channels
1340 try:
1341 for i in group.variables["control_channel_indices"][...]:
1342 item = self.definition_widget.control_channels_selector.item(i)
1343 item.setCheckState(Qt.Checked)
1344 except KeyError:
1345 print("no variable control_channel_indices, please select control channels manually")
1346 # Other Data
1347 try:
1348 self.response_transformation_matrix = group.variables["response_transformation_matrix"][
1349 ...
1350 ].data
1351 except KeyError:
1352 self.response_transformation_matrix = None
1353 try:
1354 self.output_transformation_matrix = group.variables["reference_transformation_matrix"][
1355 ...
1356 ].data
1357 except KeyError:
1358 self.output_transformation_matrix = None
1359 self.define_transformation_matrices(None, dialog=False)
1360
1361 if (
1362 environment_name is None
1363 ): # environment_name is passed when the saved environment doesn't
1364 # match the current environment
1365 self.definition_widget.ramp_selector.setValue(group.test_level_ramp_time)
1366 self.specification_signal = group.variables["control_signal"][...].data
1367 self.select_python_module(None, group.control_python_script)
1368 index = self.definition_widget.control_function_input.findText(
1369 group.control_python_function
1370 )
1371 if index == -1:
1372 index = 0
1373 default = self.definition_widget.control_function_input.itemText(index)
1374 print(
1375 f'Warning: control function "{group.control_python_function}" '
1376 f'not found, defaulting to "{default}"'
1377 )
1378 self.definition_widget.control_function_input.setCurrentIndex(index)
1379 self.definition_widget.control_parameters_text_input.setText(
1380 group.control_python_function_parameters
1381 )
1382 self.setup_specification_table()
1383 self.show_signal()
1384
1385 def update_gui(self, queue_data):
1386 if super().update_gui(queue_data):
1387 return
1388 message, data = queue_data
1389 if message == "time_data":
1390 response_data, output_data, signal_delay = data
1391 max_y = -1e15
1392 min_y = 1e15
1393 for curve, this_data in zip(
1394 self.plot_data_items["control_signal_measurement"], response_data
1395 ):
1396 x, y = curve.getOriginalDataset()
1397 if y is not None:
1398 if np.max(y) > max_y:
1399 max_y = np.max(y)
1400 if np.min(y) < min_y:
1401 min_y = np.min(y)
1402 if self.max_plot_samples == x.size:
1403 x += (this_data.size) / self.data_acquisition_parameters.sample_rate
1404 y = np.roll(y, -this_data.size)
1405 y[-this_data.size :] = this_data
1406 else:
1407 x = np.concatenate(
1408 (
1409 x,
1410 x[-1]
1411 + (
1412 (1 + np.arange(this_data.size))
1413 / self.data_acquisition_parameters.sample_rate
1414 ),
1415 ),
1416 axis=0,
1417 )
1418 y = np.concatenate((y, this_data), axis=0)
1419 else:
1420 x = np.arange(this_data.size) / self.data_acquisition_parameters.sample_rate
1421 y = this_data
1422 curve.setData(x[-self.max_plot_samples :], y[-self.max_plot_samples :])
1423 # Display the data
1424 for curve, this_output in zip(
1425 self.plot_data_items["output_signal_measurement"], output_data
1426 ):
1427 x, y = curve.getOriginalDataset()
1428 if y is not None:
1429 if self.max_plot_samples == x.size:
1430 x += (this_output.size) / self.data_acquisition_parameters.sample_rate
1431 y = np.roll(y, -this_output.size)
1432 y[-this_output.size :] = this_output
1433 else:
1434 x = np.concatenate(
1435 (
1436 x,
1437 x[-1]
1438 + (
1439 (1 + np.arange(this_output.size))
1440 / self.data_acquisition_parameters.sample_rate
1441 ),
1442 ),
1443 axis=0,
1444 )
1445 y = np.concatenate((y, this_output), axis=0)
1446 else:
1447 x = np.arange(this_output.size) / self.data_acquisition_parameters.sample_rate
1448 y = this_output
1449 curve.setData(x[-self.max_plot_samples :], y[-self.max_plot_samples :])
1450 if signal_delay is None:
1451 self.plot_data_items["signal_range"].setData(np.ones(5) * x[-1], np.zeros(5))
1452 elif message == "control_data":
1453 self.last_control_data, self.last_output_data = data
1454 self.update_control_plots()
1455 max_y = np.max(self.last_control_data)
1456 min_y = np.min(self.last_control_data)
1457 for curve, this_data in zip(
1458 self.plot_data_items["control_signal_measurement"],
1459 self.last_control_data,
1460 ):
1461 x, y = curve.getOriginalDataset()
1462 x = np.arange(this_data.size) / self.data_acquisition_parameters.sample_rate
1463 y = this_data
1464 curve.setData(x, y)
1465 # Display the data
1466 for curve, this_output in zip(
1467 self.plot_data_items["output_signal_measurement"], self.last_output_data
1468 ):
1469 x, y = curve.getOriginalDataset()
1470 x = np.arange(this_output.size) / self.data_acquisition_parameters.sample_rate
1471 y = this_output
1472 curve.setData(x, y)
1473 sr = self.data_acquisition_parameters.sample_rate
1474 self.plot_data_items["signal_range"].setData(
1475 np.array(
1476 (
1477 0,
1478 0,
1479 (self.environment_parameters.control_signal.shape[-1] - 1) / sr,
1480 (self.environment_parameters.control_signal.shape[-1] - 1) / sr,
1481 0,
1482 )
1483 ),
1484 1.05 * np.array((min_y, max_y, max_y, min_y, min_y)),
1485 )
1486 elif message == "control_predictions":
1487 (
1488 _, # times,
1489 self.excitation_prediction,
1490 self.response_prediction,
1491 _, # prediction,
1492 ) = data
1493 self.plot_predictions()
1494 elif message == "interactive_control_sysid_update":
1495 if self.interactive_control_law_widget is not None:
1496 self.interactive_control_law_widget.update_ui_sysid(*data)
1497 elif message == "interactive_control_update":
1498 if self.interactive_control_law_widget is not None:
1499 self.interactive_control_law_widget.update_ui_control(data)
1500 elif message == "enable_control":
1501 self.enable_control(True)
1502 elif message == "enable":
1503 widget = None
1504 for parent in [
1505 self.definition_widget,
1506 self.run_widget,
1507 self.system_id_widget,
1508 self.prediction_widget,
1509 ]:
1510 try:
1511 widget = getattr(parent, data)
1512 break
1513 except AttributeError:
1514 continue
1515 if widget is None:
1516 raise ValueError(f"Cannot Enable Widget {data}: not found in UI")
1517 widget.setEnabled(True)
1518 elif message == "disable":
1519 widget = None
1520 for parent in [
1521 self.definition_widget,
1522 self.run_widget,
1523 self.system_id_widget,
1524 self.prediction_widget,
1525 ]:
1526 try:
1527 widget = getattr(parent, data)
1528 break
1529 except AttributeError:
1530 continue
1531 if widget is None:
1532 raise ValueError(f"Cannot Disable Widget {data}: not found in UI")
1533 widget.setEnabled(False)
1534 else:
1535 widget = None
1536 for parent in [
1537 self.definition_widget,
1538 self.run_widget,
1539 self.system_id_widget,
1540 self.prediction_widget,
1541 ]:
1542 try:
1543 widget = getattr(parent, message)
1544 break
1545 except AttributeError:
1546 continue
1547 if widget is None:
1548 raise ValueError(f"Cannot Update Widget {message}: not found in UI")
1549 if isinstance(widget, QtWidgets.QDoubleSpinBox):
1550 widget.setValue(data)
1551 elif isinstance(widget, QtWidgets.QSpinBox):
1552 widget.setValue(data)
1553 elif isinstance(widget, QtWidgets.QLineEdit):
1554 widget.setText(data)
1555 elif isinstance(widget, QtWidgets.QListWidget):
1556 widget.clear()
1557 widget.addItems([f"{d:.3f}" for d in data])
1558
1559 def set_parameters_from_template(self, worksheet):
1560 self.definition_widget.ramp_selector.setValue(float(worksheet.cell(3, 2).value))
1561 self.select_python_module(None, worksheet.cell(4, 2).value)
1562 self.definition_widget.control_function_input.setCurrentIndex(
1563 self.definition_widget.control_function_input.findText(worksheet.cell(5, 2).value)
1564 )
1565 self.definition_widget.control_parameters_text_input.setText(
1566 "" if worksheet.cell(6, 2).value is None else str(worksheet.cell(6, 2).value)
1567 )
1568 column_index = 2
1569 while True:
1570 value = worksheet.cell(7, column_index).value
1571 if value is None or (isinstance(value, str) and value.strip() == ""):
1572 break
1573 item = self.definition_widget.control_channels_selector.item(int(value) - 1)
1574 item.setCheckState(Qt.Checked)
1575 column_index += 1
1576 self.system_id_widget.samplesPerFrameSpinBox.setValue(int(worksheet.cell(8, 2).value))
1577 self.system_id_widget.averagingTypeComboBox.setCurrentIndex(
1578 self.system_id_widget.averagingTypeComboBox.findText(worksheet.cell(9, 2).value)
1579 )
1580 self.system_id_widget.noiseAveragesSpinBox.setValue(int(worksheet.cell(10, 2).value))
1581 self.system_id_widget.systemIDAveragesSpinBox.setValue(int(worksheet.cell(11, 2).value))
1582 self.system_id_widget.averagingCoefficientDoubleSpinBox.setValue(
1583 float(worksheet.cell(12, 2).value)
1584 )
1585 self.system_id_widget.estimatorComboBox.setCurrentIndex(
1586 self.system_id_widget.estimatorComboBox.findText(worksheet.cell(13, 2).value)
1587 )
1588 self.system_id_widget.levelDoubleSpinBox.setValue(float(worksheet.cell(14, 2).value))
1589 # this should be a temporary solution - template file rework needed
1590 low, high = worksheet.cell(14, 3).value, worksheet.cell(14, 4).value
1591 if low is not None:
1592 self.system_id_widget.lowFreqCutoffSpinBox.setValue(int(low))
1593 if high is not None:
1594 self.system_id_widget.highFreqCutoffSpinBox.setValue(int(high))
1595 self.system_id_widget.levelRampTimeDoubleSpinBox.setValue(
1596 float(worksheet.cell(15, 2).value)
1597 )
1598 self.system_id_widget.signalTypeComboBox.setCurrentIndex(
1599 self.system_id_widget.signalTypeComboBox.findText(worksheet.cell(16, 2).value)
1600 )
1601 self.system_id_widget.windowComboBox.setCurrentIndex(
1602 self.system_id_widget.windowComboBox.findText(worksheet.cell(17, 2).value)
1603 )
1604 self.system_id_widget.overlapDoubleSpinBox.setValue(float(worksheet.cell(18, 2).value))
1605 self.system_id_widget.onFractionDoubleSpinBox.setValue(float(worksheet.cell(19, 2).value))
1606 self.system_id_widget.pretriggerDoubleSpinBox.setValue(float(worksheet.cell(20, 2).value))
1607 self.system_id_widget.rampFractionDoubleSpinBox.setValue(float(worksheet.cell(21, 2).value))
1608
1609 # Now we need to find the transformation matrices' sizes
1610 response_channels = self.definition_widget.control_channels_display.value()
1611 output_channels = self.definition_widget.output_channels_display.value()
1612 output_transform_row = 23
1613 if (
1614 isinstance(worksheet.cell(22, 2).value, str)
1615 and worksheet.cell(22, 2).value.lower() == "none"
1616 ):
1617 self.response_transformation_matrix = None
1618 else:
1619 while True:
1620 if worksheet.cell(output_transform_row, 1).value == "Output Transformation Matrix:":
1621 break
1622 output_transform_row += 1
1623 response_size = output_transform_row - 22
1624 response_transformation = []
1625 for i in range(response_size):
1626 response_transformation.append([])
1627 for j in range(response_channels):
1628 response_transformation[-1].append(float(worksheet.cell(22 + i, 2 + j).value))
1629 self.response_transformation_matrix = np.array(response_transformation)
1630 if (
1631 isinstance(worksheet.cell(output_transform_row, 2).value, str)
1632 and worksheet.cell(output_transform_row, 2).value.lower() == "none"
1633 ):
1634 self.output_transformation_matrix = None
1635 else:
1636 output_transformation = []
1637 i = 0
1638 while True:
1639 if worksheet.cell(output_transform_row + i, 2).value is None or (
1640 isinstance(worksheet.cell(output_transform_row + i, 2).value, str)
1641 and worksheet.cell(output_transform_row + i, 2).value.strip() == ""
1642 ):
1643 break
1644 output_transformation.append([])
1645 for j in range(output_channels):
1646 output_transformation[-1].append(
1647 float(worksheet.cell(output_transform_row + i, 2 + j).value)
1648 )
1649 i += 1
1650 self.output_transformation_matrix = np.array(output_transformation)
1651 self.define_transformation_matrices(None, dialog=False)
1652 self.load_signal(None, worksheet.cell(2, 2).value)
1653
1654 @staticmethod
1655 def create_environment_template(environment_name, workbook):
1656 worksheet = workbook.create_sheet(environment_name)
1657 worksheet.cell(1, 1, "Control Type")
1658 worksheet.cell(1, 2, "Transient")
1659 worksheet.cell(
1660 1,
1661 4,
1662 "Note: Replace cells with hash marks (#) to provide the requested parameters.",
1663 )
1664 worksheet.cell(2, 1, "Signal File")
1665 worksheet.cell(2, 2, "# Path to the file that contains the time signal that will be output")
1666 worksheet.cell(3, 1, "Ramp Time")
1667 worksheet.cell(
1668 3,
1669 2,
1670 "# Time for the environment to ramp between levels or from start or to stop.",
1671 )
1672 worksheet.cell(4, 1, "Control Python Script:")
1673 worksheet.cell(4, 2, "# Path to the Python script containing the control law")
1674 worksheet.cell(5, 1, "Control Python Function:")
1675 worksheet.cell(
1676 5,
1677 2,
1678 "# Function name within the Python Script that will serve as the control law",
1679 )
1680 worksheet.cell(6, 1, "Control Parameters:")
1681 worksheet.cell(6, 2, "# Extra parameters used in the control law")
1682 worksheet.cell(7, 1, "Control Channels (1-based):")
1683 worksheet.cell(7, 2, "# List of channels, one per cell on this row")
1684 worksheet.cell(8, 1, "System ID Samples per Frame")
1685 worksheet.cell(
1686 8,
1687 2,
1688 "# Number of Samples per Measurement Frame in the System Identification",
1689 )
1690 worksheet.cell(9, 1, "System ID Averaging:")
1691 worksheet.cell(9, 2, "# Averaging Type, should be Linear or Exponential")
1692 worksheet.cell(10, 1, "Noise Averages:")
1693 worksheet.cell(10, 2, "# Number of Averages used when characterizing noise")
1694 worksheet.cell(11, 1, "System ID Averages:")
1695 worksheet.cell(11, 2, "# Number of Averages used when computing the FRF")
1696 worksheet.cell(12, 1, "Exponential Averaging Coefficient:")
1697 worksheet.cell(12, 2, "# Averaging Coefficient for Exponential Averaging (if used)")
1698 worksheet.cell(13, 1, "System ID Estimator:")
1699 worksheet.cell(
1700 13,
1701 2,
1702 "# Technique used to compute system ID. Should be one of H1, H2, H3, or Hv.",
1703 )
1704 worksheet.cell(14, 1, "System ID Level (V RMS):")
1705 worksheet.cell(
1706 14,
1707 2,
1708 "# RMS Value of Flat Voltage Spectrum used for System Identification.",
1709 )
1710 worksheet.cell(15, 1, "System ID Ramp Time")
1711 worksheet.cell(
1712 15,
1713 2,
1714 "# Time for the system identification to ramp between levels or from start or to stop.",
1715 )
1716 worksheet.cell(16, 1, "System ID Signal Type:")
1717 worksheet.cell(16, 2, "# Signal to use for the system identification")
1718 worksheet.cell(17, 1, "System ID Window:")
1719 worksheet.cell(
1720 17,
1721 2,
1722 "# Window used to compute FRFs during system ID. Should be one of Hann or None",
1723 )
1724 worksheet.cell(18, 1, "System ID Overlap %:")
1725 worksheet.cell(18, 2, "# Overlap to use in the system identification")
1726 worksheet.cell(19, 1, "System ID Burst On %:")
1727 worksheet.cell(19, 2, "# Percentage of a frame that the burst random is on for")
1728 worksheet.cell(20, 1, "System ID Burst Pretrigger %:")
1729 worksheet.cell(
1730 20,
1731 2,
1732 "# Percentage of a frame that occurs before the burst starts in a burst random signal",
1733 )
1734 worksheet.cell(21, 1, "System ID Ramp Fraction %:")
1735 worksheet.cell(
1736 21,
1737 2,
1738 '# Percentage of the "System ID Burst On %" that will be used to ramp up to full level',
1739 )
1740 worksheet.cell(22, 1, "Response Transformation Matrix:")
1741 worksheet.cell(
1742 22,
1743 2,
1744 "# Transformation matrix to apply to the response channels. Type None if there "
1745 "is none. Otherwise, make this a 2D array in the spreadsheet and move the Output "
1746 "Transformation Matrix line down so it will fit. The number of columns should be "
1747 "the number of physical control channels.",
1748 )
1749 worksheet.cell(23, 1, "Output Transformation Matrix:")
1750 worksheet.cell(
1751 23,
1752 2,
1753 "# Transformation matrix to apply to the outputs. Type None if there is none. "
1754 "Otherwise, make this a 2D array in the spreadsheet. The number of columns should "
1755 "be the number of physical output channels in the environment.",
1756 )
1757
1758
1759# %% Environment
1760
1761
1762class TransientEnvironment(AbstractSysIdEnvironment):
1763 """Class defining calculations for the transient environment"""
1764
1765 def __init__(
1766 self,
1767 environment_name: str,
1768 queue_container: TransientQueues,
1769 acquisition_active: mp.sharedctypes.Synchronized,
1770 output_active: mp.sharedctypes.Synchronized,
1771 ):
1772 super().__init__(
1773 environment_name,
1774 queue_container.environment_command_queue,
1775 queue_container.gui_update_queue,
1776 queue_container.controller_communication_queue,
1777 queue_container.log_file_queue,
1778 queue_container.collector_command_queue,
1779 queue_container.signal_generation_command_queue,
1780 queue_container.spectral_command_queue,
1781 queue_container.data_analysis_command_queue,
1782 queue_container.data_in_queue,
1783 queue_container.data_out_queue,
1784 acquisition_active,
1785 output_active,
1786 )
1787 self.map_command(
1788 TransientCommands.PERFORM_CONTROL_PREDICTION,
1789 self.perform_control_prediction,
1790 )
1791 self.map_command(TransientCommands.START_CONTROL, self.start_control)
1792 self.map_command(TransientCommands.STOP_CONTROL, self.stop_environment)
1793 self.map_command(
1794 GlobalCommands.UPDATE_INTERACTIVE_CONTROL_PARAMETERS,
1795 self.update_interactive_control_parameters,
1796 )
1797 self.map_command(GlobalCommands.SEND_INTERACTIVE_COMMAND, self.send_interactive_command)
1798 # Persistent data
1799 self.data_acquisition_parameters = None
1800 self.environment_parameters = None
1801 self.queue_container = queue_container
1802 self.frames = None
1803 self.frequencies = None
1804 self.frf = None
1805 self.sysid_coherence = None
1806 self.sysid_response_cpsd = None
1807 self.sysid_reference_cpsd = None
1808 self.sysid_condition = None
1809 self.sysid_response_noise = None
1810 self.sysid_reference_noise = None
1811 self.control_function_type = None
1812 self.extra_control_parameters = None
1813 self.control_function = None
1814 self.aligned_output = None
1815 self.aligned_response = None
1816 self.next_drive = None
1817 self.predicted_response = None
1818 self.startup = True
1819 self.shutdown_flag = False
1820 self.repeat = False
1821 self.test_level = 0
1822 self.control_buffer = None
1823 self.output_buffer = None
1824 self.last_signal_found = None
1825 self.has_sent_interactive_control_transfer_function_results = False
1826 self.last_interactive_parameters = None
1827
1828 def initialize_environment_test_parameters(self, environment_parameters: TransientMetadata):
1829 if (
1830 self.environment_parameters is None
1831 or self.environment_parameters.control_signal.shape
1832 != environment_parameters.control_signal.shape
1833 ):
1834 self.frames = None
1835 self.frequencies = None
1836 self.frf = None
1837 self.sysid_coherence = None
1838 self.sysid_response_cpsd = None
1839 self.sysid_reference_cpsd = None
1840 self.sysid_condition = None
1841 self.sysid_response_noise = None
1842 self.sysid_reference_noise = None
1843 self.control_function_type = None
1844 self.extra_control_parameters = None
1845 self.control_function = None
1846 self.aligned_output = None
1847 self.aligned_response = None
1848 self.next_drive = None
1849 self.predicted_response = None
1850 super().initialize_environment_test_parameters(environment_parameters)
1851 self.environment_parameters: TransientMetadata
1852 # Load in the control law
1853 _, file = os.path.split(environment_parameters.control_python_script)
1854 file, _ = os.path.splitext(file)
1855 spec = importlib.util.spec_from_file_location(
1856 file, environment_parameters.control_python_script
1857 )
1858 module = importlib.util.module_from_spec(spec)
1859 spec.loader.exec_module(module)
1860 self.control_function_type = environment_parameters.control_python_function_type
1861 self.extra_control_parameters = environment_parameters.control_python_function_parameters
1862 if self.control_function_type == 1: # Generator
1863 # Get the generator function
1864 generator_function = getattr(module, environment_parameters.control_python_function)()
1865 # Get us to the first yield statement
1866 next(generator_function)
1867 # Define the control function as the generator's send function
1868 self.control_function = generator_function.send
1869 elif self.control_function_type == 2: # Class
1870 self.control_function = getattr(module, environment_parameters.control_python_function)(
1871 self.data_acquisition_parameters.sample_rate,
1872 self.environment_parameters.control_signal,
1873 self.data_acquisition_parameters.output_oversample,
1874 self.extra_control_parameters, # Required parameters
1875 self.environment_parameters.sysid_frequency_spacing, # Frequency Spacing
1876 self.frf, # Transfer Functions
1877 self.sysid_response_noise, # Noise levels and correlation
1878 self.sysid_reference_noise, # from the system identification
1879 self.sysid_response_cpsd, # Response levels and correlation
1880 self.sysid_reference_cpsd, # from the system identification
1881 self.sysid_coherence, # Coherence from the system identification
1882 self.frames, # Number of frames in the CPSD and FRF matrices
1883 self.environment_parameters.sysid_averages, # Total frames that
1884 # could be in the CPSD and FRF matrices
1885 self.aligned_output, # Last excitation signal for drive-based control
1886 self.aligned_response,
1887 ) # Last response signal for error-based correction
1888 elif self.control_function_type == 3: # Interactive Class
1889 control_class = getattr(module, environment_parameters.control_python_function)
1890 self.control_function = control_class(
1891 self.environment_name,
1892 self.gui_update_queue,
1893 self.data_acquisition_parameters.sample_rate,
1894 self.environment_parameters.control_signal,
1895 self.data_acquisition_parameters.output_oversample,
1896 self.extra_control_parameters, # Required parameters
1897 self.environment_parameters.sysid_frequency_spacing, # Frequency Spacing
1898 self.frf, # Transfer Functions
1899 self.sysid_response_noise, # Noise levels and correlation
1900 self.sysid_reference_noise, # from the system identification
1901 self.sysid_response_cpsd, # Response levels and correlation
1902 self.sysid_reference_cpsd, # from the system identification
1903 self.sysid_coherence, # Coherence from the system identification
1904 self.frames, # Number of frames in the CPSD and FRF matrices
1905 self.environment_parameters.sysid_averages, # Total frames tha
1906 # could be in the CPSD and FRF matrices
1907 self.aligned_output, # Last excitation signal for drive-based control
1908 self.aligned_response,
1909 ) # Last response signal for error-based correction
1910 self.last_interactive_parameters = None
1911 self.has_sent_interactive_control_transfer_function_results = False
1912 else: # Function
1913 self.control_function = getattr(module, environment_parameters.control_python_function)
1914
1915 def update_interactive_control_parameters(self, interactive_control_parameters):
1916 """Updates the interactive control law based on received parameters"""
1917 if self.environment_parameters.control_python_function_type == 3: # Interactive
1918 self.control_function.update_parameters(interactive_control_parameters)
1919 self.last_interactive_parameters = interactive_control_parameters
1920 else:
1921 raise ValueError(
1922 "Received an UPDATE_INTERACTIVE_CONTROL_PARAMETERS signal without an "
1923 "interactive control law. How did this happen?"
1924 )
1925
1926 def send_interactive_command(self, command):
1927 """General method that can be used by an interactive UI object to pass commands
1928 and data to its corresponding computation object"""
1929 if self.environment_parameters.control_python_function_type == 3: # Interactive
1930 self.control_function.send_command(command)
1931 else:
1932 raise ValueError(
1933 "Received an SEND_INTERACTIVE_COMMAND signal without an interactive "
1934 "control law. How did this happen?"
1935 )
1936
1937 def system_id_complete(self, data):
1938 """Sends the message that system identification is complete and control calculations
1939 should be performed"""
1940 super().system_id_complete(data)
1941 (
1942 self.frames,
1943 _, # avg,
1944 self.frequencies,
1945 self.frf,
1946 self.sysid_coherence,
1947 self.sysid_response_cpsd,
1948 self.sysid_reference_cpsd,
1949 self.sysid_condition,
1950 self.sysid_response_noise,
1951 self.sysid_reference_noise,
1952 ) = data
1953 # Perform the control prediction
1954 self.perform_control_prediction(True)
1955
1956 def perform_control_prediction(self, sysid_update):
1957 """Performs the control prediction based on system identification information"""
1958 if self.frf is None:
1959 self.gui_update_queue.put(
1960 (
1961 "error",
1962 (
1963 "Perform System Identification",
1964 "Perform System ID before performing test predictions",
1965 ),
1966 )
1967 )
1968 return
1969 if self.control_function_type == 1: # Generator
1970 output_time_history = self.control_function(
1971 (
1972 self.data_acquisition_parameters.sample_rate,
1973 self.environment_parameters.control_signal,
1974 self.environment_parameters.sysid_frequency_spacing,
1975 self.frf, # Transfer Functions
1976 self.sysid_response_noise, # Noise levels and correlation
1977 self.sysid_reference_noise, # from the system identification
1978 self.sysid_response_cpsd, # Response levels and correlation
1979 self.sysid_reference_cpsd, # from the system identification
1980 self.sysid_coherence, # Coherence from the system identification
1981 self.frames, # Number of frames in the CPSD and FRF matrices
1982 self.environment_parameters.sysid_averages, # Total frames that could be in
1983 # the CPSD and FRF matrices
1984 self.data_acquisition_parameters.output_oversample,
1985 self.extra_control_parameters, # Required parameters
1986 self.next_drive, # Last excitation signal for drive-based control
1987 self.predicted_response, # Last response signal for error correction
1988 )
1989 )
1990 elif self.control_function_type in [2, 3]: # Class or Interactive Class
1991 if (
1992 self.environment_parameters.control_python_function == 2
1993 or not self.has_sent_interactive_control_transfer_function_results
1994 ):
1995 if sysid_update:
1996 self.control_function.system_id_update(
1997 self.environment_parameters.sysid_frequency_spacing,
1998 self.frf, # Transfer Functions
1999 self.sysid_response_noise, # Noise levels and correlation
2000 self.sysid_reference_noise, # from the system identification
2001 self.sysid_response_cpsd, # Response levels and correlation
2002 self.sysid_reference_cpsd, # from the system identification
2003 self.sysid_coherence, # Coherence from the system identification
2004 self.frames, # Number of frames in the CPSD and FRF matrices
2005 self.environment_parameters.sysid_averages, # Total frames that
2006 # could be in the CPSD and FRF matrices
2007 )
2008
2009 if self.environment_parameters.control_python_function_type == 3:
2010 self.gui_update_queue.put(
2011 (
2012 self.environment_name,
2013 (
2014 "interactive_control_sysid_update",
2015 (
2016 self.frf,
2017 self.sysid_response_noise,
2018 self.sysid_reference_noise,
2019 self.sysid_response_cpsd,
2020 self.sysid_reference_cpsd,
2021 self.sysid_coherence,
2022 ),
2023 ),
2024 )
2025 )
2026 self.has_sent_interactive_control_transfer_function_results = True
2027 if (
2028 self.environment_parameters.control_python_function_type == 2
2029 or self.last_interactive_parameters is not None
2030 ):
2031 output_time_history = self.control_function.control(
2032 self.next_drive, self.predicted_response
2033 )
2034 else:
2035 self.log("Have not yet received control parameters from interactive control law!")
2036 output_time_history = None
2037 return
2038 else: # Function
2039 output_time_history = self.control_function(
2040 self.data_acquisition_parameters.sample_rate,
2041 self.environment_parameters.control_signal,
2042 self.environment_parameters.sysid_frequency_spacing,
2043 self.frf, # Transfer Functions
2044 self.sysid_response_noise, # Noise levels and correlation
2045 self.sysid_reference_noise, # from the system identification
2046 self.sysid_response_cpsd, # Response levels and correlation
2047 self.sysid_reference_cpsd, # from the system identification
2048 self.sysid_coherence, # Coherence from the system identification
2049 self.frames, # Number of frames in the CPSD and FRF matrices
2050 self.environment_parameters.sysid_averages, # Total frames that could
2051 # be in the CPSD and FRF matrices
2052 self.data_acquisition_parameters.output_oversample,
2053 self.extra_control_parameters, # Required parameters
2054 self.next_drive, # Last excitation signal for drive-based control
2055 self.predicted_response, # Last response signal for error correction
2056 )
2057 self.next_drive = output_time_history
2058 self.show_test_prediction()
2059
2060 def show_test_prediction(self):
2061 """Sends the test predictions to the UI"""
2062 # print('Drive Signals {:}'.format(self.next_drive.shape))
2063 drive_signals = self.next_drive[:, :: self.data_acquisition_parameters.output_oversample]
2064 impulse_responses = np.moveaxis(np.fft.irfft(self.frf, axis=0), 0, -1)
2065
2066 self.predicted_response = np.zeros((impulse_responses.shape[0], drive_signals.shape[-1]))
2067
2068 for i, impulse_response_row in enumerate(impulse_responses):
2069 for _, (impulse, drive) in enumerate(zip(impulse_response_row, drive_signals)):
2070 # print('Convolving {:},{:}'.format(i,j))
2071 self.predicted_response[i, :] += sig.convolve(drive, impulse, "full")[
2072 : drive_signals.shape[-1]
2073 ]
2074
2075 # print('Response Prediction {:}'.format(self.predicted_response.shape))
2076 # print('Control Signal {:}'.format(self.environment_parameters.control_signal.shape))
2077 time_trac = trac(self.predicted_response, self.environment_parameters.control_signal)
2078 peak_voltages = np.max(np.abs(self.next_drive), axis=-1)
2079 self.gui_update_queue.put(
2080 (self.environment_name, ("excitation_voltage_list", peak_voltages))
2081 )
2082 self.gui_update_queue.put((self.environment_name, ("response_error_list", time_trac)))
2083 self.gui_update_queue.put(
2084 (
2085 self.environment_name,
2086 (
2087 "control_predictions",
2088 (
2089 np.arange(self.environment_parameters.control_signal.shape[-1])
2090 / self.data_acquisition_parameters.sample_rate,
2091 drive_signals,
2092 self.predicted_response,
2093 self.environment_parameters.control_signal,
2094 ),
2095 ),
2096 )
2097 )
2098
2099 def get_signal_generation_metadata(self):
2100 """Collects the metadata required to define the signal generation process"""
2101 return SignalGenerationMetadata(
2102 samples_per_write=self.data_acquisition_parameters.samples_per_write,
2103 level_ramp_samples=self.environment_parameters.test_level_ramp_time
2104 * self.environment_parameters.sample_rate
2105 * self.data_acquisition_parameters.output_oversample,
2106 output_transformation_matrix=self.environment_parameters.reference_transformation_matrix,
2107 )
2108
2109 def start_control(self, data):
2110 """Starts up the control to generate the signal"""
2111 if self.startup:
2112 self.test_level, self.repeat = data
2113 self.log("Starting Environment")
2114 self.siggen_shutdown_achieved = False
2115 # Set up the signal generation
2116 self.queue_container.signal_generation_command_queue.put(
2117 self.environment_name,
2118 (
2119 SignalGenerationCommands.INITIALIZE_PARAMETERS,
2120 self.get_signal_generation_metadata(),
2121 ),
2122 )
2123 self.queue_container.signal_generation_command_queue.put(
2124 self.environment_name,
2125 (
2126 SignalGenerationCommands.INITIALIZE_SIGNAL_GENERATOR,
2127 TransientSignalGenerator(self.next_drive, self.repeat),
2128 ),
2129 )
2130 self.queue_container.signal_generation_command_queue.put(
2131 self.environment_name,
2132 (SignalGenerationCommands.SET_TEST_LEVEL, self.test_level),
2133 )
2134 # Tell the signal generation to start generating signals
2135 self.queue_container.signal_generation_command_queue.put(
2136 self.environment_name, (SignalGenerationCommands.GENERATE_SIGNALS, None)
2137 )
2138 # Set up the measurement buffers
2139 n_control_channels = (
2140 len(self.environment_parameters.control_channel_indices)
2141 if self.environment_parameters.response_transformation_matrix is None
2142 else self.environment_parameters.response_transformation_matrix.shape[0]
2143 )
2144 n_output_channels = (
2145 len(self.environment_parameters.output_channel_indices)
2146 if self.environment_parameters.reference_transformation_matrix is None
2147 else self.environment_parameters.reference_transformation_matrix.shape[0]
2148 )
2149 self.control_buffer = FrameBuffer(
2150 n_control_channels,
2151 0,
2152 0,
2153 False,
2154 0,
2155 0,
2156 0,
2157 self.environment_parameters.control_signal.shape[-1],
2158 0,
2159 False,
2160 False,
2161 False,
2162 0,
2163 buffer_size_frame_multiplier=1
2164 + (
2165 self.data_acquisition_parameters.samples_per_read
2166 * BUFFER_SIZE_SAMPLES_PER_READ_MULTIPLIER
2167 / self.environment_parameters.control_signal.shape[-1]
2168 ),
2169 starting_value=0.0,
2170 )
2171 self.output_buffer = FrameBuffer(
2172 n_output_channels,
2173 0,
2174 0,
2175 False,
2176 0,
2177 0,
2178 0,
2179 self.environment_parameters.control_signal.shape[-1],
2180 0,
2181 False,
2182 False,
2183 False,
2184 0,
2185 buffer_size_frame_multiplier=1
2186 + (
2187 self.data_acquisition_parameters.samples_per_read
2188 * BUFFER_SIZE_SAMPLES_PER_READ_MULTIPLIER
2189 / self.environment_parameters.control_signal.shape[-1]
2190 ),
2191 starting_value=0.0,
2192 )
2193 self.startup = False
2194 # See if any data has come in
2195 try:
2196 acquisition_data, last_acquisition = self.queue_container.data_in_queue.get_nowait()
2197 if self.last_signal_found is not None:
2198 self.last_signal_found -= self.data_acquisition_parameters.samples_per_read
2199 if last_acquisition:
2200 self.log(
2201 f"Acquired Last Data, Signal Generation "
2202 f"Shutdown Achieved: {self.siggen_shutdown_achieved}"
2203 )
2204 else:
2205 self.log("Acquired Data")
2206 scale_factor = 0.0 if self.test_level < 1e-10 else 1 / self.test_level
2207 control_data = (
2208 acquisition_data[self.environment_parameters.control_channel_indices] * scale_factor
2209 )
2210 if self.environment_parameters.response_transformation_matrix is not None:
2211 control_data = (
2212 self.environment_parameters.response_transformation_matrix @ control_data
2213 )
2214 output_data = (
2215 acquisition_data[self.environment_parameters.output_channel_indices] * scale_factor
2216 )
2217 if self.environment_parameters.reference_transformation_matrix is not None:
2218 output_data = (
2219 self.environment_parameters.reference_transformation_matrix @ output_data
2220 )
2221 # Add the data to the buffers
2222 self.control_buffer.add_data(control_data)
2223 self.output_buffer.add_data(output_data)
2224 if last_acquisition:
2225 # Find alignment with the specification via output
2226 self.log("Aligning signal with specification")
2227 (
2228 self.aligned_output,
2229 sample_delay,
2230 phase_change,
2231 _,
2232 ) = align_signals(
2233 self.output_buffer[:],
2234 self.next_drive[:, :: self.data_acquisition_parameters.output_oversample],
2235 correlation_threshold=0.5,
2236 )
2237 else:
2238 (
2239 self.aligned_output,
2240 sample_delay,
2241 phase_change,
2242 _,
2243 ) = (None, None, None, None)
2244 self.queue_container.gui_update_queue.put(
2245 (
2246 self.environment_name,
2247 ("time_data", (control_data, output_data, sample_delay)),
2248 )
2249 ) # Sample_delay will be None if the alignment is not found
2250 if self.aligned_output is not None:
2251 self.log(f"Alignment Found at {sample_delay} samples")
2252 self.aligned_response = shift_signal(
2253 self.control_buffer[:],
2254 self.environment_parameters.control_signal.shape[-1],
2255 sample_delay,
2256 phase_change,
2257 )
2258 time_trac = trac(self.aligned_response, self.environment_parameters.control_signal)
2259 self.gui_update_queue.put(
2260 (self.environment_name, ("control_response_error_list", time_trac))
2261 )
2262 self.queue_container.gui_update_queue.put(
2263 (
2264 self.environment_name,
2265 ("control_data", (self.aligned_response, self.aligned_output)),
2266 )
2267 )
2268 # Do the next control
2269 self.log(
2270 f"Last Signal Found: {self.last_signal_found}, "
2271 f"Current Signal Found: {sample_delay}"
2272 )
2273 # We don't want to keep a signal if it starts during the last signal.
2274 # Multiply by 0.8 to give a little wiggle room in case the
2275 # last signal wasn't found exactly at the right place.
2276 if (
2277 self.last_signal_found is None
2278 or (
2279 self.last_signal_found
2280 + self.environment_parameters.control_signal.shape[-1] * 0.8
2281 )
2282 < sample_delay
2283 ):
2284 self.next_drive = self.aligned_output
2285 self.predicted_response = self.aligned_response
2286 self.log("Computing next signal via control law")
2287 self.perform_control_prediction(False)
2288 self.last_signal_found = sample_delay
2289 else:
2290 self.log("Signal was found previously, not controlling")
2291 except mp.queues.Empty:
2292 last_acquisition = False
2293 # See if we need to keep going
2294 if self.siggen_shutdown_achieved and last_acquisition:
2295 self.shutdown()
2296 else:
2297 self.queue_container.environment_command_queue.put(
2298 self.environment_name, (TransientCommands.START_CONTROL, None)
2299 )
2300
2301 def shutdown(self):
2302 """Let the UI know that this environment has completely shut down"""
2303 self.log("Environment Shut Down")
2304 self.gui_update_queue.put((self.environment_name, ("enable_control", None)))
2305 self.startup = True
2306
2307 def stop_environment(self, data):
2308 """Starts the shutdown sequence based on commands from the UI"""
2309 self.queue_container.signal_generation_command_queue.put(
2310 self.environment_name, (SignalGenerationCommands.START_SHUTDOWN, None)
2311 )
2312
2313
2314# %% Process
2315
2316
2317def transient_process(
2318 environment_name: str,
2319 input_queue: VerboseMessageQueue,
2320 gui_update_queue: Queue,
2321 controller_communication_queue: VerboseMessageQueue,
2322 log_file_queue: Queue,
2323 data_in_queue: Queue,
2324 data_out_queue: Queue,
2325 acquisition_active: mp.sharedctypes.Synchronized,
2326 output_active: mp.sharedctypes.Synchronized,
2327):
2328 """
2329 Transient vibration environment process function called by multiprocessing
2330
2331 This function defines the Transient Vibration Environment process that
2332 gets run by the multiprocessing module when it creates a new process. It
2333 creates a TransientEnvironment object and runs it.
2334
2335 Parameters
2336 ----------
2337 environment_name : str :
2338 Name of the environment
2339 input_queue : VerboseMessageQueue :
2340 Queue containing instructions for the environment
2341 gui_update_queue : Queue :
2342 Queue where GUI updates are put
2343 controller_communication_queue : Queue :
2344 Queue for global communications with the controller
2345 log_file_queue : Queue :
2346 Queue for writing log file messages
2347 data_in_queue : Queue :
2348 Queue from which data will be read by the environment
2349 data_out_queue : Queue :
2350 Queue to which data will be written that will be output by the hardware.
2351 acquisition_active : mp.sharedctypes.Synchronized
2352 A synchronized value that indicates when the acquisition is active
2353 output_active : mp.sharedctypes.Synchronized
2354 A synchronized value that indicates when the output is active
2355 """
2356 try:
2357 # Create vibration queues
2358 queue_container = TransientQueues(
2359 environment_name,
2360 input_queue,
2361 gui_update_queue,
2362 controller_communication_queue,
2363 data_in_queue,
2364 data_out_queue,
2365 log_file_queue,
2366 )
2367
2368 spectral_proc = mp.Process(
2369 target=spectral_processing_process,
2370 args=(
2371 environment_name,
2372 queue_container.spectral_command_queue,
2373 queue_container.data_for_spectral_computation_queue,
2374 queue_container.updated_spectral_quantities_queue,
2375 queue_container.environment_command_queue,
2376 queue_container.gui_update_queue,
2377 queue_container.log_file_queue,
2378 ),
2379 )
2380 spectral_proc.start()
2381 analysis_proc = mp.Process(
2382 target=sysid_data_analysis_process,
2383 args=(
2384 environment_name,
2385 queue_container.data_analysis_command_queue,
2386 queue_container.updated_spectral_quantities_queue,
2387 queue_container.time_history_to_generate_queue,
2388 queue_container.environment_command_queue,
2389 queue_container.gui_update_queue,
2390 queue_container.log_file_queue,
2391 ),
2392 )
2393 analysis_proc.start()
2394 siggen_proc = mp.Process(
2395 target=signal_generation_process,
2396 args=(
2397 environment_name,
2398 queue_container.signal_generation_command_queue,
2399 queue_container.time_history_to_generate_queue,
2400 queue_container.data_out_queue,
2401 queue_container.environment_command_queue,
2402 queue_container.log_file_queue,
2403 queue_container.gui_update_queue,
2404 ),
2405 )
2406 siggen_proc.start()
2407 collection_proc = mp.Process(
2408 target=data_collector_process,
2409 args=(
2410 environment_name,
2411 queue_container.collector_command_queue,
2412 queue_container.data_in_queue,
2413 [queue_container.data_for_spectral_computation_queue],
2414 queue_container.environment_command_queue,
2415 queue_container.log_file_queue,
2416 queue_container.gui_update_queue,
2417 ),
2418 )
2419 collection_proc.start()
2420
2421 process_class = TransientEnvironment(
2422 environment_name, queue_container, acquisition_active, output_active
2423 )
2424 process_class.run()
2425
2426 # Rejoin all the processes
2427 process_class.log("Joining Subprocesses")
2428 process_class.log("Joining Spectral Computation")
2429 spectral_proc.join()
2430 process_class.log("Joining Data Analysis")
2431 analysis_proc.join()
2432 process_class.log("Joining Signal Generation")
2433 siggen_proc.join()
2434 process_class.log("Joining Data Collection")
2435 collection_proc.join()
2436 except Exception: # pylint: disable = broad-exception-caught
2437 print(traceback.format_exc())