48 Commits

Author SHA1 Message Date
lteichroeb 3bd29bf011 Added V2 release 2021-11-10 23:10:32 +01:00
lteichroeb 8f0facb461 Fixed UI typo 2021-11-10 23:03:13 +01:00
lteichroeb 1fc059a656 Added new files and components to the software documentation. 2021-11-10 22:08:35 +01:00
lteichroeb 6ab3778692 Fixed button formatting 2021-11-06 20:03:04 +01:00
lteichroeb e1e7db29a9 Updated software architecture description. Still missing some new files. 2021-11-03 18:08:56 +01:00
lteichroeb ac609b242f Made git ignore more strict 2021-11-03 18:08:56 +01:00
lteichroeb 8da80d949b Added missing .ini file to documentation. 2021-11-03 18:08:56 +01:00
lteichroeb ba05d67a81 Leons lost commit 2021-11-03 09:06:43 +01:00
lteichroeb dbc750be7d Added calibration export function for magnetometers. 2021-10-24 15:33:57 +02:00
lteichroeb 7c3c8aa420 Improved and extended calibration export functionality. 2021-10-24 15:12:57 +02:00
lteichroeb dc5952caf8 Fixed config load cancelling behavior. 2021-10-24 14:39:17 +02:00
lteichroeb 286874f4d9 Config file is now saved/loaded from an Appdata directory. 2021-10-24 14:33:28 +02:00
lteichroeb 95965f0931 Fixed ui_print function scrolling. 2021-10-24 14:27:19 +02:00
lteichroeb addb064f71 Moved CSV save function. 2021-10-24 14:25:14 +02:00
lteichroeb 1144e5fae9 Added arduino com port to config file. Removed log spam 2021-10-24 11:59:13 +02:00
lteichroeb 33331e829a Fixed ui_print, #6. Calls now processed in main thread. 2021-10-22 21:11:58 +02:00
lteichroeb 9e56937252 Removed unnecessary or completed TODOs. 2021-10-22 21:02:58 +02:00
lteichroeb 5a3625021e Cleaned main.py. Renamed exit_flag global. 2021-10-22 20:56:16 +02:00
lteichroeb f2c33f69fa Changed hardware initialization to be asynchronous. 2021-10-22 20:38:51 +02:00
lteichroeb d7ade91f80 Ambient field calibration results can be saved to csv file. 2021-10-15 11:47:20 +02:00
lteichroeb 747e424871 Calibration results can now be saved to config. 2021-10-15 11:21:56 +02:00
lteichroeb a08ba26384 Fixed data logging. 2021-10-15 10:05:30 +02:00
lteichroeb de4e54e7e0 Fixed and extended csv_logging.py 2021-09-28 19:42:38 +02:00
lteichroeb d02bde9631 Fixed csv_threading.py 2021-09-28 17:46:09 +02:00
lteichroeb 4bb6536a84 Added updated coil constant defaults.
Match with thesis.
2021-09-16 19:12:08 +02:00
lteichroeb bea7f94550 Finished and tested coil constant csv export. 2021-09-14 16:03:38 +02:00
lteichroeb 151081a3c3 WIP: export csv files from calibration. 2021-09-14 13:54:50 +02:00
muellerr 4bb1bad731 Tested magnetometer calibration method. 2021-08-24 16:46:20 +02:00
lteichroeb bcadc3f273 Added user interface elements for magnetometer calibration 2021-08-23 01:02:53 +02:00
lteichroeb 244aaa8e89 Added calibration method for magnetometers. 2021-08-20 13:32:02 +02:00
muellerr 4d1f5a7437 Fixed issue with QL355TP driver. Calibration methods validated. 2021-08-19 15:08:53 +02:00
Martin Zietz 8177402ba1 Moved files. Bug fixes from clean room 2021-08-19 09:51:49 +02:00
lteichroeb 3a526ec26d Added helmholtz cage angle analysis. 2021-08-05 10:53:15 +02:00
lteichroeb 89ed805411 Implemented coil constant calibration 2021-08-04 17:48:58 +02:00
lteichroeb 3596733843 Implemented ambient field calibration tool. 2021-08-04 13:17:00 +02:00
lteichroeb 8f70f85c84 Partially completed front-end reintegration. 2021-08-03 22:29:09 +02:00
lteichroeb 11b5ea4e36 Made imports more consistent 2021-08-02 22:21:29 +02:00
lteichroeb 4827d07b6d Rewrite: Finished initial backend code. Connection to other components missing. 2021-07-28 11:30:23 +02:00
lteichroeb 80f036060a Rewrite: added command execution. Not runnable. 2021-07-28 11:18:08 +02:00
lteichroeb fa6d50d04c Rewrite partially completed. Not runnable. 2021-07-28 10:09:07 +02:00
lteichroeb 38f3793c27 Fixed socket thread not exiting properly. 2021-07-27 15:11:33 +02:00
lteichroeb bcfe4808c0 Refatoring: Moved .py files into src folder. Unified some file names 2021-07-27 15:09:24 +02:00
lteichroeb c7e793a420 Added socket interface for external applications 2021-06-23 11:53:44 +02:00
lteichroeb e5da0c87ed This call fails on Linux. Application is Linux-compatible with this fix. 2021-06-23 11:52:06 +02:00
lteichroeb faa913fcd7 Fixed error in voltage limit computation. 2021-06-05 14:41:58 +02:00
Martin Zietz 4c6164c41f Update readme.md 2021-03-27 15:22:56 +01:00
Martin Zietz 41bb8b0885 Merge branch 'Development_zietz' 2021-03-27 14:23:49 +01:00
Martin Zietz d5ca80d612 Tests with PSU in clean room
Problems:
- GUI layout not good on smaller screen
- PSU communication very slow

Added some quick fix attempts and comments where some of the trouble is.
2021-03-19 19:11:45 +01:00
46 changed files with 3420 additions and 1268 deletions
+3 -1
View File
@@ -101,5 +101,7 @@ ENV/
.idea/Python-PS2000B.iml
.idea/misc.xml
config.ini
*.ini
log.csv
.idea/misc.xml
.idea/misc.xml
.idea/Python-PS2000B.iml
+1 -1
View File
@@ -2,7 +2,7 @@
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.7 (venv)" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="Python 3.9 (Helmholtz_Test_Bench)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.7 (venv)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.9 (Helmholtz_Test_Bench)" project-jdk-type="Python SDK" />
</project>
+2
View File
@@ -15,3 +15,5 @@
*.toc
*.txss
*.aux
*.fls
*.fdb_latexmk
+1
View File
@@ -173,3 +173,4 @@
\newacronym{pla}{PLA}{Polylactic Acid, a thermoplastic polyester}
\newacronym{pc}{PC}{Personal Computer}
\newacronym{tbd}{TBD}{To Be Determined}
\newacronym{mpi}{MPI}{Message Passing Interface}
+30
View File
@@ -0,0 +1,30 @@
[X-Axis]
coil_const = 3.883e-05
ambient_field = 0.0
resistance = 3.131
max_volts = 15.0
max_amps = 5.0
relay_pin = 15
[Y-Axis]
coil_const = 3.865e-05
ambient_field = 0.0
resistance = 3.107
max_volts = 15.0
max_amps = 5.0
relay_pin = 16
[Z-Axis]
coil_const = 3.73e-05
ambient_field = 0.0
resistance = 3.129
max_volts = 15.0
max_amps = 5.0
relay_pin = 17
[Supplies]
supply_model = ql355tp
arduino_port = COM1
xy_port = COM2
z_port = COM3
+276 -106
View File
@@ -2,96 +2,135 @@
\section{Program Structure}
\glsunset{psu}
\glsunset{irs}
To operate the test bench, a Python software with graphical \gls{ui} was developed. It controls the used PS2000B Power Supply Units (\gls{psu}) as well as the Arduino microcontroller inside the switch box. This chapter focuses on the overall implementation. More detailed information is provided in the form of comments in the source code, which is available on the \gls{irs} git server.\footnote{\url{https://egit.irs.uni-stuttgart.de/zietzm/Helmholtz_Test_Bench.git}} A users guide can be found in Section \ref{sec:software_guide}.\\
Software development and testing were done in Windows 10 and Python 3.7. Some aspects may need to be adapted to use the software on a different operating system or Python version. The code was tested with a \gls{psu} or the switch box individually. However, integrated verification with both \gls{psu}s and the switch box Arduino connected simultaneously was not possible up to this point, as some of the equipment was located inside the \gls{irs} cleanroom.\\
The program file architecture is shown in Figure \ref{fig:softwarelayout}. This is meant to give an overview of the structure, therefore it does not show all interactions between the files.
\begin{figure}[hb]
To operate the test bench, a Python software with a graphical \gls{ui} was developed.
It manages the test bench hardware, such as the PS2000B and QL355TP Power Supply Units (\gls{psu}), the polarity switch box, and magnetometers.
This chapter focuses on the overall software implementation.
Detailed information is provided in the form of comments in the source code, which is available on the \gls{irs} git server\footnote{\url{https://egit.irs.uni-stuttgart.de/eive/Helmholtz_Test_Bench.git}}.
For a user manual, please refer to Section \ref{sec:software_guide}.
Software development and testing were done in Windows 10 and Python 3.9.
Some aspects may need to be adapted to use the software on a different operating system or Python version.
The software in its current state (26. October 2021) has been fully tested with the complete hardware suite.
For the complete system test, a FGM3D magnetometer, QL355TP \gls{psu}, and the polarity switcher were used and are thus recommended as a reference configuration.
\begin{figure}[h]
\centering
\includegraphics[width=0.8\textwidth]{media/Software layout}
\caption{Software file architecture}
\includegraphics[width=\textwidth]{media/complete_software_architecture}
\caption{Software file layout and architecture}
\label{fig:softwarelayout}
\end{figure}
Upon execution of \code{main.py}, the test bench devices (\gls{psu}s and switch box Arduino), program objects and variables are initialized. Next, the \gls{ui} (controlled by \code{User\_Interface.py}) is set up and displayed. Program elements needed for more complex functionalities are coded in \code{csv\_logging.py}, \code{config\_handling.py} and \code{csv\_threading.py}.\\
All program elements use the classes in the \code{cage\_func.py} file to control and read data from the test bench. Communication with the actual hardware (power supply units and switch box Arduino) is achieved via the libraries in \code{PS2000B.py} and \code{Arduino.py}, which were taken from online sources \cite{PS2000B_lib, Arduino_lib} with only minor modifications.\\
\code{globals.py} is used to easily pass frequently used variables between the different program files. It contains mainly variable initializations, that can be accessed and changed by the other program elements after importing it.\\
The application is able to operate with one or all test bench devices disconnected. For example, it is possible to generate a magnetic field in only one test bench axis with just a single \gls{psu}. The program also provides error handling for a variety of device disconnects and user mistakes. This includes protection against the commanding of excessive currents and voltages, as well as the display of warnings when attempting to enter a potentially dangerous value in the settings.
The program file architecture is shown in Figure \ref{fig:softwarelayout}.
This is meant to give an overview of the structure, therefore it does not show all interactions between the files.
Upon execution of \code{main.py}, a test bench proxy object is initialized that acts as a wrapper around the \gls{psu} and Arduino interfaces and calls.
Next, the \gls{ui} (controlled by \code{user\_interface.py}) is set up and displayed.
Advanced software functionality is split into separate files and components that are called from the \gls{ui} code.
This includes the CSV and calibration functionality for example.
All program elements use the global \code{HelmholtzCageDevice} object instance defined in the \code{helmholtz\_cage\_device.py} file to control and read data from the test bench.
Communication with some of the hardware is achieved via modified third-party libraries such as \code{PS2000B.py} and \code{Arduino.py}, which were taken from online sources \cite{PS2000B_lib, Arduino_lib}.
\code{globals.py} is used to easily pass frequently used variables between the different program files.
It contains mainly constants and defaults as well as some global instances, that can be accessed and changed by the other program elements after importing it.
The application is able to operate with one or all test bench devices disconnected.
For example, if one \gls{psu} were to be disconnected, commands and fields could still be applied to the axes accessible on the other \gls{psu}.
The program also provides error handling for a variety of device disconnects and user mistakes.
This includes protection against the commanding of excessive currents and voltages, as well as the display of warnings when attempting to enter a potentially dangerous value in the settings.
\section{Program Files}
\subsection{Main Executable \code{main.py}}
The main executable file contains the framework code for start and end of the program.\\
During initialization the information from the configuration file is read out to set the necessary constants. Then the test bench is prepared using the \code{setup\_all} function (see Section \ref{sec:cage_func}), the \gls{ui} is initialized and its \code{mainloop} started.\\
The \code{program\_end} function in this file ensures a safe shutdown of all equipment at the end of the program. It is immediately called when the user closes the window. The function stops any other operational threads (see Section \ref{sec:csv_exec}) and powers down the \gls{psu}s and Arduino. It also asks the user to store possible unsaved log data and then destroys the application window.\\
For exceptions that were not caught by lower level error handling, the main file also includes a global error protection. In such a case an error message is displayed and the \code{program\_end} function called. This is to ensure a safe shutdown, regardless of the current program state.
The main executable file contains the code to start (\code{program\_start}) and stop (\code{program\_end}) the program.
During program initialization the information from the default configuration file is read out to set the necessary constants.
The startup defaults may be modified according to the currently used hardware setup.
Afterwards, the global Helmholtz cage and magnetometer proxy device instances are created.
These instances are requisite for the following \gls{ui} initialization, but do not yet need to be connected to hardware devices.
Afterwards, the graphical and TCP interfaces are created, and the hardware initialization is triggered in a non-blocking, asynchronous function call.
Finally, the UI's \code{mainloop} is started.
The \code{program\_end} function in this file ensures a safe shutdown of all equipment at the end of the program.
It is immediately called when the user closes the window.
The function stops any other operational threads (see Section \ref{sec:csv_exec}) and powers down the \gls{psu}s and Arduino.
It also asks the user to store possible unsaved log data and then destroys the application window.
For exceptions that were not caught by lower levels, the main file also includes application-level error handling.
In such a case an error message is displayed and the \code{program\_end} function called to ensure a safe shutdown, regardless of the current program state.
\subsection{Global Variables File \code{globals.py}}
This file holds global variables and constants that are used often and by more than one module.\\
Examples of these are \code{PS2000B} and \code{ArduinoCtrl} objects that represent the test bench devices, \code{Axis} objects for each spatial axis but also status indicators like \code{exitFlag}, which signals the end of the program to all modules.\\
The file also contains the default values for all constants used to run the test bench, like coil constants, resistances and maximum currents. These are stored in a hard-coded dictionary, which can be used to generate a default configuration. The dictionary also includes the minimum and maximum safe value for each constant. These limits are used to warn users if they attempt to set a value that may damage equipment. In the future, it may be advantageous to store this information in a separate configuration file to allow easier modification.
This file holds global variables and constants that are shared between modules.
Examples of these are the \code{CAGE\_DEVICE} and \code{MAGNETOMETER} objects that represent hardware devices, but also status indicators like \code{exit\_flag}, which signals the end of the program to all components.
The file also contains the default values for all constants used to run the test bench, like coil constants, resistances, and maximum currents.
These are stored in a hard-coded dictionary, which is also used to regenerate the default configuration.
The dictionary also includes the minimum and maximum safe value for each constant, which are used to warn users if they attempt to set a value that may damage equipment.
In the future, it may be advantageous to store this information in a separate configuration file to allow easier modification.
\subsection{Test Bench Control File \code{cage\_func.py}}\label{sec:cage_func}
This file contains all classes and functions directly related to the operation of the test bench. It includes the two main control classes \code{Axis} and \code{ArduinoCtrl}, functions for initialization and shutdown as well as for controlling more than one axis at a time.
\subsection{Test Bench Control File \code{helmholtz\_cage\_device.py}}
\label{sec:cage_func}
This file contains all classes directly related to the operation of the test bench.
It includes the main control class \code{HelmholtzCageDevice}, its proxy \code{HelmholtzCageProxy}, as well as the \code{Axis} adapter object used by both.
Magnetometer interfacing is handled in the separate \code{magnetometer.py} file.
The application contains a single global \code{HelmholtzCageDevice} instance, contained in the \code{CAGE\_DEVICE} variable.
\myparagraph{Axis Control Class \code{Axis}}
This is the main class representing an axis (x,y,z) of the test bench. It contains static and dynamic status information about the axis in its attributes and the means to command the axis in its methods. During program initialization an object of this class is created for each of the three axes and stored in \code{globals.py} \\
Apart from methods to get status information from the appropriate \gls{psu}, the class contains the primary way to command the test bench in form of the \code{set\_signed\_current} method. Its parameter is a positive or negative current value in Ampere. The method first checks if this value exceeds the safe limits defined in the program configuration. If not, the appropriate \gls{psu} channel and the switch box Arduino are commanded to supply the desired current and to actuate the polarity change relay as needed.\\
To generate a specific magnetic field, the \code{set\_field} method can be called. It takes a given field value $B$ in Tesla and calculates the needed current:
\myparagraph{Main Control Class \code{HelmholtzCageDevice}}
This class provides the following important functionality:
\begin{itemize}
\item Asynchronous connection and disconnection of hardware devices (\code{connect\_hardware\_async} and \code{shutdown}).
These functions may be called at any time during program execution, and are used as the primary means of reloading configuration variables and connecting new hardware at runtime.
\item Set currents, raw fields, and compensated fields in the test bench coils.
Uses \code{\_set\_signed\_currents}, \code{\_set\_field\_raw}, and \code{\_set\_field\_compensated}.
These functions are called internally when the corresponding command is received in the \code{\_cmd\_exec\_thread}.
\item Hardware status and control is mediated by an array of \code{Axis} adapter objects.
Each axis stores its own specific configuration data and related status information.
\item Components may provide callbacks to the \code{subscribe\_status\_updates} function, to receive periodic status information about the test bench.
This is more sensible than polling, since this operation is slow.
\end{itemize}
Device access is limited to one application component at once by means of a proxy pattern.
With the \code{request\_proxy} and \code{release\_proxy} functions, up to one \code{HelmholtzCageProxy} instance may be obtained if none are currently active.
This proxy must be released with the latter function, to allow for another component to request a proxy instance.
All commands to the test bench are set by means of the proxy object, except for startup and shutdown commands, which must always be available to the application.
The hardware connection sequence goes as follows:
\begin{itemize}
\item Read the current config variables (see Section \ref{sec:config_handling}).
\item Attempt to connect to Arduino on specified com port.
\item Select \gls{psu} object type based on configuration variable.
\item Attempt to create \gls{psu} object instances.
\item Axis specific config variables are reloaded.
\item All \gls{psu} channels are zeroed and activated. The Arduino relay pins are set to their default ``low'' state.
\end{itemize}
The disconnection sequence:
\begin{itemize}
\item Zero and disable all \gls{psu} output channels. The \gls{psu} serial device is disconnected.
\item The Arduino relay pins are set to low, and the serial device is closed.
\item Information regarding the shutdown process is presented to the user.
\end{itemize}
\myparagraph{Axis Adapter Class \code{Axis}}
This class is an adapter object representing a single axis (X, Y, Z) of the test bench.
It contains static and dynamic status information about the axis in its attributes and the means to command the axis in its methods.
During program initialization an object of this class is created for each of the three axes and stored in the global \code{HelmholtzCageDevice} instance.
The class contains the primary way to command the test bench hardware in form of the \code{set\_signed\_current} method.
Its parameter is a positive or negative current value in Ampere.
The method first checks if this value exceeds the safe limits defined in the program configuration.
If not, the appropriate \gls{psu} channel and the switch box Arduino are commanded to supply the desired current and to actuate the polarity change relay as needed.
To generate a specific magnetic field, the \code{set\_field\_compensated} method can be called.
It takes a given field value $B$ in Tesla and calculates the needed current:
\begin{align}
I=\frac{B-B_0}{K}
\label{eq:field_current}
I=\frac{B-B_0}{K}
\label{eq:field_current}
\end{align}
$B_0$ is the background magnetic field in the measurement area for this axis in Tesla and $K$ is the coil constant in [\si{\tesla\per\ampere}]. The calculated value $I$ is passed to the \code{set\_signed\_current} method to command the test bench. The \code{set\_field\_simple} method works the same way, but without subtracting the ambient field.\\
$B_0$ is the background magnetic field in the measurement area for this axis in Tesla and $K$ is the coil constant in [\si{\tesla\per\ampere}].
The calculated value $I$ is passed to the \code{set\_signed\_current} method to command the test bench.
The \code{set\_field\_raw} method works the same way, but without subtracting the ambient field.
Similarly the minimum and maximum achievable field values $B_{min}$ and $B_{max}$ are calculated in the class initialization, mainly in order to provide this information to the user:
\begin{align}
B_{max} &= B_0 + I_{max}*K\\
B_{min} &= B_0 - I_{max}*K
B_{max} &= B_0 + I_{max}*K\\
B_{min} &= B_0 - I_{max}*K
\end{align}
Here $I_{max}$ is the maximum allowed current, as defined in the configuration page of the \gls{ui}.
\myparagraph{Arduino Control Class \code{ArduinoCtrl}}
This is the main class used to control the Arduino microcontroller inside the switch box. It inherits the \code{Arduino} class from the Arduino command \gls{api} \cite{Arduino_lib}. This provides a way to pass commands directly through the class object, which is created during program initialization and stored in \code{globals.py}. \\
Most commands to the switch box are passed directly through the inherited \code{Arduino} class. For example, the \code{digitalWrite()} method is called to energize a pin that actuates a specific relay. The purpose of the \code{ArduinoCtrl} class is mainly to provide supporting functionality directly related to the specific use case on the test bench, including:
\begin{itemize}
\item{Configuration of output pins used to trigger polarity switch relays}
\item{Checking Arduino connection and output pin status}
\item{\code{safe} method to set all used output pins to "LOW" for safe shutdown}
\end{itemize}
\myparagraph{Value checking function \code{value\_in\_limits}}
This function checks whether a value is within the safe limits defined in \code{globals.py}.
\myparagraph{Setup Function \code{setup\_all}}
This is the main initialization function. It is used at application start-up and during the runtime whenever the user updates settings or (re)connects a device. Its main tasks are:
\begin{itemize}
\item Read the latest information from the configuration object (see Section \ref{sec:config_handling})
\item Initiate communication with switch box Arduino and create object of \code{ArduinoCtrl} class
\item Initiate communication with \gls{psu}s
\item Create object of class \code{Axis} for each axis (x,y,z)
\end{itemize}
During these tasks it also handles different error cases, like disconnected devices or wrong settings in the configuration object.
\myparagraph{Shutdown Function \code{shut\_down\_all}}
This function is used to safely shut down all devices at the end of the program or if an error occurs. It is called in the \code{program\_end} function of \code{main.py}. Its primary tasks are:
\begin{itemize}
\item Secure all connected \gls{psu}s
\begin{itemize}
\item Set voltages and currents on both channels to \SI{0}{\volt} and \SI{0}{\ampere}
\item Disable power outputs on both channels
\end{itemize}
\item Secure Arduino
\begin{itemize}
\item Set all used output pins to "LOW"
\item Close serial connection
\end{itemize}
\item Show message to user with shutdown status of all devices and any errors encountered
\end{itemize}
During this process different error cases, like disconnected devices, are handled and the user is informed.
\myparagraph{Other Functions}
In addition to those discussed above, the file also contains functions to:
\begin{itemize}
\item Command currents or magnetic fields on all axes, based on a given vector
\item Check if all devices are still connected
\item Check if a given value is within the safe limits defined in \code{globals.py}
\end{itemize}
\newpage
\subsection{Graphical User Interface \code{User\_Interface.py}}
This file is used to construct the \gls{ui}, that the user interacts with to control the test bench. It contains several classes, each with code to build a specific section of the \gls{ui} as well as methods to perform the actions that are triggered by different user inputs in that section.\\
@@ -112,57 +151,188 @@ An instance of this class represents the application window. The program \code{m
The menu bar at the very top of the application window is constructed by this class. It contains methods to implement each option of the menu. At this time, it is only used to switch between modes, but more functionalities could be added in the future if needed.
\myparagraph{Static Manual Input Mode Class \code{ManualMode}}
The manual input mode interface is used to manually command static values of magnetic fields or currents on the test bench. It is placed in the main area of the application window, the layout can be seen in Figure \ref{fig:manualmodepure}. The methods provided in this class interface mainly with the \code{cage\_func.py} file to command the devices.
The manual input mode interface is used to manually command static values of magnetic fields or currents on the test bench.
It is placed in the main area of the application window, the layout can be seen in Figure \ref{fig:manualmodepure}.
The methods provided in this class interface mainly with the \code{globals.CAGE\_DEVICE} instance to command the devices.
\myparagraph{CSV Sequence Execution Mode Class \code{ExecuteCSVMode}}
This class constructs and operates the interface for executing magnetic field sequences from a \gls{csv} file. It is placed in the application's main area, its layout is shown in Figure \ref{fig:csvmodepure}.\\
Apart from a method to load \gls{csv} files, the class manages the separate thread that needs to be created every time a sequence is run. For this purpose, it interfaces mainly with the \code{csv\_threading.py} file to initialize, start and (if needed) stop the thread object. More details on the multithreading functionality is provided in Section \ref{sec:csv_exec}.
This class constructs and operates the interface for executing magnetic field sequences from a \gls{csv} file.
It is placed in the application's main area, its layout is shown in Figure \ref{fig:csvmodepure}.
Buttons are provided to load and run \gls{csv} sequences.
To execute the sequence without blocking the \gls{ui}, a separate execution thread defined in \code{csv\_threading.py} is created and managed by this class.
Hardware control is acquired upon creation of the thread, or an exception is returned otherwise.
More details on the execution thread is provided in Section \ref{sec:csv_exec}.
\myparagraph{Magnetometer Calibration Mode Class \code{CalibrateMagnetometer}}
This class constructs magnetometer calibration interface.
It is placed in the application's main area, its layout is shown in Figure TODO.
This class creates and manages a separate execution thread defined in \code{calibration.py} to preform the calibration without blocking the \gls{ui}.
Hardware control is acquired upon creation of the thread, or an exception is returned otherwise.
More details on the calibration thread is provided in Section \ref{sec:calibration_processes}.
Communication with the running thread is preformed by means of the \code{view\_mpi\_queue} queue variable, which is regularly polled in \code{update\_view}.
This is where different \gls{mpi} commands are delegated to their respective handlers.
For example, the live completion status and final calibration results are handled here.
It should be of note, that the calibration threads do not store any results or information, instead these are contained in variables defined near the top of \code{\_\_init\_\_}.
\myparagraph{Ambient Field Calibration Mode Class \code{CalibrateAmbientField}}
This class consists of an identical structure to the Magnetometer Calibration View Class.
The only major difference is that this view provides an interface for two calibration procedures; The ambient field and the coil constants.
All relevant calibration worker threads can also be found in \code{calibration.py}.
\myparagraph{Settings Page Class \code{Configuration}}
The program settings page is constructed in this class. It also controls the reading and writing of configuration files. Like the actual test bench control modes, the settings page is placed in the application's main area. Its layout can be seen in Figure \ref{fig:settingspure}.\\
The class generates and reads out all entry fields the user can use to set program constants like used serial ports, resistances and current limits. It interfaces with the \code{config\_handling.py} file to store this information in a \code{ConfigParser} object and to read and write it from and to configuration files (see Section \ref{sec:config_handling}).\\
Individual entry fields can be highlighted to point out values that exceed the safe limits defined in \code{globals.py} to the user.
The program settings page is constructed in this class.
It provides a \gls{ui} to edit application settings and also controls the reading and writing of configuration files.
Like the test bench control modes, the settings page is placed in the application's main area.
Its layout can be seen in Figure \ref{fig:settingspure}.
It interfaces with the \code{config\_handling.py} file to store this information in a \code{ConfigParser} object.
The \code{ConfigParser} object can then also be read and written to and from configuration files (see Section \ref{sec:config_handling}).
Individual entry fields are automatically highlighted when values exceed the safe limits defined in \code{globals.py} to notify the user.
Usage instructions and additional information can be found in Section \ref{sec:settings_guide}.
\myparagraph{Data Logging Configuration Page Class \code{ConfigureLogging}}
This class generates the page for configuring the logging of test bench data to a \gls{csv} file. Similarly to the settings page it is placed in the main area, its layout is shown in Figure \ref{fig:loggingpure}.\\
The page provides a number of checkboxes used to select what specific data is to be logged. This information is stored in a list of string keys. Whenever a row of data needs to be logged, a method in the class calls the \code{log\_datapoint} function in \code{csv\_logging.py}. To indicate what data to log, the string key list is passed as the function parameter (see Section \ref{sec:logging}). \\
There are two options to control when and how often data is logged: in regular intervals and on event. These can also be used at the same time. The information, which of these is enabled, is stored in two boolean attributes of the class object. For logging in regular intervals a method in the \code{ConfigureLogging} class object periodically calls itself. \\
For logging on event, a data point is generated whenever a significant change on the test bench is commanded. As of now, this is simply done by inserting a piece of code in every function where it was seen as appropriate. However, this is somewhat inconsistent and carries the risk of missing some state changes. A better balance between logging consistency and excessive data generation should be devised in a future update.
This class generates the page for configuring and controlling the logging of test bench data to a \gls{csv} file.
Similarly to the settings page it is placed in the main area, its layout is shown in Figure \ref{fig:loggingpure}.
The page provides a number of checkboxes used to select what specific data is to be logged.
This information is stored in a dictionary of log options.
Whenever a row of data needs to be logged, a method in the class calls the \code{log\_datapoint} function in \code{csv\_logging.py}.
To indicate what data to log, the config dictionary is passed as the function parameter (see Section \ref{sec:logging}).
There are two options to control when and how often data is logged: in regular intervals and on event.
These can also be used at the same time.
The information, which of these is enabled, is stored in two boolean attributes of the class object.
For logging in regular intervals a method in the \code{ConfigureLogging} class object periodically calls itself.
For logging on event, a data point is generated whenever a significant change on the test bench is commanded.
As of now, this is simply done by inserting a piece of code in every function where it was seen as appropriate.
However, this is somewhat inconsistent and carries the risk of missing some state changes.
A better balance between logging consistency and excessive data generation should be devised in a future update.
\myparagraph{Status Display Class \code{StatusDisplay}}
The status display (bottom left in Figure \ref{fig:ui_overview}) is used to monitor the state of the test bench devices. The individual values are displayed and updated through labels tied to variables of type \code{tkinter.StringVar()}, which are stored in a dictionary. The \code{update\_labels} method then polls the current values from the \code{Axis} and \code{ArduinoCtrl} objects described in Section \ref{sec:cage_func} and updates the label variables. This is done both periodically and on major test bench commands.
The status display (bottom left in Figure \ref{fig:ui_overview}) is used to monitor the state of the test bench devices.
The individual values are displayed and updated through labels tied to variables of type \code{tkinter.StringVar()}, which are stored in a dictionary.
\code{StatusDisplay} subscribes to status updates from \code{HelmholtzCageDevice}, by registering a callback that queues the new information.
The \code{update\_labels} method is called with the new status information and updates the label variables.
A shared queue and regular polling for new status information in the main thread is used to avoid calling Tkinter code outside the main thread.
Updates are received from the cage object at a polling rate of \SI{1}{\second}.
\myparagraph{Output Console Class \code{OutputConsole}}
The output console in the bottom right of the \gls{ui} (see Figure \ref{fig:ui_overview}) is generated in this class. The main body is a \code{tkinter.Text} widget. Printing of information to it is done via the custom \code{ui\_print} function, which can be used exactly like the built-in \code{print}.
The output console in the bottom right of the \gls{ui} (see Figure \ref{fig:ui_overview}) is generated in this class.
The main body is a \code{tkinter.Text} widget.
Printing of information to it is done via the \code{ui\_print} function defined in \code{utility.py}, which can be used exactly like the built-in \code{print}.
\subsection{Sequence Execution File \code{csv\_threading.py}}
\label{sec:csv_exec}
This file contains code for executing a timed sequence of magnetic field vectors from a \gls{csv} file.
To do this without interfering with the \gls{ui}, it must run in a separate thread.
The main class for this is \code{ExecCSVThread}.
It inherits the \code{Thread} class from the \code{threading} library, so each of its instances represents a unique thread.
Its main method, apart from those needed to start and stop it, is \code{execute\_sequence}.
It takes the desired sequence in the form of a \code{numpy} array and commands the test bench at the appropriate times.
The method also continuously polls that all devices are still connected and that the run has not been aborted by user input or closing of the main application window.
Apart from the main class this file contains functions to read data from a \gls{csv} file to a \code{numpy} array and check that no values in it exceed the test bench limits.
There is also a function to generate a plot of the data for display in the \gls{ui}.
The line plot is modified to visually reflect the discrete and nearly instantaneous change of fields in the test bench (see result in Figure \ref{fig:ui_overview}).
\subsection{Calibration Procedures \code{calibration.py}}
\label{sec:calibration_processes}
This file contains the worker thread objects \code{AmbientFieldCalibration}, \code{CoilConstantCalibration}, and \code{MagnetometerCalibration}.
All of these threads start by checking for and acquiring hardware during instantiation, while still in the main thread.
Then upon running, the main code in \code{calibration\_procedure} is executed; This function may freely block and make use of sleep commands.
Typically, calibration procedures should save two data detail levels: Processed data that is displayed as a final result, and detailed, raw data points that can be exported by the user to apply custom algorithms or verify the applications function.
The calibration procedures communicate with the main thread via a \gls{mpi} queue.
Depending on whether the procedure completes successfully or not, a \code{finished} or \code{failed} status signal is sent.
In addition, a \code{calibration\_data} message is sent together with ``\code{finished}'' to convey the final results to the main thread.
Other signals may also be used, such as \code{progress} to indicate the current completion status to the main thread.
Data that should be passed to the calibration procedure from the main thread, is done so through the constructor.
\subsection{Configuration Handling File \code{config\_handling.py}}
\label{sec:config_handling}
This file contains functions and variables for reading and writing configuration files.
The processing is done using the \code{configparser} library.
The current program configuration is stored in \code{CONFIG\_OBJECT}, an instance of \code{ConfigParser}.
It contains all configurable information and can be stored in its entirety in a .ini file.
The \code{write\_config\_to\_file} and \code{get\_config\_from\_file} functions are used to read/write the entire \code{CONFIG\_OBJECT} from/to such a file.
An example is provided in Appendix \ref{app:example_files}.
To access or change specific values the \code{read\_from\_config} and \code{edit\_config} functions are provided.
The latter also checks if a value is within the safe limits defined in \code{globals.py}.
If it is not, the user is warned and asked to confirm, before the value is written to \code{CONFIG\_OBJECT}.
The \code{check\_config} function does this check for all values of a provided configuration object.
If excessive values are found, a warning message is displayed and the configuration \gls{ui} page opened, where they are highlighted.
\subsection{Sequence Execution File \code{csv\_threading.py}}\label{sec:csv_exec}
This file contains code for executing a timed sequence of magnetic field vectors from a \gls{csv} file. To do this without interfering with the \gls{ui}, it must run in a separate thread.\\
The main class for this is \code{ExecCSVThread}. It inherits the \code{Thread} class from the \code{threading} library, so each of its instances represents a unique thread. Its main method, apart from those needed to start and stop it, is \code{execute\_sequence}. It takes the desired sequence in the form of a \code{numpy} array and commands the test bench at the appropriate times. When a new field vector is set, all related actions need to be performed before the scheduler returns to the main thread. A \code{threading.Lock} object is used to ensure this. The method also continuously checks that all devices are still connected and that the run has not been aborted by user input or closing of the main application window.\\
Apart from the main class this file contains functions to read data from a \gls{csv} file to a \code{numpy} array and check that no values in it exceed the test bench limits. There is also a function to generate a plot of the data for display in the \gls{ui}. The line plot is modified to reflect the discrete and nearly instantaneous change of fields in the test bench (see result in Figure \ref{fig:ui_overview}).
\newpage
\subsection{Configuration Handling File \code{config\_handling.py}}\label{sec:config_handling}
This file contains functions and variables for reading and writing configuration files. The processing is done using the \code{configparser} library. \\
The current program configuration is stored in \code{CONFIG\_OBJECT}, an instance of \code{ConfigParser}. It contains all configurable information and can be stored in its entirety in a .ini file. The \code{write\_config\_to\_file} and \code{get\_config\_from\_file} functions are used to read/write the entire \code{CONFIG\_OBJECT} from/to such a file. An example is provided in Appendix \ref{app:example_files}.\\
To access or change specific values the \code{read\_from\_config} and \code{edit\_config} functions are provided. The latter also checks if a value is within the safe limits defined in \code{globals.py}. If it is not, the user is warned and asked to confirm, before the value is written to \code{CONFIG\_OBJECT}.\\
The \code{check\_config} function does this check for all values of a provided configuration object. If excessive values are found, a warning message is displayed and the configuration \gls{ui} page opened, where they are highlighted.\\
The last function of the file is \code{reset\_config\_to\_default}, which overwrites the entire \code{CONFIG\_OBJECT} with the default values defined in \code{globals.py}.
\subsection{Data Logging Handling File \code{csv\_logging.py}}\label{sec:logging}
This file handles the logging of test bench status data to a \gls{csv} file, using the \code{pandas} library. Within the program the logged data is stored in a \code{pandas.DataFrame} object, which represents a table with column headers and data rows.\\
The information about what data can be logged and how to access the specific values is stored in a central dictionary. Its keys are the names of the values as they are displayed in the \gls{ui}, e.g. "Voltage Setpoint" or "Actual Current". The keys are used as handles for accessing the dictionary elements and as labels for the checkboxes in the logging configuration \gls{ui} page (see Figure \ref{fig:loggingpure}). The \code{ConfigureLogging} class of the \gls{ui} generates a list containing the keys belonging to the ticked checkboxes, which is then passed to the logging functions to indicate what data should be logged.
The keys are intentionally identical to the labels used for the status display. This makes it easier for the user to know what each value means. It may also allow unifying some of the code for these two functionalities in the future.\\
The dictionary elements themselves are a string representation of the respective attribute names in the \code{Axis} class. They are used with the \code{getattr} command to get the values of these attributes from the individual axis objects.\\
To log a data point, the mentioned list of keys is passed to the \code{log\_datapoint} function. For each of its elements, the data from all three axes is read out using \code{getattr} as described above. To form the correct number of column headers, the key list is expanded to include each item three times, for example \code{["Target Field"]} becomes \code{["Target Field X", "Target Field Y", "Target Field Z"]}. Together with the values this forms a new \code{DataFrame}, which is then appended to the previously logged data.\\
When the logging is finished, the entire \code{DataFrame} can then be saved to a \gls{csv} file using the \code{write\_to\_file} function. The file also provides functions to allow the user to choose a filename and clear the logged data.
\newpage
\subsection{Data Logging Handling File \code{csv\_logging.py}}
\label{sec:logging}
This file handles the logging of test bench status data to a \gls{csv} file, using the \code{pandas} library.
Within the program the logged data is stored in a \code{pandas.DataFrame} object, which represents a table with column headers and data rows.
A \code{logging\_selection\_options} dict, contains a key-value list of logging options and their display labels, which are used to generate the \gls{ui}.
The \code{ConfigureLogging} class of the \gls{ui} then later generates a list containing the keys belonging to the ticked checkboxes, which is then passed to the logging functions to indicate what data should be logged.
To log a data point, this option dict is passed to the \code{log\_datapoint} function.
According to the options set, data is aggregated from the \code{CAGE\_DEVICE} and \code{MAGNETOMETER} components and assembled into a key-value dictionary.
Since no unified set of keys covering all of the logged parameters exist, new, understandable keys were selected.
The dict of log values is appended to the end of a Python list that collects every data point.
When the logging is finished, a \gls{csv} file is assembled and saved using the \code{write\_to\_file} function.
Assembly consists of first prepending two headers to the data rows, one containing the dictionary keys, and one containing the user-readable long names.
The long names can be obtained from the \code{get\_long\_column\_header} function.
The file also provides functions to allow the user to choose a filename and clear the logged data.
\subsection{TCP Remote Control \code{socket\_control.py}}
This class is responsible for enabling TCP control of the Helmholtz cage hardware.
In addition, it is the sole data path for magnetometer data to ingress into the control software.
As with all other software components, the command processing thread accesses the hardware using the global \code{g.CAGE\_DEVICE} and \code{g.MAGNETOMETER} instances, and uses the proxy model to prevent control collisions.
The \code{socket\_control.py} file defines the \code{SocketInterfaceThread} class, which provides a listener thread to accept incoming TCP connections.
The listener thread instance is defined at application start-up in \code{main.py}.
For each established connection, a \code{ClientConnectionThread} instance is created.
All the main logic and command parsing is handled the client connection/session threads.
The \gls{api} is documented in Chapter \ref{sec:tcp_api} and also in the application source code.
Note: Following an application crash, the application may present an error in acquiring the TCP port.
This may be fixed by waiting a few minutes, or restarting the computer.
Perhaps this special case can be fixed with a more rigorous exit handler in the future.
\subsection{Hardware Control Libraries}
\myparagraph{PS2000B \gls{psu} Control Library \code{PS2000B.py}}
The code necessary to control the power supply units was adapted from S. Spr{\"o}ßig \cite{PS2000B_lib} with only minor modifications. The library provides the class \code{PS2000B} and some supporting functions. Each object of the class represents a physical \gls{psu}. These objects can be used to access status information and command the device, for example to set currents and voltages. More information can be found in the readme file provided by the original author.\\
The main modification to this library done as part of this thesis, was to implement independent commanding of both \gls{psu} channels. For this an additional parameter \code{channel} (integer type, 0 or 1) was added to all methods of the \code{PS2000B} class that interact with the device.
\myparagraph{``\code{PSUDevice}'' Abstract Class and PSU Drivers \code{psu\_device.py} / \code{PS2000B.py}}
An abstract class \code{PSUDevice} describing any compatible power supply object is used to allow for quick and easy addition of new device support.
This improvement was implemented since power supplies may be replaced or upgraded on a moderately frequent basis.
The abstract class exposes functions to set currents and voltages, and also to query the current device status.
QL355TP devices are natively supported with the \code{PSUDeviceQL355TP} object, that is also defined in \code{psu\_device.py}.
Since the code was at some point migrated to require a consistent interface for PSU objects, the original PS2000B library had to be put in a wrapper class (\code{PSUDevicePS2000B}).
The external library provides the class \code{PS2000B} and some supporting functions, and was adapted from S. Sprößig \cite{PS2000B_lib} with only minor modifications.
More information can be found in the read me file provided by the original author.
The library objects provide effectively the same set of functions advertised by the abstract class, as such the wrapper is quite trivial.
In the future, it may make sense to migrate the library code directly into the \code{PSUDevicePS2000B} class.
The main modification to the PS2000B library done as part of this thesis, was to implement independent commanding of both \gls{psu} channels.
For this an additional parameter \code{channel} (integer type, 0 or 1) was added to all methods of the \code{PS2000B} class that interact with the device.
Additionally the properties \code{PS2000B.output1}, \code{PS2000B.voltage1} and \code{PS2000B.current1} were duplicated to support the second channel (e.g. \code{PS2000B.output2}).
\myparagraph{Arduino Command \gls{api} \code{arduino.py}}
To control the Arduino microcontroller the online library from \cite{Arduino_lib} was integrated without major modifications. To use it, the provided \code{prototype.ino} file needs to be transferred to the Arduino. An object of class \code{Arduino} can then be used to control the board (connected via \gls{usb}) with methods that closely resemble the ones used in the standard Arduino programming language. More details can be found in the readme file provided by the original author \cite{Arduino_lib}.
\myparagraph{Arduino Command \gls{api} \code{arduino.py} / \code{arduino\_device.py}}
To control the Arduino microcontroller, an interface and wrapper class is used.
First, the online library from \cite{Arduino_lib} is integrated without major modifications to expose a hardware control \gls{api}.
It communicates with the provided \code{prototype.ino} Arduino program to allow toggling and control of its pins with methods that closely resemble the ones used in the standard Arduino programming language.
More details can be found in the read me file provided by the original author \cite{Arduino_lib}.
The \code{Arduino} class is then wrapped in the \code{ArduinoDevice} class defined in \code{arduino\_device.py}, to expose a more polarity-switcher-relevant \gls{api}.
This simplifies its use in the already complex \code{HelmholtzCageDevice} function.
\myparagraph{Magnetometer Device \code{magnetometer.py}}
The magnetometer object is hardware-agnostic, and merely mirrors the state of an external physical device.
To enable quick and optimal compatibility with new magnetometer devices, data is ingested by means of the TCP port and the application functions purely as a listener.
External magnetometer interface scripts will first push their data according to the \gls{api} defined in \code{socker\_control.py}, which will then be forwarded to the global \code{MagnetometerProxy} instance.
This means the magnetometer proxy object is purely passive, and functions like a simple data container.
The magnetometer object mirrors the state of the TCP connection, which is presented as a connected/disconnected state.
\section{Conversion to Executable}
The program is compiled to a .exe executable file to enable simple use without a Python installation. For this, the "Auto Py to Exe" tool developed by B. Vollebregt \cite{auto_py_to_exe} is used. The resulting files are distributed in a separate repository on the \gls{irs} git server.\footnote{\url{https://egit.irs.uni-stuttgart.de/zietzm/Helmholtz_Test_Bench_Releases}} When a new software version is ready for publication, the following procedure should be used to release it:
+6 -1
View File
@@ -452,4 +452,9 @@ Figure \ref{fig:settingspure} shows a screenshot of the \gls{ui} layout with the
\item Press "Save current config" button to update the current configuration file
\item Press "Save current config as..." button to save to a new configuration file, set new file name in file dialogue (e.g. "myconfig.ini")
\end{itemize}
\end{enumerate}
\end{enumerate}
\subsection{TCP Remote Control}
\label{sec:tcp_api}
TODO.
@@ -129,7 +129,7 @@
\setlength{\parindent}{0mm}
%\setlength{\unitlength}{1mm}
%\sloppy
\sloppy
% ********************************************************************
% Begin of document
@@ -170,6 +170,7 @@
\glsunset{pc}
\glsunset{tbd}
\glsunset{com}
\glsunset{api}
\include{Chapters/03_Software}
\include{Chapters/04_Users Guide}
+1 -1
View File
@@ -5,7 +5,7 @@
$B$ & Magnetic flux density & \si{\tesla} \\[0.6em]
$B_0$ & Ambient magnetic flux density & \si{\tesla} \\[0.6em]
$B_{max}$ & Maximum achievable magnetic flux density & \si{\tesla} \\[0.6em]
$B_{max}$ & Minimum achievable magnetic flux density & \si{\tesla} \\[0.6em]
$B_{min}$ & Minimum achievable magnetic flux density & \si{\tesla} \\[0.6em]
$I$ & Current & \si{\ampere} \\[0.6em]
$I_{max}$ & Maximum current & \si{\ampere} \\[0.6em]
$K$ & Coil constant & \si{\tesla\per\ampere} \\[0.6em]
-165
View File
@@ -1,165 +0,0 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<<
/Title ()
/Author ()
/Subject ()
/Keywords ()
/Creator (yExport 1.5)
/Producer (org.freehep.graphicsio.pdf.YPDFGraphics2D 1.5)
/CreationDate (D:20210314231237+01'00')
/ModDate (D:20210314231237+01'00')
/Trapped /False
>>
endobj
2 0 obj
<<
/Type /Catalog
/Pages 3 0 R
/ViewerPreferences 4 0 R
/OpenAction [5 0 R /Fit]
>>
endobj
4 0 obj
<<
/FitWindow true
/CenterWindow false
>>
endobj
5 0 obj
<<
/Parent 3 0 R
/Type /Page
/Contents 6 0 R
>>
endobj
6 0 obj
<<
/Length 7 0 R
/Filter [/ASCII85Decode /FlateDecode]
>>
stream
Gb"/*D3*J\&cSq?_,!?9F;;NG'_Tjnj1%M[f^Yg:&-R`*:Ud5gX1`1jrUTD=Uc?jr]6>W+;U,Od"'Ii5
^'m"4LGL4#@LE,IgA>$X*gAF6^QA+HqE=*[gdW_/EJH'_Y"BH%S#E'nkW_oRm<nZ&]"cE+g$Ar<\t<SU
=oS#R&\B='-j5OerdfJ,k@Q^IiS4k)Y+;&mAiN?S`sOV:43UCq[d_CdZ@5A0T,6TCT.AOGb-e[;A4fW$
'abRBg[S[:1I5+(0"V#!N'(>'i@lT\iF7MI!I%RP1lZWE&glYgfRPanCQ5jp3"[a^njAAtc2:N2:XD=g
d)/YCN%qq^milXkZX*3G$Y=6pN"<!DoUUpe<Y(>2#rhSSrb=.LX./=b=,\hN't_&:rc2$*%qL12lPoEW
h(SZ?P,nUP5?raMHY<DQP<4=O4B8XcSQujXj%OXfN#:r4jbJ'ecHNKHo5KG"@J0C^cKmc]L\2[uhL/Rl
3DS70&#rs#)kRYY&Xe@g+_&^34IIdX\FMV!fN1$G@#[1gZ\Wo0rWG6A(hg_$`%#AF,RHBZmQ.QFnbqXr
nkXA6ii*k#2?%]&5Hf:]%;@mK+7c;QYH),cRgN^8K+L:@,/BJuodg6*\O'PH/0)\#6]C,>h4J<2D$!eK
fjmu)369W@)m8d,nc&5aY=$sZd^1#`&uY!8NaD/=6aNi7U%;*XgI&NP+6#t4m(X1p7VR@$_k(`iY%'a8
0#0&J_0LNj&J&LKf9<(,Qk3nfVOI.,J-d%RL'c5"jKO_6/5cXd3!%WCdYo*#7Ee&%Ql<d@ak2:Pp7"M:
88&oS.:Sq^<.a6#Yg>u=e[FNN_n$VY,#p"PZ.-S.[Oo,N;Zgb2[>!qfl%/u3\5a7II\:P!K5n)MiHLf3
-<&@mZ1E3+IQEOhRn'Z23Z*ooXla9i>i;ZQknIaa#nA0#f4W5CF9UCim-t61rOVhqI41O&4RgPMG&(./
C\V7:esiQXETf7'ju!+Q<,eg!)-N!W,u#`u.'u^"(R-Dp.,kT=6L;i*YqJ?-/igO];^*]t:IkO];C4kN
?GGibk.P&7\,!T@g+%eNr'V<u//d-qO%!>2q4Sq?%;_K6!Z(Gf!?H2:<t^Z#-:`5]DXXW8>LWmuKRo;l
^-<YS#imrVT7\#X&K/od5U*':<uu.4Z2>/Cq,rp$fl`bsa<c[K*t#[&kWj%ciI/,gCr#Xtn<9=AQ.q+4
FNjOSr/_TD(HLj'bBNtmJ1OB`3T(S\!]/"WilYc$E%F5lF:#7sY1jYrJ;99FcV.$J`fan7hj0c+G:li#
+q-G+UH:CUD.IiT(\u>5aF1C!(]-AkL4!g[6P2T2eFo74S:e0N4G"B'r.<^iM^50i1)!Z$gt%-N9miUR
RrXl&1j`GULjf)%.6>(YP:2m=>`^LIb;K:cE7Y)264$8l*=3/tkRX;Cp!HZKG7/49WZ/5sIQnV@qNNp7
G+;^=bl`1t48d\):\(4'anAgW8jDGA%T5-Zm>A!(;'DY=;D?b1U_d!:+AmYhc31l(XE!5T$;P"^\2eN2
.p+C`j0rl3ej2$"QF(-5!UJbnk_-=U2<Sl<(&59N.:4QMQB;*,8bk!\_'4siBos%g/_^(<nARFU>^;i"
N9,gs/TCDA^MZuU$Edi0D2L;6NdC:,+oqIf#Z\6T@gG,@gMW'p\Sh/YO5OG9hC"#?L!*BsCnh+qU#Ol2
,=VcCq&s'r/G&0cJLoB1M(l6rc-<MpG<n<a9%l!n,Kl"Rk7N*(PX1>Zs"So3,R9ea'_E%M\[jIuRe(<S
\FMV!(s<FEQCj?ooBZ*4Q\\4rg!^EPO@js@QZY<WAVmsL(H+l-:$.dL&tXlIE"XsPS7LGiJ7N@I]18s9
[G17>]YmC\r45$-1R2B\clpH=0r,!5R0`o))/71F#+iNaQiiAPQ^M!W"^#+up9,'0L`%::H[pP?"dh<F
DU66p-"AAHK3Xq7/:a.lqIuohGe5S!F0*-Zk8fMe=@);9MDigb0bn=pTqLrX6+rqnE[L9UAprUBN.M_;
_%jn$MS]jlM=CfUDjoLZagYbC<2c:;&OM<-^*Ra),U)^?.&]1XoE&+U9Rp,T2WGDW5**TaR>YDUMEpen
RK5(%T?=1XS'O"cs14d,Y*bW)o1h)]ehIJ,b#(uXJcl6Zj\12)k,$BJ$9f,OQoX`e@4qfAgjS$HRTGV5
a=KR<c%s94.:H!0;8S3Y6\!eNlO5[FfSk7XN>V8.?f=\_`+d(4l:oqubdkE$9rI"hnCebOi7"cWQ.Jae
PRmVROmfREKd>:;HS"dT+jl)fI=Kf5_-E<ufU(>O)-b&YXk[FX`A2dgTeI1'Qmqc%"KuSQImI<$,)i,M
k4(*CMc";Kp@2X04oHRboj>Y>b\2?YjB`C2'0L1,7^4UQe5I9\"U1LM3\9ftT$3-sB3:.u\1='-MJTPI
`B?XCOYCkBE/)EsKk4+7LI^STV"O&6=\opo"EW)@0b&&iI0mul%E<0G!OL.$YP`T<\VsJ*UT1I5hI::G
PJ(;m]/XNbZ15:6-^8O4Z)LO.nh:O>9$e@Nr^F!pISWK,O0)>s4dGa#ZWgj_(#"qo/B4GHOfD^sr(\@!
s6ZHW+/QZ)5Q:fghd`J~>
endstream
endobj
7 0 obj
2645
endobj
8 0 obj
<<
/Type /Font
/Subtype /Type1
/Name /F1
/BaseFont /Helvetica
/Encoding 9 0 R
>>
endobj
9 0 obj
<<
/Type /Encoding
/Differences [0 /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /.notdef /breve /caron /circumflex /dotaccent /hungarumlaut /ogonek /ring /tilde /space /exclam /quotedbl /numbersign /dollar /percent /ampersand /quotesingle /parenleft /parenright /asterisk /plus /comma /hyphen /period /slash /zero /one /two /three /four /five /six /seven /eight /nine /colon /semicolon /less /equal /greater /question /at /A /B /C /D /E /F /G /H /I /J /K /L /M /N /O /P /Q /R /S /T /U /V /W /X /Y /Z /bracketleft /backslash /bracketright /asciicircum /underscore /grave /a /b /c /d /e /f /g /h /i /j /k /l /m /n /o /p /q /r /s /t /u /v /w /x /y /z /braceleft /bar /braceright /asciitilde /.notdef /bullet /dagger /daggerdbl /ellipsis /emdash /endash /florin /fraction /guilsinglleft /guilsinglright /minus /perthousand /quotedblbase /quotedblleft /quotedblright /quoteleft /quoteright /quotesinglbase /trademark /fi /fl /Lslash /OE /Scaron /Ydieresis /Zcaron /dotlessi /lslash /oe /scaron /zcaron /.notdef /Euro /exclamdown /cent /sterling /currency /yen /brokenbar /section /dieresis /copyright /ordfeminine /guillemotleft /logicalnot /.notdef /registered /macron /degree /plusminus /twosuperior /threesuperior /acute /mu /paragraph /periodcentered /cedilla /onesuperior /ordmasculine /guillemotright /onequarter /onehalf /threequarters /questiondown /Agrave /Aacute /Acircumflex /Atilde /Adieresis /Aring /AE /Ccedilla /Egrave /Eacute /Ecircumflex /Edieresis /Igrave /Iacute /Icircumflex /Idieresis /Eth /Ntilde /Ograve /Oacute /Ocircumflex /Otilde /Odieresis /multiply /Oslash /Ugrave /Uacute /Ucircumflex /Udieresis /Yacute /Thorn /germandbls /agrave /aacute /acircumflex /atilde /adieresis /aring /ae /ccedilla /egrave /eacute /ecircumflex /edieresis /igrave /iacute /icircumflex /idieresis /eth /ntilde /ograve /oacute /ocircumflex /otilde /odieresis /divide /oslash /ugrave /uacute /ucircumflex /udieresis /yacute /thorn /ydieresis]
>>
endobj
3 0 obj
<<
/Parent null
/Type /Pages
/MediaBox [0.0000 0.0000 723.00 557.00]
/Resources 10 0 R
/Kids [5 0 R]
/Count 1
>>
endobj
11 0 obj
[/PDF /Text /ImageC]
endobj
12 0 obj
<<
/F1 8 0 R
>>
endobj
13 0 obj
<<
/S /Transparency
/CS /DeviceRGB
/I true
/K false
>>
endobj
14 0 obj
<<
/Alpha1
<<
/ca 1.0000
/CA 1.0000
/BM /Normal
/AIS false
>>
>>
endobj
10 0 obj
<<
/ProcSet 11 0 R
/Font 12 0 R
/ExtGState 14 0 R
>>
endobj
xref
0 15
0000000000 65535 f
0000000015 00000 n
0000000315 00000 n
0000005659 00000 n
0000000445 00000 n
0000000521 00000 n
0000000609 00000 n
0000003364 00000 n
0000003387 00000 n
0000003520 00000 n
0000006160 00000 n
0000005830 00000 n
0000005870 00000 n
0000005915 00000 n
0000006017 00000 n
trailer
<<
/Size 15
/Root 2 0 R
/Info 1 0 R
>>
startxref
6254
%%EOF
BIN
View File
Binary file not shown.
-422
View File
@@ -1,422 +0,0 @@
# This file contains all classes and functions directly related to the operation of the helmholtz test bench.
# The two main classes are Axis and ArduinoCtrl, see their definitions for details.
# import packages:
import numpy as np
import serial
import traceback
from tkinter import messagebox
# import other project files
from User_Interface import ui_print
from pyps2000b import PS2000B
from Arduino import Arduino
import config_handling as config
import globals as g
class Axis:
# Main class representing an axis (x,y,z) of the test bench
# contains static and dynamic status information about this axis and methods to control it
def __init__(self, index, device, PSU_channel, arduino_pin):
# static information
self.index = index # index of this axis, 0->X, 1->Y, 2->Z
self.device = device # power supply object for this axis (PS2000B class object)
self.channel = PSU_channel # power supply unit channel (0 or 1)
self.ardPin = arduino_pin # output pin on the arduino for switching polarity on this axis
self.name = g.AXIS_NAMES[index] # get name of this axis from list in globals.py (e.g. "X-Axis"
self.port = g.PORTS[index] # get serial port of this axis PSU
# read static information from the configuration object (which has read it from the config file or settings):
self.resistance = float(config.read_from_config(self.name, "resistance", config.CONFIG_OBJECT))
self.max_amps = float(config.read_from_config(self.name, "max_amps", config.CONFIG_OBJECT))
self.max_volts = float(config.read_from_config(self.name, "max_volts", config.CONFIG_OBJECT))
self.coil_constant = float(config.read_from_config(self.name, "coil_const", config.CONFIG_OBJECT))
self.ambient_field = float(config.read_from_config(self.name, "ambient_field", config.CONFIG_OBJECT))
max_field = self.max_amps * self.coil_constant # calculate max field reachable in this axis
self.max_field = np.array([-max_field, max_field]) # make array with min/max reachable field (w/o compensation)
# calculate max and min field that can be reached after compensating for the ambient field
self.max_comp_field = np.array([self.ambient_field - max_field, self.ambient_field + max_field]) # [min, max]
# initialize dynamic information, this is updated by self.update_status_info() later
self.connected = "Not Connected"
self.output_active = "Unknown" # power output on the PSU enabled?
self.remote_ctrl_active = "Unknown" # remote control on the PSU enabled?
self.voltage_setpoint = 0 # target voltage on PSU [V]
self.voltage = 0 # actual voltage on PSU [V]
self.current_setpoint = 0 # target current on PSU [A]
self.current = 0 # actual current on PSU [A]
self.polarity_switched = "Unknown" # polarity switched on the Arduino?
self.target_field_comp = 0 # field to be created by coil pair (this is sent to the coils) [T]
self.target_field = 0 # field that should occur in measurement area (ambient still needs to be compensated) [T]
self.target_current = 0 # signed current that should pass through coil pair [A]
if self.device is not None:
self.update_status_info()
def update_status_info(self): # Read out the values of the dynamic parameters stored in this object and update them
try: # try to read out the data, this will fail on connection error to PSU
self.device.update_device_information(self.channel) # update the information in the device object
device_status = self.device.get_device_status_information(self.channel) # get object with new status info
if device_status.output_active: # is the power output active?
self.output_active = "Active"
else:
self.output_active = "Inactive"
# is remote control active, allowing the device to be controlled by this program?
if device_status.remote_control_active:
self.remote_ctrl_active = "Active"
else:
self.remote_ctrl_active = "Inactive"
# get currents and voltages:
self.voltage = self.device.get_voltage(self.channel)
self.voltage_setpoint = self.device.get_voltage_setpoint(self.channel)
self.current = self.device.get_current(self.channel)
self.current_setpoint = self.device.get_current_setpoint(self.channel)
except (serial.serialutil.SerialException, IndexError): # Connection error, usually the PSU is unplugged
if self.connected == "Connected": # only show error messages if the device was connected before this error
# Show error as print-out in console and as pop-up:
ui_print("Connection Error with %s PSU on %s" % (self.name, self.port))
messagebox.showerror("PSU Error", "Connection Error with %s PSU on %s" % (self.name, self.port))
# set status attributes to connection error status:
self.connected = "Connection Error"
self.output_active = "Unknown"
self.remote_ctrl_active = "Unknown"
else: # no communications error
self.connected = "Connected" # PSU is connected
def print_status(self): # print out the current status of the PSU channel (not used at the moment)
ui_print("%s, %0.2f V, %0.2f A"
% (self.device.get_device_status_information(self.channel),
self.device.get_voltage(self.channel), self.device.get_current(self.channel)))
def power_down(self): # temporary powerdown, set outputs to 0 but keep connections enabled
try:
# set class object attributes to 0 to reflect shutdown in status displays, log files etc.
self.target_current = 0
self.target_field = 0
self.target_field_comp = 0
if self.device is not None: # there is a PSU connected for this axis
self.device.set_voltage(0, self.channel) # set voltage on PSU channel to 0
self.device.set_current(0, self.channel) # set current on PSU channel to 0
self.device.disable_output(self.channel) # disable power output on PSU channel
g.ARDUINO.digitalWrite(self.ardPin, "LOW") # set arduino pin for polarity switch relay to unpowered state
except Exception as e: # some error was encountered
# show error message:
ui_print("Error while powering down %s: %s" % (self.name, e))
messagebox.showerror("PSU Error!", "Error while powering down %s: \n%s" % (self.name, e))
def set_signed_current(self, value):
# sets current with correct polarity on this axis, this is the primary way to control the test bench
# ui_print("Attempting to set current", value, "A")
self.target_current = value # show target value in object attribute for status display, logging etc.
if abs(value) > self.max_amps: # prevent excessive currents
self.power_down() # set output to 0 and deactivate
raise ValueError("Invalid current value on %s. Tried %0.2fA, max. %0.2fA allowed"
% (self.name, value, self.max_amps))
elif value >= 0: # switch the e-box relay to change polarity as needed
g.ARDUINO.digitalWrite(self.ardPin, "LOW") # command the output pin on the arduino in the electronics box
elif value < 0:
g.ARDUINO.digitalWrite(self.ardPin, "HIGH") # command the output pin on the arduino in the electronics box
# determine voltage limit to be set on PSU, must be high enough to not limit the current:
# min. 8V, max. max_volts, in-between as needed with current value (+margin to not limit current)
maxVoltage = min(max(1.3 * value * self.resistance, 8), self.max_volts) # limit voltage
if self.connected == "Connected": # only try to command the PSU if its actually connected
self.device.set_current(abs(value), self.channel) # set desired current
self.device.set_voltage(maxVoltage, self.channel) # set voltage limit
self.device.enable_output(self.channel) # activate the power output
else: # the PSU is not connected
ui_print(self.name, "not connected, can't set current.")
def set_field_simple(self, value): # forms magnetic field as specified by value, w/o cancelling ambient field
self.target_field = value # update object attribute for display
self.target_field_comp = value # same as above, bc no compensation
current = value / self.coil_constant # calculate needed current
self.set_signed_current(current) # command the test bench
def set_field(self, value): # forms magnetic field as specified by value, corrected for ambient field
self.target_field = value # update object attribute for display
field = value - self.ambient_field # calculate needed field after compensation
self.target_field_comp = field # update object attribute for display
current = field / self.coil_constant # calculate needed current
self.set_signed_current(current) # command the test bench
class ArduinoCtrl(Arduino):
# main class to control the electronics box (which means commanding the arduino inside)
# inherits from the Arduino library
def __init__(self):
self.connected = "Unknown" # connection status attribute, nominal "Connected"
self.pins = [0, 0, 0] # initialize list with pins to switch relay of each axis
for i in range(3): # get correct pins from the config
self.pins[i] = int(config.read_from_config(g.AXIS_NAMES[i], "relay_pin", config.CONFIG_OBJECT))
ui_print("\nConnecting to Arduino...")
try: # try to set up the arduino
Arduino.__init__(self) # search for connected arduino and connect by initializing arduino library class
for pin in self.pins:
self.pinMode(pin, "Output")
self.digitalWrite(pin, "LOW")
except Exception as e: # some error occurred, usually the arduino is not connected
ui_print("Connection to Arduino failed.", e)
self.connected = "Not Connected"
else: # connection was successfully established
self.connected = "Connected"
ui_print("Arduino ready.")
def update_status_info(self): # update the attributes stored in this class object
if self.connected == "Connected": # only do this if arduino is connected (initialize new instance to reconnect)
try: # try to read the status of the pins from the arduino
for axis in g.AXES: # go through all three axes
if g.ARDUINO.digitalRead(axis.ardPin): # pin is HIGH --> relay is switched
axis.polarity_switched = "True" # set attribute in axis object accordingly
else: # pin is LOW --> relay is not switched
axis.polarity_switched = "False" # set attribute in axis object accordingly
except Exception as e: # some error occurred while trying to read status, usually arduino is disconnected
# show warning messages to alert user
ui_print("Error with Arduino:", e)
messagebox.showerror("Error with Arduino!", "Connection Error with Arduino: \n%s" % e)
for axis in g.AXES: # set polarity switch attributes in axis objects to "Unknown"
axis.polarity_switched = "Unknown"
self.connected = "Connection Error" # update own connection status
else: # no error occurred --> data was read successfully
self.connected = "Connected" # update own connection status
def safe(self): # sets relay switching pins to low to depower most of the electronics box
for pin in self.pins:
self.digitalWrite(pin, "LOW")
def value_in_limits(axis, key, value): # Check if value is within safe limits (set in globals.py)
# axis is string with axis name, e.g. "X-Axis"
# key specifies which value to check, e.g. current
max_value = g.default_arrays[key][1][g.AXIS_NAMES.index(axis)] # get max value from dictionary in globals.py
min_value = g.default_arrays[key][2][g.AXIS_NAMES.index(axis)] # get min value from dictionary in globals.py
if float(value) > float(max_value): # value is too high
return 'HIGH'
elif float(value) < float(min_value): # value is too low
return 'LOW'
else: # value is within limits
return 'OK'
def setup_all(): # main test bench initialization function
# creates device objects for all PSUs and Arduino and sets their values
# initializes an object of class Axis for all three axes (x,y,z)
# Setup Arduino:
try: # broad error handling for unforeseen errors, handling in ArduinoCtrl should catch most errors
if g.ARDUINO is not None: # the arduino has been initialized before, so we need to first close its connection
try:
g.ARDUINO.close() # close serial link
except serial.serialutil.SerialException:
pass
# serial.flush() in Arduino.close() fails when reconnecting
# this ignores it and allows serial.close() to execute (I think)
except AttributeError:
pass
# when no Arduino is connected but g.ARDUINO has been initialized then there is nothing to close
# this throws an exception, which can be ignored
g.ARDUINO = ArduinoCtrl() # initialize the arduino object from the control class, connects and sets up
except Exception as e: # some unforeseen error occurred (not connected issue handled in ArduinoCtrl class)
# show error messages to alert user
ui_print("Arduino setup failed:", e)
ui_print(traceback.print_exc())
messagebox.showerror("Error!", "Arduino setup failed:\n%s \nCheck traceback in console." % e)
# Setup PSUs and axis objects:
g.AXES = [] # initialize global list containing the three axis objects
# get serial ports for the PSUs from config
g.XY_PORT = config.read_from_config("PORTS", "xy_port", config.CONFIG_OBJECT)
g.Z_PORT = config.read_from_config("PORTS", "z_port", config.CONFIG_OBJECT)
g.PORTS = [g.XY_PORT, g.XY_PORT, g.Z_PORT] # write list with PSU port for each axis (X/Y share PSU)
# setup PSU and axis objects for X and Y axes:
ui_print("Connecting to XY Device on %s..." % g.XY_PORT)
try: # try to connect to the PSU
if g.XY_DEVICE is not None: # if PSU has previously been connected we need to close the serial link first
ui_print("Closing serial connection on XY device")
g.XY_DEVICE.serial.close()
g.XY_DEVICE = None
g.XY_DEVICE = PS2000B.PS2000B(g.XY_PORT) # setup PSU
ui_print("Connection established.")
g.X_AXIS = Axis(0, g.XY_DEVICE, 0, g.ARDUINO.pins[0]) # create axis objects (index, PSU, channel, relay pin)
g.Y_AXIS = Axis(1, g.XY_DEVICE, 1, g.ARDUINO.pins[1])
except serial.serialutil.SerialException: # communications error, usually PSU is not connected or wrong port set
g.X_AXIS = Axis(0, None, 0, g.ARDUINO.pins[0]) # create axis objects without the PSU
g.Y_AXIS = Axis(1, None, 1, g.ARDUINO.pins[1])
ui_print("XY Device not connected or incorrect port set.")
# same for the Z axis
ui_print("Connecting to Z Device on %s..." % g.Z_PORT)
try:
if g.Z_DEVICE is not None:
ui_print("Closing serial connection on Z device")
g.Z_DEVICE.serial.close()
g.Z_DEVICE = None
g.Z_DEVICE = PS2000B.PS2000B(g.Z_PORT)
ui_print("Connection established.")
g.Z_AXIS = Axis(2, g.Z_DEVICE, 0, g.ARDUINO.pins[2])
except serial.serialutil.SerialException:
g.Z_AXIS = Axis(2, None, 0, g.ARDUINO.pins[2])
ui_print("Z Device not connected or incorrect port set.")
# put newly created axis objects into a list for access later
g.AXES.append(g.X_AXIS)
g.AXES.append(g.Y_AXIS)
g.AXES.append(g.Z_AXIS)
ui_print("") # print new line
def set_to_zero(device): # sets voltages and currents to 0 on all channels of a specific PSU
device.voltage1 = 0
device.current1 = 0
device.voltage2 = 0
device.current2 = 0
def power_down_all(): # on all PSUs set all outputs to 0 but keep connections enabled
for axis in g.AXES:
axis.power_down() # set outputs to 0 and pin to low on this axis
def shut_down_all(): # safe shutdown at program end or on error
# set outputs to 0 and disable connections on all devices
ui_print("\nAttempting to safely shut down all devices. Check equipment to confirm.")
# start writing string to later show how shutdown on all devices went in a single info pop-up:
message = "Tried to shut down all devices. Check equipment to confirm."
# Shut down PSUs:
if g.XY_DEVICE is not None: # the PSU has been setup before
try: # try to safe the PSU
set_to_zero(g.XY_DEVICE) # set currents and voltages to 0 for both channels
g.XY_DEVICE.disable_all() # disable power output on both channels
except BaseException as e: # some error occurred, usually device has been disconnected
ui_print("Error while deactivating XY PSU:", e) # print the problem in the console
message += "\nError while deactivating XY PSU: %s" % e # append status to the message to show later
else: # device was successfully deactivated
ui_print("XY PSU deactivated.")
message += "\nXY PSU deactivated." # append message to show later
else: # the device was not connected before
# tell user there was no need/no possibility to deactivate:
ui_print("XY PSU not connected, can't deactivate.")
message += "\nXY PSU not connected, can't deactivate."
# same as above
if g.Z_DEVICE is not None:
try:
set_to_zero(g.Z_DEVICE)
g.Z_DEVICE.disable_all()
except BaseException as e:
ui_print("Error while deactivating Z PSU:", e)
message += "\nError while deactivating Z PSU: %s" % e
else:
ui_print("Z PSU deactivated.")
message += "\nZ PSU deactivated."
else:
ui_print("Z PSU not connected, can't deactivate.")
message += "\nZ PSU not connected, can't deactivate."
# Shut down Arduino:
try:
g.ARDUINO.safe() # call safe method in ArduinoCtrl class (all relay pins to LOW)
except BaseException as e: # some error occurred
ui_print("Arduino safing unsuccessful:", e)
message += "\nArduino safing unsuccessful: %s" % e # append to the message to show later
# this throws no exception, even when arduino is not connected
# ToDo (optional): figure out error handling for this
try:
g.ARDUINO.close() # close the serial link
except BaseException as e: # something went wrong there
if g.ARDUINO.connected == "Connected": # Arduino was connected, some error occurred
ui_print("Closing Arduino connection failed:", e)
message += "\nClosing Arduino connection failed: %s" % e
else: # Arduino was not connected, so error is expected
ui_print("Arduino not connected, can't close connection.")
message += "\nArduino not connected, can't close connection."
else: # no problems, connection was successfully closed
ui_print("Serial connection to Arduino closed.")
message += "\nSerial connection to Arduino closed."
messagebox.showinfo("Program ended", message) # Show a unified pop-up with how the shutdown on each device went
def set_field_simple(vector): # forms magnetic field as specified by vector, w/o cancelling ambient field
for i in [0, 1, 2]:
try:
g.AXES[i].set_field_simple(vector[i]) # try to set the field on each axis
except ValueError as e: # a limit was violated, usually the needed current was too high
ui_print(e) # let the user know
def set_field(vector): # forms magnetic field as specified by vector, corrected for ambient field
# same as set_field_simple(), but with compensation
for i in [0, 1, 2]:
try:
g.AXES[i].set_field(vector[i])
except ValueError as e:
ui_print(e)
def set_current_vec(vector): # sets currents on each axis according to given vector
i = 0
for axis in g.AXES:
try:
axis.target_field = 0 # set target field attribute to 0 to show that current, not field is controlled atm
axis.target_field_comp = 0 # as above
axis.set_signed_current(vector[i]) # command test bench to set the current
except ValueError as e: # current was too high
ui_print(e) # print out the error message
i += 1
def devices_ok(xy_off=False, z_off=False, arduino_off=False):
# check if all devices are connected, return True if yes
# checks for individual devices can be disabled by parameters above (default not disabled)
try: # handle errors while checking connections
if not xy_off: # if check for this device is not disabled
if g.XY_DEVICE is not None: # has the handle for this device been set?
g.X_AXIS.update_status_info() # update info --> this actually communicates with the device
if g.X_AXIS.connected != "Connected": # if not connected
return False # return and exit function
else: # if handle has not been set the device is inactive --> not ok
return False
if not z_off: # same as above
if g.Z_DEVICE is not None:
g.Z_AXIS.update_status_info()
if g.Z_AXIS.connected != "Connected":
return False
else:
return False
if not arduino_off: # check not disabled
g.ARDUINO.update_status_info() # update status info --> attempts communication
if g.ARDUINO.connected != "Connected":
return False
except Exception as e: # if an error is encountered while checking the devices
messagebox.showerror("Error!", "Error while checking devices: \n%s" % e) # show error pop-up
return False # clearly something is not ok
else: # if nothing has triggered so far all devices are ok --> return True
return True
-101
View File
@@ -1,101 +0,0 @@
# This file contains functions related to logging data from the program to a CSV file.
# They are mainly but not only called by the ConfigureLogging class in User_Interface.py.
# import packages
import pandas as pd
from datetime import datetime
import os
from tkinter import filedialog
from tkinter import messagebox
# import other project files
import globals as g
import User_Interface as ui
log_data = pd.DataFrame() # pandas data frame containing the logged data, in-program representation of csv/excel data
unsaved_data = False # Bool to indicate if there is unsaved data, set to True each time a datapoint is logged
zero_time = datetime.now() # set reference for timestamps in log file, reset when log_data is cleared and restarted
# create dictionary with all value handles that could be logged
# Key: String that is displayed in UI and column headers. Also serves as handle to access dictionary elements.
# Keys are the same as the rows in the status display ToDo (optional): use this for the status display
# Content: name of the corresponding attribute in the Axis class (in cage_func.py).
# Important: attribute handle must match definition in Axis class exactly, used with getattr() to get values.
axis_data_dict = {
'PSU Status': 'connected',
'Voltage Setpoint': 'voltage_setpoint',
'Actual Voltage': 'voltage',
'Current Setpoint': 'current_setpoint',
'Actual Current': 'current',
'Target Field': 'target_field_comp',
'Trgt. Field Raw': 'target_field_comp',
'Target Current': 'target_current',
'Inverted': 'polarity_switched'
}
def triple_list(key_list): # creates list with each entry of key_list tripled with axis names before it
new_list = [] # initialize list
for key in key_list: # go through the given list
for axis_name in ['X', 'Y', 'Z']: # per given list entry create three, one for each axis
new_list.append(' '.join((axis_name, key))) # put axis_name before the given entry and append to new list
return new_list
def log_datapoint(key_list): # logs a single row of data into the log_data DataFrame
# key_list determines what data is logged
global log_data # get global dataframe with logged data
global unsaved_data # get global variable that indicates if there is unsaved data
date = datetime.now().date() # get current date
time = datetime.now().strftime("%H:%M:%S,%f") # get string with current time in correct format
t = (datetime.now() - zero_time).total_seconds() # calculate timestamp relative to the start of the logging
data = [[date, time, t]] # initialize new data row with timestamps
for key in key_list: # go through the list telling us what data to log
for axis in g.AXES: # log this data for each axis
# get relevant value from the correct AXIS object and append to new data row:
data[0].append(getattr(axis, axis_data_dict[key]))
column_names = ["Date", "Time", "t (s)", *triple_list(key_list)] # create list with the correct column headers
new_row = pd.DataFrame(data, columns=column_names) # create data frame containing the new row
log_data = log_data.append(new_row, ignore_index=True) # append the new data frame to the logged data
unsaved_data = True # tell other program parts that there is now unsaved data
def select_file(): # select a file to write logs to
directory = os.path.abspath(os.getcwd()) # get project directory
# open file selection dialogue and save path of selected file
filepath = filedialog.asksaveasfilename(initialdir=directory, title="Set log file",
filetypes=([("Comma Separated Values", "*.csv*")]),
defaultextension=[("Comma Separated Values", "*.csv*")])
if filepath == '': # this happens when file selection window is closed without selecting a file
ui.ui_print("No file selected, can't save logged data.")
return None
else: # a valid file name was entered
return filepath
def write_to_file(dataframe, filepath):
# get global variables for use in this function:
global unsaved_data
if filepath is not None: # user has selected a file and no errors occurred
ui.ui_print("Writing logged data to file", filepath)
try:
# write data collected in log_data DataFrame to csv file in german excel format:
dataframe.to_csv(filepath, index=False, sep=';', decimal=',')
except PermissionError:
message = "No permission to write to: \n%s. \nFile may be open in another program." % filepath
messagebox.showerror("Permission Error", message)
except BaseException as e:
message = "Error while trying to write to file \n%s.\n%s" % (filepath, e)
messagebox.showerror("Error!", message)
else: # no exceptions occurred
unsaved_data = False # tell everything that there is no unsaved data remaining
ui.ui_print("Log data saved to", filepath)
def clear_logged_data(): # clears all logged data from data frame
global log_data # get global variable
log_data = pd.DataFrame() # reset to an empty data frame, i.e. clearing all logged data
-178
View File
@@ -1,178 +0,0 @@
# tThis file contains code for executing a sequence of magnetic fields from a csv file.
# To do this without crashing the UI it has to run in a separate thread using the threading module.
# import packages:
import time
import pandas
import numpy as np
from threading import *
from tkinter import messagebox
import matplotlib.pyplot as plt
# import other project files:
import User_Interface as ui
import cage_func as func
import globals as g
class ExecCSVThread(Thread):
# main class for executing a CSV sequence
# it inherits the threading.Thread class, enabling sequence execution in a separate thread
def __init__(self, array, parent, controller):
Thread.__init__(self)
self.array = array # numpy array containing data from csv to be executed
self.parent = parent # object from which this class is called, here the ExecuteCSVMode object of the UI
self.controller = controller # object on which mainloop() is running, usually the main UI window
self.__stop_event = Event() # event which can be set to stop the thread execution if needed
def run(self): # called to start the execution of the thread
ui.ui_print("\nStarting Sequence Execution...")
self.execute_sequence(self.array, 0.1, self.parent, self.controller) # run sequence
# when the sequence has ended, reset buttons on the UI:
if not g.exitFlag: # main window is open
self.parent.select_file_button["state"] = "normal"
self.parent.execute_button["state"] = "normal"
self.parent.stop_button["state"] = "disabled"
self.parent.reinit_button["state"] = "normal"
# setup ability to interrupt thread (https://stackoverflow.com/questions/323972/is-there-any-way-to-kill-a-thread)
def stop(self): # stop thread execution, can be called from another thread to kill this one
self.__stop_event.set()
def stopped(self): # returns true if the thread has been stopped, used to check if a run should continue
return self.__stop_event.is_set()
def execute_sequence(self, array, delay, parent, controller):
# main execution method of the class
# runs through array with times and desired fields and commands test bench accordingly
# array format: [time (s), xField (T), yField (T), zField (T)]
func.power_down_all() # sets outputs on PSUs to 0 and Arduino pins to LOW before starting
t_zero = time.time() # set reference time for start of run
# Check if everything is properly connected:
all_connected = func.devices_ok(parent.xy_override.get(), parent.z_override.get(),
parent.arduino_override.get())
# True or False depending on devices status, checks for some devices may be overridden by user
i = 0 # index of the current array row
while i < len(array) and all_connected and not self.stopped() and not g.exitFlag:
# while array is not finished, devices are connected, user has not cancelled and application is running
t = time.time() - t_zero # get time relative to start of run
if t >= array[i, 0]: # time for this row has come
g.threadLock.acquire() # execute all lines until threadLock.release() before going back to main thread
# check if everything is still connected before sending commands:
all_connected = func.devices_ok(parent.xy_override.get(), parent.z_override.get(),
parent.arduino_override.get())
if all_connected:
field_vec = array[i, 1:4] # extract desired field vector
ui.ui_print("%0.5f s: t = %0.2f s, target field vector ="
% (time.time() - t_zero, array[i, 0]), field_vec * 1e6, "\u03BCT")
func.set_field(field_vec) # send field vector to test bench
controller.StatusDisplay.update_labels() # update status display after change
# log change to the log file if user has selected event logging in the Configure Logging window
logger = controller.pages[ui.ConfigureLogging] # get object of logging configurator
if logger.event_logging: # data should be logged when test bench is commanded
logger.log_datapoint() # log data
i = i + 1 # next row
g.threadLock.release() # allow going back to main thread now
elif t <= array[i, 0] - delay - 0.02: # is there enough time to sleep before the next row?
time.sleep(delay) # sleep to give other threads time to run
if not self.stopped() and not g.exitFlag and all_connected: # sequence ended without interruption
ui.ui_print("Sequence executed, powering down channels.")
elif all_connected: # interrupted by user
ui.ui_print("Sequence cancelled, powering down channels.")
elif not all_connected: # interrupted by device error
ui.ui_print("Error with at least one device, sequence aborted.")
messagebox.showwarning("Device Error!", "Error with at least one device, sequence aborted.")
else: # if this happens there is a mistake in the logic above, it really should not
# tell the user something weird happened:
ui.ui_print("Encountered unexpected sequence end state:"
"\nThread Stopped:", self.stopped(), ", Application Closed:", g.exitFlag,
", Devices connected:", all_connected)
messagebox.showwarning("Unexpected state",
"Encountered unexpected sequence end state, see console output for details.")
func.power_down_all() # set currents and voltages to 0, set arduino pins to low
def read_csv_to_array(filepath): # convert a given csv file to a numpy array
# csv format: time (s); xField (T); yField (T); zField (T) (german excel)
# decimal commas
file = pandas.read_csv(filepath, sep=';', decimal=',', header=0) # read csv file without column headers
array = file.to_numpy() # convert csv to array
return array
def check_array_ok(array):
# check if any magnetic fields in an array exceed the test bench limits and if so display a warning message
values_ok = True
for i in [0, 1, 2]: # go through axes/columns
max_val = g.AXES[i].max_comp_field[1] # get limits the test bench can do
min_val = g.AXES[i].max_comp_field[0]
data = array[:, i + 1] # extract data for this axis from array
# noinspection PyTypeChecker
if any(data > max_val) or any(data < min_val): # if any datapoint is out of bounds
values_ok = False
if not values_ok: # show warning pop-up if values are exceeding limits
messagebox.showwarning("Value Limits Warning!", "Found field values exceeding limits of test bench."
"\nSee plot and check values in csv.")
def plot_field_sequence(array, width, height): # create plot of fixed size (pixels) from array
# ToDo (optional): polar plots, plots of angle...
fig_dpi = 100 # set figure resolution (dots per inch)
px = 1/fig_dpi # get pixel to inch size conversion
figure = plt.Figure(figsize=(width*px, height*px), dpi=fig_dpi) # create figure with correct size
# noinspection PyTypeChecker,SpellCheckingInspection
axes = figure.subplots(3, sharex=True, sharey=True, gridspec_kw={'hspace': 0.4}) # create subplots with shared axes
figure.suptitle("Magnetic Field Sequence") # set figure title
# modify data to show instantaneous jumps in field to reflect test bench operation
new_array = np.array([[0, 0, 0, 0]], dtype=float) # initialize modified array, zeros to show start from no fields
last_vals = [0, 0, 0] # [x,y,z] field values from last data point (zero here), used to create step in data
for row in array[:, 0:4]: # go through each row in the original array
# create extra datapoint at current timestamp, with field values from last to create a "step" in the plot:
new_array = np.append(new_array, [[row[0], *last_vals]], axis=0)
new_array = np.append(new_array, [row], axis=0) # add actual datapoint for current timestamp
last_vals = row[1:4] # save values from current timestamp for next
new_array = np.append(new_array, [[new_array[-1, 0], 0, 0, 0]], axis=0) # append last datapoint with 0 fields
# extract data and plot:
t = new_array[:, 0] # extract time column
for i in [0, 1, 2]: # go through all three axes
data = new_array[:, i + 1] * 1e6 # extract field column of this axis and convert to microtesla
max_val = g.AXES[i].max_comp_field[1] * 1e6 # get limits of achievable field
min_val = g.AXES[i].max_comp_field[0] * 1e6
plot = axes[i] # get appropriate subplot
plot.plot(t, data, linestyle='solid', marker='.') # plot data
if any(data > max_val): # if any value is higher than the maximum
plot.axhline(y=max_val, linestyle='dashed', color='r') # plot horizontal line to show maximum
# add label to line:
plot.text(t[-1], max_val, "max", horizontalalignment='center', verticalalignment='top', color='r')
if any(data < min_val): # same as above
plot.axhline(y=min_val, linestyle='dashed', color='r')
plot.text(t[-1], min_val, "min", horizontalalignment='center', color='r')
plot.set_title(g.AXIS_NAMES[i], size=10) # set subplot title (e.g. "X-Axis")
# set shared axis labels:
axes[2].set_xlabel("Time (s)")
axes[1].set_ylabel("Magnetic Field (\u03BCT)")
return figure # return the created figure to be inserted somewhere
+70 -55
View File
@@ -1,46 +1,26 @@
#!/usr/bin/env python
# Main file of the program. Run this file to start the application.
# import packages:
from os.path import exists
import traceback
from tkinter import messagebox
# import other project files:
import cage_func as func
from User_Interface import HelmholtzGUI
from User_Interface import ui_print
import User_Interface as ui
import globals as g
import config_handling as config
import csv_logging as log
from src.helmholtz_cage_device import HelmholtzCageDevice
import src.globals as g
import src.config_handling as config
import src.csv_logging as log
from src.magnetometer import MagnetometerProxy
from src.user_interface import HelmholtzGUI
from src.socket_control import SocketInterfaceThread
from src.utility import ui_print
def program_end(): # called on exception or when user closes application
# safely shuts everything down and saves any unsaved data
def program_start():
""" Application entry point """
print("Starting application")
g.exitFlag = True # tell everything else the application has been closed
if g.app is not None: # the main Tkinter app object has been initialized before
if g.app.pages[ui.ExecuteCSVMode].csv_thread is not None: # check if a thread for executing CSVs exists
g.app.pages[ui.ExecuteCSVMode].csv_thread.stop() # stop the thread
func.shut_down_all() # shut down devices
if log.unsaved_data: # Check if there is logged data that has not been saved yet
# open pop-up to ask user if he wants to save the data:
save_log = messagebox.askquestion("Save log data?", "There seems to be unsaved logging data. "
"Do you wish to write it to a file now?")
if save_log == 'yes': # user has chosen yes
filepath = log.select_file() # let user select a file to write to
log.write_to_file(log.log_data, filepath) # write the data to the chosen file
if g.app is not None:
g.app.destroy() # close application
try: # start normal operations
config.CONFIG_FILE = 'config.ini' # set the config file path
# ToDo: remember what the last config file was
config.CONFIG_FILE = config.get_default_config_path() # set the config file path
if not exists(config.CONFIG_FILE): # config file does not exist yet
print("Config file not found, creating new from defaults.")
config.reset_config_to_default() # create configuration object from defaults
@@ -48,31 +28,66 @@ try: # start normal operations
config.CONFIG_OBJECT = config.get_config_from_file(config.CONFIG_FILE) # read configuration data from config file
print("Starting setup...")
func.setup_all() # initiate communication with devices and initialize all major program objects
# initiate communication with devices and initialize all major program objects
g.CAGE_DEVICE = HelmholtzCageDevice()
# Mostly a data structure to hold field data broadcast by connected tcp client with HW access.
g.MAGNETOMETER = MagnetometerProxy()
print("\nOpening User Interface...")
# initialize user interface
g.app = HelmholtzGUI()
# tell all functions that the user interface is now runnings
g.exit_flag = False
g.app = HelmholtzGUI() # initialize user interface
g.exitFlag = False # tell all functions that the user interface is now running
g.app.state('zoomed') # open UI in maximized window
g.app.StatusDisplay.continuous_label_update(g.app, 500) # initiate regular Status Display updates (ms)
ui_print("Program Initialized")
config.check_config(config.CONFIG_OBJECT) # check config for values exceeding limits
# Connect hardware to HelmholtzCageDevice adapter object
g.CAGE_DEVICE.connect_hardware_async()
ui_print("\nStarting setup...") # do setup again, so it is printed in the UI console ToDo: do it only once
func.setup_all() # initiate communication with devices and initialize all major program objects
# check config for values exceeding limits
config.check_config(config.CONFIG_OBJECT)
g.app.protocol("WM_DELETE_WINDOW", program_end) # call program_end function if user closes the application
# Create TCP/Socket listener
socket_controller = SocketInterfaceThread()
socket_controller.start()
g.app.mainloop() # start main program loop
# call program_end function if user closes the application
g.app.protocol("WM_DELETE_WINDOW", program_end)
ui_print("Application initialized")
# start main program loop
g.app.mainloop()
except Exception as e: # An error has occurred somewhere in the program
print("\nAn error occurred, Shutting down.")
# shop pup-up error message:
message = "%s.\nSee python console traceback for more details. " \
"\nShutting down devices, check equipment to confirm." % e
messagebox.showerror("Error!", message)
print(traceback.print_exc()) # print error traceback in the python console
program_end() # safely close everything and shut down devices
def program_end():
""" Safely shuts everything down and saves any unsaved data.
Called on exception or when user closes application """
# tell everything else the application has been closed
g.exit_flag = True
# shut down devices and end all threads.
g.CAGE_DEVICE.destroy()
if log.unsaved_data: # Check if there is logged data that has not been saved yet
# open pop-up to ask user if he wants to save the data:
save_log = messagebox.askquestion("Save log data?", "There seems to be unsaved logging data. "
"Do you wish to write it to a file now?")
if save_log == 'yes': # user has chosen yes
filepath = log.select_file() # let user select a file to write to
log.write_to_file(filepath) # write the data to the chosen file
if g.app is not None:
g.app.destroy() # close application
if __name__ == '__main__':
try:
program_start()
except Exception as e: # An error has occurred somewhere in the program
print("\nAn error occurred, shutting down.")
# shop pop-up error message:
message = "%s.\nSee Python console traceback for more details. " \
"\nShutting down devices, check equipment to confirm." % e
messagebox.showerror("Error!", message)
# print error traceback in the python console
print(traceback.print_exc())
# safely close everything and shut down devices
program_end()
+1 -1
View File
@@ -26,7 +26,7 @@ Initial Version
### V 1.1 (10.03.2021)
- updated UI with user feedback
### V 1.15 (27.03.2021)
### V 1.1.1 (27.03.2021)
- added documentation
- tweaks and ToDo's after testing with all devices connected (communication with PSUs is too slow)
- UI tweaks to better fit smaller display of cleanroom PC
+14 -5
View File
@@ -1,5 +1,14 @@
numpy==1.19.3 # bug in numpy 1.19.4, 1.19.3 used as workaround
pyserial~=3.5
future~=0.18.2
pandas~=1.1.5
matplotlib~=3.3.2
appdirs==1.4.4
cycler==0.10.0
future==0.18.2
kiwisolver==1.3.2
matplotlib==3.3.4
numpy==1.19.3
pandas==1.1.5
Pillow==8.4.0
pyparsing==2.4.7
pyserial==3.5
python-dateutil==2.8.2
pytz==2021.3
scipy==1.7.1
six==1.16.0
+40
View File
@@ -0,0 +1,40 @@
from src.arduino import Arduino
from src.utility import ui_print
import src.config_handling as config_handling
import src.globals as g
class ArduinoDevice(Arduino):
""" Main class to control the electronics box (which means commanding the arduino inside).
Inherits from the Arduino library. All axis indices are go from 0-2 for x,y and z respectively"""
def __init__(self, com_port):
self.pins = [0, 0, 0] # initialize list with pins to switch relay of each axis
for i in range(3): # get correct pins from the config
self.pins[i] = int(config_handling.read_from_config(g.AXIS_NAMES[i], "relay_pin", config_handling.CONFIG_OBJECT))
# try to set up the arduino. Exceptions are handled by caller
# search for connected arduino and connect by initializing arduino library class
Arduino.__init__(self, port=com_port, timeout=2)
for pin in self.pins:
self.pinMode(pin, "Output")
self.digitalWrite(pin, "LOW")
def set_axis_polarity(self, axis_index, reverse):
"""Sets the polarity of the axis (indexed with 0-2). True is reverse polarity"""
if reverse:
self.digitalWrite(self.pins[axis_index], "HIGH")
else:
self.digitalWrite(self.pins[axis_index], "LOW")
def get_axis_polarity(self, idx):
"""Returns a bool indicating whether the axis polarity is reversed (True)."""
return self.digitalRead(self.pins[idx]) # pin is HIGH --> relay is switched
def idle(self):
"""Sets relay switching pins to low to de-power most of the electronics box"""
for pin in self.pins:
self.digitalWrite(pin, "LOW")
def shutdown(self):
self.idle()
+396
View File
@@ -0,0 +1,396 @@
from math import pi, sqrt, sin, cos
import time
from datetime import datetime
from threading import Thread
import numpy as np
import scipy.optimize
from src.utility import ui_print
from src.exceptions import DeviceBusy, DeviceAccessError
import src.globals as g
class AmbientFieldCalibration(Thread):
"""Varies the coil-generated fields until a configuration is reached which zeros the connected magnetometer.
The magnetometer does not need to be centered. The axes of the magnetometer must match the coil configuration!"""
# Timeout/settling time for the calibration procedure. An acceptable duration for the PI is required
SETTLE_TIME = 45
# PID controller time delta
TIME_DELTA = 0.5
P_CONTROL = -7e3 # 0.2 A/s slew-rate at 40uT
I_CONTROL = 0 # -1e4 # 0.01A/s slew-rate for 1uTs
I_LIMIT = 1e-7 # uTs, Limit I to 0.025 A/s slew-rate to prevent wind-up
# D_CONTROL = Not implemented for now
def __init__(self, view_queue):
Thread.__init__(self)
self.view_queue = view_queue
# Axis currents. Incremented by PID loop
self.axis_currents = np.array([0, 0, 0], dtype=float)
# Used for I control
self.error_integral = np.array([0, 0, 0], dtype=float)
# Hardware checks are done in the init method to allow for exception handling in main thread
# This means the run method should/must be called directly after Thread object creation.
# Make sure we really have magnetometer data
if not g.MAGNETOMETER.connected:
ui_print("\nError: The magnetometer is not connected. Required for ambient field calibration.")
raise DeviceAccessError("The magnetometer is not connected. Required for ambient field calibration.")
# Acquire cage device. This resource will only be released after the thread is ended.
try:
self.cage_dev = g.CAGE_DEVICE.request_proxy()
except DeviceBusy:
ui_print("\nError: Failed to acquire coil control. Required for ambient field calibration.")
raise DeviceAccessError("Failed to acquire coil control. Required for ambient field calibration.")
def run(self):
try:
self.calibration_procedure()
self.put_message('finished', None)
except Exception as e:
self.put_message('failed', e)
finally:
self.cage_dev.close()
def calibration_procedure(self):
raw_experiment_data = []
start_time = datetime.now()
target_time = 0
current_time = datetime.now()
while (current_time - start_time).seconds < self.SETTLE_TIME:
# Each axis runs its own PID controller. They are slightly coupled by unorthogonality, which should
# hopefully not destabilize the feedback loop
for i in range(3):
# Error in tesla
dt = self.TIME_DELTA
e = g.MAGNETOMETER.field[i]
# Change in control current
du = e * self.P_CONTROL + self.error_integral[i] * self.I_CONTROL
self.axis_currents[i] += du*dt
# Update integral
# Add increment
self.error_integral[i] += e*dt
# Clamp range
self.error_integral = np.clip(self.error_integral, -self.I_LIMIT, self.I_LIMIT)
# Apply new field actuation
self.cage_dev.set_signed_currents(self.axis_currents)
# Set new progress indicator for UI
self.put_message('progress', (current_time - start_time).seconds / self.SETTLE_TIME)
# Sleep until next iteration
target_time += self.TIME_DELTA
sleep_time = ((start_time - current_time).total_seconds() + target_time)
if sleep_time > 0:
time.sleep(sleep_time)
current_time = datetime.now()
coil_constants = np.array([g.CAGE_DEVICE.axes[i].coil_const for i in range(3)])
for i, axis in zip(range(3), ['x', 'y', 'z']):
raw_experiment_data.append({'axis': axis,
'cancellation_current': -self.axis_currents[i],
'ambient_field': -self.axis_currents[i] * coil_constants[i],
'residual_field': g.MAGNETOMETER.field[i]})
results = {'ambient': -self.axis_currents,
'ambient_ut': -self.axis_currents * coil_constants * 1e6,
'residual': g.MAGNETOMETER.field,
'raw_data': raw_experiment_data}
self.put_message('ambient_data', results)
# Put device into an off and ready state
self.cage_dev.idle()
def put_message(self, command, arg):
self.view_queue.put({'cmd': command, 'arg': arg})
class CoilConstantCalibration(Thread):
MEASUREMENT_RANGE = 3 # A. Will extend into negative and positive sign
MEASUREMENT_POINTS = 4 # Excludes zero. eg 0.5, 1, 1.5, 2
SETTLE_TIME = 3 # Time until new measurement is ready after setting current
def __init__(self, view_queue):
Thread.__init__(self)
self.view_queue = view_queue
# Hardware checks are done in the init method to allow for exception handling in main thread
# This means the run method should/must be called directly after Thread object creation.
# Make sure we really have magnetometer data
if not g.MAGNETOMETER.connected:
ui_print("\nError: The magnetometer is not connected. Required for ambient field calibration.")
raise DeviceAccessError("The magnetometer is not connected. Required for ambient field calibration.")
# Acquire cage device. This resource will only be released after the thread is ended.
try:
self.cage_dev = g.CAGE_DEVICE.request_proxy()
except DeviceBusy:
ui_print("\nError: Failed to acquire coil control. Required for ambient field calibration.")
raise DeviceAccessError("Failed to acquire coil control. Required for ambient field calibration.")
def run(self):
try:
self.calibration_procedure()
self.put_message('finished', None)
except Exception as e:
self.put_message('failed', e)
finally:
self.cage_dev.close()
def calibration_procedure(self):
# All generated fields will be compared to this using a simple difference method
ambient_field = g.MAGNETOMETER.field
# This generates linearly spaced current setpoints and excludes zero
currents = np.linspace(-self.MEASUREMENT_RANGE, self.MEASUREMENT_RANGE, self.MEASUREMENT_POINTS * 2 + 1)
currents = np.delete(currents, self.MEASUREMENT_POINTS)
# Stores three vectors that correspond to the x,y,z actuation field directions.
axis_field_directions = []
# Result variables
coil_constants = np.zeros(3)
k_deviations = np.zeros(3)
raw_experiment_data = []
# The coil constant must be determined for every axis
for i in range(3):
k_samples = []
for c_idx, c in enumerate(currents):
# Set current
c_vec = [0, 0, 0]
c_vec[i] = c
self.cage_dev.set_signed_currents(c_vec)
time.sleep(self.SETTLE_TIME)
# Get coil constant
field = g.MAGNETOMETER.field
field_diff_mag = np.linalg.norm(field - ambient_field)
sign = 1 if field[i] - ambient_field[i] >= 0 else -1
k = (field_diff_mag * sign) / c
k_samples.append(k)
# Save vector as principal coil direction if it is the last sample with the largest positive current
if c_idx == currents.shape[0] - 1:
axis_field_directions.append(g.MAGNETOMETER.field - ambient_field)
# Set new progress indicator for UI
self.put_message('progress', ((c_idx / (self.MEASUREMENT_POINTS * 2)) + i) / 3)
# Save into raw data vector
raw_experiment_data.append({'axis': i,
'I_x': c_vec[0],
'I_y': c_vec[1],
'I_z': c_vec[2],
'delta_mag_B': field_diff_mag,
'sign': sign,
'K': k})
# Average samples for axis
coil_constants[i] = np.average(k_samples)
k_deviations[i], _ = self.calculate_standard_deviation(k_samples)
# Put device into an off and ready state
self.cage_dev.idle()
angles = {'xy': self.angle_between(axis_field_directions[0], axis_field_directions[1]) * 180 / pi,
'yz': self.angle_between(axis_field_directions[1], axis_field_directions[2]) * 180 / pi,
'xz': self.angle_between(axis_field_directions[0], axis_field_directions[2]) * 180 / pi}
self.put_message('coil_constant_results', {'k': coil_constants,
'k_dev': k_deviations,
'angle': angles,
'raw_data': raw_experiment_data})
@staticmethod
def calculate_standard_deviation(data):
n = len(data)
average = 0
for datapoint in data:
average += datapoint / n
std_dev = 0
deviations = []
for datapoint in data:
std_dev += (datapoint - average) ** 2
deviations.append(datapoint - average)
std_dev = sqrt(std_dev / n)
return std_dev, deviations
@staticmethod
def angle_between(v1, v2):
""" Returns the angle in radians between vectors 'v1' and 'v2'"""
v1_u = v1 / np.linalg.norm(v1)
v2_u = v2 / np.linalg.norm(v2)
return np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0))
def put_message(self, command, arg):
self.view_queue.put({'cmd': command, 'arg': arg})
class MagnetometerCalibration(Thread):
TEST_VECTOR_MAGNITUDE = 100e-6 # In Tesla. Chosen so it can be achieved with a 3A PSU.
def __init__(self, view_queue, calibration_points, calibration_interval):
Thread.__init__(self)
self.view_queue = view_queue
self.calibration_points = calibration_points
self.calibration_interval = calibration_interval
# Hardware checks are done in the init method to allow for exception handling in main thread
# This means the run method should/must be called directly after Thread object creation.
# Make sure we really have magnetometer data
if not g.MAGNETOMETER.connected:
ui_print("\nError: The magnetometer is not connected. Required for ambient field calibration.")
raise DeviceAccessError("The magnetometer is not connected. Required for ambient field calibration.")
# Acquire cage device. This resource will only be released after the thread is ended.
try:
self.cage_dev = g.CAGE_DEVICE.request_proxy()
except DeviceBusy:
ui_print("\nError: Failed to acquire coil control. Required for ambient field calibration.")
raise DeviceAccessError("Failed to acquire coil control. Required for ambient field calibration.")
def run(self):
try:
self.calibration_procedure()
self.put_message('finished', None)
except Exception as e:
self.put_message('failed', e)
finally:
self.cage_dev.close()
def calibration_procedure(self):
# According to method outlined in:
# Zikmund, A. & Janosek, Michal. (2014). Calibration procedure for triaxial magnetometers without a compensating
# system or moving parts.
# This contains the raw experiment data for exporting
# Each row is a dict containing the applied vector and measured vector
raw_data = []
# Find sensor offsets. They must be found prior to applying the chosen calibration algorithm
# This will be accurate if the cage was recently calibrated
self.cage_dev.set_field_compensated([0, 0, 0])
# Sleep for a certain duration to allow psu to stabilize output and magnetometer to supply readings
time.sleep(self.calibration_interval)
# The offsets can easily be read from the magnetometer
offsets = g.MAGNETOMETER.field
# Save data point to raw_data list
raw_data.append({'applied_x': 0, 'applied_y': 0, 'applied_z': 0,
'measured_x': offsets[0], 'measured_y': offsets[1], 'measured_z': offsets[2]})
# Set new progress indicator for UI
self.set_progress(True, 0)
# Generate our set of test vectors
test_vectors = self.fibonacci_sphere(self.calibration_points)
# Holds the knowns for each row of our system of equations. These are M, B_x, B_y, B_z
# (B_E is constant for the test and not stored in the array)
# Each sensor axis has its own independent system of equations
samples = [[], [], []]
# Collect sensor data for each test vector
for vec_idx, test_vec in enumerate(test_vectors):
# Command output
applied_vec = test_vec * self.TEST_VECTOR_MAGNITUDE
self.cage_dev.set_field_raw(applied_vec)
# Sleep for a certain duration to allow psu to stabilize output and magnetometer to supply readings
time.sleep(self.calibration_interval)
# Read output and save to array for solver later
raw_reading = g.MAGNETOMETER.field
reading = raw_reading - offsets
for i in range(3):
row = {'m': reading[i], 'b_x': applied_vec[0], 'b_y': applied_vec[1], 'b_z': applied_vec[2]}
# print("[Axis {}] {}".format(i, row))
samples[i].append(row)
# Save data point to raw_data list
raw_data.append({'applied_x': applied_vec[0], 'applied_y': applied_vec[1], 'applied_z': applied_vec[2],
'measured_x': raw_reading[0], 'measured_y': raw_reading[1], 'measured_z': raw_reading[2]})
# Set new progress indicator for UI
self.set_progress(True, vec_idx + 1)
# Put device into an off and ready state
self.cage_dev.idle()
# Use collected data to build and solve system of equations
sensor_parameters = self.solve_system(samples, offsets)
# Pass results to UI
self.put_message('calibration_data', {'results': sensor_parameters, 'raw_data': raw_data})
def set_progress(self, offset_complete, test_vec_index):
progress = int(offset_complete) * 0.2 + (test_vec_index / self.calibration_points) * 0.8
self.put_message('progress', progress)
def solve_system(self, samples, offset_data):
# Calculate magnitude of ambient field
b_e_x = g.CAGE_DEVICE.axes[0].ambient_field
b_e_y = g.CAGE_DEVICE.axes[1].ambient_field
b_e_z = g.CAGE_DEVICE.axes[2].ambient_field
b_e = sqrt(b_e_x**2 + b_e_y**2 + b_e_z**2)
# Perform least squares optimization on all magnetometer axes
sensor_parameters = []
for axis, axis_samples in enumerate(samples):
result = scipy.optimize.least_squares(self.residual_function, (1.0, pi/4, pi/4, pi/4), args=(b_e, axis_samples), gtol=1e-13)
s, alpha_e, alpha, beta = result.x
residual = np.max(np.abs(result.fun))
sensor_parameters.append({'sensitivity': s,
'offset': offset_data[axis],
'alpha_e': alpha_e,
'alpha': alpha,
'beta': beta,
'residual': residual})
return sensor_parameters
# Function passed to scipy for the optimization
@staticmethod
def residual_function(x, b_e, samples):
# Unpack vector. These unknown parameters are described in the calibration paper
s, alpha_e, alpha, beta = x
# Residual vector
res = []
for sample in samples:
# Unpack row coefficients:
m = sample['m']
b_x = sample['b_x']
b_y = sample['b_y']
b_z = sample['b_z']
res.append(m - s * (b_e*sin(alpha_e) + b_x*cos(alpha)*cos(beta) + b_y*cos(alpha)*sin(beta) + b_z*sin(alpha)))
return res
@staticmethod
def fibonacci_sphere(samples):
"""
Algorithm to generate roughly equally spaced points on a sphere
From https://stackoverflow.com/a/26127012"""
points = []
phi = pi * (3.0 - sqrt(5.0)) # golden angle in radians
for i in range(samples):
y = 1 - (i / float(samples - 1)) * 2 # y goes from 1 to -1
radius = sqrt(1 - y * y) # radius at y
theta = phi * i # golden angle increment
x = cos(theta) * radius
z = sin(theta) * radius
points.append(np.array([x, y, z]))
return points
def put_message(self, command, arg):
self.view_queue.put({'cmd': command, 'arg': arg})
+32 -21
View File
@@ -2,14 +2,16 @@
# The configparser module is used for processing. Config files are of type .ini
# import packages:
import pathlib
import appdirs
from configparser import ConfigParser
from tkinter import messagebox
# import other project files:
import globals as g
import cage_func as func
# noinspection PyPep8Naming
import User_Interface as ui
import src.globals as g
from src.utility import ui_print
import src.user_interface as ui
import src.helmholtz_cage_device as helmholtz_cage_device
global CONFIG_FILE # string storing the path of the used config file
global CONFIG_OBJECT # object of type ConfigParser(), storing all configuration information
@@ -34,7 +36,7 @@ def read_from_config(section, key, config_object): # read a specific value from
value = section_obj[key] # get relevant value in the section
return value
except KeyError as e: # a section or key was used, that does not exist
ui.ui_print("Error while reading config file:", e)
ui_print("Error while reading config file:", e)
raise KeyError("Could not find key", key, "in config file.")
@@ -51,7 +53,7 @@ def edit_config(section, key, value, override=False): # edit a specific value i
try:
value_ok = 'OK'
if section in g.AXIS_NAMES and not override: # only check values in axis sections and not if check overridden
value_ok = func.value_in_limits(section, key, value) # check if value is ok, too high or too low
value_ok = helmholtz_cage_device.value_in_limits(section, key, value) # check if value is ok, too high or too low
if value_ok == 'HIGH': # value is too high
max_value = g.default_arrays[key][1][g.AXIS_NAMES.index(section)] # get max value for message printing
@@ -71,23 +73,21 @@ def edit_config(section, key, value, override=False): # edit a specific value i
try:
section_obj = CONFIG_OBJECT[section] # get relevant section in the config
except KeyError: # there is no such section
ui.ui_print("Could not find section", section, "in config file, creating new.")
ui_print("Could not find section", section, "in config file, creating new.")
CONFIG_OBJECT.add_section(section) # create the missing section
section_obj = CONFIG_OBJECT[section] # get the object of the section
try:
section_obj[key] = str(value) # set value for correct entry in the section
except KeyError: # there is no entry with this key
ui.ui_print("Could not find key", key, "in config file, creating new.")
ui_print("Could not find key", key, "in config file, creating new.")
CONFIG_OBJECT.set(section, key, str(value)) # create the entry and set the value
except KeyError as e: # key for section or specific value does not exist in the dictionary for max/min values
ui.ui_print("Error while editing config file:", e)
ui_print("Error while editing config file:", e)
raise KeyError("Could not find key", key, "in config file.") # return an error
def check_config(config_object): # check all numeric values in the config and see if they are within safe limits
ui.ui_print("Checking config file for values exceeding limits:")
concerns = {} # initialize dictionary for found problems
problem_counter = 0 # count the number of values that exceed limits
i = 0
@@ -101,17 +101,22 @@ def check_config(config_object): # check all numeric values in the config and s
if not min_value <= value <= max_value: # value is not in safe limits
concerns[axis].append(key) # add this entry to the problem dictionary
problem_counter += 1
if len(concerns[axis]) == 0: # no problems were found for this axis
concerns[axis].append("No problems detected.")
ui.ui_print(axis, ":", *concerns[axis]) # print out results for this axis
i += 1
if problem_counter > 0: # some values are not ok
# shop pup-up warning message:
# Print errors to console
ui_print("Config file contains errors:")
for axis in g.AXIS_NAMES:
# print out results for each axis
ui_print(axis, ":", *concerns[axis])
# shop pop-up warning message:
messagebox.showwarning("Warning!", "Found %i value(s) exceeding limits in config file. Check values "
"to ensure correct operation and avoid equipment damage!" % problem_counter)
g.app.show_frame(ui.Configuration) # open configuration window so user can check values
g.app.show_frame(ui.HardwareConfiguration) # open configuration window so user can check values
else:
pass
# If everything is fine, don't generate log spam
# ui_print("Config file checked.")
def reset_config_to_default(): # reset values in config object to defaults (set in globals.py)
@@ -126,6 +131,12 @@ def reset_config_to_default(): # reset values in config object to defaults (set
config.set(axis_name, key, str(g.default_arrays[key][0][i])) # set values
i += 1
config.add_section("PORTS") # add section for PSU serial ports
for key in g.default_ports.keys(): # go through dictionary of default serial ports
config.set("PORTS", key, str(g.default_ports[key])) # set the value for each axis
config.add_section("Supplies") # add section for PSU serial ports
for key in g.default_psu_config.keys(): # go through dictionary of default serial ports
config.set("Supplies", key, str(g.default_psu_config[key])) # set the value for each axis
def get_default_config_path():
config_folder = pathlib.Path(appdirs.user_config_dir(appname="helmholtz_test_bench", appauthor="IRS"))
config_folder.mkdir(parents=True, exist_ok=True)
return str(config_folder.joinpath("config.ini"))
+189
View File
@@ -0,0 +1,189 @@
# This file contains functions related to logging data from the program to a CSV file.
# They are mainly but not only called by the ConfigureLogging class in user_interface.py.
from datetime import datetime
import os
import csv
from tkinter import filedialog
from tkinter import messagebox
import src.globals as g
from src.utility import ui_print
log_data = [] # List containing the logged data, in-program representation of csv numerical data
unsaved_data = False # Bool to indicate if there is unsaved data, set to True each time a datapoint is logged
zero_time = datetime.now() # set reference for timestamps in log file, reset when log_data is cleared and restarted
# Dictionary listing the options that control what is present is the log output
# Key: The option that this control sets
# Value: displayed in the UI
logging_selection_options = {
'timestamp': 'Timestamp',
'device_stat': 'PSU + Arduino Status',
'x_axis': 'Log X-Axis Data',
'y_axis': 'Log Y-Axis Data',
'z_axis': 'Log Z-Axis Data',
'v': 'Voltage Setpoint',
'v_actual': 'Actual Voltage',
'i': 'Current Setpoint',
'i_actual': 'Actual Current',
'tgt_field': 'Target Field',
'tgt_field_raw': 'Target Field Raw',
'tgt_field_i': 'Target Current',
'inv': 'Inverted',
'mag_stat': 'Magnetometer Status',
'mag_field': 'Magnetometer Reading',
}
# This returns a dictionary defining the human readable column headers (Value) corresponding to the short forms (key)
# It has many similarities to the options above, but there is not a perfect correspondence between columns and logging
# options. This is both in terms of the user friendly name, which can differ, and the options.
def get_long_column_header():
header = {
'date': 'Date',
'time': 'Timestamp',
't': 'Time',
'arduino_stat': 'Arduino Status',
'xy_psu_stat': 'XY PSU Status',
'z_psu_stat': 'Z PSU Status',
'mag_stat': 'Magnetometer Status',
}
for i in ['x', 'y', 'z']:
i_caps = i.upper()
header[f'{i}_v'] = f'{i_caps}-Axis Voltage Setpoint'
header[f'{i}_v_actual'] = f'{i_caps}-Axis Actual Voltage'
header[f'{i}_i'] = f'{i_caps}-Axis Current Setpoint'
header[f'{i}_i_actual'] = f'{i_caps}-Axis Actual Current'
header[f'{i}_tgt_field'] = f'{i_caps}-Axis Target Field'
header[f'{i}_tgt_field_raw'] = f'{i_caps}-Axis Target Field Raw'
header[f'{i}_tgt_field_i'] = f'{i_caps}-Axis Target Current'
header[f'{i}_inv'] = f'{i_caps}-Axis Inverted'
header[f'{i}_mag_field'] = f'Magnetometer Reading {i_caps}'
return header
def log_datapoint(settings):
"""Logs a single row of data into the log_data list"""
# key_list determines what data is logged
global log_data # get global dataframe with logged data
global unsaved_data # get global variable that indicates if there is unsaved data
# --- PRODUCE NEW DATA ROW ---
# d for 'data'
d = {}
# Timestamp
if settings['timestamp']:
d['date'] = datetime.now().date() # get current date
d['time'] = datetime.now().strftime("%H:%M:%S.%f") # get string with current time in correct format
d['t'] = (datetime.now() - zero_time).total_seconds() # calculate timestamp relative to the start of the logging
# PSU + Arduino status
if settings['device_stat']:
d['arduino_stat'] = g.CAGE_DEVICE.arduino is not None
d['xy_psu_stat'] = g.CAGE_DEVICE.psu1 is not None
d['z_psu_stat'] = g.CAGE_DEVICE.psu2 is not None
# Axis data
axes = []
if settings['x_axis']:
axes.append(0)
if settings['y_axis']:
axes.append(1)
if settings['z_axis']:
axes.append(2)
for i in axes:
pre = ['x_', 'y_', 'z_'][i]
axis = g.CAGE_DEVICE.axes[i]
# Cached PSU state data
if axis.psu:
psu_state = axis.psu.cached_channel_state(axis.channel)
else:
psu_state = {'voltage_setpoint': None,
'voltage': None,
'current_setpoint': None,
'current': None}
if settings['v']:
d[pre + 'v'] = psu_state['voltage_setpoint']
if settings['v_actual']:
d[pre + 'v_actual'] = psu_state['voltage']
if settings['i']:
d[pre + 'i'] = psu_state['current_setpoint']
if settings['i_actual']:
d[pre + 'i_actual'] = psu_state['current']
# All the rest
if settings['tgt_field']:
d[pre + 'tgt_field'] = axis.target_field
if settings['tgt_field_raw']:
d[pre + 'tgt_field_raw'] = axis.target_field_raw
if settings['tgt_field_i']:
d[pre + 'tgt_field_i'] = axis.target_current
if settings['inv']:
d[pre + 'inv'] = axis.polarity
# Mangetometer field
if settings['mag_field']:
for i in [0, 1, 2]:
pre = ['x_', 'y_', 'z_'][i]
d[pre + 'mag_field'] = g.MAGNETOMETER.field[i]
# Magnetometer status
if settings['mag_stat']:
d['mag_stat'] = g.MAGNETOMETER.connected
# Save the datapoint
log_data.append(d) # append the new data to the current data
unsaved_data = True # tell other program parts that there is now unsaved data
def select_file(): # select a file to write logs to
directory = os.path.abspath(os.getcwd()) # get project directory
# open file selection dialogue and save path of selected file
filepath = filedialog.asksaveasfilename(initialdir=directory, title="Set log file",
filetypes=([("Comma Separated Values", "*.csv*")]),
defaultextension=[("Comma Separated Values", "*.csv*")])
if filepath == '': # this happens when file selection window is closed without selecting a file
ui_print("No file selected, can't save logged data.")
return None
else: # a valid file name was entered
return filepath
def write_to_file(filepath):
# get global variables for use in this function:
global unsaved_data
global log_data
if filepath is not None: # user has selected a file and no errors occurred
ui_print("Writing logged data to file", filepath)
try:
# write data collected in log_data list to csv file in excel format:
with open(filepath, 'w', newline='') as f:
writer = csv.writer(f)
headers = log_data[0].keys()
long_headers = [get_long_column_header()[header] for header in headers]
writer.writerow(long_headers)
writer.writerow(headers)
for row in log_data:
writer.writerow(row.values())
except PermissionError:
message = "No permission to write to: \n%s. \nFile may be open in another program." % filepath
messagebox.showerror("Permission Error", message)
except Exception as e:
message = "Error while trying to write to file \n%s.\n%s" % (filepath, e)
messagebox.showerror("Error!", message)
else: # no exceptions occurred
unsaved_data = False # tell everything that there is no unsaved data remaining
ui_print("Log data saved to", filepath)
def clear_logged_data(): # clears all logged data from data frame
global log_data # get global variable
log_data = [] # reset to an empty list, i.e. clearing all logged data
+197
View File
@@ -0,0 +1,197 @@
# This file contains code for executing a sequence of magnetic fields from a csv file.
# To do this without crashing the UI it has to run in a separate thread using the threading module.
import time
from io import StringIO
import pandas
import numpy as np
from threading import *
from tkinter import messagebox
import matplotlib.pyplot as plt
from src.exceptions import DeviceBusy, DeviceAccessError
from src.utility import ui_print
import src.user_interface as ui
import src.globals as g
class ExecCSVThread(Thread):
# main class for executing a CSV sequence
# it inherits the threading.Thread class, enabling sequence execution in a separate thread
def __init__(self, array, parent, controller):
Thread.__init__(self)
self.array = array # numpy array containing data from csv to be executed
self.parent = parent # object from which this class is called, here the ExecuteCSVMode object of the UI
self.controller = controller # object on which mainloop() is running, usually the main UI window
# Acquire cage device. This resource will only be released after the thread is ended.
try:
self.cage_dev = g.CAGE_DEVICE.request_proxy()
except DeviceBusy:
raise DeviceAccessError("Failed to acquire coil control. Required for ambient field calibration.")
self._stop_event = Event() # event which can be set to stop the thread execution if needed
def run(self): # called to start the execution of the thread
ui_print("\nStarting Sequence Execution...")
try:
self.execute_sequence(self.array, 0.1, self.parent, self.controller) # run sequence
finally:
self.cage_dev.idle() # set currents and voltages to 0, set arduino pins to low
# Release the proxy so other components can use it
self.cage_dev.close()
# when the sequence has ended, reset buttons on the UI:
if not g.exit_flag: # main window is open
self.parent.select_file_button["state"] = "normal"
self.parent.execute_button["state"] = "normal"
self.parent.stop_button["state"] = "disabled"
self.parent.reinit_button["state"] = "normal"
# setup ability to interrupt thread (https://stackoverflow.com/questions/323972/is-there-any-way-to-kill-a-thread)
def stop(self): # stop thread execution, can be called from another thread to kill this one
self._stop_event.set()
@property
def stopped(self): # returns true if the thread has been stopped, used to check if a run should continue
return self._stop_event.is_set()
def execute_sequence(self, array, delay, parent, controller):
# main execution method of the class
# runs through array with times and desired fields and commands test bench accordingly
# array format: [time (s), xField (T), yField (T), zField (T)]
self.cage_dev.idle() # sets outputs on PSUs to 0 and Arduino pins to LOW before starting
t_zero = time.time() # set reference time for start of run
# Check if everything is properly connected:
all_connected = (parent.xy_override.get() or g.CAGE_DEVICE.psu1 is not None) and\
(parent.z_override.get() or g.CAGE_DEVICE.psu2 is not None) and\
(parent.arduino_override.get() or g.CAGE_DEVICE.arduino is not None)
# True or False depending on devices status, checks for some devices may be overridden by user
if not all_connected:
ui_print("Required devices are not present, sequence aborted.")
messagebox.showwarning("Device Error!", "Required devices are not present, sequence aborted.")
return
i = 0 # index of the current array row
while i < len(array):
if self.stopped or g.exit_flag:
# Interrupt sequence
ui_print("Sequence interrupted, powering down channels.")
# Channels powered down in run function
return
# while array is not finished, devices are connected, user has not cancelled and application is running
t = time.time() - t_zero # get time relative to start of run
target_t = array[i, 0] # Target execution time of data point
if t >= target_t: # time for this row has come
field_vec = array[i, 1:4] # extract desired field vector
ui_print("[{:5.3f}s] B=[{:.1f}, {:.1f}, {:.1f}]\u03BCT for t={:.2f}s".format(t,
field_vec[0] * 1e6,
field_vec[1] * 1e6,
field_vec[2] * 1e6,
target_t))
self.cage_dev.set_field_compensated(field_vec) # send field vector to test bench
# log change to the log file if user has selected event logging in the Configure Logging window
logger = controller.pages[ui.ConfigureLogging] # get object of logging configurator
if logger.event_logging: # data should be logged when test bench is commanded
logger.log_datapoint() # log data
i = i + 1 # next row
elif t <= target_t - delay - 0.02: # is there enough time to sleep before the next row?
time.sleep(delay) # sleep to give other threads time to run
ui_print("Sequence executed, powering down channels.")
def read_csv_to_array(filepath): # convert a given csv file to a numpy array
# csv format: time (s); xField (T); yField (T); zField (T) (german excel)
# decimal or period commas. Do not use these characters as a thousands seperator!
with open(filepath, 'r') as csv_file:
# Normalize seperators
csv_string = csv_file.read().replace(',', '.')
# read csv file without column headers
file = pandas.read_csv(StringIO(csv_string), sep=';', decimal='.', header=0)
array = file.to_numpy() # convert csv to array
return array
def check_array_ok(array):
"""Checks if values are within limits, and if not shows a warning message."""
# check if any magnetic fields in an array exceed the test bench limits
warnings = []
for i in [0, 1, 2]: # go through axes/columns
# get limits the test bench can do
min_val, max_val = g.CAGE_DEVICE.axes[i].max_comp_field
for row_idx in range(array.shape[0]):
data_point = array[row_idx, i + 1] # extract data for this axis from array
if data_point > max_val or data_point < min_val:
# Out of bounds
warnings.append({'row': row_idx+1, 'axis': g.AXIS_NAMES[i]})
# show warning pop-up if values are exceeding limits
nr_warnings = len(warnings)
if nr_warnings > 0:
warning_msg = "Found field values exceeding limits of test bench.\n"
# Only print the first three warnings
for i in range(min(nr_warnings, 3)):
warning_msg += "[Line {}] {} exceeds limits.\n".format(warnings[i]['row'], warnings[i]['axis'])
if nr_warnings > 3:
warning_msg += "And {} more...".format(nr_warnings - 3)
# Show all warnings collectively
messagebox.showwarning("Value Limits Warning!", warning_msg)
def plot_field_sequence(array, width, height): # create plot of fixed size (pixels) from array
# ToDo (optional): polar plots, plots of angle...
fig_dpi = 100 # set figure resolution (dots per inch)
px = 1/fig_dpi # get pixel to inch size conversion
figure = plt.Figure(figsize=(width*px, height*px), dpi=fig_dpi) # create figure with correct size
# noinspection PyTypeChecker,SpellCheckingInspection
axes = figure.subplots(3, sharex=True, sharey=True, gridspec_kw={'hspace': 0.4}) # create subplots with shared axes
figure.suptitle("Magnetic Field Sequence") # set figure title
# modify data to show instantaneous jumps in field to reflect test bench operation
new_array = np.array([[0, 0, 0, 0]], dtype=float) # initialize modified array, zeros to show start from no fields
last_vals = [0, 0, 0] # [x,y,z] field values from last data point (zero here), used to create step in data
for row in array[:, 0:4]: # go through each row in the original array
# create extra datapoint at current timestamp, with field values from last to create a "step" in the plot:
new_array = np.append(new_array, [[row[0], *last_vals]], axis=0)
new_array = np.append(new_array, [row], axis=0) # add actual datapoint for current timestamp
last_vals = row[1:4] # save values from current timestamp for next
new_array = np.append(new_array, [[new_array[-1, 0], 0, 0, 0]], axis=0) # append last datapoint with 0 fields
# extract data and plot:
t = new_array[:, 0] # extract time column
for i in [0, 1, 2]: # go through all three axes
data = new_array[:, i + 1] * 1e6 # extract field column of this axis and convert to microtesla
min_val, max_val = g.CAGE_DEVICE.axes[i].max_comp_field * 1e6 # get limits of achievable field
plot = axes[i] # get appropriate subplot
plot.plot(t, data, linestyle='solid', marker='.') # plot data
if any(data > max_val): # if any value is higher than the maximum
plot.axhline(y=max_val, linestyle='dashed', color='r') # plot horizontal line to show maximum
# add label to line:
plot.text(t[-1], max_val, "max", horizontalalignment='center', verticalalignment='top', color='r')
if any(data < min_val): # same as above
plot.axhline(y=min_val, linestyle='dashed', color='r')
plot.text(t[-1], min_val, "min", horizontalalignment='center', color='r')
plot.set_title(g.AXIS_NAMES[i], size=10) # set subplot title (e.g. "X-Axis")
# set shared axis labels:
axes[2].set_xlabel("Time (s)")
axes[1].set_ylabel("Magnetic Field (\u03BCT)")
return figure # return the created figure to be inserted somewhere
+13
View File
@@ -0,0 +1,13 @@
class ProxyNotOwnedException(Exception):
"""Should not occur in correct operation. For whatever reason, this means the proxy was invalidated."""
pass
class DeviceAccessError(Exception):
"""General error indicating that HW access failed."""
pass
class DeviceBusy(DeviceAccessError):
"""Error thrown when the HW proxy (i.e. access) cannot be acquired"""
pass
+20 -17
View File
@@ -3,20 +3,17 @@
import numpy as np
XY_DEVICE = None # XY PSU object will be stored here (class PS2000B)
Z_DEVICE = None # Z PSU object will be stored here (class PS2000B)
ARDUINO = None # Arduino object will be stored here (class ArduinoCtrl)
# Main Tkinter application object will be stored here (class HelmholtzGUI)
app = None
# Axis objects will be stored here (class Axis)
X_AXIS = None
Y_AXIS = None
Z_AXIS = None
# The main access point for all hardware commands
CAGE_DEVICE = None
AXES = None # list containing [X_AXIS, Y_AXIS, Z_AXIS]
# Magnetometer proxy object providing access to mag. data from an external client per tcp interface
MAGNETOMETER = None
app = None # Main Tkinter application object will be stored here (class HelmholtzGUI)
AXIS_NAMES = ["X-Axis", "Y-Axis", "Z-Axis"] # list with the names of each axis, used mainly for printing functions
# list with the names of each axis, used mainly for printing functions
AXIS_NAMES = ["X-Axis", "Y-Axis", "Z-Axis"]
global XY_PORT # serial port for XY PSU will be stored here (string)
global Z_PORT # serial port for Z PSU will be stored here (string)
@@ -25,7 +22,7 @@ global PORTS # list containing [XY_PORT, XY_PORT, Z_PORT], used in loops where
global threadLock # thread locking object, used to force threads to perform actions in a certain order (threading.Lock)
exitFlag = True # False when main window is open, True otherwise
exit_flag = True # False when main window is open, True otherwise
# Create dictionaries with default Constants and maximum/minimum values
# Used to create default configs and to check if user inputs are within safe limits
@@ -35,15 +32,21 @@ exitFlag = True # False when main window is open, True otherwise
# Dictionary for numerical values:
# format: key: [default values], [maximum values], [minimum values]
default_arrays = {
"coil_const": np.array([[38.957, 40.408, 37.754], [50, 50, 50], [0, 0, 0]]) * 1e-6, # Coil constants [x,y,z] [T/A]
"coil_const": np.array([[38.83, 38.65, 37.30], [50, 50, 50], [0, 0, 0]]) * 1e-6, # Coil constants [x,y,z] [T/A]
"ambient_field": np.array([[0, 0, 0], [200, 200, 200], [-200, -200, -200]]) * 1e-6, # ambient magnetic field [T]
"resistance": np.array([[3.131, 3.107, 3.129], [5, 5, 5], [1, 1, 1]], dtype=float), # resistance of circuits [Ohm]
"max_volts": np.array([[15, 15, 15], [16, 16, 16], [0, 0, 0]], dtype=float), # max. voltage, limited to 16V by used diodes! [V]
"max_amps": np.array([[5, 5, 5], [6, 6, 6], [0, 0, 0]], dtype=float), # max. allowed current (A)
"relay_pin": [[15, 16, 17], [15, 16, 17], [15, 16, 17]] # pins on the arduino for reversing [x,y,z] polarity
}
# Dictionary for PSU serial ports:
default_ports = {
"xy_port": "COM1", # Default serial port where PSU for X- and Y-Axes is connected
"z_port": "COM2", # Default serial port where PSU for Z-Axis is connected
# Dictionary for PSU configuration:
default_psu_config = {
"supply_model": "ql355tp",
'arduino_port': "COM",
"xy_port": "COM", # Default serial port where PSU for X- and Y-Axes is connected
"z_port": "COM", # Default serial port where PSU for Z-Axis is connected
}
# Configuration for socket interface
SOCKET_PORT = 6677
SOCKET_MAX_CONNECTIONS = 5
+607
View File
@@ -0,0 +1,607 @@
import time
import traceback
from threading import RLock, Thread, Event
from tkinter import messagebox
import numpy as np
from src.arduino_device import ArduinoDevice
from src.psu_device import PSUDevicePS2000B, PSUDeviceQL355TP
from src.utility import ui_print
from src.exceptions import DeviceBusy, ProxyNotOwnedException
import src.config_handling as config_handling
import src.globals as g
class HelmholtzCageDevice:
"""This is the central object for controlling all the test bench related HW. This way, access can be
synchronized and exclusive to a single controller at once. This device always exists, irrespective of
which devices are actually connected. Only the request_proxy, shutdown and destroy methods should be used !!!
Provides subscriber interface for periodic status information.
Provides proxy model to control access."""
# This class is thread safe
POLLING_INTERVAL = 1 # Seconds between polling the device state
def __init__(self):
# Indicates all the threads should be joined
self._stop_flag = Event()
# --- POLLING SUBSCRIBERS ---
# This is a list of object callbacks interested in receiving device status updates.
# This will primarily include the front-end which wants to update its display data.
# The callback functions should accept a dict as an argument of the form {'arduino':, 'axes':[{}, {}, {}]}
self._subscribers = []
# --- COMMAND QUEUEING ---
self.command_lock = RLock()
# Indicates to the command executing thread that a new command has arrived for execution
self.new_command_flag = Event()
# Contains the next command to be executed
self.command = None
# --- PROXY MODEL SETUP ---
# Locks all proxy requesting and releasing access
self.proxy_lock = RLock()
# Contains the id of the current in control proxy
self.proxy_id = None
# Contains the current context manager proxy. All other proxy instances are managed externally
self.context_manager_proxy = None
# --- STATUS VARIABLES ---
# These are used to store information about our last command to provide status updates asynchronously
self.target_field = [0, 0, 0]
self.target_field_raw = [0, 0, 0]
self.target_current = [0, 0, 0]
# --- DEVICE SETUP ---
# Must be acquired to access any hardware
self.hardware_lock = RLock()
# Com ports
self.com_port_psu1 = None
self.com_port_psu2 = None
self.com_port_arduino = None
# PSU object used
self.psu_type = None
# Hardware object variables
self.arduino = None
self.psu1 = None
self.psu2 = None
# --- AXIS CONFIGURATION ---
# This is also hardware related, but in a separate section to keep it clean.
# Get all settings from the config file
self.axes = []
# Loop over axes
for i in range(3):
# The axes talks to the HW objects (Arduino, PSU) referenced in this object
self.axes.append(Axis(i, self))
# --- Preliminary PSU type info ---
# Required post-initialization but before hardware has been connected
psu_type_string = config_handling.read_from_config("Supplies", "supply_model",
config_handling.CONFIG_OBJECT)
if psu_type_string == "ps2000b":
self.psu_type = PSUDevicePS2000B
elif psu_type_string == "ql355tp":
self.psu_type = PSUDeviceQL355TP
else:
raise Exception("Invalid psu model:\n{}".format(self.psu_type))
# --- HW COMMUNICATION THREAD ---
self._cmd_exec_thread = Thread(target=self._cmd_exec_thread_method)
self._cmd_exec_thread.start()
self._hw_poll_thread = Thread(target=self._hw_poll_thread_method)
self._hw_poll_thread.start()
# TODO: Move to proxy
def reconnect_hardware(self):
with self.hardware_lock:
self.shutdown()
self.connect_hardware()
# TODO: Move to proxy
def connect_hardware(self):
"""Connects devices. Does not check if they are already connected: Remember to call shutdown first"""
with self.hardware_lock:
# All devices are usable if the object exists. None indicates
# the device is not connected/not working properly
# Fetch config variables
self.com_port_psu1 = config_handling.read_from_config("Supplies", "xy_port",
config_handling.CONFIG_OBJECT)
self.com_port_psu2 = config_handling.read_from_config("Supplies", "z_port",
config_handling.CONFIG_OBJECT)
self.com_port_arduino = config_handling.read_from_config("Supplies", "arduino_port",
config_handling.CONFIG_OBJECT)
psu_type_string = config_handling.read_from_config("Supplies", "supply_model",
config_handling.CONFIG_OBJECT)
# Arduino setup
try:
self.arduino = ArduinoDevice(self.com_port_arduino)
ui_print("Connected to Arduino on port {}".format(self.com_port_arduino))
except Exception as e:
self.arduino = None
# show error messages to alert user
ui_print("Error connecting to Arduino:\n{}".format(e))
# PSU setup
if psu_type_string == "ps2000b":
self.psu_type = PSUDevicePS2000B
elif psu_type_string == "ql355tp":
self.psu_type = PSUDeviceQL355TP
else:
raise Exception("Invalid psu model:\n{}".format(self.psu_type))
# psu1: controls xy axis
try:
self.psu1 = self.psu_type(self.com_port_psu1)
ui_print("Connected to PSU 1 on port {}".format(self.com_port_psu1))
except Exception as e:
self.psu1 = None
ui_print("Error connecting to PSU 1:\n{}".format(e))
# psu2: controls z axis
try:
self.psu2 = self.psu_type(self.com_port_psu2)
ui_print("Connected to PSU 2 on port {}".format(self.com_port_psu2))
except Exception as e:
self.psu2 = None
ui_print("Error connecting to PSU 2:\n{}".format(e))
# The axes may not be deleted, so a special method for reinitialization is provided.
for axis in self.axes:
axis.reload_config()
# Zero and activate channels. This is a sort of "armed" state so that we can send commands later
self.idle()
def reconnect_hardware_async(self):
"""Disconnects and reconnects devices in a non-blocking call.
Acquires hardware lock and blocks other cage operations."""
connect_hardware_thread = Thread(target=self.reconnect_hardware)
connect_hardware_thread.start()
def connect_hardware_async(self):
"""Connects devices in a non-blocking call.
Acquires hardware lock and blocks other cage operations."""
connect_hardware_thread = Thread(target=self.connect_hardware)
connect_hardware_thread.start()
def idle(self):
""" Zero and activate channels """
if self.psu1 is not None:
self.psu1.idle()
if self.psu2 is not None:
self.psu2.idle()
if self.arduino is not None:
self.arduino.idle()
# Since these actions are not handled by the axes objects, also make sure to update their target field status
for axis in self.axes:
axis.target_current = 0
def request_proxy(self):
"""Returns a new HelmholtzCageProxy or None, depending on if access is available"""
with self.proxy_lock:
if not self.proxy_id:
# The interface is available, return a new proxy object
new_proxy = HelmholtzCageProxy(self)
self.proxy_id = id(new_proxy)
return new_proxy
else:
# The interface is occupied, the caller must tolerate that the request failed.
raise DeviceBusy
def __enter__(self):
"""Enables: with g.CAGE_DEVICE as dev:"""
self.context_manager_proxy = self.request_proxy()
return self.context_manager_proxy
def release_proxy(self, proxy_obj):
"""Releases the proxy to free access for other controllers. Should only be called when proxy is destroyed"""
if self.proxy_valid(proxy_obj):
# This only frees the interface if it really was the active proxy
self.proxy_id = None
# Otherwise do nothing, this case requires no behaviour
def __exit__(self, *args):
"""Enables: with g.CAGE_DEVICE as dev:"""
self.release_proxy(self.context_manager_proxy)
def proxy_valid(self, proxy_obj):
"""Returns True if the proxy currently owns the device."""
with self.proxy_lock:
return id(proxy_obj) == self.proxy_id
def subscribe_status_updates(self, callback):
# List containing all interested subscribers.
# We won't check if a callback is added twice. Not our responsibility
self._subscribers.append(callback)
def queue_command(self, proxy_obj, command):
""" Queues a dict for immediate execution containing the command for the cage as a whole.
Since the newest command should always be run, it is not a real queue (just a variable)"""
with self.proxy_lock:
if id(proxy_obj) != self.proxy_id:
raise ProxyNotOwnedException()
with self.command_lock:
# Overwrite any command that was queued but not yet executed. We now only care about the newer command
self.command = command
self.new_command_flag.set()
def _cmd_exec_thread_method(self):
"""This method forms the main thread for hardware command execution."""
while not self._stop_flag.is_set():
self.new_command_flag.wait()
self.new_command_flag.clear()
# Avoid blocking the buffer while we are executing.
with self.command_lock:
# Dicts and lists are mutable so must be (deep)copied
command_buffer = HelmholtzCageDevice._copy_command(self.command)
self.command = None # Processed commands are removed from "buffer"
if command_buffer:
# Unpack command into "action" and argument
command_string = command_buffer['command']
command_arg = command_buffer['arg']
# Check which command and delegate to responsible function
if command_string == "set_signed_currents":
self._set_signed_currents(command_arg)
elif command_string == "set_field_raw":
self._set_field_raw(command_arg)
elif command_string == "set_field_compensated":
self._set_field_compensated(command_arg)
elif command_string == 'idle':
self.idle()
else:
raise Exception("Command unknown!")
def _hw_poll_thread_method(self):
"""This method forms the main thread for hardware command execution."""
while True:
# We will have to check if we passed this statement due to a stop flag or due to the polling interval
stop_flag_set = self._stop_flag.wait(timeout=self.POLLING_INTERVAL)
if stop_flag_set:
return
ard_conn = self.arduino is not None
status_data = {'axes': [],
'arduino_connected': ard_conn}
with self.hardware_lock:
# This polls all three axes at once
for axis in self.axes:
status_data['axes'].append(axis.get_status_dict())
# Distribute status data to all interested subscribers
for subscriber in self._subscribers:
subscriber(status_data)
@staticmethod
def _copy_command(command):
# PyCharm has an issue with the deepcopy tool, so just handle the copying manually.
if command is None:
return command
try:
return {'command': command['command'],
'arg': command['arg'].copy()}
except AttributeError:
# AttributeError: '---' object has no attribute 'copy'
# Should be immutable. Otherwise we have a problem
return {'command': command['command'],
'arg': command['arg']}
def _set_field_raw(self, arg):
for axis, field in zip(self.axes, arg):
with self.hardware_lock:
axis.set_field_raw(field)
def _set_field_compensated(self, arg):
for axis, field in zip(self.axes, arg):
with self.hardware_lock:
axis.set_field_compensated(field)
def _set_signed_currents(self, arg):
"""Sets the currents in the array arg in the respective coils x->y->z.
This function imposes safety limits by clamping the current when beyond the maximum."""
# One pass for every axis
for axis, current in zip(self.axes, arg):
# Talk to hardware
with self.hardware_lock:
try:
axis.set_signed_current(current)
except Exception as e:
ui_print("Error {}: Unexpected error occured:\n{}".format(axis.name, e))
traceback.print_exc()
def get_psu_for_axis(self, axis_index):
"""Determine which channel of which psu is required"""
# TODO: This kind of stuff belongs in the config and should not be hardcoded
if axis_index == 0 or axis_index == 1:
psu = self.psu1
channel = self.psu_type.valid_channels()[axis_index]
port = self.com_port_psu1
else:
psu = self.psu2
channel = self.psu_type.valid_channels()[0]
port = self.com_port_psu2
return psu, channel, port
def destroy(self):
"""The object cannot be recovered after calling destroy"""
# Send signals to kill threads:
# TODO: Handle timeout behaviour
self._stop_flag.set()
# _cmd_exec_thread:
with self.command_lock:
self.command = None
self.new_command_flag.set() # Causes the thread to unblock
self._cmd_exec_thread.join(timeout=2)
# _hw_poll_thread:
# This thread is stopped just by setting the _stop_flag
self._hw_poll_thread.join(timeout=2)
# Shutdown the hardware
msg = self.shutdown()
messagebox.showinfo("Program ended",
msg) # Show a unified pop-up with how the shutdown on each device went
def shutdown(self):
""" Shuts down the hardware. This special command overrides the currently active proxy."""
# This waiting period is not easily removed without resulting in unexpected behaviour
with self.hardware_lock:
ui_print("\nAttempting to safely shut down all devices. Check equipment to confirm.")
# start writing string to later show how shutdown on all devices went in a single info pop-up:
message = "Tried to shut down all devices. Check equipment to confirm."
# Shutdown XY PSU
if self.psu1 is not None:
try:
self.psu1.shutdown()
self.psu1.destroy()
self.psu1 = None
except Exception as e:
ui_print("Error while deactivating XY PSU:", e) # print the problem in the console
message += "\nError while deactivating XY PSU: %s" % e # append status to the message to show later
else: # device was successfully deactivated
ui_print("XY PSU deactivated.")
message += "\nXY PSU deactivated." # append message to show later
else: # the device was not connected before
# tell user there was no need/no possibility to deactivate:
ui_print("XY PSU not connected, can't deactivate.")
message += "\nXY PSU not connected, can't deactivate."
# Shutdown Z PSU
if self.psu2 is not None:
try:
self.psu2.shutdown()
self.psu2.destroy()
self.psu2 = None
except Exception as e:
ui_print("Error while deactivating Z PSU:", e) # print the problem in the console
message += "\nError while deactivating Z PSU: %s" % e # append status to the message to show later
else: # device was successfully deactivated
ui_print("Z PSU deactivated.")
message += "\nZ PSU deactivated." # append message to show later
else: # the device was not connected before
# tell user there was no need/no possibility to deactivate:
ui_print("Z PSU not connected, can't deactivate.")
message += "\nZ PSU not connected, can't deactivate."
# Shut down arduino:
if self.arduino is not None:
try:
self.arduino.shutdown()
self.arduino.close()
self.arduino = None
except Exception as e:
ui_print("Error while deactivating Arduino:", e) # print the problem in the console
message += "\nError while deactivating Arduino: %s" % e # append status to the message to show later
else: # device was successfully deactivated
ui_print("Arduino deactivated.")
message += "\nArduino deactivated." # append message to show later
else: # the device was not connected before
# tell user there was no need/no possibility to deactivate:
ui_print("Arduino not connected, can't deactivate.")
message += "\nArduino not connected, can't deactivate."
return message
class Axis:
def __init__(self, axis_idx, cage_dev):
"""
This class is an adapter to axis-specific and non-specific (e.g. psu status) information. Most attributes
are supplied as properties, which query the parent component of that attribute/info. It also wraps device calls.
"""
self.idx = axis_idx
self.cage_dev = cage_dev
self.name = g.AXIS_NAMES[axis_idx] # Also used in some indexing operations, like in the config file
# Create other axis properties using config parameters
self.coil_const = None
self.ambient_field = None
self.resistance = None
self.max_volts = None
self.max_amps = None
self.reload_config()
# State variables
self.target_current = 0
self.polarity = False
def reload_config(self):
self.coil_const = float(config_handling.read_from_config(self.name, "coil_const", config_handling.CONFIG_OBJECT))
self.ambient_field = float(config_handling.read_from_config(self.name, "ambient_field", config_handling.CONFIG_OBJECT))
self.resistance = float(config_handling.read_from_config(self.name, "resistance", config_handling.CONFIG_OBJECT))
self.max_volts = float(config_handling.read_from_config(self.name, "max_volts", config_handling.CONFIG_OBJECT))
self.max_amps = float(config_handling.read_from_config(self.name, "max_amps", config_handling.CONFIG_OBJECT))
def set_field_raw(self, field):
self.set_signed_current(field / self.coil_const)
def set_field_compensated(self, field):
self.set_field_raw(field - self.ambient_field)
def set_signed_current(self, current):
"""Sets current on axis"""
# Check current limits
if abs(current) <= self.max_amps:
safe_current = current
else:
safe_current = self.max_amps
ui_print("Warning {}: Attempted to exceed current limit".format(self.name))
# Update state variables to be queried. This should be set even if it is only a "virtual" action with no
# connected devices.
self.target_current = safe_current
if not self.arduino:
ui_print("Warning {}: Cannot set field/current without Arduino".format(self.name))
return
if not self.psu:
ui_print("Warning {}: Cannot set field/current without PSU".format(self.name))
return
# TODO: Check for exceptions
# Set polarity on Arduino
if safe_current < 0:
# Reverse polarity
self.polarity = True # Track the state
self.arduino.set_axis_polarity(self.idx, True)
else:
# Positive polarity (default case)
self.polarity = False # Track the state
self.arduino.set_axis_polarity(self.idx, False)
# determine voltage limit to be set on PSU, must be high enough to not limit the current:
# min. 8V, max. max_volts, in-between as needed with current value (+margin to not limit current)
voltage_limit = min(max(1.3 * abs(safe_current) * self.resistance, 8), self.max_volts) # limit voltage
# Set voltages and currents. Outputs should already be active from initializer.
self.psu.set_current(self.channel, abs(safe_current))
self.psu.set_voltage(self.channel, voltage_limit)
@property
def arduino(self):
return self.cage_dev.arduino
@property
def com_port(self):
_, _, port = self.cage_dev.get_psu_for_axis(self.idx)
return port
@property
def psu(self):
psu, _, _ = self.cage_dev.get_psu_for_axis(self.idx)
return psu
@property
def channel(self):
_, channel, _ = self.cage_dev.get_psu_for_axis(self.idx)
return channel
@property
def target_field(self):
return self.target_current * self.coil_const + self.ambient_field
@property
def target_field_raw(self):
return self.target_current * self.coil_const
@property
def connected(self):
return self.psu is not None and self.arduino is not None
@property
def max_field(self):
max_field_magnitude = self.max_amps * self.coil_const
return np.array([-max_field_magnitude, max_field_magnitude])
@property
def max_comp_field(self):
max_field_magnitude = self.max_amps * self.coil_const
return np.array([self.ambient_field - max_field_magnitude, self.ambient_field + max_field_magnitude])
@property
def arduino_connected(self):
return self.arduino is not None
@property
def psu_connected(self):
return self.psu is not None
def get_status_dict(self):
"""Dict containing all data from this model to pass to the front-end. Some data is only available through
this interface, since it also polls the hardware for current set-points"""
# This is a slow operation, watch out!
if self.psu:
status = self.psu.poll_channel_state(self.channel)
else:
status = {}
if self.arduino:
status['polarity'] = self.polarity
status['connected'] = self.connected
status['port'] = self.com_port
status['channel'] = self.channel
status['target_field_raw'] = self.target_field_raw
status['target_field'] = self.target_field
status['target_current'] = self.target_current
return status
class HelmholtzCageProxy:
""" Proxy for the HelmholtzCageDevice.
This is the only way the application should communicate with the HelmholtzCageDevice object"""
def __init__(self, cage_device):
self.cage_device = cage_device
def set_signed_currents(self, vector):
self.cage_device.queue_command(self, {'command': 'set_signed_currents', 'arg': vector})
def set_field_raw(self, vector):
self.cage_device.queue_command(self, {'command': 'set_field_raw', 'arg': vector})
def set_field_compensated(self, vector):
self.cage_device.queue_command(self, {'command': 'set_field_compensated', 'arg': vector})
def idle(self):
"""Puts the helmholtz cage into an idle state with zeroed fields"""
self.cage_device.queue_command(self, {'command': 'idle', 'arg': None})
def close(self):
self.cage_device.release_proxy(self)
def __del__(self):
# This is a fallback method and should not be relied on. Call 'close' manually
if self.cage_device.proxy_valid(self):
self.cage_device.release_proxy(self)
ui_print("Warning: Proxy implicitly released. Use close() instead.")
def value_in_limits(axis, key, value):
"""Check if value is within safe limits (set in globals.py)"""
# axis is string with axis name, e.g. "X-Axis"
# key specifies which value to check, e.g. current
max_value = g.default_arrays[key][1][g.AXIS_NAMES.index(axis)] # get max value from dictionary in globals.py
min_value = g.default_arrays[key][2][g.AXIS_NAMES.index(axis)] # get min value from dictionary in globals.py
if float(value) > float(max_value): # value is too high
return 'HIGH'
elif float(value) < float(min_value): # value is too low
return 'LOW'
else: # value is within limits
return 'OK'
+28
View File
@@ -0,0 +1,28 @@
from threading import RLock
import numpy as np
class MagnetometerProxy:
"""This class facilitates magnetometer data access, which is provided by a tcp client."""
def __init__(self):
self.connected = False
self._field_lock = RLock()
self._field = np.array([0, 0, 0])
def set_connection_closed(self):
"""Used by the TCP connection manager to indicate that the client has disconnected and that no new data
will arrive."""
self.connected = False
@property
def field(self):
with self._field_lock:
return self._field
@field.setter
def field(self, val):
# If we receive data, we can assume we are connected
self.connected = True
with self._field_lock:
self._field = val
@@ -16,6 +16,7 @@
# [1] = "PS 2000B Programming Guide" from 2015-05-28
# [2] = "PS 2000B object list"
#
import time
import serial
import struct
@@ -388,7 +389,9 @@ class PS2000B:
def set_current(self, value, channel):
self.update_device_information(channel)
time.sleep(0.05)
self.enable_remote_control(channel)
time.sleep(0.05)
curr = int(round((value * 25600.0) / self.__device_information.nominal_current))
self.__send_device_data(Objects.SET_VALUE_CURRENT, curr, channel)
+258
View File
@@ -0,0 +1,258 @@
# ABC is template used to create python abstract classes
import time
from abc import ABC, abstractmethod
import serial
from src.ps2000b import PS2000B # Module containing all PS2000B HW functions and classes
from src.utility import ui_print
class PSUDevice(ABC):
"""The PSUDevice abstract class defines a generic interface for power supplies used with the test bench.
It can be subclassed to easily support new hardware."""
def __init__(self, com_port):
"""PSUDevice assumes a serial connection"""
self.com_port = com_port
self.cached_state = dict.fromkeys(self.valid_channels()) # Used for components that require state on demand
@abstractmethod
def enable_channel(self, channel_nr):
"""Most PSUs offer completely disabling or enabling a channel, beyond setting V=0, I=0.
Can throw exceptions"""
pass
@abstractmethod
def disable_channel(self, channel_nr):
"""Most PSUs offer completely disabling or enabling a channel, beyond setting V=0, I=0.
Can throw exceptions"""
pass
# Warning: not abstract, does not need to be implemented
def set_channel_state(self, channel_nr, enabled):
"""True: Enable channel, False: Disable channel
Can throw exceptions"""
if enabled:
self.enable_channel(channel_nr)
else:
self.disable_channel(channel_nr)
@abstractmethod
def set_current(self, channel_nr, current):
"""Set the current limit. Actual current dependent on OVP or OCP mode.
Can throw exceptions"""
pass
@abstractmethod
def set_voltage(self, channel_nr, voltage):
"""Set the voltage limit. Actual voltage dependent on OVP or OCP mode.
Can throw exceptions"""
pass
@abstractmethod
def poll_channel_state(self, channel_nr):
"""Return a dictionary with the entries below. WARNING: this call is blocking and potentially slow!
Can throw exceptions"""
# Should also set self.cached_state
# return {'active': False, 'remote_active': False,
# 'actual_voltage':0, 'limit_voltage':0, 'actual_current':0, 'limit_current':0}
pass
def cached_channel_state(self, channel_nr):
"""Return a dictionary with the entries below. Uses the values obtained during last poll.
May contain None-entries"""
if self.cached_state[channel_nr]:
return self.cached_state[channel_nr]
else:
return {'active': None, 'remote_active': None,
'actual_voltage': None,
'limit_voltage': None,
'actual_current': None,
'limit_current': None}
def idle(self):
"""Zero all outputs but activate channels so commands can be sent."""
for ch in self.valid_channels():
self.set_current(ch, 0)
self.set_voltage(ch, 0)
self.enable_channel(ch)
@abstractmethod
def shutdown(self):
"""Shuts the PSU down safely and makes sure ALL outputs are off."""
pass
@abstractmethod
def destroy(self):
"""Disconnects the device"""
pass
@staticmethod
@abstractmethod
def valid_channels():
"""Returns a list containing valid channel numbers"""
pass
class PSUDevicePS2000B(PSUDevice):
"""Provides HW support for the Elektro-Automatik PS2000B power supply.
This class is an adapter for the already existing PS2000B library, to have a consistent interface"""
MIN_DELAY = 0.05
def __init__(self, com_port):
super().__init__(com_port)
"""Can fail; Check for serial.SerialException"""
self.dev = PS2000B.PS2000B(com_port)
# dev_info is a class which contains hw specific constants, such as nominal voltage and current
self.dev_info = self.dev.get_device_information() # Cache this result
time.sleep(self.MIN_DELAY)
@staticmethod
def valid_channels():
# Dependent on PSU, the PS2000B has 2 channels
return [0, 1]
def enable_channel(self, channel_nr):
self.dev.enable_output(channel_nr)
time.sleep(self.MIN_DELAY)
def disable_channel(self, channel_nr):
self.dev.enable_output(channel_nr)
time.sleep(self.MIN_DELAY)
def set_current(self, channel_nr, current):
self.dev.set_current(current, channel_nr)
time.sleep(self.MIN_DELAY)
def set_voltage(self, channel_nr, voltage):
self.dev.set_voltage(voltage, channel_nr)
time.sleep(self.MIN_DELAY)
def poll_channel_state(self, channel_nr):
self.dev.update_device_information(channel_nr) # update the information in the device object
time.sleep(self.MIN_DELAY)
dev_status = self.dev.get_device_status_information(channel_nr) # get object with new status info
# This is more efficient than the dev.get_voltage() call, since it does not require another update_device_info
voltage = dev_status.actual_voltage_percent * self.dev_info.nominal_voltage # Extracted from PS2000B library
voltage_setp = self.dev.get_voltage_setpoint(channel_nr)
time.sleep(self.MIN_DELAY)
current = dev_status.actual_current_percent * self.dev_info.nominal_current
current_setp = self.dev.get_current_setpoint(channel_nr)
time.sleep(self.MIN_DELAY)
# Format should match the provided template in abstract PSUDevice class.
self.cached_state[channel_nr] = {'active': dev_status.output_active,
'remote_active': dev_status.remote_control_active,
'voltage': voltage, 'voltage_setpoint': voltage_setp,
'current': current, 'current_setpoint': current_setp}
return self.cached_state[channel_nr]
def shutdown(self):
for ch in self.valid_channels():
self.disable_channel(ch)
self.set_current(ch, 0)
self.set_voltage(ch, 0)
def destroy(self):
self.dev.serial.close()
class PSUDeviceQL355TP(PSUDevice):
"""HW interface for QL355TP from AIM-TTi Instruments"""
def __init__(self, com_port):
"""Can fail; Check for serial.SerialException"""
super().__init__(com_port)
self._serial_object = serial.Serial(
port=self.com_port,
baudrate=19200,
timeout=0.5,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
bytesize=serial.EIGHTBITS
)
self.set_output_range(0, 0) # Put the PSU into the 15V/5A range
self.set_output_range(1, 0)
self.reset_breaker() # Reset the breaker in case we are coming from an unclean state
self.cached_state = dict.fromkeys(self.valid_channels()) # Used for components that require state on demand
@staticmethod
def valid_channels():
# Dependent on PSU, the QL355TP has 2 normal channels. The auxiliary channel is not usable for our purpose
return [1, 2]
def enable_channel(self, channel_nr):
# The serial interface is documented in the QL355TP handbook
self._serial_object.write("OP{} 1\n".format(channel_nr).encode())
def disable_channel(self, channel_nr):
# The serial interface is documented in the QL355TP handbook
self._serial_object.write("OP{} 0\n".format(channel_nr).encode())
def set_current(self, channel_nr, current):
# The serial interface is documented in the QL355TP handbook
self._serial_object.write("I{} {}\n".format(channel_nr, current).encode())
def set_voltage(self, channel_nr, voltage):
# The serial interface is documented in the QL355TP handbook
self._serial_object.write("V{} {}\n".format(channel_nr, voltage).encode())
def poll_channel_state(self, channel_nr):
# Request channel state
self._serial_object.write("OP{}?\n".format(channel_nr).encode())
resp = self._serial_object.read_until()
output_active = resp.decode().rstrip()
# Request current current
self._serial_object.write("I{}O?\n".format(channel_nr).encode())
resp = self._serial_object.read_until()
# Trim whitespace and units
current = float(resp.decode().rstrip()[:-1])
# Request current setpoint
self._serial_object.write("I{}?\n".format(channel_nr).encode())
resp = self._serial_object.read_until()
# Trim whitespace, prefix and units
current_setp = float(resp.decode().rstrip()[3:-1])
# Request current voltage
self._serial_object.write("V{}O?\n".format(channel_nr).encode())
resp = self._serial_object.read_until()
# Trim whitespace and units
voltage = float(resp.decode().rstrip()[:-1])
# Request voltage setpoint
self._serial_object.write("V{}?\n".format(channel_nr).encode())
resp = self._serial_object.read_until()
# Trim whitespace and units
voltage_setp = float(resp.decode().rstrip()[3:-1])
# Format should match the provided template in abstract PSUDevice class.
# The remote_active property is assumed to always be True since it cant be read
# (it should be since we are talking to it)
self.cached_state[channel_nr] = {'active': output_active, 'remote_active': True,
'voltage': voltage, 'voltage_setpoint': voltage_setp,
'current': current, 'current_setpoint': current_setp}
return self.cached_state[channel_nr]
def set_output_range(self, channel_nr, value_range):
"""The QL355TP supports various output ranges. We require the 15V/5A mode to achieve the greatest range."""
# range 0: 15V/5A, 1=35V/3A, 2=35V/500mA
self._serial_object.write("RANGE{} {}\n".format(channel_nr, value_range).encode())
def reset_breaker(self):
"""The QL355TP includes an internal breaker, which must be reset if the
maximum voltage or current is exceeded"""
self._serial_object.write("TRIPRST\n".encode())
def shutdown(self):
for ch in self.valid_channels():
self.disable_channel(ch)
self.set_current(ch, 0)
self.set_voltage(ch, 0)
def destroy(self):
self._serial_object.close()
+196
View File
@@ -0,0 +1,196 @@
from threading import Thread
import socket
import numpy as np
import src.globals as g
from src.utility import ui_print
import src.helmholtz_cage_device as helmholtz_cage_device
# --- Definition of TCP interface ---
#
# Clients should by default initialize a TCP connection to port 6677
# The commands shown must be terminated with a single \n (newline) char
# Commands may be split across multiple packets.
# Before useful commands can be sent, declare_api_version must be called.
#
# A description of the TCP api (safety limits are always enforced):
#
# set_raw_field [X comp.] [Y comp.] [Z comp.]
# Returns: 0 or 1 for success
# Accepts decimal point formatted floats, with or without scientific notation. The float() cast must understand it.
# The field units are Tesla
# This causes an additional field of the given strength to be generated, without regard for the pre-existing
# geomagnetic/external fields.
#
# set_compensated_field [X comp.] [Y comp.] [Z comp.]
# Returns: 0 or 1 for success
# Accepts decimal point formatted floats, with or without scientific notation. The float() cast must understand it.
# The field units are Tesla
# This causes a field of exactly the given magnitude to be generated by compensating external factors such as the
# geomagnetic field.
#
# set_coil_currents [X comp.] [Y comp.] [Z comp.]
# Returns: 0 or 1 for success
# Accepts decimal point formatted floats, with or without scientific notation. The float() cast must understand it.
# The field units are Ampere
# This establishes the requested current in the individual coils.
#
# get_api_version
# Returns: a string uniquely identifying each API version.
# This function can be called before declare_api_version.
# Please dont put
#
# magnetometer_field [X comp.] [Y comp.] [Z comp.]
# Returns: 1
# Accepts decimal point formatted floats, with or without scientific notation. The float() cast must understand it.
# The field units are Tesla
# Sets the state of an a virtual magnetometer object which mirrors a physical sensor providing data by means of
# this command.
#
# declare_api_version [version]
# Returns: 0 or 1 (terminated with newline)
# Declare the api version the client application was programmed for. It must be compatible with the current
# API version. This prevents unexpected behaviour by forcing programmers to specify which API they are expecting.
# This function must be called before sending HW commands.
SOCKET_INTERFACE_API_VERSION = "2"
class ClientConnectionThread(Thread):
def __init__(self, client_socket, address):
Thread.__init__(self)
self.client_socket = client_socket
self.client_address = address
# Indicates whether this thread was providing magnetometer data. If yes, set the magnetometer proxy object as
# disconnected when the socket is closed.
self.magnetometer_connection = False
# Holds proxy model to cage device if required. Is initialized lazily to prevent always blocking interface
self._cage_dev = None
self.api_compat = False # Indicates whether the client has a compatible API version
def run(self):
msg = ''
while True:
try:
raw_msg = self.client_socket.recv(2048).decode()
# Check for end of stream
if raw_msg == "":
self.client_socket.close()
if self._cage_dev:
self._cage_dev.close()
g.MAGNETOMETER.connected = False
return
# Process message
for char in raw_msg:
if char == '\n':
msg = msg.rstrip() # Some systems will try to send \r characters... looking at you windows O_O
try:
response = self.handle_msg(msg)
except Exception as e:
ui_print("An error occurred while processing a client message")
ui_print("Msg: {}".format(msg))
ui_print(e)
response = "err"
self.client_socket.sendall((response + '\n').encode('utf-8'))
msg = ''
else:
msg += char
except ConnectionResetError as e:
ui_print("A connection was closed by the client.")
self.client_socket.close()
if self._cage_dev:
self._cage_dev.close()
g.MAGNETOMETER.connected = False
return
def handle_msg(self, message):
""" Executes command logic and returns string response (for client). """
tokens = message.split(" ")
if tokens[0] == "get_api_version":
return SOCKET_INTERFACE_API_VERSION
elif tokens[0] == "declare_api_version":
if tokens[1] == SOCKET_INTERFACE_API_VERSION:
self.api_compat = True
return "1"
else:
ui_print("Declared socket API version ({}) is incompatible with current version ({})!".format(tokens[1], SOCKET_INTERFACE_API_VERSION))
return "0"
else:
# api_compat indicates we have checked the api version and are ready to accept commands
if self.api_compat:
if tokens[0] == "set_raw_field":
x = float(tokens[1])
y = float(tokens[2])
z = float(tokens[3])
field_vec = np.array([x, y, z], dtype=np.float32)
# uncompensated
self.cage_dev.set_field_raw(field_vec)
return "1"
elif tokens[0] == "set_compensated_field":
x = float(tokens[1])
y = float(tokens[2])
z = float(tokens[3])
field_vec = np.array([x, y, z], dtype=np.float32)
# compensated
self.cage_dev.set_field_compensated(field_vec)
return "1"
elif tokens[0] == "set_coil_currents":
x = float(tokens[1])
y = float(tokens[2])
z = float(tokens[3])
current_vec = np.array([x, y, z], dtype=np.float32)
self.cage_dev.set_signed_currents(current_vec)
return "1"
elif tokens[0] == "magnetometer_field":
"""The client is sending us information about the magnetometer state. This is used for some
calibration procedures for example."""
x = float(tokens[1])
y = float(tokens[2])
z = float(tokens[3])
field = np.array([x, y, z], dtype=np.float32)
g.MAGNETOMETER.field = field
self.magnetometer_connection = True
return "1"
else:
# The message given is unknown. The programmer probably did not intend for this, so display an error
# even if is not inherently problematic.
raise Exception("The command '{}' is unknown".format(tokens[0]))
else:
raise Exception("The command '{}' may not be called before 'declare_api_version'".format(tokens[0]))
@property
def cage_dev(self):
if self._cage_dev is None:
try:
self._cage_dev = g.CAGE_DEVICE.request_proxy()
except helmholtz_cage_device.DeviceBusy:
# Return none. This will cause an error and show up as "err" on the client
# A more helpful error message is shown on application side
ui_print("Socket client attempted to acquire busy device.")
return self._cage_dev
class SocketInterfaceThread(Thread):
def __init__(self):
Thread.__init__(self, daemon=True)
self.server_socket = None
# Can throw exception, which should be passed on to the instantiator of this class
self.configure_tcp_port()
def run(self):
while True:
(client_socket, address) = self.server_socket.accept()
new_thread = ClientConnectionThread(client_socket, address)
new_thread.start()
ui_print("Accepted connection from {}".format(address))
def configure_tcp_port(self):
# Creates and configures the listening port
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server_socket.bind(('', g.SOCKET_PORT))
self.server_socket.listen(5) # Limit to max. 5 simultaneous connections
ui_print("Listening for TCP connections on port {}".format(g.SOCKET_PORT))
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
import csv
from tkinter import filedialog
import src.globals as g
def ui_print(*content):
"""prints text to built-in console, use exactly like normal print(). Requires the ui to be initialized"""
output = " ".join([str(c) for c in content])
if not g.exit_flag and g.app is not None:
# application is still running --> output window is visible
g.app.OutputConsole.put(output)
else:
# if window is not open, do normal print
print(output)
def save_dict_list_to_csv(filename, data, query_path=False):
"""Creates a csv file under the specified path containing one row for each dict in the list 'data'.
The file receives a header containing the keys of the first dict entry.
Each dict should use the same keys."""
if query_path:
filename = filedialog.asksaveasfilename(initialfile=filename, title="Select csv save location...",
filetypes=(("CSV", "*.csv"),))
with open(filename, mode='w', newline='') as csv_file:
fieldnames = data[0].keys()
csv_writer = csv.DictWriter(csv_file, fieldnames=fieldnames, delimiter=',', quotechar='"',
quoting=csv.QUOTE_NONNUMERIC)
csv_writer.writeheader()
for row in data:
csv_writer.writerow(row)
-4
View File
@@ -1,4 +0,0 @@
Date;Time;t (s);X PSU Status;Y PSU Status;Z PSU Status;X Voltage Setpoint;Y Voltage Setpoint;Z Voltage Setpoint;X Actual Voltage;Y Actual Voltage;Z Actual Voltage;X Current Setpoint;Y Current Setpoint;Z Current Setpoint;X Actual Current;Y Actual Current;Z Actual Current;X Target Field;Y Target Field;Z Target Field;X Trgt. Field Raw;Y Trgt. Field Raw;Z Trgt. Field Raw;X Target Current;Y Target Current;Z Target Current;X Inverted;Y Inverted;Z Inverted
2021-03-22;10:58:32,248216;0,0;Not Connected;Not Connected;Not Connected;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;Unknown;Unknown;Unknown
2021-03-22;10:58:33,262910;1,014694;Not Connected;Not Connected;Not Connected;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;Unknown;Unknown;Unknown
2021-03-22;10:58:34,271188;2,022972;Not Connected;Not Connected;Not Connected;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;0;Unknown;Unknown;Unknown
1 Date Time t (s) X PSU Status Y PSU Status Z PSU Status X Voltage Setpoint Y Voltage Setpoint Z Voltage Setpoint X Actual Voltage Y Actual Voltage Z Actual Voltage X Current Setpoint Y Current Setpoint Z Current Setpoint X Actual Current Y Actual Current Z Actual Current X Target Field Y Target Field Z Target Field X Trgt. Field Raw Y Trgt. Field Raw Z Trgt. Field Raw X Target Current Y Target Current Z Target Current X Inverted Y Inverted Z Inverted
2 2021-03-22 10:58:32,248216 0,0 Not Connected Not Connected Not Connected 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 Unknown Unknown Unknown
3 2021-03-22 10:58:33,262910 1,014694 Not Connected Not Connected Not Connected 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 Unknown Unknown Unknown
4 2021-03-22 10:58:34,271188 2,022972 Not Connected Not Connected Not Connected 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 Unknown Unknown Unknown

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

+35
View File
@@ -0,0 +1,35 @@
import serial
from datetime import datetime
import socket
# Lookup table for cage axis to magnetometer transformation
# First entry: Magnetometer y axis to cage x (reversed)
# axis_mapping = [[1, -1 ...]
axis_mapping = [[2, -1], [0, 1], [1, -1]]
# Helmholtz control software tcp port
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("localhost", 6677))
s.sendall("declare_api_version 2\n".encode())
# FGM3D software virtual serial port
ser = serial.Serial("COM11")
line = b""
ready = False
while True:
line += ser.read()
if line[-2:] == b"\r\n":
new_line = line[:-2].decode('ascii')
delta = datetime.now().timestamp()*1000 - int(new_line.split(';')[0])+(2*60*60*1000)
if delta < 500 and not ready:
ready = True
print("Program ready!")
if ready:
# Data is not valid otherwise
# Only use the x y and z values
tokens = [float(v.replace(',', '.')) for v in new_line.split(";")[1:-1]]
axis_fields = [tokens[m[0]]*m[1] for m in axis_mapping]
axis_fields = ["{:.6e}".format(v) for v in axis_fields]
s.sendall(("magnetometer_field {} {} {}\n".format(*axis_fields)).encode())
line = b""