1# -*- coding: utf-8 -*-
2"""
3Abstract environment that can be used to create new environment control strategies
4in the controller.
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 multiprocessing as mp
26import multiprocessing.sharedctypes # pylint: disable=unused-import
27import os
28import traceback
29from abc import ABC, abstractmethod
30from datetime import datetime
31from multiprocessing.queues import Queue
32
33import netCDF4 as nc4
34import openpyxl
35
36from .utilities import DataAcquisitionParameters, GlobalCommands, VerboseMessageQueue
37
38PICKLE_ON_ERROR = False
39
40if PICKLE_ON_ERROR:
41 import pickle
42
43
44class AbstractMetadata(ABC):
45 """Abstract class for storing metadata for an environment.
46
47 This class is used as a storage container for parameters used by an
48 environment. It is returned by the environment UI's
49 ``collect_environment_definition_parameters`` function as well as its
50 ``initialize_environment`` function. Various parts of the controller and
51 environment will query the class's data members for parameter values.
52
53 Classes inheriting from AbstractMetadata must define:
54 1. store_to_netcdf - A function defining the way the parameters are
55 stored to a netCDF file saved during streaming operations.
56 """
57
58 @abstractmethod
59 def store_to_netcdf(
60 self, netcdf_group_handle: nc4._netCDF4.Group
61 ): # pylint: disable=c-extension-no-member
62 """Store parameters to a group in a netCDF streaming file.
63
64 This function stores parameters from the environment into the netCDF
65 file in a group with the environment's name as its name. The function
66 will receive a reference to the group within the dataset and should
67 store the environment's parameters into that group in the form of
68 attributes, dimensions, or variables.
69
70 This function is the "write" counterpart to the retrieve_metadata
71 function in the AbstractUI class, which will read parameters from
72 the netCDF file to populate the parameters in the user interface.
73
74 Parameters
75 ----------
76 netcdf_group_handle : nc4._netCDF4.Group
77 A reference to the Group within the netCDF dataset where the
78 environment's metadata is stored.
79
80 """
81
82
83class AbstractUI(ABC):
84 """Abstract User Interface class defining the interface with the controller
85
86 This class is used to define the interface between the User Interface of a
87 environment in the controller and the main controller."""
88
89 @abstractmethod
90 def __init__(
91 self,
92 environment_name: str,
93 environment_command_queue: VerboseMessageQueue,
94 controller_communication_queue: VerboseMessageQueue,
95 log_file_queue: Queue,
96 ):
97 """
98 Stores data required by the controller to interact with the UI
99
100 This class stores data required by the controller to interact with the
101 user interface for a given environment. This includes the environment
102 name and queues to pass information between the controller and
103 environment. It additionally initializes the ``command_map`` which is
104 used by the Test Profile functionality to map profile instructions to
105 operations on the user interface.
106
107
108 Parameters
109 ----------
110 environment_name : str
111 The name of the environment
112 environment_command_queue : VerboseMessageQueue
113 A queue that will provide instructions to the corresponding
114 environment
115 controller_communication_queue : VerboseMessageQueue
116 The queue that relays global communication messages to the controller
117 log_file_queue : Queue
118 The queue that will be used to put messages to the log file.
119
120
121 """
122 self._environment_name = environment_name
123 self._log_name = environment_name + " UI"
124 self._log_file_queue = log_file_queue
125 self._environment_command_queue = environment_command_queue
126 self._controller_communication_queue = controller_communication_queue
127 self._command_map = {
128 "Start Control": self.start_control,
129 "Stop Control": self.stop_control,
130 }
131
132 @property
133 def command_map(self) -> dict:
134 """Dictionary mapping profile instructions to functions of the UI that
135 are called when the instruction is executed."""
136 return self._command_map
137
138 @abstractmethod
139 def start_control(self):
140 """Runs the corresponding environment in the controller"""
141
142 @abstractmethod
143 def stop_control(self):
144 """Stops the corresponding environment in the controller"""
145
146 @abstractmethod
147 def collect_environment_definition_parameters(self) -> AbstractMetadata:
148 """
149 Collect the parameters from the user interface defining the environment
150
151 Returns
152 -------
153 AbstractMetadata
154 A metadata or parameters object containing the parameters defining
155 the corresponding environment.
156
157 """
158
159 @abstractmethod
160 def initialize_data_acquisition(self, data_acquisition_parameters: DataAcquisitionParameters):
161 """Update the user interface with data acquisition parameters
162
163 This function is called when the Data Acquisition parameters are
164 initialized. This function should set up the environment user interface
165 accordingly.
166
167 Parameters
168 ----------
169 data_acquisition_parameters : DataAcquisitionParameters :
170 Container containing the data acquisition parameters, including
171 channel table and sampling information.
172
173 """
174
175 @abstractmethod
176 def initialize_environment(self) -> AbstractMetadata:
177 """
178 Update the user interface with environment parameters
179
180 This function is called when the Environment parameters are initialized.
181 This function should set up the user interface accordingly. It must
182 return the parameters class of the environment that inherits from
183 AbstractMetadata.
184
185 Returns
186 -------
187 AbstractMetadata
188 An AbstractMetadata-inheriting object that contains the parameters
189 defining the environment.
190
191 """
192
193 @abstractmethod
194 def retrieve_metadata(
195 self, netcdf_handle: nc4._netCDF4.Dataset
196 ): # pylint: disable=c-extension-no-member
197 """Collects environment parameters from a netCDF dataset.
198
199 This function retrieves parameters from a netCDF dataset that was written
200 by the controller during streaming. It must populate the widgets
201 in the user interface with the proper information.
202
203 This function is the "read" counterpart to the store_to_netcdf
204 function in the AbstractMetadata class, which will write parameters to
205 the netCDF file to document the metadata.
206
207 Note that the entire dataset is passed to this function, so the function
208 should collect parameters pertaining to the environment from a Group
209 in the dataset sharing the environment's name, e.g.
210
211 ``group = netcdf_handle.groups[self.environment_name]``
212 ``self.definition_widget.parameter_selector.setValue(group.parameter)``
213
214 Parameters
215 ----------
216 netcdf_handle : nc4._netCDF4.Dataset :
217 The netCDF dataset from which the data will be read. It should have
218 a group name with the enviroment's name.
219
220 """
221
222 @abstractmethod
223 def update_gui(self, queue_data: tuple):
224 """Update the environment's graphical user interface
225
226 This function will receive data from the gui_update_queue that
227 specifies how the user interface should be updated. Data will usually
228 be received as ``(instruction,data)`` pairs, where the ``instruction`` notes
229 what operation should be taken or which widget should be modified, and
230 the ``data`` notes what data should be used in the update.
231
232 Parameters
233 ----------
234 queue_data : tuple
235 A tuple containing ``(instruction,data)`` pairs where ``instruction``
236 defines and operation or widget to be modified and ``data`` contains
237 the data used to perform the operation.
238 """
239
240 @property
241 def log_file_queue(self) -> Queue:
242 """A property containing a reference to the queue accepting messages
243 that will be written to the log file"""
244 return self._log_file_queue
245
246 @property
247 def environment_command_queue(self) -> VerboseMessageQueue:
248 """A property containing a reference to the queue accepting commands
249 that will be delivered to the environment"""
250 return self._environment_command_queue
251
252 @property
253 def controller_communication_queue(self) -> VerboseMessageQueue:
254 """A property containing a reference to the queue accepting global
255 commands that will be delivered to the controller"""
256 return self._controller_communication_queue
257
258 @property
259 def environment_name(self):
260 """A property containing the environment's name"""
261 return self._environment_name
262
263 @property
264 def log_name(self):
265 """A property containing the name that the UI will be referenced by in
266 the log file, which will typically be ``self.environment_name + ' UI'``"""
267 return self._log_name
268
269 def log(self, message: str):
270 """Write a message to the log file
271
272 This function puts a message onto the ``log_file_queue`` so it will
273 eventually be written to the log file.
274
275 When written to the log file, the message will include the date and
276 time that the message was queued, the name that the UI uses in the log
277 file (``self.log_file``), and then the message itself.
278
279 Parameters
280 ----------
281 message : str :
282 A message that will be written to the log file.
283
284 """
285 self.log_file_queue.put(f"{datetime.now()}: {self.log_name} -- {message}\n")
286
287 @staticmethod
288 @abstractmethod
289 def create_environment_template(
290 environment_name: str, workbook: openpyxl.workbook.workbook.Workbook
291 ):
292 """Creates a template worksheet in an Excel workbook defining the
293 environment.
294
295 This function creates a template worksheet in an Excel workbook that
296 when filled out could be read by the controller to re-create the
297 environment.
298
299 This function is the "write" counterpart to the
300 ``set_parameters_from_template`` function in the ``AbstractUI`` class,
301 which reads the values from the template file to populate the user
302 interface.
303
304 Parameters
305 ----------
306 environment_name : str :
307 The name of the environment that will specify the worksheet's name
308 workbook : openpyxl.workbook.workbook.Workbook :
309 A reference to an ``openpyxl`` workbook.
310
311 """
312
313 @abstractmethod
314 def set_parameters_from_template(self, worksheet: openpyxl.worksheet.worksheet.Worksheet):
315 """
316 Collects parameters for the user interface from the Excel template file
317
318 This function reads a filled out template worksheet to create an
319 environment. Cells on this worksheet contain parameters needed to
320 specify the environment, so this function should read those cells and
321 update the UI widgets with those parameters.
322
323 This function is the "read" counterpart to the
324 ``create_environment_template`` function in the ``AbstractUI`` class,
325 which writes a template file that can be filled out by a user.
326
327
328 Parameters
329 ----------
330 worksheet : openpyxl.worksheet.worksheet.Worksheet
331 An openpyxl worksheet that contains the environment template.
332 Cells on this worksheet should contain the parameters needed for the
333 user interface.
334
335 """
336
337
338class AbstractEnvironment(ABC):
339 """Abstract Environment class defining the interface with the controller
340
341 This class is used to define the operation of an environment within the
342 controller, which must be completed by subclasses inheriting from this
343 class. Children of this class will sit in a While loop in the
344 ``AbstractEnvironment.run()`` function. While in this loop, the
345 Environment will pull instructions and data from the
346 ``command_queue`` and then use the ``command_map`` to map those instructions
347 to functions in the class.
348
349 All child classes inheriting from AbstractEnvironment will require functions
350 to be defined for global operations of the controller, which are already
351 mapped in the ``command_map``. Any additional operations must be defined
352 by functions and then added to the command_map when initilizing the child
353 class.
354
355 All functions called via the ``command_map`` must accept one input argument
356 which is the data passed along with the command. For functions that do not
357 require additional data, this argument can be ignored, but it must still be
358 present in the function's calling signature.
359
360 The run function will continue until one of the functions called by
361 ``command_map`` returns a truthy value, which signifies the controller to
362 quit. Therefore, any functions mapped to ``command_map`` that should not
363 instruct the program to quit should not return any value that could be
364 interpreted as true."""
365
366 def dump_to_dict(self):
367 """Dumps the environment to a dictionary to be pickled if an error occurs"""
368 if PICKLE_ON_ERROR:
369 state = self.__dict__.copy()
370 for key, value in state.items():
371 try:
372 pickle.dumps(value)
373 print(f"{key} is pickleable")
374 except Exception: # pylint: disable=broad-exception-caught
375 print(f"{key} is not pickleable")
376 state[key] = None
377 return state
378 else:
379 return self.__dict__.copy()
380
381 def __init__(
382 self,
383 environment_name: str,
384 command_queue: VerboseMessageQueue,
385 gui_update_queue: Queue,
386 controller_communication_queue: VerboseMessageQueue,
387 log_file_queue: Queue,
388 data_in_queue: Queue,
389 data_out_queue: Queue,
390 acquisition_active: mp.sharedctypes.Synchronized,
391 output_active: mp.sharedctypes.Synchronized,
392 ):
393 self._environment_name = environment_name
394 self._command_queue = command_queue
395 self._gui_update_queue = gui_update_queue
396 self._controller_communication_queue = controller_communication_queue
397 self._log_file_queue = log_file_queue
398 self._data_in_queue = data_in_queue
399 self._data_out_queue = data_out_queue
400 self._command_map = {
401 GlobalCommands.QUIT: self.quit,
402 GlobalCommands.INITIALIZE_DATA_ACQUISITION: self.initialize_data_acquisition_parameters,
403 GlobalCommands.INITIALIZE_ENVIRONMENT_PARAMETERS: self.initialize_environment_test_parameters,
404 GlobalCommands.STOP_ENVIRONMENT: self.stop_environment,
405 }
406 self._acquisition_active = acquisition_active
407 self._output_active = output_active
408
409 @property
410 def acquisition_active(self):
411 """Flag to check if acquisition is active"""
412 # print('Checking if Acquisition Active: {:}'.format(bool(self._acquisition_active.value)))
413 return bool(self._acquisition_active.value)
414
415 @property
416 def output_active(self):
417 """Flag to check if output is active"""
418 # print('Checking if Output Active: {:}'.format(bool(self._output_active.value)))
419 return bool(self._output_active.value)
420
421 @abstractmethod
422 def initialize_data_acquisition_parameters(
423 self, data_acquisition_parameters: DataAcquisitionParameters
424 ):
425 """Initialize the data acquisition parameters in the environment.
426
427 The environment will receive the global data acquisition parameters from
428 the controller, and must set itself up accordingly.
429
430 Parameters
431 ----------
432 data_acquisition_parameters : DataAcquisitionParameters :
433 A container containing data acquisition parameters, including
434 channels active in the environment as well as sampling parameters.
435 """
436
437 @abstractmethod
438 def initialize_environment_test_parameters(self, environment_parameters: AbstractMetadata):
439 """
440 Initialize the environment parameters specific to this environment
441
442 The environment will recieve parameters defining itself from the
443 user interface and must set itself up accordingly.
444
445 Parameters
446 ----------
447 environment_parameters : AbstractMetadata
448 A container containing the parameters defining the environment
449
450 """
451
452 @abstractmethod
453 def stop_environment(self, data):
454 """Stop the environment gracefully
455
456 This function defines the operations to shut down the environment
457 gracefully so there is no hard stop that might damage test equipment
458 or parts.
459
460 Parameters
461 ----------
462 data : Ignored
463 This parameter is not used by the function but must be present
464 due to the calling signature of functions called through the
465 ``command_map``
466
467 """
468
469 @property
470 def environment_command_queue(self) -> VerboseMessageQueue:
471 """The queue that provides commands to the environment."""
472 return self._command_queue
473
474 @property
475 def data_in_queue(self) -> Queue:
476 """The queue from which data is delivered to the environment"""
477 return self._data_in_queue
478
479 @property
480 def data_out_queue(self) -> Queue:
481 """The queue to which data is written that will be output to exciters"""
482 return self._data_out_queue
483
484 @property
485 def gui_update_queue(self) -> Queue:
486 """The queue that GUI update instructions are written to"""
487 return self._gui_update_queue
488
489 @property
490 def controller_communication_queue(self) -> Queue:
491 """The queue that global controller updates are written to"""
492 return self._controller_communication_queue
493
494 @property
495 def log_file_queue(self) -> Queue:
496 """The queue that log file messages are written to"""
497 return self._log_file_queue
498
499 def log(self, message: str):
500 """Write a message to the log file
501
502 This function puts a message onto the ``log_file_queue`` so it will
503 eventually be written to the log file.
504
505 When written to the log file, the message will include the date and
506 time that the message was queued, the name of the environment, and
507 then the message itself.
508
509 Parameters
510 ----------
511 message : str :
512 A message that will be written to the log file.
513 """
514 self.log_file_queue.put(f"{datetime.now()}: {self.environment_name} -- {message}\n")
515
516 @property
517 def environment_name(self) -> str:
518 """A string defining the name of the environment"""
519 return self._environment_name
520
521 @property
522 def command_map(self) -> dict:
523 """A dictionary that maps commands received by the ``command_queue`` to functions in the class"""
524 return self._command_map
525
526 def map_command(self, key, function):
527 """A function that maps an instruction to a function in the ``command_map``
528
529 Parameters
530 ----------
531 key :
532 The instruction that will be pulled from the ``command_queue``
533
534 function :
535 A reference to the function that will be called when the ``key``
536 message is received.
537
538 """
539 self._command_map[key] = function
540
541 def run(self):
542 """The main function that is run by the environment's process
543
544 A function that is called by the environment's process function that
545 sits in a while loop waiting for instructions on the command queue.
546
547 When the instructions are recieved, they are separated into
548 ``(message,data)`` pairs. The ``message`` is used in conjuction with
549 the ``command_map`` to identify which function should be called, and
550 the ``data`` is passed to that function as the argument. If the
551 function returns a truthy value, it signals to the ``run`` function
552 that it is time to stop the loop and exit.
553
554
555 """
556 self.log(f"Starting Process with PID {os.getpid()}")
557 while True:
558 # Get the message from the queue
559 message, data = self.environment_command_queue.get(self.environment_name)
560 # Call the function corresponding to that message with the data as argument
561 try:
562 function = self.command_map[message]
563 except KeyError:
564 self.log(
565 f"Undefined Message {message}, acceptable messages are {[key for key in self.command_map]}"
566 )
567 continue
568 try:
569 halt_flag = function(data)
570 except Exception: # pylint: disable=broad-exception-caught
571 tb = traceback.format_exc()
572 self.log(f"ERROR\n\n {tb}")
573 self.gui_update_queue.put(
574 (
575 "error",
576 (
577 f"{self.environment_name} Error",
578 f"!!!UNKNOWN ERROR!!!\n\n{tb}",
579 ),
580 )
581 )
582 if PICKLE_ON_ERROR:
583 with open(
584 f"debug_data/{self.environment_name}_error_state.txt", "w", encoding="utf-8"
585 ) as f:
586 f.write(f"{tb}")
587 with open(f"debug_data/{self.environment_name}_error_state.pkl", "wb") as f:
588 dic = self.dump_to_dict()
589 pickle.dump(dic, f)
590 print("Done Writing Pickle File from Error...")
591 halt_flag = False
592 # If we get a true value, stop.
593 if halt_flag:
594 self.log("Stopping Process")
595 break
596
597 def quit(self, data): # pylint: disable=unused-argument
598 """Returns True to stop the ``run`` while loop and exit the process
599
600 Parameters
601 ----------
602 data : Ignored
603 This parameter is not used by the function but must be present
604 due to the calling signature of functions called through the
605 ``command_map``
606
607 Returns
608 -------
609 True :
610 This function returns True to signal to the ``run`` while loop
611 that it is time to close down the environment.
612
613 """
614 return True
615
616
617def run_process(
618 environment_name: str,
619 input_queue: VerboseMessageQueue,
620 gui_update_queue: Queue,
621 controller_communication_queue: VerboseMessageQueue,
622 log_file_queue: Queue,
623 data_in_queue: Queue,
624 data_out_queue: Queue,
625 acquisition_active: mp.sharedctypes.Synchronized,
626 output_active: mp.sharedctypes.Synchronized,
627):
628 """A function called by ``multiprocessing.Process`` to start the environment
629
630 This function should not be called directly, but used as a template for
631 other environments to start up.
632
633 Parameters
634 ----------
635 environment_name : str :
636 The name of the environment
637
638 input_queue : VerboseMessageQueue :
639 The command queue for the environment
640
641 gui_update_queue : Queue :
642 The queue that accepts GUI update ``(message,data)`` pairs.
643
644 controller_communication_queue : VerboseMessageQueue :
645 The queue where global instructions to the controller can be written
646
647 log_file_queue : Queue :
648 The queue where logging messages can be written
649
650 data_in_queue : Queue :
651 The queue from which the environment will receive data from the
652 acquisition hardware
653
654 data_out_queue : Queue :
655 The queue to which the environment should write data so it will be output
656 to the excitation devices in the output hardware
657
658 """
659 process_class = AbstractEnvironment( # pylint: disable=abstract-class-instantiated
660 environment_name,
661 input_queue,
662 gui_update_queue,
663 controller_communication_queue,
664 log_file_queue,
665 data_in_queue,
666 data_out_queue,
667 acquisition_active,
668 output_active,
669 )
670 process_class.run()