forked from zietzm/Helmholtz_Test_Bench
Introduced red status indicator line, minor warning fixes
This commit is contained in:
+50
-7
@@ -9,6 +9,7 @@ import numpy as np
|
||||
from threading import *
|
||||
from tkinter import messagebox
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||
|
||||
from src.exceptions import DeviceBusy, DeviceAccessError
|
||||
from src.utility import ui_print
|
||||
@@ -79,6 +80,9 @@ class ExecCSVThread(Thread):
|
||||
messagebox.showwarning("Device Error!", "Required devices are not present, sequence aborted.")
|
||||
return
|
||||
|
||||
# Initialize plot
|
||||
figure, avx_lines = display_plot(parent)
|
||||
|
||||
i = 0 # index of the current array row
|
||||
while i < len(array):
|
||||
if self.stopped or g.exit_flag:
|
||||
@@ -88,9 +92,17 @@ class ExecCSVThread(Thread):
|
||||
return
|
||||
|
||||
# while array is not finished, devices are connected, user has not cancelled and application is running
|
||||
|
||||
t = time.time() - t_zero # get time relative to start of run
|
||||
target_t = array[i, 0] # Target execution time of data point
|
||||
|
||||
# Update figure
|
||||
try:
|
||||
for j in range(4):
|
||||
avx_lines[j].set_data([t, t], [0, 1])
|
||||
parent.plot_canvas.draw() # equivalent to matplotlib.show()
|
||||
except DeviceAccessError as e:
|
||||
ui_print("Failed to update figure: ", e)
|
||||
|
||||
if t >= target_t: # time for this row has come
|
||||
field_vec = array[i, 1:4] # extract desired field vector
|
||||
ui_print("[{:5.3f}s] B=[{:.1f}, {:.1f}, {:.1f}]\u03BCT for t={:.2f}s".format(t,
|
||||
@@ -106,7 +118,6 @@ class ExecCSVThread(Thread):
|
||||
logger = controller.pages[ui.ConfigureLogging] # get object of logging configurator
|
||||
if logger.event_logging: # data should be logged when test bench is commanded
|
||||
logger.log_datapoint() # log data
|
||||
|
||||
i = i + 1 # next row
|
||||
|
||||
elif t <= target_t - delay - 0.02: # is there enough time to sleep before the next row?
|
||||
@@ -117,7 +128,7 @@ class ExecCSVThread(Thread):
|
||||
|
||||
def read_csv_to_array(filepath): # convert a given csv file to a numpy array
|
||||
# csv format: time [s], xField [T], yField [T], zField [T] (german excel)
|
||||
# decimal or period commas. Do not use these characters as a thousands separator!
|
||||
# decimal or period commas. Do not use these characters as a thousand separator!
|
||||
with open(filepath, 'r') as csv_file:
|
||||
# Normalize separators
|
||||
csv_string = csv_file.read()
|
||||
@@ -153,6 +164,38 @@ def check_array_ok(array):
|
||||
messagebox.showwarning("Value Limits Warning!", warning_msg)
|
||||
|
||||
|
||||
def display_plot(parent): # create plot of fixed size (pixels) from array
|
||||
# calculate available height for plot (in pixels):
|
||||
height_others = 0 # initialize variable to calculate height of other widgets
|
||||
for element in parent.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 = parent.parent.winfo_height() - height_others - 50 # height of parent frame - other widgets - margin
|
||||
width = min(parent.parent.winfo_width() - 100, 1100) # set width to available space but max. 1100
|
||||
|
||||
# Create plot
|
||||
figure = plot_field_sequence(parent.sequence_array, width, height) # create figure to be displayed
|
||||
# Clear previous plots first
|
||||
try:
|
||||
if parent.plot_canvas is not None:
|
||||
parent.plot_canvas.get_tk_widget().destroy()
|
||||
except Exception as e:
|
||||
ui_print("Something went wrong while plotting csv data!", e)
|
||||
messagebox.showerror("Error!", "Something went wrong while plotting csv data: \n%s" % e)
|
||||
pass
|
||||
|
||||
axes = figure.axes
|
||||
avx_lines = [axes[0].axvline(x=0, color="r"), axes[1].axvline(x=0, color="r"),
|
||||
axes[2].axvline(x=0, color="r"), axes[3].axvline(x=0, color="r")]
|
||||
# Show new plot
|
||||
parent.plot_canvas = FigureCanvasTkAgg(figure, parent.plot_frame) # create canvas to draw figure on
|
||||
parent.plot_canvas.draw() # equivalent to matplotlib.show()
|
||||
parent.plot_canvas.get_tk_widget().grid(row=0, column=0, sticky="nesw") # place canvas in the UI
|
||||
|
||||
return figure, avx_lines
|
||||
|
||||
|
||||
def plot_field_sequence(array, width, height): # create plot of fixed size (pixels) from array
|
||||
# ToDo (optional): polar plots, plots of angle...
|
||||
fig_dpi = 100 # set figure resolution (dots per inch)
|
||||
@@ -160,12 +203,12 @@ def plot_field_sequence(array, width, height): # create plot of fixed size (pix
|
||||
figure = plt.Figure(figsize=(width*px, height*px), dpi=fig_dpi) # create figure with correct size
|
||||
|
||||
# noinspection PyTypeChecker,SpellCheckingInspection
|
||||
axes = figure.subplots(4, sharex=True, sharey=False, gridspec_kw={'hspace': 0.4}) # create subplots with shared axes
|
||||
axes = figure.subplots(4, sharex=True, sharey=False, gridspec_kw={'hspace': 0.4}) # make subplots with shared axes
|
||||
|
||||
figure.suptitle("Magnetic Field Sequence") # set figure title
|
||||
|
||||
# modify data to show instantaneous jumps in field to reflect test bench operation
|
||||
new_array = np.array([[0, 0, 0, 0, 0]], dtype=float) # initialize modified array, zeros to show start from no fields
|
||||
new_array = np.array([[0, 0, 0, 0, 0]], dtype=float) # initialize modified array, zeros to show start from no field
|
||||
|
||||
last_values = [0, 0, 0, 0] # [x,y,z, rr] field values / rot rate from last data point (zero here)
|
||||
for row in array[:, 0:5]: # go through each row in the original array
|
||||
@@ -200,8 +243,8 @@ def plot_field_sequence(array, width, height): # create plot of fixed size (pix
|
||||
ylim_mag = ([min(axes[0].get_ylim()), max(axes[0].get_ylim()),
|
||||
min(axes[1].get_ylim()), max(axes[1].get_ylim()),
|
||||
min(axes[2].get_ylim()), max(axes[2].get_ylim())])
|
||||
for i in range(3): axes[0].set_ylim(min(ylim_mag),max(ylim_mag))
|
||||
|
||||
for i in range(3):
|
||||
axes[0].set_ylim(min(ylim_mag), max(ylim_mag))
|
||||
|
||||
# set shared axis labels:
|
||||
axes[2].set_xlabel("Time [s])")
|
||||
|
||||
+34
-45
@@ -20,7 +20,6 @@ import os.path
|
||||
from datetime import datetime
|
||||
from math import pi
|
||||
import warnings
|
||||
warnings.filterwarnings("error")
|
||||
|
||||
# import other project files:
|
||||
import src.globals as g
|
||||
@@ -33,6 +32,9 @@ from src.exceptions import DeviceAccessError, MagFieldOutOfBounds
|
||||
from src.utility import ui_print, save_dict_list_to_csv, save_dict_list_to_csv2, load_dict_list_from_csv
|
||||
import src.helmholtz_cage_device as helmholtz_cage_device
|
||||
|
||||
# Filter warning messages
|
||||
warnings.filterwarnings("error")
|
||||
|
||||
# Define global font styles:
|
||||
screen_height_limit1 = 1100 # Limit after which font size gets reduced
|
||||
screen_height_limit2 = 900 # Limit after which font size gets reduced
|
||||
@@ -311,11 +313,11 @@ class ManualMode(Frame):
|
||||
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))
|
||||
messagebox.showerror("Unexpected Value! Unexpected value encountered: compensate = ", str(compensate))
|
||||
var.set("(%0.1f to %0.1f \u03BCT)" % (field[0], field[1])) # update the label text with the new values
|
||||
|
||||
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
|
||||
self.compensate_checkbox.config(state=DISABLED) # disable the compensation of the ambient field checkbox
|
||||
|
||||
# update the labels showing the min/max achievable values
|
||||
for i, var in enumerate(self.max_value_vars): # go through the max value labels for each axis
|
||||
@@ -374,8 +376,8 @@ class ManualMode(Frame):
|
||||
pass
|
||||
else: # this really should never happen
|
||||
ui_print("Unexpected value encountered: compensate =", compensate)
|
||||
messagebox.showerror("Unexpected Value!",
|
||||
("Unexpected value encountered: compensate =", compensate))
|
||||
messagebox.showerror("Unexpected Value! Unexpected value encountered: compensate = ",
|
||||
str(compensate))
|
||||
except helmholtz_cage_device.DeviceBusy:
|
||||
ui_print("Error: Could not acquire control. Is the HW already in use?")
|
||||
|
||||
@@ -522,7 +524,7 @@ class ExecuteCSVMode(Frame):
|
||||
self.generate_load_csv_button.grid(row=0, column=1, padx=5, pady=5)
|
||||
row_counter += 1
|
||||
info_text = Label(self,
|
||||
text="Generate data of a circular, counter clockwise motion around an arbitrary axis and strength.",
|
||||
text="Generate data of a circular, counter clockwise motion around an arbitrary axis.",
|
||||
padx=100, pady=3)
|
||||
info_text.grid(row=row_counter, column=col_counter, padx=0, sticky=W)
|
||||
row_counter += 1
|
||||
@@ -692,7 +694,6 @@ class ExecuteCSVMode(Frame):
|
||||
self.plot_canvas = FigureCanvasTkAgg(figure, self.plot_frame) # create canvas to draw figure on
|
||||
self.plot_canvas.draw() # equivalent to matplotlib.show()
|
||||
self.plot_canvas.get_tk_widget().grid(row=0, column=0, sticky="nesw") # place canvas in the UI
|
||||
|
||||
|
||||
def export_csv_sequence(self): # Generate and export csv sequence
|
||||
# Generate rotation data
|
||||
@@ -701,7 +702,7 @@ class ExecuteCSVMode(Frame):
|
||||
rot_sequence = dict(enumerate(t))
|
||||
for i in range(len(t)):
|
||||
rot_sequence[i] = {'Time [s]]': t[i], 'xField [T]': x[i], 'yField [T]': y[i], 'zField [T]': z[i],
|
||||
'Rotation Rate [deg/s]': rr[i] }
|
||||
'Rotation Rate [deg/s]': rr[i]}
|
||||
# Save dictionary to disk
|
||||
save_dict_list_to_csv2('test_sequence_rotation.csv', rot_sequence, query_path=True)
|
||||
ui_print("Saved test sequence to disc.")
|
||||
@@ -798,11 +799,11 @@ class ExecuteCSVMode(Frame):
|
||||
self.rot_cycle_number_vars.set("{:d}".format(rot_cycle_number))
|
||||
# Variable rotation rate
|
||||
# Solve integral of r(t) = r_0 + r_1 * t + r_2 * t ^ 2 + r3 * sin(r_4 * t + r_5) from 0 to tau
|
||||
R = 360 * rot_cycle_number # [deg] Cycle number multiples of full circle
|
||||
angle = 360 * rot_cycle_number # [deg] Cycle number multiples of full circle
|
||||
func = lambda tau2: r[0] + r[1] * tau2 + r[2] * tau2 ** 2 + r[3] * (np.sin(r[4] * tau2 + r[5]))
|
||||
func_integral = lambda tau: R - (r[0] * tau + r[1] / 2 * tau ** 2 + r[2] / 3 * tau ** 3
|
||||
- r[3] * (np.cos(r[4] * tau + r[5]) + np.cos(r[5]))
|
||||
+ r[3] * r[4] * (np.sin(r[4] * tau + r[5]) - np.sin(r[5])))
|
||||
func_integral = lambda tau: angle - (r[0] * tau + r[1] / 2 * tau ** 2 + r[2] / 3 * tau ** 3
|
||||
- r[3] * (np.cos(r[4] * tau + r[5]) + np.cos(r[5]))
|
||||
+ r[3] * r[4] * (np.sin(r[4] * tau + r[5]) - np.sin(r[5])))
|
||||
tau_solution = -1
|
||||
i = 0 # iterator and guess variable [s]
|
||||
while tau_solution < 0 or abs(func_integral(tau_solution)) > 0.5:
|
||||
@@ -1129,7 +1130,7 @@ class CalibrateAmbientField(Frame):
|
||||
if cmd == 'finished':
|
||||
self.reactivate_buttons()
|
||||
elif cmd == 'failed':
|
||||
messagebox.showerror("Calibration error", "Error occured during calibration:\n{}".format(arg))
|
||||
messagebox.showerror("Calibration error", "Error occurred during calibration:\n{}".format(arg))
|
||||
self.reactivate_buttons()
|
||||
elif cmd == 'progress':
|
||||
self.calibration_procedure_progress_var.set(min(int(arg * 100), 100))
|
||||
@@ -1518,7 +1519,8 @@ class CalibrateMagnetometerSimple(Frame):
|
||||
# Notes on the calibration method
|
||||
calibration_method_notes_frame = LabelFrame(self.right_column, text="Calibration method notes:")
|
||||
calibration_method_notes_frame.grid(row=row_counter, column=1, padx=(100, 0), pady=20, sticky="nw")
|
||||
label = "-Implementation according to Zikmund et al. [DOI: 10.1109/I2MTC.2014.6860790]\n-Points created by Fibonacci sphere\n-Only accounts for hard-iron offset and MGM scaling errors!"
|
||||
label = "-Implementation according to Zikmund et al. [DOI: 10.1109/I2MTC.2014.6860790]\n" \
|
||||
"-Points created by Fibonacci sphere\n-Only accounts for hard-iron offset and MGM scaling errors!"
|
||||
calibration_method_notes = Label(calibration_method_notes_frame, anchor='w', justify='left', text=label)
|
||||
calibration_method_notes.grid(row=3, column=0, padx=5, pady=5, sticky="nw")
|
||||
|
||||
@@ -1624,7 +1626,7 @@ class CalibrateMagnetometerSimple(Frame):
|
||||
if cmd == 'finished':
|
||||
self.reactivate_buttons()
|
||||
elif cmd == 'failed':
|
||||
messagebox.showerror("Calibration error", "Error occured during calibration:\n{}".format(arg))
|
||||
messagebox.showerror("Calibration error", "Error occurred during calibration:\n{}".format(arg))
|
||||
self.reactivate_buttons()
|
||||
elif cmd == 'progress':
|
||||
self.calibration_procedure_progress_var.set(min(int(arg * 100), 100))
|
||||
@@ -1686,7 +1688,7 @@ class CalibrateMagnetometerSimple(Frame):
|
||||
try:
|
||||
calibration_points = self.calibration_points_var.get()
|
||||
calibration_interval = self.calibration_interval_var.get()
|
||||
compensated_field = self.self.compensated_field_var.get()
|
||||
compensated_field = self.compensated_field_var.get()
|
||||
calibration_mag_field = self.mag_field_magnitude_var.get() * 1e-6 # converted to micro Tesla
|
||||
if calibration_mag_field <= 0 or calibration_mag_field > g.MAG_MAG_FIELD:
|
||||
raise MagFieldOutOfBounds
|
||||
@@ -1918,7 +1920,7 @@ class CalibrateMagnetometerComplete(Frame):
|
||||
calibration_point_nr_unit = Label(controls_frame, text="s (> 2 s)")
|
||||
calibration_point_nr_unit.grid(row=2, column=2, pady=5, sticky="nw")
|
||||
# Oversampling
|
||||
calibration_point_nr_label = Label(controls_frame, text="Oversampling (samples/setpoint)")
|
||||
calibration_point_nr_label = Label(controls_frame, text="Oversampling (samples/set point)")
|
||||
calibration_point_nr_label.grid(row=3, column=0, pady=5, sticky="nw")
|
||||
calibration_point_nr_entry = Entry(controls_frame, textvariable=self.calibration_oversampling_var)
|
||||
calibration_point_nr_entry.grid(row=3, column=1, pady=5, sticky="nw")
|
||||
@@ -2040,19 +2042,7 @@ class CalibrateMagnetometerComplete(Frame):
|
||||
results_label_unit = Label(calibration_results_frame, text="-")
|
||||
results_label_unit.grid(row=row_counter + row, column=1 + 3, padx=5, pady=5, sticky="nw")
|
||||
row_counter += 3
|
||||
"""
|
||||
# A_mat
|
||||
results_label_a_mat_inv = Label(calibration_results_frame, text="A =")
|
||||
results_label_a_mat_inv.grid(row=row_counter+1, column=0, padx=5, pady=5, sticky="nw")
|
||||
for row in range(3):
|
||||
for column in range(3):
|
||||
axis_data = Entry(calibration_results_frame,
|
||||
textvariable=self.a_mat_result_vars[row][column],
|
||||
width=15,
|
||||
state='readonly')
|
||||
axis_data.grid(row=row_counter+row, column=1 + column, padx=5, pady=5, sticky="nw")
|
||||
row_counter += 3
|
||||
"""
|
||||
|
||||
# b
|
||||
results_label_a_mat_inv = Label(calibration_results_frame, text="b^T =")
|
||||
results_label_a_mat_inv.grid(row=row_counter, column=0, padx=5, pady=5, sticky="nw")
|
||||
@@ -2090,11 +2080,15 @@ class CalibrateMagnetometerComplete(Frame):
|
||||
# Notes on the calibration method
|
||||
calibration_method_notes_frame = LabelFrame(self.right_column, text="Calibration method notes:")
|
||||
calibration_method_notes_frame.grid(row=0, column=0, padx=(100, 0), pady=20, sticky="nw")
|
||||
label = "-Implementation of calibration according to Kok et al. [ISBN: 978-0-9824438-5-9]\n-Implementation of ellipsoid fit according to Li et al. [DOI: 10.1109/GMAP.2004.1290055]\n-Points created by Fibonacci sphere\n-Accounts for soft-iron (A matrix) and hard-iron (b offset vector) effects!\n-Measured to calibrated field function: h=A^-1 (h_m-b)"
|
||||
label = "-Implementation of calibration according to Kok et al. [ISBN: 978-0-9824438-5-9]\n" \
|
||||
"-Implementation of ellipsoid fit according to Li et al. [DOI: 10.1109/GMAP.2004.1290055]\n" \
|
||||
"-Points created by Fibonacci sphere\n" \
|
||||
"-Accounts for soft-iron (A matrix) and hard-iron (b offset vector) effects!\n" \
|
||||
"-Measured to calibrated field function: h=A^-1 (h_m-b)"
|
||||
calibration_method_notes = Label(calibration_method_notes_frame, anchor='w', justify='left', text=label)
|
||||
calibration_method_notes.grid(row=0, column=0, padx=5, pady=5, sticky="nw")
|
||||
|
||||
# Plot frame (overwrite plotframe)
|
||||
# Plot frame
|
||||
plot_frame = LabelFrame(self.right_column, text="Result plots:")
|
||||
plot_frame.grid(row=1, column=0, padx=(100, 0), pady=20, sticky="nw")
|
||||
fig1 = plt.figure('MGM_cal_complete_left', figsize=(2.5, 3), dpi=100)
|
||||
@@ -2152,7 +2146,7 @@ class CalibrateMagnetometerComplete(Frame):
|
||||
if cmd == 'finished':
|
||||
self.reactivate_buttons()
|
||||
elif cmd == 'failed':
|
||||
messagebox.showerror("Calibration error", "Error occured during calibration:\n{}".format(arg))
|
||||
messagebox.showerror("Calibration error", "Error occurred during calibration:\n{}".format(arg))
|
||||
self.reactivate_buttons()
|
||||
elif cmd == 'progress':
|
||||
self.calibration_procedure_progress_var.set(min(int(arg * 100), 100))
|
||||
@@ -2411,7 +2405,7 @@ class HardwareConfiguration(Frame):
|
||||
self.file_select_frame.grid_columnconfigure(ALL, weight=1)
|
||||
self.file_select_frame.grid(row=row_counter, column=0, sticky=W, padx=20)
|
||||
|
||||
# Create and place buttons
|
||||
# Create and place buttons in frame
|
||||
# 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)
|
||||
@@ -2451,10 +2445,7 @@ class HardwareConfiguration(Frame):
|
||||
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
|
||||
row_counter += 2
|
||||
|
||||
# setup main entry field for operational constants
|
||||
# setup frame:
|
||||
@@ -2506,7 +2497,6 @@ class HardwareConfiguration(Frame):
|
||||
|
||||
self.update_fields() # set current values from config file
|
||||
|
||||
# Label(self, text="", pady=3).grid(row=row_counter, column=0) # add spacer
|
||||
row_counter += 1
|
||||
|
||||
# Setup buttons to implement and restore defaults
|
||||
@@ -2516,7 +2506,7 @@ class HardwareConfiguration(Frame):
|
||||
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
|
||||
# Create and place buttons in frame
|
||||
# button to read the values from all fields, update the config and reinitialize the test bench
|
||||
implement_button = Button(self.buttons_frame, text="Update and Reinitialize", command=self.implement,
|
||||
pady=5, padx=5, font=BIG_BUTTON_FONT)
|
||||
@@ -2527,7 +2517,6 @@ class HardwareConfiguration(Frame):
|
||||
restore_button.grid(row=0, column=1, padx=5)
|
||||
|
||||
row_counter += 1
|
||||
# Label(self, text="", pady=3).grid(row=row_counter, column=0) # add spacer
|
||||
|
||||
def page_switch(self): # function that is called when switching to this window
|
||||
self.update_fields() # update values in the entry fields from config
|
||||
@@ -2649,7 +2638,7 @@ class HardwareConfiguration(Frame):
|
||||
# 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*")])
|
||||
defaultextension="*.ini*")
|
||||
|
||||
if filename == '': # this happens when file selection window is closed without selecting a file
|
||||
ui_print("No file selected, could not save config.")
|
||||
@@ -2835,8 +2824,8 @@ class ConfigureLogging(Frame):
|
||||
|
||||
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 bench commands
|
||||
self.regular_logging = False # tell everything it is time to stop periodic logging
|
||||
self.event_logging = False # tell everything it is time to stop logging on test bench commands
|
||||
self.write_to_file_button["state"] = "normal" # enable write to file button
|
||||
self.stop_logging_button["state"] = "disabled" # disable stop logging button
|
||||
self.start_logging_button["state"] = "normal" # enable start logging button
|
||||
@@ -2852,7 +2841,7 @@ class ConfigureLogging(Frame):
|
||||
else: # a valid filename was selected
|
||||
log.write_to_file(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
|
||||
def clear_data(self): # called on button press, asks user if logged data should be saved 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 or cancel clearing it:
|
||||
save_log = messagebox.askyesnocancel("Save log data?", "There seems to be unsaved logging data. "
|
||||
|
||||
Reference in New Issue
Block a user