From db81aff2db060e972015a14e6e1f1362f9913b63 Mon Sep 17 00:00:00 2001 From: Martin Zietz Date: Sun, 14 Feb 2021 15:14:48 +0100 Subject: [PATCH] added data logging functionality --- .gitignore | 1 + User_Interface.py | 91 +++++++++++++++++++++++++++++++++++++++++-- csv_logging.py | 99 +++++++++++++++++++++++++++++++++++++++++++++++ csv_threading.py | 3 +- main.py | 18 +++++---- 5 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 csv_logging.py diff --git a/.gitignore b/.gitignore index f9dee33..0d0cc32 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,4 @@ ENV/ .idea/misc.xml config.ini *.ini +log.csv diff --git a/User_Interface.py b/User_Interface.py index 5e7c224..5d20e27 100644 --- a/User_Interface.py +++ b/User_Interface.py @@ -9,11 +9,13 @@ import numpy as np import os from os.path import exists import threading +from datetime import datetime import globals as g import cage_func as func import csv_threading as csv import config_handling as config +import csv_logging as log NORM_FONT = () HEADER_FONT = ("Arial", 13, "bold") @@ -40,7 +42,7 @@ class HelmholtzGUI(Tk): self.pages = {} # dictionary for storing all pages - for P in [ManualMode, Configuration, ExecuteCSVMode]: + for P in [ManualMode, Configuration, ExecuteCSVMode, ConfigureLogging]: page = P(mainArea, self) self.pages[P] = page page.grid(row=0, column=0, sticky="nsew") @@ -73,6 +75,8 @@ class TopMenu: menu.add_cascade(label="Mode", menu=ModeSelector) 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 @@ -87,6 +91,10 @@ class TopMenu: def execute_csv_mode(window): window.show_frame(ExecuteCSVMode) + @staticmethod + def logging(window): + window.show_frame(ConfigureLogging) + class ManualMode(Frame): @@ -598,7 +606,7 @@ class ExecuteCSVMode(Frame): pass def load_csv(self): # load in csv file to be executed - directory = os.path.abspath(os.getcwd()) # get directory of current config file + directory = os.path.abspath(os.getcwd()) # get project directory # open file selection dialogue and save path of selected file filename = filedialog.askopenfilename(initialdir=directory, title="Select CSV File", filetypes=(("Comma Separated Values", "*.csv*"), ("All Files", "*.*"))) @@ -662,11 +670,86 @@ class ConfigureLogging(Frame): self.parent = parent 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.grid_rowconfigure(ALL, weight=1) self.grid_columnconfigure(ALL, weight=1) row_counter = 0 + # 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) + + 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) + 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) + 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) + + row_counter += 1 + + # Create checkboxes to select what data to log + 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.active_keys = [] # list with all the keys relating to the currently ticked checkboxes + + # generate and place all the checkboxes: + row = 0 + for key in log.axis_data_dict.keys(): + 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 + 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): + ui_print("Started data logging.") + self.update_choices() # update list with ticked checkboxes + self.regular_logging = True + log.zero_time = datetime.now() + self.periodic_log(1000) # ToDo: get interval from entry field + self.write_to_file_button["state"] = "disabled" + self.stop_logging_button.tkraise() # switch button to stop + + def stop_logging(self): + ui_print("Stopped data logging. Remember to save data to file!") + self.regular_logging = False + self.write_to_file_button["state"] = "normal" + self.start_logging_button.tkraise() # switch button to start + + @staticmethod + def write_to_file(): + filepath = log.select_file() # select a file to write to + log.write_to_file(log.log_data, filepath) # write logged data to the file + + def update_choices(self): + self.active_keys = [] + for key in self.checkbox_vars.keys(): + if self.checkbox_vars[key].get(): # box is ticked + self.active_keys.append(key) + + def periodic_log(self, interval): # logs data in regular intervals (ms) + if self.regular_logging: # logging in intervals is active + log.log_datapoint(self.active_keys) # add datapoint with active keys to log data frame + self.controller.after(interval, lambda: self.periodic_log(interval)) # call function again after interval + class StatusDisplay(Frame): @@ -716,7 +799,7 @@ class StatusDisplay(Frame): self.update_labels() - def continuous_label_update(self, controller, interval): # update display values in regular intervals + def continuous_label_update(self, controller, interval): # update display values in regular intervals (ms) self.update_labels() if g.app is not None: # app ist still running # ToDo (optional): prevent call after program close @@ -766,7 +849,7 @@ class OutputConsole(Frame): # console to print stuff in, similar to standard py def ui_print(*content): # prints text to built in console output = "" for text in content: - output = " ".join((output, str(text))) # append content + output = " ".join((output, str(text))) # merge all contents into one string if not g.exitFlag: output = "".join(("\n", output)) # begin new line each time g.app.OutputConsole.console.insert(END, output) # print to console diff --git a/csv_logging.py b/csv_logging.py new file mode 100644 index 0000000..00fed3d --- /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 pandas as pd +import globals as g +from datetime import datetime +import os +from tkinter import filedialog +from tkinter import messagebox +import User_Interface as ui + +log_data = pd.DataFrame() # pandas data frame containing logged data +logging = False # Bool to indicate if data should be logged at the moment +unsaved_data = False # Bool to indicate if there is unsaved data, set to True each time a datapoint is logged +zero_time = datetime.now() + +# 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 init_log_dataframe(key_list): # probably not needed, ToDo: remove + global log_data + columns = triple_list(key_list) + log_data = pd.DataFrame(columns=columns) + + +def log_datapoint(key_list): # ToDo: comments + global log_data + global unsaved_data + date = datetime.now().date() + time = datetime.now().strftime("%H:%M:%S,%f") + t = (datetime.now() - zero_time).total_seconds() + data = [[date, time, t]] + for key in key_list: + for axis in g.AXES: + data[0].append(getattr(axis, axis_data_dict[key])) # get value + column_names = ["Date", "Time", "t (s)", *triple_list(key_list)] + new_row = pd.DataFrame(data, columns=column_names) + log_data = log_data.append(new_row, ignore_index=True) + unsaved_data = True + + +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 # data has been saved, so no unsaved data remains + + +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. clear all logged data diff --git a/csv_threading.py b/csv_threading.py index ad80021..3721dcc 100644 --- a/csv_threading.py +++ b/csv_threading.py @@ -74,7 +74,8 @@ class ExecCSVThread(Thread): time.sleep(delay) # check again if everything is connected before starting next loop run: - all_connected = func.devices_ok(parent.xy_override.get(), parent.z_override.get(), parent.arduino_override.get()) + all_connected = func.devices_ok(parent.xy_override.get(), parent.z_override.get(), + parent.arduino_override.get()) if not self.stopped() and not g.exitFlag and all_connected: # sequence ended without interruption ui.ui_print("Sequence executed, powering down channels.") diff --git a/main.py b/main.py index 14f02e0..58dd69a 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,6 @@ from os.path import exists import traceback from tkinter import messagebox -import time import cage_func as func from User_Interface import HelmholtzGUI @@ -9,18 +8,23 @@ 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(): +def program_end(): # called on exception or when user closes application g.exitFlag = True # tell everything else the application has been closed if g.app is not None: if g.app.pages[ui.ExecuteCSVMode].csv_thread is not None: # end possible csv execution thread g.app.pages[ui.ExecuteCSVMode].csv_thread.stop() # stop thread - # g.app.pages[ui.ExecuteCSVMode].csv_thread.join() # wait for thread to finish - # ToDo: figure out why this doesn't work with join() - time.sleep(0.2) # give the thread time to finish, workaround to avoid join() - # g.app = None # reset to None so nothing tries to print in the UI output func.shut_down_all() # shut down devices + + if log.unsaved_data: # 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 @@ -51,7 +55,7 @@ try: # start normal operations ui_print("\nStarting setup...") # do it again, so it is printed in the UI console ToDo: do it only once func.setup_all() # initiate communication, set handles - g.app.protocol("WM_DELETE_WINDOW", program_end) # call program end function if user closes the application + g.app.protocol("WM_DELETE_WINDOW", program_end) # call program_end function if user closes the application g.app.mainloop()