diff --git a/.gitignore b/.gitignore index 37b8976..0d0cc32 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,8 @@ ENV/ .idea/misc.xml .idea/Python-PS2000B.iml .idea/Python-PS2000B.iml +.idea/Python-PS2000B.iml +.idea/misc.xml +config.ini +*.ini +log.csv diff --git a/.idea/Python-PS2000B.iml b/.idea/Python-PS2000B.iml index 98105af..653cec9 100644 --- a/.idea/Python-PS2000B.iml +++ b/.idea/Python-PS2000B.iml @@ -2,7 +2,7 @@ - + diff --git a/.idea/misc.xml b/.idea/misc.xml index 6724169..5ea737b 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/Arduino/arduino.py b/Arduino/arduino.py index 597c58b..37f0cfd 100644 --- a/Arduino/arduino.py +++ b/Arduino/arduino.py @@ -1,3 +1,4 @@ +# This file enables control of a connected Arduino microcontroller. #!/usr/bin/env python import logging import itertools diff --git a/ArduinoTest.py b/ArduinoTest.py deleted file mode 100644 index 2436f73..0000000 --- a/ArduinoTest.py +++ /dev/null @@ -1,29 +0,0 @@ -import time - -from Arduino import Arduino - -print("Searching for Arduino...") -board = Arduino() -print("Arduino found.") -board.pinMode(15, "Output") -board.pinMode(16, "Output") -board.pinMode(17, "Output") -board.digitalWrite(15, "LOW") -board.digitalWrite(16, "LOW") -board.digitalWrite(17, "LOW") - - -i = 0 -while i <= 1: - print("running: ", i) - for var in [15,16,17]: - board.digitalWrite(var, "HIGH") - time.sleep(0.5) - time.sleep(5) - for var in [15,16,17]: - board.digitalWrite(var, "LOW") - time.sleep(0.5) - time.sleep(2) - i = i + 1 - -board.close() diff --git a/One_Unit_Test.py b/One_Unit_Test.py deleted file mode 100644 index 99d492a..0000000 --- a/One_Unit_Test.py +++ /dev/null @@ -1,72 +0,0 @@ -# import platform -import time as t -import numpy as np -import settings as g -import cage_func as func -from pyps2000b import PS2000B - -# User Inputs/Configuration---------------------------------- -Test1 = 0 -Test2 = 1 -Test3 = 0 -Test4 = 0 - -# Constants: -g.COIL_CONST = np.array([38.6, 38.45, 37.9]) * 1e-9 # Coil constants [x,y,z] in T/A -g.AMBIENT_FIELD = np.array([80]) * 1e-6 # ambient magnetic field in measurement area, to be cancelled out -g.RESISTANCES = np.array([3.9, 3.9, 1]) # resistance of [x,y,z] circuits -g.MAX_WATTS = np.array([8, 8, 0]) # max. allowed power for [x,y,z] circuits - -# COM-Ports for power supply units: -XY_PORT = "COM7" -g.XY_DEVICE = PS2000B.PS2000B(XY_PORT) - -g.MAX_AMPS = np.sqrt(g.MAX_WATTS / g.RESISTANCES) -#print(g.MAX_AMPS) - - -'''def print_status(): - print("Output 1:") - func.print_status(g.X_AXIS) - print("Output 2:") - func.print_status(g.Y_AXIS)''' - - -func.set_to_zero(g.XY_DEVICE) -#print_status() -t.sleep(3) - -if Test1 == 1: - print("setting") - g.XY_DEVICE.voltage1 = 5 - g.XY_DEVICE.current1 = 1 - g.XY_DEVICE.enable_all() - t.sleep(1) - #print_status() - t.sleep(5) - print("setting to zero") - func.set_to_zero(g.XY_DEVICE) - -if Test2 == 1: - print("setting current") - g.XY_DEVICE.set_current(0.2, 0) - g.XY_DEVICE.set_voltage(5, 0) - g.XY_DEVICE.enable_all() - t.sleep(1) - #print_status() - t.sleep(5) - func.set_to_zero(g.XY_DEVICE) - -if Test4 == 1: - func.set_axis_current(g.XY_DEVICE, 0.2) - t.sleep(1) - print_status() - t.sleep(10) - func.set_to_zero(g.XY_DEVICE) - t.sleep(1) - -#print_status() - -g.XY_DEVICE.disable_all() - -#print_status() diff --git a/Test cases.xlsx b/Test cases.xlsx new file mode 100644 index 0000000..da0c57a Binary files /dev/null and b/Test cases.xlsx differ diff --git a/Test1.csv b/Test cases/Test1.csv similarity index 100% rename from Test1.csv rename to Test cases/Test1.csv diff --git a/Test cases/Test2.csv b/Test cases/Test2.csv new file mode 100644 index 0000000..3466d12 --- /dev/null +++ b/Test cases/Test2.csv @@ -0,0 +1,15 @@ +Time (s);xField (T);yField (T);zField (T) +0;0,0000145;0,0000255;0,0000195 +0,1;-0,000015;0,000025;0,00002 +0,2;-0,0000155;-0,0000245;0,0000205 +0,3;-0,000016;-0,000024;-0,000021 +0,4;0,0000165;-0,0000235;-0,0000215 +0,5;0,000017;0,000023;-0,000022 +0,6;0,0000175;0,0000225;0,0000225 +0,7;-0,000018;-0,000022;0,000023 +0,8;0,0000185;-0,0000215;-0,0000235 +0,9;-0,000019;0,000021;-0,000024 +1;-0,0000195;-0,0000205;-0,0000245 +1,1;0,00002;0,00002;0,000025 +1,2;-0,0000205;-0,0000195;-0,0000245 +1,3;0,000021;0,000019;0,000024 diff --git a/Test cases/Test2_slow.csv b/Test cases/Test2_slow.csv new file mode 100644 index 0000000..b88ab13 --- /dev/null +++ b/Test cases/Test2_slow.csv @@ -0,0 +1,15 @@ +Time (s);xField (T);yField (T);zField (T) +0;0,0000145;0,0000255;0,0000195 +1;-0,000015;0,000025;0,00002 +2;-0,0000155;-0,0000245;0,0000205 +3;-0,000016;-0,000024;-0,000021 +4;0,0000165;-0,0000235;-0,0000215 +5;0,000017;0,000023;-0,000022 +6;0,0000175;0,0000225;0,0000225 +7;-0,000018;-0,000022;0,000023 +8;0,0000185;-0,0000215;-0,0000235 +9;-0,000019;0,000021;-0,000024 +10;-0,0000195;-0,0000205;-0,0000245 +11;0,00002;0,00002;0,000025 +12;-0,0000205;-0,0000195;-0,0000245 +13;0,000021;0,000019;0,000024 diff --git a/Test cases/out of bounds.csv b/Test cases/out of bounds.csv new file mode 100644 index 0000000..3e10ebc --- /dev/null +++ b/Test cases/out of bounds.csv @@ -0,0 +1,12 @@ +Time (s);xField (T);yField (T);zField (T); +0;0,00015;-0,00015;0,00002;150 +1;0,00017;-0,00017;0,00002;170 +2;0,00018;-0,00018;0,00002;180 +3;0,00019;-0,00019;0,00002;190 +4;0,0002;-0,0002;0,00002;200 +5;0,00021;0,00021;0,00002;210 +6;0,00022;-0,00022;0,00002;220 +7;0,0002;-0,0002;0,00002;200 +8;0,00018;-0,00018;0,00002;180 +9;0,00005;-0,00005;0,00002;50 +10;-0,00004;0,00004;0,00002;-40 diff --git a/User_Interface.py b/User_Interface.py index 8c259f3..94cb111 100644 --- a/User_Interface.py +++ b/User_Interface.py @@ -1,80 +1,504 @@ +# This file contains classes to build all elements of the graphical user interface. +# These classes also contain the methods that are executed when UI elements are activated. + +# import packages for user interface: from tkinter import * from tkinter import ttk -import settings as g -import cage_func as func -import numpy as np +from tkinter import messagebox +from tkinter import filedialog +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg -NORM_FONT = () +# import general packages: +import numpy as np +import os +from os.path import exists +import threading +from datetime import datetime + +# import other project files: +import globals as g +import cage_func as func +import csv_threading as csv +import config_handling as config +import csv_logging as log + +# define font styles: +HEADER_FONT = ("Arial", 13, "bold") SUB_HEADER_FONT = ("Arial", 9, "bold") BIG_BUTTON_FONT = ("Arial", 11, "bold") +SMALL_BUTTON_FONT = ("Arial", 9) class HelmholtzGUI(Tk): - + # main application window, almost everything else here es called from this class + # Inherited base class: Tk(), main application window class def __init__(self): Tk.__init__(self) - Tk.wm_title(self, "Helmholtz Cage Control") - Tk.wm_iconbitmap(self, "Helmholtz.ico") + Tk.wm_title(self, "Helmholtz Cage Control") # set title of the window + Tk.wm_iconbitmap(self, "Helmholtz.ico") # set application icon - self.Menu = TopMenu(self) # displays menu bar at the top + self.Menu = TopMenu(self) # display dropdown menu bar at the top (see TopMenu class for details) - mainArea = Frame(self) - mainArea.pack(side="top", fill="both", expand=False) + mainArea = Frame(self, padx=10, pady=10) # create main area Frame where controls of each mode are displayed + mainArea.pack(side="top", fill="both", expand=True) # pack main area at the top of the window - mainArea.grid_rowconfigure(0, weight=1) + mainArea.grid_rowconfigure(0, weight=1) # configure rows and columns of the Tkinter grid to expand with window mainArea.grid_columnconfigure(0, weight=1) - self.frames = {} # dictionary for storing all pages + # initialize the GUI pages for the different modes and setup switching between them + # see https://pythonprogramming.net/change-show-new-frame-tkinter/ for explanation + # switching between pages is done with show_frame() method - for F in [TestFrame, ManualMode]: - frame = F(mainArea, self) - self.frames[F] = frame - frame.grid(row=0, column=0, sticky="nsew") + self.pages = {} # dictionary for storing all pages (different modes, displayed in main area) + for P in [ManualMode, Configuration, ExecuteCSVMode, ConfigureLogging]: # do this for every mode page + page = P(mainArea, self) # initialize the page with the mainArea frame as the parent + self.pages[P] = page # add the page to the dictionary + page.grid(row=0, column=0, sticky="nsew") # place all pages in the same place in the GUI - self.StatusDisplay = StatusDisplay(self, self) - self.StatusDisplay.pack(side="bottom", fill="x", expand=False) + # setup status display and output console + status_frame = Frame(self) # create frame to house them + status_frame.pack(side="bottom", fill="x", expand=False) # place at bottom of main window, expand to full width + status_frame.grid_columnconfigure(1, weight=1) # make column 1, (output console), expand to fill full width - self.show_frame(ManualMode) + # initialize and place status display: + self.StatusDisplay = StatusDisplay(status_frame, self) + self.StatusDisplay.grid(row=0, column=0, sticky="nesw") - def show_frame(self, key): - frame = self.frames[key] # gets correct page from the dictionary - frame.tkraise() # brings this frame to the front + # initialize and place output console: + self.OutputConsole = OutputConsole(status_frame) + self.OutputConsole.grid(row=0, column=1, sticky="nesw") + + self.show_frame(ManualMode) # show manual mode to start with + + def show_frame(self, key): # method to switch between pages in the main area + frame = self.pages[key] # get correct page from the dictionary + frame.page_switch() # update displays in this page with window-specific update function + frame.tkraise() # bring this frame to the front class TopMenu: - + # the menu bar at the top of the window def __init__(self, window): - menu = Menu(window) - window.config(menu=menu) + menu = Menu(window) # initialize Menu object + window.config(menu=menu) # put menu at the top of the window - ModeSelector = Menu(menu) - menu.add_cascade(label="Mode", menu=ModeSelector) + ModeSelector = Menu(menu) # create a submenu object + menu.add_cascade(label="Mode", menu=ModeSelector) # add a dropdown with the submenu object + # create the different options in the dropdown: ModeSelector.add_command(label="Static Manual Input", command=lambda: self.manual_mode(window)) + ModeSelector.add_command(label="Execute CSV Sequence", command=lambda: self.execute_csv_mode(window)) + ModeSelector.add_separator() + ModeSelector.add_command(label="Configure Data Logging", command=lambda: self.logging(window)) + ModeSelector.add_command(label="Settings...", command=lambda: self.configuration(window)) @staticmethod - def manual_mode(window): + def manual_mode(window): # switch to the manual mode page window.show_frame(ManualMode) + @staticmethod + def configuration(window): # switch to the settings page + window.show_frame(Configuration) -class TestFrame(Frame): # ToDo: remove + @staticmethod + def execute_csv_mode(window): # switch to the CSV execution page + window.show_frame(ExecuteCSVMode) - def __init__(self, parent, controller): - Frame.__init__(self, parent) - - one = Label(self, text="One", bg="red") - one.pack(fill=X) - two = Label(self, text="Two", bg="blue") - two.pack() - button = ttk.Button(self, text="Print stuff", command=lambda: print("Hello")) - button.pack() + @staticmethod + def logging(window): # switch to the logging settings page + window.show_frame(ConfigureLogging) class ManualMode(Frame): - # ToDo: Display maximum values - # ToDo: Add option to cancel ambient field - # ToDo: Add buttons to safe and set to 0 + # Mode for manually setting currents and fields on the test stand. + # Inherits the Frame object from Tkinter and is placed in the mainArea of the application window. + def __init__(self, parent, controller): + Frame.__init__(self, parent) # initialize the frame object + self.controller = controller # object on which mainloop() is running, usually main window + + self.grid_rowconfigure(ALL, weight=1) # configure rows and columns of the Tkinter grid to expand with window + self.grid_columnconfigure(ALL, weight=1) + + row_counter = 0 # keep track of which row in the main grid we are in + + # setup title text + header = Label(self, text="Manual Input Mode", font=HEADER_FONT, pady=3) + header.grid(row=row_counter, column=0) + + row_counter += 1 + + # Setup Dropdown Menu for input mode + dropdown_frame = Frame(self) # create frame to house dropdown + dropdown_frame.grid_rowconfigure(ALL, weight=1) + dropdown_frame.grid_columnconfigure(ALL, weight=1) + dropdown_frame.grid(row=row_counter, column=0) # place frame on the page + + self.input_mode = StringVar() # variable that is changed by the dropdown selection + # make dictionary with information on all modes. + # content: [function to call on button press, unit text to be displayed, function to call on dropdown change] + self.modes = {"Magnetic Field": [self.execute_field, "\u03BCT", self.switch_to_field_mode], + "Current": [self.execute_current, "A", self.switch_to_current_mode]} + self.unit = StringVar() # variable to store the unit of the current mode, used to update a label with the unit + default_mode = list(self.modes.keys())[0] # setup which mode to show at the beginning + + # create the dropdown. parameters: (frame to place it in, variable changed, starting selection, all options) + input_mode_selector = ttk.OptionMenu(dropdown_frame, self.input_mode, default_mode, *self.modes.keys()) + + input_mode_selector.grid(row=0, column=1, sticky=W) # place dropdown on the grid + dropdown_frame.grid_columnconfigure(1, minsize=115) # set size of column with dropdown to keep it from moving + # Add a description before the dropdown: + selector_label = Label(dropdown_frame, text="Select Input Mode:", padx=10, pady=10) + selector_label.grid(row=0, column=0) + + row_counter = row_counter + 1 + + # Setup Entry fields for field/current values + # create and configure frame to house fields: + self.entries_frame = Frame(self) + self.entries_frame.grid_rowconfigure(ALL, weight=1) + self.entries_frame.grid_columnconfigure(ALL, weight=1) + self.entries_frame.grid_columnconfigure(2, weight=1, minsize=20) # set column width so it doesn't move around + self.entries_frame.grid_columnconfigure(3, weight=1, minsize=110) + self.entries_frame.grid(row=row_counter, column=0) + + entry_texts = ["X-Axis:", "Y-Axis:", "Z-Axis:"] # row labels + self.entry_vars = [DoubleVar() for _ in range(3)] # variables that are changed by entries into the fields + self.max_value_vars = [StringVar() for _ in range(3)] # variables for labels showing the min/max values + + # Build up the entry field table: + row = 0 + for text in entry_texts: # go through x,y,z axis rows + field = ttk.Entry(self.entries_frame, textvariable=self.entry_vars[row]) # create entry field + self.entry_vars[row].set(0) # set its value to 0 to start + field.grid(row=row, column=1, sticky=W) + axis_label = Label(self.entries_frame, text=text, padx=5, pady=10) # create label showing the axis + axis_label.grid(row=row, column=0, sticky=W) + unit_label = Label(self.entries_frame, textvariable=self.unit) # create updatable label showing the unit + unit_label.grid(row=row, column=2, sticky=W) + # create updatable label showing the min/max achievable values: + max_value_label = Label(self.entries_frame, textvariable=self.max_value_vars[row]) + max_value_label.grid(row=row, column=3, sticky=W) + row = row + 1 + + row_counter += 1 + + # setup checkbox for compensating ambient field + checkbox_frame = Frame(self, padx=20) # create frame to house it + checkbox_frame.grid(row=row_counter, column=0, sticky=W) + + self.compensate = BooleanVar(value=True) # create variable to be changed by the checkbox + # create checkbox: + self.compensate_checkbox = Checkbutton(checkbox_frame, text="Compensate ambient field", + variable=self.compensate, onvalue=True, offvalue=False) + self.compensate_checkbox.pack(side="left") + + row_counter += 1 + + # Setup buttons + self.buttons_frame = Frame(self) + self.buttons_frame.grid_rowconfigure(ALL, weight=1) + self.buttons_frame.grid_columnconfigure(ALL, weight=1) + self.buttons_frame.grid(row=row_counter, column=0) + + Label(self.buttons_frame, text="").grid(row=0, column=0) # add spacer + + # add button for executing the current entries + execute_button = Button(self.buttons_frame, text="Execute!", command=self.execute, + pady=5, padx=5, font=BIG_BUTTON_FONT) + execute_button.grid(row=row_counter, column=0, padx=5) + + # add button for quick power_down + power_down_button = Button(self.buttons_frame, text="Power Down All", command=self.power_down, + pady=5, padx=5, font=BIG_BUTTON_FONT) + power_down_button.grid(row=row_counter, column=1, padx=5) + + # add button for reinitialization of devices + reinit_button = Button(self.buttons_frame, text="Reinitialize", command=self.reinitialize, + pady=5, padx=5, font=BIG_BUTTON_FONT) + reinit_button.grid(row=row_counter, column=2, padx=5) + + row_counter = row_counter + 1 + + Label(self, text="", pady=10).grid(row=row_counter, column=0) # add spacer + + self.input_mode.trace_add('write', self.change_mode_callback) # call mode change function on dropdown change + self.input_mode.set(default_mode) # call up default mode at the start + self.compensate.trace_add('write', self.change_mode_callback) # call mode change function on checkbox change + + def page_switch(self): # function that is called when switching to this page in the UI + self.modes[self.input_mode.get()][2]() # update max values and units, e.g. calls update_max_fields function + + # noinspection PyUnusedLocal + # not sure what the parameters are for, but it doesn't work without them + def change_mode_callback(self, var, index, mode): # called whenever input mode dropdown or checkbox is changed + self.unit.set(self.modes[self.input_mode.get()][1]) # change unit text + self.modes[self.input_mode.get()][2]() # update max values, e.g. calls update_max_fields function + + def switch_to_field_mode(self): # called when switching to magnetic field entry mode + self.compensate_checkbox.config(state=NORMAL) # enable the compensate ambient field checkbox + + # update the labels showing the min/max achievable values + compensate = self.compensate.get() # read out if compensate field checkbox is checked (True or False) + i = 0 + for var in self.max_value_vars: # go through the max value labels for each axis + if not compensate: # ambient field should not be compensated + field = g.AXES[i].max_field * 1e6 # get max values from the axis object + elif compensate: # ambient field should be compensated + field = g.AXES[i].max_comp_field * 1e6 + else: # this really should never happen + field = [0, 0] + ui_print("Unexpected value encountered: compensate =", compensate) + messagebox.showerror("Unexpected Value!", ("Unexpected value encountered: compensate =", compensate)) + var.set("(%0.1f to %0.1f \u03BCT)" % (field[0], field[1])) # update the label text with the new values + i += 1 + + def switch_to_current_mode(self): # called when switching to the input current mode + self.compensate_checkbox.config(state=DISABLED) # disable the compensate ambient field checkbox + + # update the labels showing the min/max achievable values + i = 0 + for var in self.max_value_vars: # go through the max value labels for each axis + var.set("(%0.2f to %0.2f A)" % (-g.AXES[i].max_amps, g.AXES[i].max_amps)) # update the label + i += 1 + + def reinitialize(self): # called on "Reinitialize!" button press + func.setup_all() # reinitialize all PSUs and the Arduino + + # log change to the log file if user has selected event logging in the Configure Logging window + logger = self.controller.pages[ConfigureLogging] # get object of logging configurator + if logger.event_logging: # data should be logged when test stand is commanded + logger.log_datapoint() # log data + + def power_down(self): # called on "power down" button press + func.power_down_all() # power down outputs on all PSUs and the Arduino + + # log change to the log file if user has selected event logging in the Configure Logging window + logger = self.controller.pages[ConfigureLogging] # get object of logging configurator + if logger.event_logging: # data should be logged when test stand is commanded + logger.log_datapoint() # log data + + def execute(self): # called on "Execute!" button press + # reads values from the entry fields and commands the test stand accordingly + + vector = np.array([0, 0, 0], dtype=float) # initialize vector to later send to test stand + i = 0 + try: # try to read values from the entry fields + for var in self.entry_vars: + vector[i] = var.get() # write read out value to correct position in the vector + i += 1 + except TclError as e: # user did not enter correct format somewhere + messagebox.showwarning("Invalid Entry", "Invalid entry:\n%s" % e) # show warning message + else: # no issues while reading entries (user entered correct format) + function_to_call = self.modes[self.input_mode.get()][0] # get function of appropriate mode + function_to_call(vector) # call function (self.execute_field() or self.execute_current()) + self.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 = self.controller.pages[ConfigureLogging] # get object of logging configurator + if logger.event_logging: # data should be logged when test stand is commanded + logger.log_datapoint() # log data + + def execute_field(self, vector): # convert magnetic field vector and send to test stand + ui_print("Field executing:", vector, "\u03BCT") + compensate = self.compensate.get() # read out if compensate ambient field checkbox is ticked + if compensate: # ambient field should be compensated + func.set_field(vector * 1e-6) # convert to Tesla and send to test stand + elif not compensate: # ambient field should not be compensated + func.set_field_simple(vector * 1e-6) # convert to Tesla and send to test stand + else: # this really should never happen + ui_print("Unexpected value encountered: compensate =", compensate) + messagebox.showerror("Unexpected Value!", ("Unexpected value encountered: compensate =", compensate)) + + @staticmethod + def execute_current(vector): # send current vector to the test stand + ui_print("Current executing:", vector, "A") + func.set_current_vec(vector) # command test stand + + +class ExecuteCSVMode(Frame): + # Mode for executing magnetic field sequences from csv files. + # Inherits the Frame object from Tkinter and is placed in the mainArea of the application window. + # Multithreading is used to execute the sequence without crashing the UI + + def __init__(self, parent, controller): + Frame.__init__(self, parent) + self.parent = parent # parent UI object, e.g. the mainArea frame + self.controller = controller # object on which mainloop() is running, usually main window + + # Functional init: + self.csv_thread = None # the thread object for executing a csv sequence + self.sequence_array = None # array containing the values from the csv file + + # Build UI: + self.grid_rowconfigure(ALL, weight=1) # configure rows and columns of the Tkinter grid to expand with window + self.grid_columnconfigure(ALL, weight=1) + + row_counter = 0 # keep track of which grid row we are in + + self.row_elements = [] # make list of elements in rows to later calculate height available for plot + + # setup headline + header = Label(self, text="Execute CSV Mode", font=HEADER_FONT, pady=3) + header.grid(row=row_counter, column=0, padx=100, sticky=W) + self.row_elements.append(header) # add to list of row elements + + row_counter += 1 + + # Setup buttons + # Setup frame to house buttons: + self.top_buttons_frame = Frame(self) + self.top_buttons_frame.grid_rowconfigure(ALL, weight=1) + self.top_buttons_frame.grid_columnconfigure(ALL, weight=1) + self.top_buttons_frame.grid(row=row_counter, column=0, sticky=W, padx=20) + + self.row_elements.append(self.top_buttons_frame) # add frame to list of row elements + + # Create and place buttons: + # add button for selecting a csv file to execute: + self.select_file_button = Button(self.top_buttons_frame, text="Select csv file...", command=self.load_csv, + pady=5, padx=5, font=SMALL_BUTTON_FONT) + self.select_file_button.grid(row=0, column=0, padx=5) + # add button to start running the sequence from the file: + self.execute_button = Button(self.top_buttons_frame, text="Run Sequence", command=self.run_sequence, + pady=5, padx=5, font=SMALL_BUTTON_FONT, state="disabled") + self.execute_button.grid(row=0, column=1, padx=5) + # add button to stop/interrupt the sequence: + self.stop_button = Button(self.top_buttons_frame, text="Stop Run", command=self.stop_run, + pady=5, padx=5, font=SMALL_BUTTON_FONT, state="disabled") + self.stop_button.grid(row=0, column=2, padx=5) + # add button to reinitialize devices (e.g. after reconnecting a device) + self.reinit_button = Button(self.top_buttons_frame, text="Reinitialize Devices", command=self.reinitialize, + pady=5, padx=5, font=SMALL_BUTTON_FONT) + self.reinit_button.grid(row=0, column=3, padx=5) + + row_counter += 1 + + # setup checkboxes to disable checks if devices are connected (enables testing with some devices missing) + # setup frame to house checkboxes: + self.checkbox_frame = Frame(self) + self.checkbox_frame.grid_rowconfigure(ALL, weight=1) + self.checkbox_frame.grid_columnconfigure(ALL, weight=1) + self.checkbox_frame.grid(row=row_counter, column=0, sticky=W, padx=20) + + self.row_elements.append(self.checkbox_frame) # add frame to list of row elements + + # setup label to explain checkbox function: + checkbox_label = Label(self.checkbox_frame, text="Disable device connection checks:") + checkbox_label.grid(row=0, column=0, sticky=W, padx=3) + + # create variables for the checkboxes: + self.xy_override = BooleanVar(value=False) # True to disable connection check for XY PSU + self.z_override = BooleanVar(value=False) # True to disable connection check for Z PSU + self.arduino_override = BooleanVar(value=False) # True to disable connection check for arduino + # create checkboxes: + xy_checkbox = Checkbutton(self.checkbox_frame, text="XY PSU", + variable=self.xy_override, onvalue=True, offvalue=False) + xy_checkbox.grid(row=0, column=1, padx=3) + z_checkbox = Checkbutton(self.checkbox_frame, text="Z PSU", + variable=self.z_override, onvalue=True, offvalue=False) + z_checkbox.grid(row=0, column=2, padx=3) + arduino_checkbox = Checkbutton(self.checkbox_frame, text="Arduino", + variable=self.arduino_override, onvalue=True, offvalue=False) + arduino_checkbox.grid(row=0, column=3, padx=3) + + row_counter += 1 + + # make frame for plot of csv values (plot is generated and placed in display_plot() method) + self.plotFrame = Frame(self) + self.plotFrame.grid_rowconfigure(0, weight=1) + self.plotFrame.grid_columnconfigure(0, weight=1) + self.plotFrame.grid(row=row_counter, column=0, sticky="nsw", padx=10, pady=10) + + def page_switch(self): # function that is called when switching to this window + # every class in the UI needs this, even if it doesn't do anything + pass + + def load_csv(self): # load in csv file to be executed + directory = os.path.abspath(os.getcwd()) # get project directory + # open file selection dialogue and store path of selected file + filename = filedialog.askopenfilename(initialdir=directory, title="Select CSV File", + filetypes=(("Comma Separated Values", "*.csv*"), ("All Files", "*.*"))) + if exists(filename): # does the file exist? + ui_print("File selected:", filename) + try: # try to read data to an array + self.sequence_array = csv.read_csv_to_array(filename) # read array from csv + except BaseException as e: # something went wrong, probably wrong format in csv + # display error messages: + ui_print("Error while opening file:", e) + messagebox.showerror("Error!", "Error while opening file: \n%s" % e) + + try: # try to check the values and display the plot + csv.check_array_ok(self.sequence_array) # check for values exceeding limits + self.display_plot() # plot data and display + except BaseException as e: # something went wrong, probably wrong format in csv + # display error messages: + ui_print("Error while processing data from file:", e) + messagebox.showerror("Error!", "Error while processing data from file: \n%s" % e) + else: # nothing went wrong + self.execute_button["state"] = "normal" # activate run button --> enable execution + elif filename == '': # this happens when file selection window is closed without selecting a file + ui_print("No file selected, could not load.") + else: # file does not exist + # display error messages: + ui_print("Selected file", filename, "does not exist, could not load.") + messagebox.showerror("File not found", "Selected file %s does not exist, could not load." % filename) + + def run_sequence(self): # called on run button press, starts thread for executing the sequence + # (de)activate buttons as needed: + self.select_file_button["state"] = "disabled" + self.execute_button["state"] = "disabled" + self.stop_button["state"] = "normal" + self.reinit_button["state"] = "disabled" + + # setup thread for running the sequence: + # More info: https://www.tutorialspoint.com/python/python_multithreading.htm + g.threadLock = threading.Lock() # create thread locking object, used to ensure all devices switch at once later + # create thread object: + self.csv_thread = csv.ExecCSVThread(self.sequence_array, self, self.controller) + self.csv_thread.start() # start thread + + def stop_run(self): # called on stop button press, interrupts sequence execution + self.csv_thread.stop() # call stop method of thread object, this will cause the csv loop to end + # (de)activate buttons as needed: + self.select_file_button["state"] = "normal" + self.execute_button["state"] = "normal" + self.stop_button["state"] = "disabled" + self.reinit_button["state"] = "normal" + + # log change to the log file if user has selected event logging in the Configure Logging window + logger = self.controller.pages[ConfigureLogging] # get object of logging configurator + if logger.event_logging: # data should be logged when test stand is commanded + logger.log_datapoint() # log data + + def reinitialize(self): # called on "Reinitialize devices" button press + func.setup_all() # reinitialize all PSUs and the Arduino + + # log change to the log file if user has selected event logging in the Configure Logging window + logger = self.controller.pages[ConfigureLogging] # get object of logging configurator + if logger.event_logging: # data should be logged when test stand is commanded + logger.log_datapoint() # log data + + def display_plot(self): # generate and display a plot of the data loaded from a csv file + # calculate available height for plot (in pixels): + height_others = 0 # initialize variable to calculate height of other widgets + for element in self.row_elements: # go through all rows in the widget except the plot frame + height_others += element.winfo_height() # add up heights + # calculate available plot height: + height = self.parent.winfo_height() - height_others - 50 # height of parent frame - other widgets - margin + + width = min(self.parent.winfo_width() - 100, 1100) # set width to available space but max. 1100 + + figure = csv.plot_field_sequence(self.sequence_array, width, height) # create figure to be displayed + plotCanvas = FigureCanvasTkAgg(figure, self.plotFrame) # create canvas to draw figure on + plotCanvas.draw() # equivalent to matplotlib.show() + plotCanvas.get_tk_widget().grid(row=0, column=0, sticky="nesw") # place canvas in the UI + + +class Configuration(Frame): + # generate settings window to set program constants + + # noinspection PyUnusedLocal def __init__(self, parent, controller): Frame.__init__(self, parent) @@ -83,116 +507,523 @@ class ManualMode(Frame): row_counter = 0 - # Setup Dropdown Menu for input mode - dropdown_frame = Frame(self) - dropdown_frame.grid_rowconfigure(ALL, weight=1) - dropdown_frame.grid_columnconfigure(ALL, weight=1) - dropdown_frame.grid(row=row_counter, column=0) - self.input_mode = StringVar() - # make dictionary with information on all modes. - # content: [function to call on button press, unit text to be displayed] - self.modes = {"Magnetic Field": [self.execute_field, "\u03BCT"], - "Current": [self.execute_current, "A"]} - # "Raw Current": [self.input_raw_current, "A"]} ToDo (optional): make functions for this - self.unit = StringVar() - default_mode = list(self.modes.keys())[0] + # create headline: + header = Label(self, text="Configuration Window", font=HEADER_FONT, pady=3) + header.grid(row=row_counter, column=0, padx=100, sticky=W) - input_mode_selector = ttk.OptionMenu(dropdown_frame, self.input_mode, default_mode, *self.modes.keys()) + row_counter += 1 - input_mode_selector.grid(row=0, column=1, sticky=W) # place dropdown on the grid - dropdown_frame.grid_columnconfigure(1, minsize=115) # set size of column with dropdown to keep it from moving + # Setup buttons to select config file + # Setup frame to house buttons: + self.file_select_frame = Frame(self) + self.file_select_frame.grid_rowconfigure(ALL, weight=1) + self.file_select_frame.grid_columnconfigure(ALL, weight=1) + self.file_select_frame.grid(row=row_counter, column=0, sticky=W, padx=20) - selector_label = Label(dropdown_frame, text="Select Input Mode:", padx=10, pady=10) - selector_label.grid(row=0, column=0) + # Create and place buttons + # button to load a config file: + load_file_button = Button(self.file_select_frame, text="Load config file...", command=self.load_config, + pady=5, padx=5, font=SMALL_BUTTON_FONT) + load_file_button.grid(row=0, column=0, padx=5) + # button to save the current settings to the currently selected file + save_button = Button(self.file_select_frame, text="Save current config", command=self.save_config, + pady=5, padx=5, font=SMALL_BUTTON_FONT) + save_button.grid(row=0, column=1, padx=5) + # button to save current settings to a file selected by the user + save_as_button = Button(self.file_select_frame, text="Save current config as...", command=self.save_config_as, + pady=5, padx=5, font=SMALL_BUTTON_FONT) + save_as_button.grid(row=0, column=2, padx=5) - row_counter = row_counter + 1 + row_counter += 1 - # Setup Entry fields - self.entries_frame = Frame(self) - self.entries_frame.grid_rowconfigure(ALL, weight=1) - self.entries_frame.grid_columnconfigure(ALL, weight=1) - self.entries_frame.grid_columnconfigure(2, weight=1, minsize=20) - self.entries_frame.grid(row=row_counter, column=0) + # Setup entry fields for the PSU serial ports: + # setup frame: + port_frame = Frame(self) + port_frame.grid_rowconfigure(ALL, weight=1) + port_frame.grid_columnconfigure(ALL, weight=1) + port_frame.grid(row=row_counter, column=0, sticky=W) - entry_texts = ["X-Axis:", "Y-Axis:", "Z-Axis:"] - self.entry_vars = [StringVar() for _ in range(len(entry_texts))] + # text for the description labels: + entry_texts = ["XY PSU Serial Port:", "Z PSU Serial Port:"] + # create variables to store the port names and set to current names + self.XY_port = StringVar(value=g.XY_PORT) + self.Z_port = StringVar(value=g.Z_PORT) + port_vars = [self.XY_port, self.Z_port] # list to store both port variables row = 0 - for text in entry_texts: - field = ttk.Entry(self.entries_frame, textvariable=self.entry_vars[row]) - self.entry_vars[row].set(0) + for text in entry_texts: # do this for both ports + field = Entry(port_frame, textvariable=port_vars[row]) # create entry field field.grid(row=row, column=1, sticky=W) - axis_label = Label(self.entries_frame, text=text, padx=5, pady=10) + axis_label = Label(port_frame, text=text, padx=5, pady=10) # create description label axis_label.grid(row=row, column=0, sticky=W) - unit_label = Label(self.entries_frame, textvariable=self.unit) - unit_label.grid(row=row, column=2, sticky=W) + info_label = Label(port_frame, text="e.g. COM10") # add label with example of right format + info_label.grid(row=row, column=2, sticky=W) + row += 1 + + row_counter += 1 + + Label(self, text="", pady=0).grid(row=row_counter, column=0) # add spacer + row_counter += 1 + + # setup main entry field for operational constants + # setup frame: + value_frame = Frame(self) + value_frame.grid_rowconfigure(ALL, weight=1) + value_frame.grid_columnconfigure(ALL, weight=1) + value_frame.grid(row=row_counter, column=0) + + # Setup dictionary to generate entry table from + # {Key: [[x-value,y-value,z-value], unit, description, config file key, unit conversion factor]} + self.entries = { + "Coil Constants:": [[DoubleVar() for _ in range(3)], "\u03BCT/A", "", "coil_const", 1e6], + "Ambient Field:": [[DoubleVar() for _ in range(3)], "\u03BCT", + "Field to be compensated", "ambient_field", 1e6], + "Resistances:": [[DoubleVar() for _ in range(3)], "\u03A9", + "Resistance of coils + equipment", "resistance", 1], + "Max. Current:": [[DoubleVar() for _ in range(3)], "A", "Max. allowed current", "max_amps", 1], + "Max. Voltage:": [[DoubleVar() for _ in range(3)], "V", + "Max. allowed voltage, must not exceed 16V!", "max_volts", 1], + "Arduino Pins:": [[IntVar() for _ in range(3)], "-", "Should be 15, 16, 17", "relay_pin", 1] + } + + self.fields = {} # setup dictionary with all entry fields + + # Fill in header (axis names): + col = 1 + for text in ["X-Axis", "Y-Axis", "Z-Axis"]: + label = Label(value_frame, text=text, font=SUB_HEADER_FONT) + label.grid(row=0, column=col, sticky="ew") + col += 1 + # generate table with entries, unit labels and descriptions: + row = 1 + for key in self.entries.keys(): # go through the different values to be set + self.fields[key] = [] # initialize empty list for fields in the entry field dictionary + for axis in range(3): # go through all three axes + field = Entry(value_frame, textvariable=self.entries[key][0][axis], width=10) # setup entry field + field.grid(row=row, column=axis + 1, sticky=W, padx=2) + self.fields[key].append(field) # safe access to field to entry field dictionary + axis_label = Label(value_frame, text=key, padx=5, pady=5) # add label with variable name + axis_label.grid(row=row, column=0, sticky=W) + unit_label = Label(value_frame, text=self.entries[key][1]) # add label with unit + unit_label.grid(row=row, column=4, sticky=W) + description_label = Label(value_frame, text=self.entries[key][2]) # add label with description + description_label.grid(row=row, column=5, sticky=W) row = row + 1 - row_counter = row_counter + 1 + row_counter += 1 - # Setup execute button - Label(self, text="").grid(row=row_counter, column=0) # add spacer - row_counter = row_counter + 1 - execute_button = Button(self, text="Execute!", command=self.execute, + self.update_fields() # set current values from config file + + Label(self, text="", pady=10).grid(row=row_counter, column=0) # add spacer + row_counter += 1 + + # Setup buttons to implement and restore defaults + # Setup frame to house buttons: + self.buttons_frame = Frame(self) + self.buttons_frame.grid_rowconfigure(ALL, weight=1) + self.buttons_frame.grid_columnconfigure(ALL, weight=1) + self.buttons_frame.grid(row=row_counter, column=0, sticky=W, padx=20) + + # Create and place buttons + # button to read the values from all fields, update the config and reinitialize the test stand + implement_button = Button(self.buttons_frame, text="Update and Reinitialize", command=self.implement, + pady=5, padx=5, font=BIG_BUTTON_FONT) + implement_button.grid(row=0, column=0, padx=5) + # button to restore default settings + restore_button = Button(self.buttons_frame, text="Restore Defaults", command=self.restore_defaults, pady=5, padx=5, font=BIG_BUTTON_FONT) - execute_button.grid(row=row_counter, column=0, columnspan=2) + restore_button.grid(row=0, column=1, padx=5) - # Add spacer to Frame below - row_counter = row_counter + 1 - Label(self, text="", pady=10).grid(row=row_counter, column=0) + row_counter += 1 + Label(self, text="", pady=10).grid(row=row_counter, column=0) # add spacer - self.input_mode.trace_add('write', self.change_mode_callback) # call mode change function on dropdown change - self.input_mode.set(default_mode) # call up default mode at the start + def page_switch(self): # function that is called when switching to this window + self.update_fields() # update values in the entry fields from config - def change_mode_callback(self, var, index, mode): # not sure what the parameters are for, but they are necessary - self.unit.set(self.modes[self.input_mode.get()][1]) # change unit text + def restore_defaults(self): # restore default settings + config.reset_config_to_default() # overwrite config file with default + ui_print("\nReinitializing devices...") + func.setup_all() # setup everything with the defaults + self.update_fields() # update fields in config window - def execute(self): - function_to_call = self.modes[self.input_mode.get()][0] # get function of appropriate mode - vector = np.array([0, 0, 0], dtype=float) - i = 0 - for var in self.entry_vars: - vector[i] = float(var.get()) - i = i + 1 - function_to_call(vector) # call function - # ToDo: update status display here + def update_fields(self): # set current values for all entry variables from config file + # set values for PSU serial ports: + self.XY_port.set(g.XY_PORT) + self.Z_port.set(g.Z_PORT) - @staticmethod - def execute_field(vector): - print("field executing", vector) + for key in self.entries.keys(): # go through the main value table + for i in [0, 1, 2]: # go through all three axes + # get value from config file: + value = config.read_from_config(g.AXIS_NAMES[i], self.entries[key][3], config.CONFIG_OBJECT) + self.entries[key][0][i].set(value) # set initial value on the entry field variable + type_value = self.entries[key][0][i].get() # get value with correct type by reading out field variable + factor = self.entries[key][4] # get unit conversion factor + self.entries[key][0][i].set(round(type_value * factor, 3)) # set value with correct unit conversion + + # check if value is within safe limits: + value_check = func.value_in_limits(g.AXIS_NAMES[i], self.entries[key][3], value) + if value_check == 'OK': # value is acceptable + self.fields[key][i].config(background="White") # set colour of this entry to white + else: # value exceeds limits + self.fields[key][i].config(background="Red") # set colour of this entry to red to show problem + + def write_values(self): # update config object with user inputs into entry fields and reinitialize devices + + # set serial ports for PSUs: + config.edit_config("PORTS", "xy_port", self.XY_port.get()) + config.edit_config("PORTS", "z_port", self.Z_port.get()) + + # set numeric values for all axes + for key in self.entries.keys(): # go through rows of entry table + for i in [0, 1, 2]: # go through columns of entry table (axes) + try: + value = self.entries[key][0][i].get() # get value from field + except TclError as e: # wrong format entered, e.g. text in number fields + ui_print("Invalid entry for %s %s %s" % (g.AXIS_NAMES[i], key, e)) # print error message + # field will go back to the last valid value + + else: # format is ok + factor = self.entries[key][4] # get unit conversion factor + if factor not in [0, 1]: # prevent div/0 and conversion of int variables to float + value = value / factor # do unit conversion + + # Check if value is within safe limits + config_key = self.entries[key][3] # get handle by which value is indexed in config file + value_ok = func.value_in_limits(g.AXIS_NAMES[i], config_key, value) # perform value check + + if value_ok == 'OK': # value is within safe limits + config.edit_config(g.AXIS_NAMES[i], config_key, value) # write new value to config file + else: # value is not within limits + unit = self.entries[key][1] # get unit string for error messages + axis = g.AXIS_NAMES[i] # get axis name for error messages + + if value_ok == 'HIGH': # value is too high + max_value = g.default_arrays[config_key][1][i] # get max value + # assemble error message: + message = "Attempted to set too high value for {s} {k}\n" \ + "{v} {unit}, max. {mv} {unit} allowed.\n" \ + "Excessive values may damage equipment!\n" \ + "Do you really want to use this value?" \ + .format(s=axis, k=key, v=value * factor, mv=round(max_value * factor, 1), unit=unit) + elif value_ok == 'LOW': # value is too low + min_value = g.default_arrays[config_key][2][i] # get min value + # assemble error message: + message = "Attempted to set too low value for {s} {k}\n" \ + "{v} {unit}, min. {mv} {unit} allowed.\n" \ + "Excessive values may damage equipment!\n" \ + "Do you really want to use this value?" \ + .format(s=axis, k=key, v=value * factor, mv=round(min_value * factor, 1), unit=unit) + else: # this should be impossible + message = "Unknown case for value limits check, this should not happen." + + # display pop-up message to ask user if he really wants the value + answer = messagebox.askquestion("Value out of Bounds", message) + # answer becomes 'yes' or 'no' depending on user choice + if answer == 'yes': # user really wants the value + # call function to write new value to config file with override=True + config.edit_config(g.AXIS_NAMES[i], config_key, value, True) + # if user chooses 'no' nothing happens, old value is kept + + def implement(self): # "Update and Reinitialize" button, update config with new values and reinitialize devices + self.write_values() # write current values from entry fields to config object + ui_print("\nReinitializing devices...") + func.setup_all() # reinitialize devices and program with new values + self.update_fields() # update entry fields to show new values + + def load_config(self): # load configuration from some config file + directory = os.path.dirname(os.path.abspath(config.CONFIG_FILE)) # get directory of current config file + # open file selection dialogue at current path and save path of selected file + filename = filedialog.askopenfilename(initialdir=directory, title="Select Config File", + filetypes=(("Config File", "*.ini*"), ("All Files", "*.*"))) + if exists(filename): # does the file exist? + config.CONFIG_FILE = filename # set global config file to the new file + config.CONFIG_OBJECT = config.get_config_from_file(filename) # load from config file to config object + config.check_config(config.CONFIG_OBJECT) # check and display warnings if values are out of bounds + + ui_print("\nReinitializing devices...") + func.setup_all() # reinitialize devices and program with new values + self.update_fields() # update entry fields to show new values + elif filename == '': # this happens when file selection window is closed without selecting a file + ui_print("No file selected, could not load config.") + else: # file does not exist + ui_print("Selected file", filename, "does not exist, could not load config.") + + def save_config_as(self): # save current configuration to a new config file + directory = os.path.dirname(os.path.abspath(config.CONFIG_FILE)) # get directory of current config file + # open file selection dialogue and save path of selected file + filename = filedialog.asksaveasfilename(initialdir=directory, title="Save config to file", + filetypes=([("Config File", "*.ini*")]), + defaultextension=[("Config File", "*.ini*")]) + + if filename == '': # this happens when file selection window is closed without selecting a file + ui_print("No file selected, could not save config.") + else: # a file name was entered + config.CONFIG_FILE = filename # set global config file to the new file + self.write_values() # write current entry field values to the config object + config.write_config_to_file(config.CONFIG_OBJECT) # write contents of config object to file + func.setup_all() # reinitialize devices and program with new values + self.update_fields() # update entry fields to show values as they are in the config + + def save_config(self): # same as save_config_as() but with the current config file + self.write_values() # write current entry field values to the config object + config.write_config_to_file(config.CONFIG_OBJECT) # write contents of config object to file + func.setup_all() # reinitialize devices and program with new values + self.update_fields() # update entry fields to show values as they are in the config + + +class ConfigureLogging(Frame): + # generate window to configure data logging to csv + # ToDo: support logging of axis-independent info like Arduino status + # ToDo (optional): Add option to select which axes to log data from + + def __init__(self, parent, controller): + Frame.__init__(self, parent) + self.parent = parent # parent object, here tha mainArea frame + self.controller = controller # object on which mainloop() is running, usually main window + + self.log_file = None # string containing path of log file + self.regular_logging = False # True if data should be logged regularly + self.event_logging = False # True if data should be logged every time a command is sent to the test stand + # log_datapoint() has to be called wherever a command is sent to the test stand and data should be logged + # it does not happen automatically whenever something is sent to the test stand + # It is done mainly in the functions for UI buttons, but rather inconsistently ToDo(optional): make consistent + + self.grid_rowconfigure(ALL, weight=1) + self.grid_columnconfigure(ALL, weight=1) + + row_counter = 0 + + # setup headline + header = Label(self, text="Configure Data Logging", font=HEADER_FONT, pady=3) + header.grid(row=row_counter, column=0, padx=100, sticky=W) + + row_counter += 1 + + # Create and place buttons + # Setup frame to house buttons: + self.top_buttons_frame = Frame(self) + self.top_buttons_frame.grid_rowconfigure(ALL, weight=1) + self.top_buttons_frame.grid_columnconfigure(ALL, weight=1) + self.top_buttons_frame.grid(row=row_counter, column=0, sticky=W, padx=20, pady=5) + + # button to stop data logging + self.stop_logging_button = Button(self.top_buttons_frame, text="Stop Logging", command=self.stop_logging, + pady=5, padx=5, font=SMALL_BUTTON_FONT) + self.stop_logging_button.grid(row=0, column=0, padx=5) + # button to start data logging + self.start_logging_button = Button(self.top_buttons_frame, text="Start Logging", command=self.start_logging, + pady=5, padx=5, font=SMALL_BUTTON_FONT) + self.start_logging_button.grid(row=0, column=0, padx=5) # same place as stop logging button, replace each other + # button to write log data to a file + self.write_to_file_button = Button(self.top_buttons_frame, text="Write data to file", font=SMALL_BUTTON_FONT, + command=self.write_to_file, pady=5, padx=5, state="disabled") + self.write_to_file_button.grid(row=0, column=1, padx=5) + # button to clear all logged data + self.clear_data_button = Button(self.top_buttons_frame, text="Clear logged data", font=SMALL_BUTTON_FONT, + command=self.clear_data, pady=5, padx=5, state="disabled") + self.clear_data_button.grid(row=0, column=2, padx=5) + + row_counter += 1 + + # Create label showing how many datapoints have been logged + # setup frame: + self.log_label_frame = Frame(self) + self.log_label_frame.grid_rowconfigure(ALL, weight=1) + self.log_label_frame.grid_columnconfigure(ALL, weight=1) + self.log_label_frame.grid(row=row_counter, column=0, sticky=W, padx=20) + + self.logged_datapoints = IntVar() # create variable to display number of logged datapoints + # Add description label: + datapoints_description = Label(self.log_label_frame, text="Datapoints logged:") + datapoints_description.grid(row=0, column=0, sticky=W) + # Add updatable label to show how much data has been logged + datapoints_label = Label(self.log_label_frame, textvariable=self.logged_datapoints) + datapoints_label.grid(row=0, column=1, sticky=W) + + row_counter += 1 + + # create checkboxes and entries to set when and how often data should be logged + # setup frame: + self.settings_frame = Frame(self) + self.settings_frame.grid_rowconfigure(ALL, weight=1) + self.settings_frame.grid_columnconfigure(ALL, weight=1) + self.settings_frame.grid(row=row_counter, column=0, sticky=W, padx=20) + + # create checkbox variable for logging in regular time intervals + self.regular_logging_var = BooleanVar(value=True) + # create checkbox variable for logging whenever test stand is commanded + self.event_logging_var = BooleanVar(value=True) + self.log_interval = DoubleVar(value=1) # create variable for logging interval entry field + + # create checkboxes for regular and event logging: + self.regular_logging_checkbox = Checkbutton(self.settings_frame, text="Log in regular intervals", + variable=self.regular_logging_var, onvalue=True, offvalue=False) + self.event_logging_checkbox = Checkbutton(self.settings_frame, text="Log whenever test stand is commanded", + variable=self.event_logging_var, onvalue=True, offvalue=False) + self.regular_logging_checkbox.grid(row=0, column=0, sticky=W) + self.event_logging_checkbox.grid(row=1, column=0, sticky=W, columnspan=3) + + # Set up entry field for setting logging interval + # Add description label for logging interval entry: + interval_label = Label(self.settings_frame, text=" Interval (s):") + interval_label.grid(row=0, column=1, sticky=W) + # Add entry field to set interval + self.interval_entry = Entry(self.settings_frame, textvariable=self.log_interval) + self.interval_entry.grid(row=0, column=2, sticky=W) + + row_counter += 1 + + # Create checkboxes to select what data to log + # setup frame: + self.checkbox_frame = Frame(self) + self.checkbox_frame.grid_rowconfigure(ALL, weight=1) + self.checkbox_frame.grid_columnconfigure(ALL, weight=1) + self.checkbox_frame.grid(row=row_counter, column=0, sticky=W, padx=10, pady=10) + + self.checkbox_vars = {} # dictionary containing the bool variables changed by the checkboxes + self.checkboxes = [] # list containing all the checkbox objects, used to lock/unlock all of them + self.active_keys = [] # list with all keys of the currently ticked checkboxes + + # add general description headline: + checkbox_label = Label(self.checkbox_frame, text="Select which data to log:") + checkbox_label.grid(row=0, column=0, columnspan=2) + # generate and place all checkboxes: + row = 1 + for key in log.axis_data_dict.keys(): # go through all loggable values + self.checkbox_vars[key] = BooleanVar(value=True) # create variable for checkbox and put it in dictionary + checkbox = Checkbutton(self.checkbox_frame, text=key, # generate checkbox + variable=self.checkbox_vars[key], onvalue=True, offvalue=False) + checkbox.grid(row=row, column=0, sticky=W) # place checkbox in UI + self.checkboxes.append(checkbox) # add created checkbox to list of all checkboxes + row += 1 + + def page_switch(self): # function that is called when switching to this window + # every class in the UI needs this, even if it doesn't do anything + pass + + def start_logging(self): # start logging data (called by button) + ui_print("Started data logging.") + self.update_choices() # update list with ticked checkboxes + self.regular_logging = self.regular_logging_var.get() # check if regular logging checkbox is ticked + self.event_logging = self.event_logging_var.get() # check if event logging checkbox is ticked + self.update_datapoint_count() # start regular update of label showing how many datapoints have been collected + + if self.logged_datapoints.get() == 0: # no data has been logged so far + # (if condition is here to keep timestamps consistent when repeatedly starting/stopping) + log.zero_time = datetime.now() # set reference time for timestamps in log + + # get settings for regular logging: + error = False # initialize variable to store if an error occurred + if self.regular_logging: # regular logging checkbox is ticked + try: # try to get log interval from entry field + interval_ms = int(self.log_interval.get() * 1000) # get value and convert to ms + except TclError as e: # invalid entry for log interval + # show error pop-up: + messagebox.showwarning("Wrong entry format!", "Invalid entry for log interval:\n%s" % e) + self.event_logging = False # don't start event logging if there is a problem + error = True # save that an error was encountered + else: # no problems while reading out the log interval + self.periodic_log(interval_ms) # start periodic logging + + if (self.regular_logging or self.event_logging) and not error: # logging is active and no error during setup + # lock/unlock buttons and checkboxes: + self.write_to_file_button["state"] = "disabled" + self.clear_data_button["state"] = "normal" + self.lock_checkboxes() + self.stop_logging_button.tkraise() # switch button to "stop" + + def stop_logging(self): # stop the data logging, called by "Stop Logging" button + ui_print("Stopped data logging. Remember to save data to file!") + self.regular_logging = False # tell everything its time to stop periodic logging + self.event_logging = False # tell everything its time to stop logging on test stand commands + self.write_to_file_button["state"] = "normal" # enable write to file button + self.unlock_checkboxes() # enable checkboxes + self.start_logging_button.tkraise() # switch start/stop button to "start" + + def write_to_file(self): # lets user select a file and writes logged data to it + filepath = log.select_file() # select a file to write to + if filepath is None: # no valid file was selected + # ask user if he wants to try again: + try_again = messagebox.askquestion("No file selected", "No valid file was selected. Try again?") + if try_again == 'yes': # user wants to try again + self.write_to_file() # call same function again so user can retry + else: # a valid filename was selected + log.write_to_file(log.log_data, filepath) # write logged data to the file + + def clear_data(self): # called on button press, asks user if he want to save logged data and then deletes it + if log.unsaved_data: # there is logged data that has not been written to a file 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 before deleting?") + if save_log == 'yes': # user has chosen yes + self.write_to_file() # run write to file function to save data + log.clear_logged_data() # delete the logged data + log.unsaved_data = False # tell everything that there is no unsaved data remaining + self.logged_datapoints.set(len(log.log_data)) # update the label showing how much data has been logged + ui_print("Log data cleared.") + + def update_choices(self): # updates the list storing which checkboxes are currently ticked + # (this is passed to logging functions and determines which data is logged) + + self.active_keys = [] # initialize the list + for key in self.checkbox_vars.keys(): # go through all checkboxes + if self.checkbox_vars[key].get(): # box is ticked + self.active_keys.append(key) # add corresponding item to the list + + def lock_checkboxes(self): # lock all checkboxes, so they can not be modified while logging + # lock all checkboxes: + for checkbox in [*self.checkboxes, self.event_logging_checkbox, self.regular_logging_checkbox]: + checkbox.config(state=DISABLED) + self.interval_entry.config(state=DISABLED) # lock logging interval entry field + + def unlock_checkboxes(self): # opposite of lock checkboxes + for checkbox in [*self.checkboxes, self.event_logging_checkbox, self.regular_logging_checkbox]: + checkbox.config(state=NORMAL) + self.interval_entry.config(state=NORMAL) + + def periodic_log(self, interval): # logs data in regular intervals (ms) + if self.regular_logging: # logging in intervals is still active + self.log_datapoint() # add a datapoint to the log data frame + self.controller.after(interval, lambda: self.periodic_log(interval)) # call same function again after time + + def log_datapoint(self): # log a single datapoint based on which checkboxes are ticked try: - func.set_field_simple(vector*1e-6) # ToDo: change to set_field - except ValueError as e: - print(e) + log.log_datapoint(self.active_keys) # add datapoint with active checkboxes to log data frame + except Exception as e: # some error occurred + messagebox.showerror("Error!", "Error while logging data: \n%s" % e) - @staticmethod - def execute_current(vector): - print("current executing:", vector) - try: - func.set_current_vec(vector) - except ValueError as e: - print(e) + def update_datapoint_count(self): # update label with how many datapoints have been logged + if self.regular_logging or self.event_logging: # logging is still active + self.logged_datapoints.set(len(log.log_data)) # update label with number of rows in log_data + self.controller.after(1000, self.update_datapoint_count) # call function again after 1 second class StatusDisplay(Frame): + # status display to show information on test stand status in real time + # noinspection PyUnusedLocal def __init__(self, parent, controller): Frame.__init__(self, parent, relief=SUNKEN, bd=1) + # configure Tkinter grid self.grid_rowconfigure(ALL, weight=1) self.grid_columnconfigure(ALL, weight=1) rowCounter = 0 # keep track of which row we are at in the grid layout x_pad = 10 # centrally set padding + # create column headers (X-Axis etc.) col = 0 - for header in ["", "X-Axis", "Y-Axis", "Z-Axis"]: # create Column headers + for header in ["", "X-Axis", "Y-Axis", "Z-Axis"]: # define Column headers + # create label: headLabel = Label(self, text=header, font=SUB_HEADER_FONT, borderwidth=1, relief="flat", anchor="w", padx=x_pad) headLabel.grid(row=rowCounter, column=col, sticky="ew") col = col + 1 # move to next column - rowCounter = rowCounter + 1 # increase row counter to place future stuff below header - # define content of row entries + rowCounter += 1 # increase row counter to place future stuff below header + + # define content of row entries: + # ToDo (optional): Use the central dictionary, currently defined in csv_logging.py TextLabels = ["PSU Serial Port:", "PSU Channel:", "PSU Status:", "Arduino Status:", "", "Output:", "Remote Control:", "Voltage Setpoint:", "Actual Voltage:", "Current Setpoint:", "Actual Current:", "", @@ -203,36 +1034,41 @@ class StatusDisplay(Frame): # prepare list of lists to contain all labels for row entries in all columns: self.Labels = [[] for _ in range(self.columnNo)] - self.label_dict = {} - for name in TextLabels: - self.label_dict[name] = [StringVar() for _ in range(self.columnNo - 1)] - # add labels for row titles + # create dictionary to associate (changing) label variables with their labels: + self.label_dict = {} # initialize dictionary + for name in TextLabels: # go through all rows + self.label_dict[name] = [StringVar() for _ in range(self.columnNo - 1)] # create variables for labels + # add static labels for row titles: self.Labels[0].append(Label(self, text=name, borderwidth=1, relief="flat", anchor="w", padx=x_pad)) - for col in range(self.columnNo - 1): # add labels vor values + for col in range(self.columnNo - 1): # go through columns + # add changeable labels for values: self.Labels[col + 1].append(Label(self, textvariable=self.label_dict[name][col], borderwidth=1, relief="flat", anchor="w", padx=x_pad)) + # place all labels in grid layout col = 0 - for LabelCol in self.Labels: # place row entries in grid layout for all columns - for row in range(self.rowNo): # place row entries - LabelCol[row].grid(row=row + rowCounter, column=col, sticky="nsew") - col = col + 1 - # rowCounter = rowCounter + self.rowNo # increase row counter to place future stuff below this + for LabelCol in self.Labels: # go through table columns + for row in range(self.rowNo): # go through table rows + LabelCol[row].grid(row=row + rowCounter, column=col, sticky="nsew") # place label + col += 1 - # add button for reinitialization - reinit_button = Button(self, text="Reinitialize", command=func.setup_axes, - pady=5, padx=5, font=BIG_BUTTON_FONT) - reinit_button.grid(row=0, column=self.columnNo+1, rowspan=3) + self.update_labels() # fill in all values - self.update_labels(controller) + def continuous_label_update(self, controller, interval): # update display values in regular intervals (ms) + if not g.exitFlag: # application ist still running + self.update_labels() # update the label values + # call function again after time interval: + controller.after(interval, lambda: self.continuous_label_update(controller, interval)) - def update_labels(self, controller): - # ToDo: do this with a dictionary + def update_labels(self): # update all values in the status display + g.ARDUINO.update_status_info() # get latest status info from arduino i = 0 - for axis in g.AXES: - if axis.device is not None: - axis.update_status_info() - self.label_dict["PSU Serial Port:"][i].set(g.ports[i]) + for axis in g.AXES: # go through all three axes + if axis.device is not None: # there is a PSU for this axis connected + axis.update_status_info() # get latest status info from PSU + # update all label variables with current values: + # ToDo (optional): Use the central dictionary currently defined in csv_logging.py for this + self.label_dict["PSU Serial Port:"][i].set(g.PORTS[i]) self.label_dict["PSU Channel:"][i].set(axis.channel) self.label_dict["PSU Status:"][i].set(axis.connected) self.label_dict["Arduino Status:"][i].set(g.ARDUINO.connected) # ToDo (optional): make this multicolumn @@ -246,5 +1082,38 @@ class StatusDisplay(Frame): self.label_dict["Trgt. Field Raw:"][i].set("%0.3f \u03BCT" % (axis.target_field_comp * 1e6)) self.label_dict["Target Current:"][i].set("%0.3f A" % axis.target_current) self.label_dict["Inverted:"][i].set(axis.polarity_switched) - i = i + 1 - controller.after(500, lambda: self.update_labels(controller)) + i += 1 + + +class OutputConsole(Frame): + # console to print information to user in, similar to standard python output + + def __init__(self, parent): + Frame.__init__(self, parent, relief=SUNKEN, bd=1) + + # configure Tkinter grid: + self.grid_rowconfigure(ALL, weight=1) + self.grid_columnconfigure(0, weight=1, minsize=60) # console needs to have a minimum width + + scrollbar = Scrollbar(self) # setup scrollbar + self.console = Text(self) # setup main console widget + self.console.bind("", lambda e: "break") # prevent user from writing into the console + + scrollbar.grid(row=0, column=1, sticky="ns") # place the scrollbar and stretch vertically + self.console.grid(row=0, column=0, sticky="nesw") # place the output console + # link scrollbar to the console + scrollbar.config(command=self.console.yview) + self.console.config(yscrollcommand=scrollbar.set) + + +def ui_print(*content): # prints text to built-in console, use exactly like normal print() + output = "" # initialize output as empty string + for text in content: # go through all elements to be printed + output = " ".join((output, str(text))) # add element to the output string + + if not g.exitFlag: # application is still running --> output window is visible + output = "".join(("\n", output)) # begin new line each time + g.app.OutputConsole.console.insert(END, output) # print to console + g.app.OutputConsole.console.see(END) # scroll console to bottom + else: # if window is not open, do normal print + print(output) diff --git a/cage_func.py b/cage_func.py index d246ccb..75dd911 100644 --- a/cage_func.py +++ b/cage_func.py @@ -1,34 +1,48 @@ -from pyps2000b import PS2000B -from Arduino import Arduino -import settings as g -import pandas -import time +# This file contains all classes and functions directly related to the operation of the helmholtz test stand. +# 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 stand + # 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 - self.device = device # power supply object (PS2000B class) - self.channel = PSU_channel # power supply unit channel (1 or 2) + self.index = index # index of this axis, 0->X, 1->Y, 2->Z + self.device = device # power supply object for this axis (PS2000B class) + 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] - self.port = g.ports[index] + 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 - self.resistance = 0 # [Ohm] - # maximum allowable values to pass through this circuit - self.max_watts = 0 # [W] - self.max_amps = 0 # [A] - self.max_volts = 0 # [V] + # 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 = 0 # coil constant of this axis [T/A] - self.ambient_field = 0 # ambient field in this axis [T] - # ToDo: get this info from settings file + 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)) - # dynamic information + 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? @@ -46,265 +60,362 @@ class Axis: if self.device is not None: self.update_status_info() - def update_status_info(self): # Read out the values of the parameters stored in this class and update them - try: - self.device.update_device_information(self.channel) - device_status = self.device.get_device_status_information(self.channel) - if device_status.output_active: + 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" + 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" + 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): - # print("Connection Error with %s PSU on %s" % (self.name, self.port)) + + 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: - self.connected = "Connected" + else: # no communications error + self.connected = "Connected" # PSU is connected - if g.ARDUINO.connected == "Connected": - try: - if g.ARDUINO.digitalRead(self.ardPin): # ToDo: Test if this actually works - self.polarity_switched = "True" - else: self.polarity_switched = "False" - except Exception: - print("Error with Arduino") - self.polarity_switched = "Unknown" - g.ARDUINO.connected = "Connection Error" - else: - g.ARDUINO.connected = "Connected" - - def print_status(self): # axis = axis control variable, stored in settings.py - 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 print_status(self): # print out the current status of the device (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 - self.target_current = 0 - self.target_field = 0 - self.target_field_comp = 0 - self.device.set_voltage(0, self.channel) - self.device.set_current(0, self.channel) - self.device.disable_output(self.channel) - g.ARDUINO.digitalWrite(self.ardPin, "LOW") + 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 - def set_signed_current(self, value): # sets current with correct polarity on this axis - device = self.device - channel = self.channel - ardPin = self.ardPin - # print("Attempting to set current", value, "A") - self.target_current = value - if self.connected == "Connected": - if abs(value) > self.max_amps: # prevent excessive currents - self.power_down() # set output to 0 and deactivate - raise ValueError("Invalid current value. Tried %0.2fA, max. %0.2fA allowed" % (value, self.max_amps)) - elif value >= 0: # switch polarity as needed - pass # g.ARDUINO.digitalWrite(ardPin, "LOW") ToDo: reactivate and tie to arduino - elif value < 0: - pass # g.ARDUINO.digitalWrite(ardPin, "HIGH") ToDo: reactivate - else: - raise Exception("This should be impossible.") - maxVoltage = min(max(1.1 * self.max_amps * self.resistance, 8), self.max_volts) # limit voltage# - # print("sending values to device: U =", maxVoltage, "I =", abs(value)) - device.set_current(abs(value), channel) - device.set_voltage(maxVoltage, channel) - device.enable_output(channel) - else: - print(self.name, "not connected, can't set current.") + 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 stand + + # 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 - self.target_field_comp = value - current = value / self.coil_constant - self.set_signed_current(current) + 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 stand def set_field(self, value): # forms magnetic field as specified by value, corrected for ambient field - self.target_field = value - field = value - self.ambient_field - self.target_field_comp = field - current = field / self.coil_constant - self.set_signed_current(current) + 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 stand class ArduinoCtrl(Arduino): + # main class to control the electronics box (which means commanding the arduino inside) + # inherits from the Arduino library - def __init__(self, pins): - self.connected = "Unknown" - self.pins = pins - print("Connecting to Arduino...") - try: - Arduino.__init__(self) # search for connected arduino and connect + 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: - g.ARDUINO.pinMode(pin, "Output") - g.ARDUINO.digitalWrite(pin, "LOW") - except Exception: - print("Connection to Arduino failed.") + 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: - g.arduino_connected = "Connected" - print("Arduino ready.") + else: # connection was successfully established + self.connected = "Connected" + ui_print("Arduino ready.") - def safe(self): # sets output pins to low and closes serial connection + 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: - g.ARDUINO.digitalWrite(pin, "LOW") + self.digitalWrite(pin, "LOW") -def setup_axes(): # creates device objects for all PSUs and sets their values - g.AXES = [] +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 - print("Connecting to XY Device on %s..." % g.XY_PORT) - try: - if g.XY_DEVICE is not None: - print("closing serial connection on XY device") + 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 stand 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 + # 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("\nConnecting 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 - print("Connection established.") - g.X_AXIS = Axis(0, g.XY_DEVICE, 0, g.ARDUINO.pins[0]) # create axis objects + 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: - g.X_AXIS = Axis(0, None, 0, g.ARDUINO.pins[0]) # create axis objects + 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]) - print("XY Device not connected or incorrect port set.") + ui_print("XY Device not connected or incorrect port set.") - print("Connecting to Z Device on %s..." % g.XY_PORT) + # 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) - print("Connection established.") + 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]) - print("Z Device not connected or incorrect port set.") + 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) - i = 0 - for axis in g.AXES: # ToDo: move to axis init - axis.resistance = g.RESISTANCES[i] - axis.max_watts = g.MAX_WATTS[i] - axis.max_amps = np.sqrt(axis.max_watts / axis.resistance) - print(axis.name, "max Current:", axis.max_amps) - axis.max_volts = g.MAX_VOLTS[i] - - axis.coil_constant = g.COIL_CONST[i] - axis.ambient_field = g.AMBIENT_FIELD[i] - i = i+1 + ui_print("") # print new line -def activate_all(): # enables remote control and output on all PSUs and channels - g.XY_DEVICE.enable_all() - g.Z_DEVICE.enable_all() - - -def deactivate_all(): # disables remote control and output on all PSUs and channels - # ToDo: add check if device is connected - try: - g.XY_DEVICE.disable_all() - except BaseException: - print("XY PSU deactivation unsuccessful.") - else: - print("XY PSU deactivated.") - try: - g.Z_DEVICE.disable_all() - except BaseException: - print("Z PSU deactivation unsuccessful.") - else: - print("Z PSU deactivated.") - - -def print_status_3(): - print("X-Axis:") - g.X_AXIS.print_status() - print("Y-Axis:") - g.Y_AXIS.print_status() - print("Z-Axis:") - g.Z_AXIS.print_status() - - -def set_to_zero(device): # sets voltages and currents to 0 +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(): # temporary, set all outputs to 0 but keep connections enabled - set_to_zero(g.XY_DEVICE) - set_to_zero(g.Z_DEVICE) - g.ARDUINO.safe() +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(): # shutdown at program end or on error, set outputs to 0 and disable connections - # ToDo: better messages, check if things are connected first - print("\nAttempting to safely shut down all devices. Check equipment to confirm.") - try: set_to_zero(g.XY_DEVICE) - except: - print("XY PSU set to 0 unsuccessful.") +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: - print("XY PSU currents and voltages set to 0.") - try: set_to_zero(g.Z_DEVICE) - except: - print("Z PSU set to 0 unsuccessful.") - else: - print("Z PSU currents and voltages set to 0.") - deactivate_all() - try: g.ARDUINO.safe() - except: - print("Arduino safing unsuccessful.") - # else: # commented out bc this throws no exception, even when arduino is not connected - # print("Arduino pins set to LOW.") # ToDo: figure out error handling for this - try: g.ARDUINO.close() - except: - print("Closing Arduino connection failed.") - else: - print("Serial connection to Arduino closed.") + 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]: - g.AXES[i].set_field_simple(vector[i]) + 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]: - g.AXES[i].set_field(vector[i]) + try: + g.AXES[i].set_field(vector[i]) + except ValueError as e: + ui_print(e) -def set_current_vec(vector): # sets needed currents on each axis for given vector +def set_current_vec(vector): # sets currents on each axis according to given vector i = 0 for axis in g.AXES: - axis.set_signed_current(vector[i]) - i = i + 1 + 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 stand to set the current + except ValueError as e: # current was too high + ui_print(e) # print out the error message + i += 1 -def execute_csv(filepath, printing=0): # runs through csv file containing times and desired field vectors - # csv format: time (s); xField (T); yField (T); zField (T) - # decimal commas - print("Reading File:", filepath) - file = pandas.read_csv(filepath, sep=';', decimal=',', header=0) # read csv file - array = file.to_numpy() # convert csv to array - t_zero = time.time() - t_ref = t_zero - i = 0 - print("Starting Execution...") - while i < len(array): - t = time.time() - t_zero - if t >= array[i, 0]: - field_vec = array[i, 1:4] - print("t = %0.2f s, target field vector = " % (array[i, 0]), field_vec) - set_field(field_vec) - i = i + 1 - if t - t_ref >= 1 and printing == 1: # print status every second - print_status_3() - t_ref = t - print("File executed, powering down channels.") - power_down_all() # set currents and voltages to 0, set arduino pins to low +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 diff --git a/config_handling.py b/config_handling.py new file mode 100644 index 0000000..66ef1fd --- /dev/null +++ b/config_handling.py @@ -0,0 +1,131 @@ +# This file contains functions and variables related to reading and writing configuration files. +# The configparser module is used for processing. Config files are of type .ini + +# import packages: +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 + +global CONFIG_FILE # string storing the path of the used config file +global CONFIG_OBJECT # object of type ConfigParser(), storing all configuration information +# CONFIG_OBJECT is what is mostly read/written by the program +# CONFIG_FILE is only used to export/import to/from a file + + +def get_config_from_file(file): # read a config file to a config_object + config_object = ConfigParser() # initialize config parser + config_object.read(file) # open config file + return config_object # return config object, that contains all info from the file + + +def write_config_to_file(config_object): # write contents of config object to a config file + with open(CONFIG_FILE, 'w') as conf: # Write changes to config file + config_object.write(conf) + + +def read_from_config(section, key, config_object): # read a specific value from a config object + try: + section_obj = config_object[section] # get relevant section + 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) + raise KeyError("Could not find key", key, "in config file.") + + +def edit_config(section, key, value, override=False): # edit a specific value in the CONFIG_OBJECT + # section: Section of the config, e.g. "X-Axis" or "PORTS" + # key: name of the value in the section, e.g. max_amps + # value: new value to be written into the config + # override: Bool to allow user to force writing a value into the config, even if it exceeds the safe limit + + global CONFIG_OBJECT # get the global config object to edit it + + # ToDo (optional): add check for data types, e.g. int for arduino ports + # Check if value to write is within acceptable limits (set in dictionary in globals.py): + 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 + + 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 + message = "Prevented writing too high value for {s} {k} to config file:\n" \ + "{v}, max. {mv} allowed. Erroneous values may damage equipment!" \ + .format(s=section, k=key, v=value, mv=max_value) + raise ValueError(message) # return an error with the message attached + + elif value_ok == 'LOW': # value is too low + min_value = g.default_arrays[key][2][g.AXIS_NAMES.index(section)] # get min value for message printing + message = "Prevented writing too low value for {s} {k} to config file:\n" \ + "{v}, max. {mv} allowed. Erroneous values may damage equipment!" \ + .format(s=section, k=key, v=value, mv=min_value) + raise ValueError(message) # return an error with the message attached + + if value_ok == 'OK' or override: # value is within limits or user has overridden the checks + 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.") + 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.") + 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) + 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 + for axis in g.AXIS_NAMES: # go through all 3 axes + concerns[axis] = [] # create dictionary entry for this axis + for key in g.default_arrays.keys(): # go over entries in this axis + value = float(read_from_config(axis, key, config_object)) # read value to check from config file + max_value = g.default_arrays[key][1][i] # get max value + min_value = g.default_arrays[key][2][i] # get min value + + 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: + 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 + + +def reset_config_to_default(): # reset values in config object to defaults (set in globals.py) + config = ConfigParser() # reinitialize empty config object + global CONFIG_OBJECT # get the global config object + CONFIG_OBJECT = config # reset it to the empty object + + i = 0 + for axis_name in g.AXIS_NAMES: # go through axes + config.add_section(axis_name) # add section for this axis + for key in g.default_arrays.keys(): # go through dictionary with default values + 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 diff --git a/csv_logging.py b/csv_logging.py new file mode 100644 index 0000000..bca8962 --- /dev/null +++ b/csv_logging.py @@ -0,0 +1,99 @@ +# 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 +import globals as g +from datetime import datetime +import os +from tkinter import filedialog +from tkinter import messagebox + +# import other project files +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 axis.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 not 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 diff --git a/csv_threading.py b/csv_threading.py new file mode 100644 index 0000000..6ec1fc8 --- /dev/null +++ b/csv_threading.py @@ -0,0 +1,166 @@ +# 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 +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("Starting 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 stand 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 stand + 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 stand 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 stand 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 stand 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 stand." + "\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... + # ToDo (optional): show graphs as steps (as performed by test stand) + 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 + + t = array[:, 0] # extract time column + for i in [0, 1, 2]: # go through all three axes + data = 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 diff --git a/globals.py b/globals.py new file mode 100644 index 0000000..281add2 --- /dev/null +++ b/globals.py @@ -0,0 +1,49 @@ +# This file is used to hold global variables that are used by more than one file of the program. +# Instead of always passing all variables to functions, this file can simply be imported to get them. + +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) + +# Axis objects will be stored here (class Axis) +X_AXIS = None +Y_AXIS = None +Z_AXIS = None + +AXES = None # list containing [X_AXIS, Y_AXIS, Z_AXIS] + +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 + +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) + +global PORTS # list containing [XY_PORT, XY_PORT, Z_PORT], used in loops where info on each axis is needed + +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 + +# Create dictionaries with default Constants and maximum/minimum values +# Used to create default configs and to check if user inputs are within safe limits +# ToDo: check actual maximum ratings (or refine after testing) +# ToDo: put this into a config file + +# Dictionary for numerical values: +# format: key: [default values], [maximum values], [minimum values] +default_arrays = { + "coil_const": np.array([[38.6, 38.45, 37.9], [50, 50, 50], [0, 0, 0]]) * 1e-6, # Coil constants [x,y,z] [T/A] + "ambient_field": np.array([[30, 30, 30], [200, 200, 200], [-200, -200, -200]]) * 1e-6, # ambient magnetic field [T] + "resistance": np.array([[1.7, 1.7, 1.7], [5, 5, 5], [1, 1, 1]], dtype=float), # resistance of circuits [Ohm] + "max_volts": np.array([[14, 14, 14], [16, 16, 16], [0, 0, 0]], dtype=float), # max. voltage, limited to 16V by used diodes! [V] + "max_amps": np.array([[4.5, 4.5, 4.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 +} diff --git a/main.py b/main.py index f75a627..3428d40 100644 --- a/main.py +++ b/main.py @@ -1,28 +1,78 @@ -import User_Interface as ui -import cage_func as func +# Main file of the program. Run this file to start the application. + +# import packages: +from os.path import exists import traceback -import settings as g +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 + + +def program_end(): # called on exception or when user closes application + # safely shuts everything down and saves any unsaved data + + 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 - # Connect to Arduino: - g.ARDUINO = func.ArduinoCtrl(g.RELAY_PINS) - print("Connecting to PSUs...") - func.setup_axes() # initiate communication, set handles + config.CONFIG_FILE = 'config.ini' # set the config file path + # ToDo: remember what the last config file was + 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 + config.write_config_to_file(config.CONFIG_OBJECT) # write the configuration object to a new file + + 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 print("\nOpening User Interface...") - '''g.TestValuesX = ui.TestValues() - g.TestValuesY = ui.TestValues() - #g.TestValuesZ = ui.TestValues() - g.TestValues = [g.TestValuesX, g.TestValuesY]#, g.TestValuesZ]''' - application = ui.HelmholtzGUI() - application.mainloop() + 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 -except BaseException as e: # if there is an error, print what happened + 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 + + g.app.protocol("WM_DELETE_WINDOW", program_end) # call program_end function if user closes the application + + g.app.mainloop() # start main program loop + + +except Exception as e: # An error has occurred somewhere in the program print("\nAn error occurred, Shutting down.") - print(e) - print(traceback.print_exc()) - -finally: # safely shut everything down at the end - func.shut_down_all() + # 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 diff --git a/main_old.py b/main_old.py deleted file mode 100644 index 1f5f244..0000000 --- a/main_old.py +++ /dev/null @@ -1,36 +0,0 @@ -import numpy as np -import settings as g -import cage_func as func -# from pyps2000b import PS2000B -from Arduino import Arduino - -# User Inputs/Configuration---------------------------------- -# Desired output: -mag_vec1 = np.array([10, 10, 5])*1e-6 - -# Constants: -g.COIL_CONST = np.array([38.6, 38.45, 37.9]) * 1e-9 # Coil constants [x,y,z] in T/A -g.AMBIENT_FIELD = np.array([80]) * 1e-6 # ambient magnetic field in measurement area, to be cancelled out -g.RESISTANCES = np.array([3.9, 1, 1]) # resistance of [x,y,z] circuits -g.MAX_WATTS = np.array([8, 0, 0]) # max. allowed power for [x,y,z] circuits -g.MAX_VOLTS = 16 # max. allowed voltage, limited to 16V by used diodes! - -# COM-Ports for power supply units: -g.XY_PORT = "COM1" # placeholders -g.Z_PORT = "COM2" - -# Code starts here------------------------------------------ -g.MAX_AMPS = np.sqrt(g.MAX_WATTS / g.RESISTANCES) # calculate maximum currents in each axis - -print("Connecting to PSUs...") -func.setup_axes() # initiate communication, set handles -print("Connecting to Arduino...") -g.ARDUINO = Arduino() # search for connected arduino and set handle -print("Arduino found, configuring pins.") -func.setup_arduino() -print("Activating PSU outputs...") -func.activate_all() # activate remote control and outputs on PSUs - -func.set_field_simple(mag_vec1) - -func.deactivate_all() diff --git a/LICENSE b/pyps2000b/LICENSE similarity index 100% rename from LICENSE rename to pyps2000b/LICENSE diff --git a/pyps2000b/PS2000B.py b/pyps2000b/PS2000B.py index be9d496..a1a3d7a 100644 --- a/pyps2000b/PS2000B.py +++ b/pyps2000b/PS2000B.py @@ -1,3 +1,5 @@ +# This file enables communication with PS2000B Power Supply Units. + #!/usr/bin/env python3 # coding=utf-8 # Python access to Elektro Automatik PS 2000 B devices via USB/serial diff --git a/requirements.txt b/requirements.txt index 6409c7c..088bdfa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ numpy==1.19.3 # bug in numpy 1.19.4, 1.19.3 used as workaround pyserial~=3.5 future~=0.18.2 -pandas \ No newline at end of file +pandas~=1.1.5 +matplotlib~=3.3.2 \ No newline at end of file diff --git a/settings.py b/settings.py deleted file mode 100644 index 4ef4052..0000000 --- a/settings.py +++ /dev/null @@ -1,35 +0,0 @@ -import numpy as np - -XY_DEVICE = None -Z_DEVICE = None - -X_AXIS = None # object structure: (device, channel, arduino pin, axis index) -Y_AXIS = None -Z_AXIS = None - -AXES = None # list containing [X_AXIS, Y_AXIS, Z_AXIS] - -# Constants: -COIL_CONST = np.array([38.6, 38.45, 37.9]) * 1e-6 # Coil constants [x,y,z] in T/A -AMBIENT_FIELD = np.array([80, 80, 80]) * 1e-6 # ambient magnetic field in measurement area, to be cancelled out -RESISTANCES = np.array([4.5, 8, 1]) # resistance of [x,y,z] circuits -MAX_WATTS = np.array([8, 25, 0]) # max. allowed power for [x,y,z] circuits -MAX_VOLTS = [16, 16, 16] # max. allowed voltage, limited to 16V by used diodes! - -# COM-Ports for power supply units: -XY_PORT = "COM7" # placeholders -Z_PORT = "COM11" - -AXIS_NAMES = ["X-Axis", "Y-Axis", "Z-Axis"] -ports = [XY_PORT, XY_PORT, Z_PORT] - -global ARDUINO - -RELAY_PINS = [15, 16, 17] # pin on the Arduino for switching relay of each axis [x,y,z] - -# ToDo: make proper settings file to read from and write to - -global TestValuesX -global TestValuesY -global TestValuesZ -global TestValues diff --git a/example.py b/zz old test files etc/example.py similarity index 100% rename from example.py rename to zz old test files etc/example.py diff --git a/example2.py b/zz old test files etc/example2.py similarity index 100% rename from example2.py rename to zz old test files etc/example2.py