-
Notifications
You must be signed in to change notification settings - Fork 0
Code Overview
The code can be separated into two Python processes (client and server) and several threads.
-
The server process sends TCP messages to the client process and creates a thread to send serial commands for each device.
-
The client process contains the main GUI thread that the user interacts with, and several sub-threads to handle asynchronous communication with the server process.
Additionally, there is a ton of boiler-plate Qt code. Thankfully most of this is generated automatically by opening the .ui files in Qt Creator, making changes, saving, and then running the following command:
pyuic5 /path/to/ui/file/file.ui > /path/to/python/file/ui_file.py
For consistency, it would be wise to keep the same naming scheme. Looking at the ui_*.py in the gui/ directory should make it object what the pyuic5 tool does.
The goal with the client code is to prevent any unresponsiveness in the user interface. This means keeping everything that can cause delays in a separate thread. To do this, there are several objects defined:
-
Commobject, which handles ALL sending and receiving of TCP messages to the server. -
DeviceManagerobject, which keeps track of all the current device information. -
Calibratorobject, which handles the movement of the stepper motors during calibration. -
Daqobject, which handles stepper motor movement, voltage regulator setting, and holds the data collected, during a scan.
The DeviceManager, Calibrator and Daq objects all communicate with the Comm object through the PyQt signal/slot system. For example, the Comm emits its sig_data signal after it has successfully received a message from the server. This signal is connected to the DeviceManager's on_datafunction which unwraps the received message and associates the data with each device. Again, all of these connections exist outside the main GUI thread to prevent unresponsive behavior.
To get information from the devices into the GUI, the GUI thread has a QTimer object which calls update_display_values every 50 ms. This fixes the GUI's updates to a pre-determined interval so that random fluctuations in the TCP communication speed do not affect the responsiveness of the GUI.
Calibration and scanning are largely the same process, although the latter has more steps. When the user starts the stepper motor calibration, or starts a scan, either a Calibrator or Daq object is created, and making changes in the GUI is disabled. These objects emit PyQt signals which connect to the Comm's add_message_to_queue function. The Comm object will then try to send messages from its queue in sequence to avoid data races. Both the Calibrator and Daq objects are deleted upon their completion, and their threads are stopped safely before the GUI is re-enabled. This is important because PyQt will complain if still-running threads are overwritten or deleted.
This largely depends on the nature of the feature. However, here are some guidelines:
I would start by implementing them with Qt Creator, then remaking the ui_*.py file as above. Then run the code and make sure nothing was broken. Unfortunately, all the control names are tightly-integrated into the program code, so changing these is not advised, lest you have to change all occurrences throughout. I have tried to be self-documenting to some extent by naming QLineEdits with "txt" as a prefix (a throwback to Visual Basic), QLabels with "lbl", QRadioButtons with "rb", etc.
Control values are accessed by the Qt widget's member functions. For QLineEdit, that is the .text() method. There are many examples in the code of ways to check the validity of a text box entry. I am a fan of the try ... except ValueError construction, where the code simply tries to cast the QLineEdit's .text() value to a float or int if the entry is supposed to be numeric.
For purely GUI-related options, e.g. enabling or disabling controls based on a QCheckBox, I prefer to separate that code to the MainWindow.py file. This keeps things a bit cleaner.
Start by changing the server code. The message sent back to the GUI is entirely constructed in the poll function of ScannerServer.py. This value makes it way to the GUI by:
-
Commobject reads the TCP buffer and sends it to theDeviceManagerobject. -
DeviceManagerobject parses the message and sets the values of each device. -
update_display_valuesmethod on the GUI thread is called every 50 ms, which reads the values from theDeviceManager
To this end, one should only have to modify the server message, modify the parsing function, and modify update_display_values.
If the value is needed elsewhere, e.g. during a scan, it should not be a problem, since the Daq object is created with a reference to the DeviceManager's device dictionary. Therefore it has access to all the current values stored in the DeviceManager. Indeed, it uses this to fill in the current reading at each point in the scan.
The file scan.py contains the Scan object class. Each time the Daq object finishes, its stored data is copied (via copy.deepcopy) to a new Scan object, and saved in the GUI's self._past_scans list. The Review tab in the GUI is also updated to reflect the new scan.
To change the output format, start by looking at how the data array is created in the Daq object. It uses a numpy array which is set up to have the position and voltage columns filled-in, and the current column set to np.nan. This is important because when the scan is drawn, if it finds a nan value, it draws a grey square at that point.
The data is filled in the Daq's scan method.
Scan data access is done by using the column names. I.e. data[0]['pos'] will select the first row of the data array, and the value in the 'pos' (position) column. Again, at various places in the code, it is expected that the scan's position and voltage columns are initialized properly, even though the current column may be nan.