# -*- coding: utf-8 -*- import os import time import csv import threading from datetime import datetime import tkinter as tk from tkinter import ttk, messagebox import pysmu import numpy as np import matplotlib matplotlib.use('TkAgg') from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure from collections import deque class DeviceDisconnectedError(Exception): pass class BatteryTester: def __init__(self, root): # Color scheme self.bg_color = "#2E3440" self.fg_color = "#D8DEE9" self.accent_color = "#5E81AC" self.warning_color = "#BF616A" self.success_color = "#A3BE8C" # Main window configuration self.root = root self.root.title("ADALM1000 - Battery Capacity Tester (CC Test)") self.root.geometry("1000x800") self.root.minsize(800, 700) self.root.configure(bg=self.bg_color) # Device and measurement state self.session_active = False self.measuring = False self.test_running = False self.interval = 0.1 # Measurement interval self.log_dir = os.path.expanduser("~/adalm1000/logs") os.makedirs(self.log_dir, exist_ok=True) # Battery test parameters self.capacity = tk.DoubleVar(value=0.2) # Battery capacity in Ah self.charge_cutoff = tk.DoubleVar(value=1.45) # Charge cutoff voltage self.discharge_cutoff = tk.DoubleVar(value=0.9) # Discharge cutoff voltage self.rest_time = tk.DoubleVar(value=0.1) # Rest time in hours self.c_rate = tk.DoubleVar(value=0.1) # C-rate for test (default C/5 = 0.2) # Test progress tracking self.test_phase = tk.StringVar(value="Idle") self.capacity_ah = tk.DoubleVar(value=0.0) self.charge_capacity = tk.DoubleVar(value=0.0) # Added for charge capacity tracking self.coulomb_efficiency = tk.DoubleVar(value=0.0) # Added for efficiency calculation # Data buffers self.time_data = deque() self.voltage_data = deque() self.current_data = deque() self.phase_data = deque() # Initialize UI and device self.setup_ui() self.init_device() # Ensure proper cleanup self.root.protocol("WM_DELETE_WINDOW", self.on_close) def setup_ui(self): """Configure the user interface""" self.style = ttk.Style() self.style.theme_use('clam') # Configure styles self.style.configure('.', background=self.bg_color, foreground=self.fg_color) self.style.configure('TFrame', background=self.bg_color) self.style.configure('TLabel', background=self.bg_color, foreground=self.fg_color) self.style.configure('TButton', background=self.accent_color, foreground=self.fg_color, padding=6, font=('Helvetica', 10, 'bold')) self.style.map('TButton', background=[('active', self.accent_color), ('disabled', '#4C566A')], foreground=[('active', self.fg_color), ('disabled', '#D8DEE9')]) self.style.configure('TEntry', fieldbackground="#3B4252", foreground=self.fg_color) self.style.configure('Header.TLabel', font=('Helvetica', 14, 'bold'), foreground=self.accent_color) self.style.configure('Value.TLabel', font=('Helvetica', 12, 'bold')) self.style.configure('Status.TLabel', font=('Helvetica', 10)) self.style.configure('Warning.TButton', background=self.warning_color) self.style.configure('Success.TButton', background=self.success_color) # Main layout self.content_frame = ttk.Frame(self.root) self.content_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Header area header_frame = ttk.Frame(self.content_frame) header_frame.pack(fill=tk.X, pady=(0, 20)) ttk.Label(header_frame, text="ADALM1000 Battery Capacity Tester (CC Test)", style='Header.TLabel').pack(side=tk.LEFT) # Status indicator self.status_light = tk.Canvas(header_frame, width=20, height=20, bg=self.bg_color, bd=0, highlightthickness=0) self.status_light.pack(side=tk.RIGHT, padx=10) self.status_indicator = self.status_light.create_oval(2, 2, 18, 18, fill='red') self.connection_label = ttk.Label(header_frame, text="Disconnected") self.connection_label.pack(side=tk.RIGHT) # Reconnect button self.reconnect_btn = ttk.Button(header_frame, text="Reconnect", command=self.reconnect_device) self.reconnect_btn.pack(side=tk.RIGHT, padx=10) # Measurement display display_frame = ttk.LabelFrame(self.content_frame, text=" Live Measurements ", padding=15) display_frame.pack(fill=tk.BOTH, expand=False) # Measurement values measurement_labels = [ ("Voltage (V)", "V"), ("Current (A)", "A"), ("Test Phase", ""), ("Elapsed Time", "s"), ("Discharge Capacity", "Ah"), ("Charge Capacity", "Ah"), ("Coulomb Eff.", "%"), ] for i, (label, unit) in enumerate(measurement_labels): ttk.Label(display_frame, text=f"{label}:", font=('Helvetica', 11)).grid(row=i//2, column=(i%2)*2, sticky=tk.W, pady=5) value_label = ttk.Label(display_frame, text="0.000", style='Value.TLabel') value_label.grid(row=i//2, column=(i%2)*2+1, sticky=tk.W, padx=10) if unit: ttk.Label(display_frame, text=unit).grid(row=i//2, column=(i%2)*2+2, sticky=tk.W) if i == 0: self.voltage_label = value_label elif i == 1: self.current_label = value_label elif i == 2: self.phase_label = value_label elif i == 3: self.time_label = value_label elif i == 4: self.capacity_label = value_label elif i == 5: self.charge_capacity_label = value_label elif i == 6: self.efficiency_label = value_label # Control area controls_frame = ttk.Frame(self.content_frame) controls_frame.pack(fill=tk.X, pady=(10, 10), padx=0) # Parameters frame params_frame = ttk.LabelFrame(controls_frame, text="Test Parameters", padding=10) params_frame.pack(side=tk.LEFT, fill=tk.X, expand=True) # Battery capacity ttk.Label(params_frame, text="Battery Capacity (Ah):").grid(row=0, column=0, sticky=tk.W) ttk.Entry(params_frame, textvariable=self.capacity, width=6).grid(row=0, column=1, padx=5, sticky=tk.W) # Charge cutoff ttk.Label(params_frame, text="Charge Cutoff (V):").grid(row=1, column=0, sticky=tk.W) ttk.Entry(params_frame, textvariable=self.charge_cutoff, width=6).grid(row=1, column=1, padx=5, sticky=tk.W) # Discharge cutoff ttk.Label(params_frame, text="Discharge Cutoff (V):").grid(row=2, column=0, sticky=tk.W) ttk.Entry(params_frame, textvariable=self.discharge_cutoff, width=6).grid(row=2, column=1, padx=5, sticky=tk.W) # Rest time ttk.Label(params_frame, text="Rest Time (hours):").grid(row=3, column=0, sticky=tk.W) ttk.Entry(params_frame, textvariable=self.rest_time, width=6).grid(row=3, column=1, padx=5, sticky=tk.W) # C-rate for test (C/5 by default) ttk.Label(params_frame, text="Test C-rate:").grid(row=0, column=2, sticky=tk.W, padx=(10,0)) ttk.Entry(params_frame, textvariable=self.c_rate, width=4).grid(row=0, column=3, padx=5, sticky=tk.W) ttk.Label(params_frame, text="(e.g., 0.2 for C/5)").grid(row=0, column=4, sticky=tk.W) # Start/Stop buttons button_frame = ttk.Frame(controls_frame) button_frame.pack(side=tk.RIGHT, padx=10) self.start_button = ttk.Button(button_frame, text="START TEST", command=self.start_test, style='TButton') self.start_button.pack(side=tk.TOP, pady=5) self.stop_button = ttk.Button(button_frame, text="STOP TEST", command=self.stop_test, style='Warning.TButton', state=tk.DISABLED) self.stop_button.pack(side=tk.TOP, pady=5) # Plot area self.plot_frame = ttk.Frame(self.content_frame) self.plot_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=(0, 5)) self.setup_plot() # Status bar self.status_var = tk.StringVar() self.status_var.set("Ready") self.status_label = ttk.Label(self.root, textvariable=self.status_var, style='Status.TLabel', padding=(0, 5), anchor=tk.W) self.status_label.place(x=20, relx=0, rely=1.0, anchor='sw', relwidth=0.96, height=28) def setup_plot(self): """Configure the matplotlib plot""" self.fig = Figure(figsize=(8, 5), dpi=100, facecolor='#2E3440') self.fig.subplots_adjust(left=0.1, right=0.9, top=0.9, bottom=0.15) self.ax = self.fig.add_subplot(111) self.ax.set_facecolor('#3B4252') # Voltage plot self.line_voltage, = self.ax.plot([], [], color='#00BFFF', label='Voltage (V)', linewidth=2) self.ax.set_ylabel("Voltage (V)", color='#00BFFF') self.ax.tick_params(axis='y', labelcolor='#00BFFF') # Current plot (right axis) self.ax2 = self.ax.twinx() self.line_current, = self.ax2.plot([], [], 'r-', label='Current (A)', linewidth=2) self.ax2.set_ylabel("Current (A)", color='r') self.ax2.tick_params(axis='y', labelcolor='r') self.ax.set_xlabel('Time (s)', color=self.fg_color) self.ax.set_title('Battery Test (CC)', color=self.fg_color) self.ax.tick_params(axis='x', colors=self.fg_color) self.ax.grid(True, color='#4C566A') self.ax.legend(loc='upper left') self.ax2.legend(loc='upper right') # Embed plot self.canvas = FigureCanvasTkAgg(self.fig, master=self.plot_frame) self.canvas.draw() canvas_widget = self.canvas.get_tk_widget() canvas_widget.configure(bg='#2E3440', bd=0, highlightthickness=0) canvas_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=True, pady=(10, 0)) def init_device(self): """Initialize the ADALM1000 device with continuous measurement""" try: # First try to clean up any existing session if hasattr(self, 'session'): try: self.session.end() del self.session except: pass # Add small delay to allow device to reset time.sleep(1) self.session = pysmu.Session(ignore_dataflow=True, queue_size=10000) if not self.session.devices: raise Exception("No ADALM1000 detected - check connections") self.dev = self.session.devices[0] # Reset channels self.dev.channels['A'].mode = pysmu.Mode.HI_Z self.dev.channels['B'].mode = pysmu.Mode.HI_Z self.dev.channels['A'].constant(0) self.dev.channels['B'].constant(0) self.session.start(0) self.status_light.itemconfig(self.status_indicator, fill='green') self.connection_label.config(text="Connected") self.status_var.set("Device connected | Ready to measure") self.session_active = True self.start_button.config(state=tk.NORMAL) # Start continuous measurement thread self.measurement_event = threading.Event() self.measurement_event.set() self.measurement_thread = threading.Thread( target=self.continuous_measurement, daemon=True ) self.measurement_thread.start() except Exception as e: self.handle_device_error(e) def continuous_measurement(self): """Continuous measurement with moving average filtering""" filter_window_size = 10 voltage_window = [] current_window = [] # Initialize start_time for measurements if not hasattr(self, 'start_time'): self.start_time = time.time() while self.measurement_event.is_set() and self.root.winfo_exists(): try: # Read multiple samples for better accuracy samples = self.dev.read(filter_window_size, 500, True) if not samples: raise DeviceDisconnectedError("No samples received") # Get voltage from Channel B (HI_Z mode) and current from Channel A raw_voltage = np.mean([s[1][0] for s in samples]) # Channel B voltage raw_current = np.mean([s[0][1] for s in samples]) # Channel A current current_time = time.time() - (self.start_time if hasattr(self, 'start_time') else time.time()) # Apply moving average filter voltage_window.append(raw_voltage) current_window.append(raw_current) if len(voltage_window) > filter_window_size: voltage_window.pop(0) current_window.pop(0) voltage = np.mean(voltage_window) current = np.mean(current_window) # Store filtered data self.time_data.append(current_time) self.voltage_data.append(voltage) self.current_data.append(current) # Update UI with filtered values if self.root.winfo_exists(): self.root.after(0, lambda: self.update_measurement_display(voltage, current, current_time)) # Save data if in active test if self.test_running and hasattr(self, 'filename'): with open(self.filename, 'a', newline='') as f: writer = csv.writer(f) writer.writerow([ f"{current_time:.3f}", f"{voltage:.6f}", f"{current:.6f}", self.test_phase.get(), f"{self.capacity_ah.get():.4f}", f"{self.charge_capacity.get():.4f}", f"{self.coulomb_efficiency.get():.1f}" ]) time.sleep(max(0.05, self.interval)) except Exception as e: error_msg = str(e) if self.root.winfo_exists(): self.root.after(0, lambda msg=error_msg: self.handle_device_error(f"Measurement error: {msg}") if self.root.winfo_exists() else None) break def start_test(self): """Start the full battery test cycle""" if not self.test_running: try: # Validate inputs if self.capacity.get() <= 0: raise ValueError("Battery capacity must be positive") if self.charge_cutoff.get() <= self.discharge_cutoff.get(): raise ValueError("Charge cutoff must be higher than discharge cutoff") if self.c_rate.get() <= 0: raise ValueError("C-rate must be positive") # Reset timing for new test self.measurement_start_time = time.time() self.test_start_time = time.time() # Calculate target current test_current = self.c_rate.get() * self.capacity.get() if test_current > 0.2: raise ValueError("Current must be ≤200mA (0.2A) for ADALM1000") # Clear previous data self.time_data.clear() self.voltage_data.clear() self.current_data.clear() self.phase_data.clear() self.capacity_ah.set(0.0) self.charge_capacity.set(0.0) self.coulomb_efficiency.set(0.0) # Setup new log file timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") self.filename = os.path.join(self.log_dir, f"battery_test_{timestamp}.csv") with open(self.filename, 'w', newline='') as f: writer = csv.writer(f) writer.writerow(["Time(s)", "Voltage(V)", "Current(A)", "Phase", "Discharge_Capacity(Ah)", "Charge_Capacity(Ah)", "Coulomb_Eff(%)"]) # Start test thread self.test_running = True self.start_time = time.time() self.last_update_time = time.time() self.test_phase.set("Initial Discharge") self.start_button.config(state=tk.DISABLED) self.stop_button.config(state=tk.NORMAL) self.status_var.set(f"Test started | Discharging to {self.discharge_cutoff.get()}V @ {test_current:.3f}A") # Start test sequence in a new thread self.test_thread = threading.Thread(target=self.run_test_sequence, daemon=True) self.test_thread.start() except Exception as e: messagebox.showerror("Error", str(e)) @staticmethod def format_time(seconds): """Convert seconds to hh:mm:ss format""" hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) seconds = int(seconds % 60) return f"{hours:02d}:{minutes:02d}:{seconds:02d}" def stop_test(self): """Stop the current test""" self.test_running = False self.measuring = False if hasattr(self, 'dev'): self.dev.channels['A'].constant(0) # Set zero current # Write final summary if we have a filename if hasattr(self, 'filename'): self.write_cycle_summary() self.start_button.config(state=tk.NORMAL) self.stop_button.config(state=tk.DISABLED) if hasattr(self, 'filename'): self.status_var.set(f"Test completed in {self.format_time(self.time_data[-1])} | Results saved to: {os.path.basename(self.filename)}") else: self.status_var.set("Test stopped | No data saved") def center_window(self, window): """Center a window on screen""" window.update_idletasks() width = window.winfo_width() height = window.winfo_height() x = (window.winfo_screenwidth() // 2) - (width // 2) y = (window.winfo_screenheight() // 2) - (height // 2) window.geometry(f'{width}x{height}+{x}+{y}') def run_test_sequence(self): """Run the complete test sequence (discharge-rest-charge-rest-discharge) once""" try: # Calculate target current test_current = self.c_rate.get() * self.capacity.get() # 1. Initial Discharge (to known state) #self.test_phase.set("Initial Discharge") #self.status_var.set(f"Discharging to {self.discharge_cutoff.get()}V @ {test_current:.3f}A") #self.measuring = True #self.dev.channels['A'].mode = pysmu.Mode.SIMV #self.dev.channels['A'].constant(-test_current) self.dev.channels['B'].mode = pysmu.Mode.HI_Z #while self.test_running: # if not self.voltage_data: # time.sleep(0.1) # continue # current_voltage = self.voltage_data[-1] # current_current = abs(self.current_data[-1]) # self.status_var.set( # f"Discharging: {current_voltage:.3f}V / {self.discharge_cutoff.get()}V | " # f"Current: {current_current:.3f}A | " # f"Time: {self.format_time(self.time_data[-1])}" # ) # if current_voltage <= self.discharge_cutoff.get(): # break # time.sleep(0.5) #if not self.test_running: # return # 2. Rest period after initial discharge #self.test_phase.set("Resting (Post-Discharge)") #self.measuring = False #self.dev.channels['A'].mode = pysmu.Mode.HI_Z #self.dev.channels['A'].constant(0) #rest_end_time = time.time() + (self.rest_time.get() * 3600) #while time.time() < rest_end_time and self.test_running: # time_left = max(0, rest_end_time - time.time()) # self.status_var.set( # f"Resting after discharge | " # f"Time left: {self.format_time(time_left)} | " # f"Next: Charging to {self.charge_cutoff.get()}V" # ) # time.sleep(1) #if not self.test_running: # return # 3. Charge (constant current) self.test_phase.set("Charge") self.status_var.set(f"Charging to {self.charge_cutoff.get()}V @ {test_current:.3f}A") self.measuring = True self.dev.channels['A'].mode = pysmu.Mode.SIMV self.dev.channels['A'].constant(test_current) self.charge_capacity.set(0.0) target_voltage = self.charge_cutoff.get() self.last_update_time = time.time() while self.test_running: if not self.voltage_data: time.sleep(0.1) continue current_voltage = self.voltage_data[-1] measured_current = abs(self.current_data[-1]) time_elapsed = time.time() - self.last_update_time # Update charge capacity self.charge_capacity.set(self.charge_capacity.get() + measured_current * time_elapsed / 3600) self.last_update_time = time.time() self.status_var.set( f"Charging: {current_voltage:.3f}V / {target_voltage}V | " f"Current: {measured_current:.3f}A | " f"Capacity: {self.charge_capacity.get():.4f}Ah | " f"Time: {self.time_data[-1]:.1f}s" ) if current_voltage >= target_voltage: break time.sleep(0.5) if not self.test_running: return # 4. Rest period after charge self.test_phase.set("Resting (Post-Charge)") self.measuring = False self.dev.channels['A'].mode = pysmu.Mode.HI_Z self.dev.channels['A'].constant(0) rest_end_time = time.time() + (self.rest_time.get() * 3600) while time.time() < rest_end_time and self.test_running: time_left = max(0, rest_end_time - time.time()) self.status_var.set( f"Resting after charge | " f"Time left: {time_left/60:.1f} min | " f"Next: Final discharge to {self.discharge_cutoff.get()}V" ) time.sleep(1) if not self.test_running: return # 5. Final Discharge (capacity measurement) self.test_phase.set("Final Discharge") self.status_var.set(f"Final discharge to {self.discharge_cutoff.get()}V @ {test_current:.3f}A") self.measuring = True self.dev.channels['A'].mode = pysmu.Mode.SIMV self.dev.channels['A'].constant(-test_current) self.capacity_ah.set(0.0) self.last_update_time = time.time() while self.test_running: if not self.current_data: time.sleep(0.1) continue current_voltage = self.voltage_data[-1] current_current = abs(self.current_data[-1]) # Calculate discharged capacity self.capacity_ah.set(self.capacity_ah.get() + current_current * (time.time() - self.last_update_time) / 3600) self.last_update_time = time.time() self.status_var.set( f"Discharging: {current_voltage:.3f}V / {self.discharge_cutoff.get()}V | " f"Current: {current_current:.3f}A | " f"Capacity: {self.capacity_ah.get():.4f}Ah | " f"Time: {self.time_data[-1]:.1f}s" ) # Check for discharge completion if current_voltage <= self.discharge_cutoff.get(): break time.sleep(0.5) # Calculate Coulomb efficiency if self.charge_capacity.get() > 0: efficiency = (self.capacity_ah.get() / self.charge_capacity.get()) * 100 self.coulomb_efficiency.set(efficiency) # Update GUI and show results self.test_phase.set("Test Complete") self.status_var.set( f"Test complete | " f"Discharge Capacity: {self.capacity_ah.get():.3f}Ah | " f"Charge Capacity: {self.charge_capacity.get():.3f}Ah | " f"Efficiency: {self.coulomb_efficiency.get():.1f}%" ) # Show summary dialog self.root.after(0, lambda: messagebox.showinfo("Test Complete", f"Test complete\n\n" f"Discharge Capacity: {self.capacity_ah.get():.3f}Ah\n" f"Charge Capacity: {self.charge_capacity.get():.3f}Ah\n" f"Coulomb Efficiency: {self.coulomb_efficiency.get():.1f}%\n\n" f"({self.capacity_ah.get()/self.capacity.get()*100:.1f}% of rated capacity)")) # Write final summary to log file self.write_cycle_summary() except Exception as e: error_msg = str(e) if self.root.winfo_exists(): self.root.after(0, lambda msg=error_msg: messagebox.showerror("Test Error", msg)) finally: # Automatically stop the test after completion self.root.after(0, self.stop_test) def update_measurement_display(self, voltage, current, current_time): """Update display with current measurements""" try: self.voltage_label.config(text=f"{voltage:.4f}") self.current_label.config(text=f"{current:.4f}") self.phase_label.config(text=self.test_phase.get()) self.time_label.config(text=self.format_time(current_time)) self.capacity_label.config(text=f"{self.capacity_ah.get():.4f}") self.charge_capacity_label.config(text=f"{self.charge_capacity.get():.4f}") self.efficiency_label.config(text=f"{self.coulomb_efficiency.get():.1f}") # Update plot with proper scaling self.update_plot() except Exception as e: print(f"GUI update error: {e}") def write_cycle_summary(self): """Write cycle summary to the log file""" if not hasattr(self, 'filename'): return summary_line = ( f"Discharge={self.capacity_ah.get():.4f}Ah, " f"Charge={self.charge_capacity.get():.4f}Ah, " f"Efficiency={self.coulomb_efficiency.get():.1f}%" ) with open(self.filename, 'a', newline='') as f: f.write(summary_line + "\n") def update_plot(self): """Update plot with proper scaling and limits""" if not self.time_data: return # Update plot data self.line_voltage.set_data(self.time_data, self.voltage_data) self.line_current.set_data(self.time_data, self.current_data) # Set x-axis to always show from 0 to current max time min_time = 0 # Always start from 0 max_time = self.time_data[-1] + 1 # Add 1 second padding self.ax.set_xlim(min_time, max_time) self.ax2.set_xlim(min_time, max_time) # Auto-scale y-axes with some margin if self.voltage_data: voltage_margin = 0.2 min_voltage = max(0, min(self.voltage_data) - voltage_margin) max_voltage = max(self.voltage_data) + voltage_margin self.ax.set_ylim(min_voltage, max_voltage) if self.current_data: current_margin = 0.05 min_current = min(self.current_data) - current_margin max_current = max(self.current_data) + current_margin self.ax2.set_ylim(min_current, max_current) self.canvas.draw() def handle_device_error(self, error): """Handle device connection errors""" if not self.root.winfo_exists(): # Check if window still exists return error_msg = str(error) print(f"Device error: {error_msg}") self.root.after_idle(lambda: self.status_light.itemconfig(self.status_indicator, fill='red')) self.connection_label.config(text="Disconnected") self.status_var.set(f"Device error: {error_msg}") self.session_active = False self.test_running = False self.measuring = False if hasattr(self, 'start_button'): self.start_button.config(state=tk.DISABLED) if hasattr(self, 'stop_button'): self.stop_button.config(state=tk.DISABLED) # Clear plot + buffers self.time_data.clear() self.voltage_data.clear() self.current_data.clear() if hasattr(self, 'line_voltage') and hasattr(self, 'line_current'): self.line_voltage.set_data([], []) self.line_current.set_data([], []) self.ax.set_xlim(0, 1) self.ax2.set_xlim(0, 1) self.canvas.draw() # Clean up session if hasattr(self, 'session'): try: if self.session_active: self.session.end() del self.session except: pass if self.root.winfo_exists(): # Double-check before showing message try: messagebox.showerror( "Device Connection Error", f"Could not connect to ADALM1000:\n\n{error_msg}\n\n" "1. Check USB cable connection\n" "2. Try the Reconnect button\n" "3. Restart the application if problem persists" ) except: pass # Ignore errors if window is being destroyed def reconnect_device(self): """Reconnect the device""" self.status_var.set("Attempting to reconnect...") self.test_running = False self.measuring = False if hasattr(self, 'measurement_event'): self.measurement_event.clear() # Wait for threads to finish if hasattr(self, 'measurement_thread'): self.measurement_thread.join(timeout=1.0) if hasattr(self, 'test_thread'): self.test_thread.join(timeout=1.0) # Reset before reinitializing self.handle_device_error("Reconnecting...") self.init_device() def on_close(self): """Clean up on window close""" if hasattr(self, 'measurement_event'): self.measurement_event.clear() if hasattr(self, 'measurement_thread'): self.measurement_thread.join(timeout=1.0) if hasattr(self, 'test_thread'): self.test_thread.join(timeout=1.0) if hasattr(self, 'session') and self.session: try: if self.session_active: self.session.end() except: pass self.root.destroy() if __name__ == "__main__": root = tk.Tk() try: app = BatteryTester(root) root.mainloop() except Exception as e: if root.winfo_exists(): messagebox.showerror("Fatal Error", f"Application failed: {str(e)}") else: print(f"Fatal Error: {e}") try: root.destroy() except: pass