# -*- coding: utf-8 -*- import os import time import sys import csv import traceback import numpy as np import pysmu import threading from datetime import datetime from collections import deque import matplotlib matplotlib.use('Qt5Agg') from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QPushButton, QLineEdit, QCheckBox, QFrame, QMessageBox, QFileDialog, QComboBox) from PyQt5.QtCore import Qt, QTimer, pyqtSignal, pyqtSlot, QObject, QThread from PyQt5.QtGui import QDoubleValidator import matplotlib as mpl mpl.rcParams['font.family'] = 'sans-serif' mpl.rcParams['font.sans-serif'] = ['DejaVu Sans', 'Arial', 'Liberation Sans', 'Verdana'] mpl.rcParams['axes.edgecolor'] = '#D8DEE9' mpl.rcParams['text.color'] = '#D8DEE9' mpl.rcParams['axes.labelcolor'] = '#D8DEE9' mpl.rcParams['xtick.color'] = '#D8DEE9' mpl.rcParams['ytick.color'] = '#D8DEE9' SAMPLE_RATE = 100000 # Samples per second BLOCK_SIZE = 500 # Samples per read MIN_VOLTAGE = 0.0 # Minimum allowed voltage MAX_VOLTAGE = 5.0 # Maximum allowed voltage UPDATE_INTERVAL = 100 # GUI update interval in ms class ADALM1000Worker(QObject): data_ready = pyqtSignal(float, float, float, str) status_update = pyqtSignal(str, str) test_completed = pyqtSignal(str) error_occurred = pyqtSignal(str, str) capacity_update = pyqtSignal(float, float, int) def __init__(self, device, dev_idx): super().__init__() self.device = device self.dev_idx = dev_idx self.running = True self.measuring = False self.logging = False self.log_file = None self.log_writer = None self.test_running = False self.request_stop = False self.test_phase = "Idle" self.cycle_count = 0 self.capacity_ah = 0.0 self.energy_wh = 0.0 self.start_time = 0 self.last_time = 0 self.last_voltage = 0 self.last_current = 0 self.mode = "Live Monitoring" self.params = { 'capacity': 1.0, 'c_rate': 0.1, 'charge_cutoff': 1.43, 'discharge_cutoff': 0.01, 'rest_time': 0.5, 'continuous': False } self.set_hiz() def set_hiz(self): """Set device to High Impedance mode""" for ch in self.device.channels.values(): ch.mode = pysmu.Mode.HI_Z ch.constant(0) def set_simv(self, current): """Set device to SIMV mode with specified current""" self.device.channels['B'].mode = pysmu.Mode.HI_Z self.device.channels['A'].mode = pysmu.Mode.SIMV self.device.channels['A'].constant(current) def start_logging(self, filename): """Start data logging to CSV file""" try: self.log_file = open(filename, 'w', newline='') self.log_writer = csv.writer(self.log_file) self.log_writer.writerow([ "Time(s)", "Voltage(V)", "Current(A)", "Phase", "Capacity(Ah)", "Power(W)", "Energy(Wh)" ]) self.logging = True return True except Exception as e: self.error_occurred.emit(f"Device {self.dev_idx}", f"Log start failed: {str(e)}") return False def stop_logging(self): """Stop data logging and close file""" if self.logging and self.log_file: try: # Write summary if hasattr(self, 'start_time') and self.start_time: duration = time.time() - self.start_time self.log_file.write("\n# TEST SUMMARY\n") self.log_file.write(f"# End Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") self.log_file.write(f"# Duration: {self.format_time(duration)}\n") self.log_file.write(f"# Total Capacity: {self.capacity_ah:.6f} Ah\n") self.log_file.write(f"# Total Energy: {self.energy_wh:.6f} Wh\n") self.log_file.write(f"# Cycle Count: {self.cycle_count}\n") self.log_file.close() except Exception as e: self.error_occurred.emit(f"Device {self.dev_idx}", f"Log stop failed: {str(e)}") finally: self.log_file = None self.log_writer = None self.logging = False def format_time(self, 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 start_test(self, mode, params): """Start a test with specified parameters""" self.mode = mode self.params = params self.test_running = True self.request_stop = False self.capacity_ah = 0.0 self.energy_wh = 0.0 self.cycle_count = 0 self.start_time = time.time() self.last_time = self.start_time self.test_phase = "Starting" self.status_update.emit(f"Device {self.dev_idx}", f"{mode} started") def stop_test(self): """Stop current test and reset device""" self.test_running = False self.request_stop = True self.set_hiz() self.test_phase = "Idle" self.status_update.emit(f"Device {self.dev_idx}", "Test stopped") def run(self): """Main measurement loop""" while self.running: try: if not self.measuring: time.sleep(0.1) continue # Read a block of samples samples = self.device.read(BLOCK_SIZE, SAMPLE_RATE, True) if not samples: time.sleep(0.01) continue # Process samples vA = np.array([s[0][0] for s in samples]) iA = np.array([s[0][1] for s in samples]) # Calculate averages voltage = np.mean(vA) current = np.mean(iA) current_time = time.time() # Only calculate elapsed time when a test is running elapsed = 0 if self.test_running and self.start_time > 0: elapsed = current_time - self.start_time # Update capacity and energy if self.last_time > 0 and self.test_running: dt = current_time - self.last_time self.capacity_ah += abs(current) * dt / 3600 self.energy_wh += (voltage * abs(current)) * dt / 3600 self.last_time = current_time self.last_voltage = voltage self.last_current = current # Handle test modes if self.test_running: self.handle_test_mode(voltage, current) else: self.test_phase = "Monitoring" # Emit data to GUI self.data_ready.emit( elapsed, voltage, current, self.test_phase ) # Update capacity display self.capacity_update.emit( self.capacity_ah, self.energy_wh, self.cycle_count ) # Log data if enabled if self.logging and self.log_file and not self.log_file.closed: try: self.log_writer.writerow([ f"{elapsed:.4f}", f"{voltage:.6f}", f"{current:.6f}", self.test_phase, f"{self.capacity_ah:.4f}", f"{voltage * abs(current):.4f}", f"{self.energy_wh:.4f}" ]) except Exception as e: self.error_occurred.emit(f"Device {self.dev_idx}", f"Log write error: {str(e)}") except Exception as e: self.error_occurred.emit(f"Device {self.dev_idx}", f"Measurement error: {str(e)}") traceback.print_exc() time.sleep(1) def handle_test_mode(self, voltage, current): """Execute test logic based on current mode""" if self.mode == "Discharge Test": self.handle_discharge_test(voltage, current) elif self.mode == "Charge Test": self.handle_charge_test(voltage, current) elif self.mode == "Cycle Test": self.handle_cycle_test(voltage, current) elif self.mode == "Live Monitoring": self.test_phase = "Monitoring" def handle_discharge_test(self, voltage, current): """Discharge test logic""" self.test_phase = "Discharging" discharge_current = -abs(self.params['c_rate'] * self.params['capacity']) # Set discharge current if not already set if abs(current - discharge_current) > 0.01: self.set_simv(discharge_current) # Check for discharge cutoff if voltage <= self.params['discharge_cutoff'] or self.request_stop: self.set_hiz() self.test_running = False self.test_phase = "Completed" self.test_completed.emit(f"Device {self.dev_idx}") def handle_charge_test(self, voltage, current): """Charge test logic""" self.test_phase = "Charging" charge_current = abs(self.params['c_rate'] * self.params['capacity']) # Set charge current if not already set if abs(current - charge_current) > 0.01: self.set_simv(charge_current) # Check for charge cutoff if voltage >= self.params['charge_cutoff'] or self.request_stop: self.set_hiz() self.test_running = False self.test_phase = "Completed" self.test_completed.emit(f"Device {self.dev_idx}") def handle_cycle_test(self, voltage, current): """Cycle test logic with state machine""" if self.test_phase == "Discharging": discharge_current = -abs(self.params['c_rate'] * self.params['capacity']) if abs(current - discharge_current) > 0.01: self.set_simv(discharge_current) if voltage <= self.params['discharge_cutoff'] or self.request_stop: self.set_hiz() self.test_phase = "Rest (Post-Discharge)" self.rest_start_time = time.time() elif self.test_phase == "Rest (Post-Discharge)": if time.time() - self.rest_start_time >= self.params['rest_time'] * 3600 or self.request_stop: self.test_phase = "Charging" elif self.test_phase == "Charging": charge_current = abs(self.params['c_rate'] * self.params['capacity']) if abs(current - charge_current) > 0.01: self.set_simv(charge_current) if voltage >= self.params['charge_cutoff'] or self.request_stop: self.set_hiz() self.test_phase = "Rest (Post-Charge)" self.rest_start_time = time.time() elif self.test_phase == "Rest (Post-Charge)": if time.time() - self.rest_start_time >= self.params['rest_time'] * 3600 or self.request_stop: self.cycle_count += 1 if self.params['continuous'] and not self.request_stop: self.test_phase = "Discharging" else: self.test_running = False self.test_phase = "Completed" self.test_completed.emit(f"Device {self.dev_idx}") # Initial state for cycle test elif self.test_phase in ["Starting", "Idle"]: self.test_phase = "Discharging" def stop(self): """Stop the worker thread""" self.running = False self.measuring = False self.test_running = False self.set_hiz() if self.logging and self.log_file: self.stop_logging() class BatteryTesterGUI(QMainWindow): def __init__(self): super().__init__() # Color scheme self.bg_color = "#2E3440" self.fg_color = "#D8DEE9" self.accent_color = "#5E81AC" self.warning_color = "#BF616A" self.success_color = "#A3BE8C" # Device and measurement state self.session = None self.devices = [] self.workers = {} self.threads = {} self.current_device_idx = 0 self.session_active = False self.measuring = False self.log_dir = os.path.expanduser("~/battery_tester/logs") os.makedirs(self.log_dir, exist_ok=True) # Data buffers self.time_data = deque(maxlen=10000) self.voltage_data = deque(maxlen=10000) self.current_data = deque(maxlen=10000) self.display_time_data = deque(maxlen=1000) self.display_voltage_data = deque(maxlen=1000) self.display_current_data = deque(maxlen=1000) # Initialize measurement variables self.capacity_ah = 0.0 self.energy_wh = 0.0 self.cycle_count = 0 self.start_time = time.time() self.last_update_time = self.start_time # Default parameters self.params = { 'capacity': 1.0, 'c_rate': 0.1, 'charge_cutoff': 1.43, 'discharge_cutoff': 0.01, 'rest_time': 0.5, 'continuous': True } # Initialize UI self.setup_ui() self.current_mode = "Live Monitoring" self.change_mode(self.current_mode) # Set window properties self.setWindowTitle("ADALM1000 Battery Tester") self.resize(1000, 800) self.setMinimumSize(800, 700) # Status update timer self.status_timer = QTimer() self.status_timer.timeout.connect(self.update_status_and_plot) self.status_timer.start(UPDATE_INTERVAL) # Reduced update frequency # Initialize devices self.init_devices() self.update_button_colors() def init_devices(self): """Initialize ADALM1000 devices""" try: self.session = pysmu.Session(ignore_dataflow=True, queue_size=20000) self.devices = self.session.devices if not self.devices: QMessageBox.warning(self, "No Devices", "No ADALM1000 devices found!") return self.status_bar.showMessage(f"Found {len(self.devices)} device(s)") self.session.start(0) # Create workers and threads for each device for idx, dev in enumerate(self.devices): worker = ADALM1000Worker(dev, idx) thread = QThread() worker.moveToThread(thread) # Connect signals worker.data_ready.connect(self.update_data) worker.status_update.connect(self.update_status) worker.test_completed.connect(self.test_completed) worker.error_occurred.connect(self.show_error) worker.capacity_update.connect(self.update_capacity) thread.started.connect(worker.run) self.workers[idx] = worker self.threads[idx] = thread # Add device to combo box self.device_combo.addItem(f"ADALM1000-{idx}") # Start threads for thread in self.threads.values(): thread.start() # Enable measurement for current device self.enable_measurement(True) self.session_active = True except Exception as e: QMessageBox.critical(self, "Error", f"Device initialization failed: {str(e)}") traceback.print_exc() def enable_measurement(self, enable): """Enable/disable measurement for current device""" if self.current_device_idx in self.workers: self.workers[self.current_device_idx].measuring = enable def setup_ui(self): """Configure the user interface""" # Main widget and layout self.central_widget = QWidget() self.setCentralWidget(self.central_widget) self.main_layout = QVBoxLayout(self.central_widget) self.main_layout.setContentsMargins(8, 8, 8, 8) self.main_layout.setSpacing(8) # Base style base_style = f""" font-size: 10pt; color: {self.fg_color}; """ # Mode and device selection frame mode_frame = QFrame() mode_frame.setFrameShape(QFrame.StyledPanel) mode_frame.setStyleSheet(f""" QFrame {{ border: 1px solid {self.accent_color}; border-radius: 5px; padding: 5px; }} QLabel {base_style} """) mode_layout = QHBoxLayout(mode_frame) mode_layout.setContentsMargins(5, 2, 5, 2) # Test mode selection self.mode_label = QLabel("Test Mode:") mode_layout.addWidget(self.mode_label) self.mode_combo = QComboBox() self.mode_combo.addItems(["Live Monitoring", "Discharge Test", "Charge Test", "Cycle Test"]) self.mode_combo.setStyleSheet(f""" QComboBox {{ {base_style} background-color: #3B4252; border: 1px solid #4C566A; border-radius: 3px; padding: 2px; min-height: 24px; }} """) self.mode_combo.currentTextChanged.connect(self.change_mode) mode_layout.addWidget(self.mode_combo, 1) # Device selection self.device_label = QLabel("Device:") mode_layout.addWidget(self.device_label) self.device_combo = QComboBox() self.device_combo.setStyleSheet(f""" QComboBox {{ {base_style} background-color: #3B4252; border: 1px solid #4C566A; border-radius: 3px; padding: 2px; min-height: 24px; }} """) self.device_combo.currentIndexChanged.connect(self.device_changed) mode_layout.addWidget(self.device_combo, 1) self.main_layout.addWidget(mode_frame) # Header area header_frame = QFrame() header_frame.setFrameShape(QFrame.NoFrame) header_layout = QHBoxLayout(header_frame) header_layout.setContentsMargins(0, 0, 0, 0) self.title_label = QLabel("ADALM1000 Battery Tester") self.title_label.setStyleSheet(f"font-size: 14pt; font-weight: bold; color: {self.accent_color};") header_layout.addWidget(self.title_label, 1) # Status indicator self.status_light = QLabel() self.status_light.setFixedSize(16, 16) self.status_light.setStyleSheet("background-color: green; border-radius: 8px;") header_layout.addWidget(self.status_light) self.connection_label = QLabel("Disconnected") self.connection_label.setStyleSheet(base_style) header_layout.addWidget(self.connection_label) self.main_layout.addWidget(header_frame) # Measurement display display_frame = QFrame() display_frame.setFrameShape(QFrame.StyledPanel) display_frame.setStyleSheet(f""" QFrame {{ border: 1px solid {self.accent_color}; border-radius: 5px; padding: 3px; }} QLabel {base_style} """) display_layout = QGridLayout(display_frame) display_layout.setHorizontalSpacing(5) display_layout.setVerticalSpacing(2) display_layout.setContentsMargins(5, 3, 5, 3) # Measurement fields measurement_fields = [ ("Voltage", "V"), ("Current", "A"), ("Elapsed Time", "s"), ("Energy", "Wh"), ("Test Phase", None), ("Capacity", "Ah"), ("Cycle Count", None), ("Coulomb Eff.", "%") ] for i, (label, unit) in enumerate(measurement_fields): row = i // 2 col = (i % 2) * 2 # Container for each measurement container = QWidget() container.setFixedHeight(24) container_layout = QHBoxLayout(container) container_layout.setContentsMargins(2, 0, 2, 0) container_layout.setSpacing(2) # Label lbl = QLabel(f"{label}:") lbl.setStyleSheet("min-width: 85px;") container_layout.addWidget(lbl) # Value field value_text = "0.000" if unit else ("Idle" if label == "Test Phase" else "0") value_lbl = QLabel(value_text) value_lbl.setAlignment(Qt.AlignRight) value_lbl.setStyleSheet(""" font-weight: bold; min-width: 65px; max-width: 65px; """) container_layout.addWidget(value_lbl) # Unit if unit: unit_lbl = QLabel(unit) unit_lbl.setStyleSheet("min-width: 20px;") container_layout.addWidget(unit_lbl) display_layout.addWidget(container, row, col) # Assign references widgets = [ display_layout.itemAtPosition(r, c).widget().layout().itemAt(1).widget() for r in range(4) for c in [0, 2] ] (self.voltage_label, self.current_label, self.time_label, self.energy_label, self.phase_label, self.capacity_label, self.cycle_label, self.efficiency_label) = widgets self.main_layout.addWidget(display_frame) # Control area controls_frame = QFrame() controls_frame.setFrameShape(QFrame.NoFrame) controls_layout = QHBoxLayout(controls_frame) controls_layout.setContentsMargins(0, 0, 0, 0) # Parameters frame self.params_frame = QFrame() self.params_frame.setFrameShape(QFrame.StyledPanel) self.params_frame.setStyleSheet(f""" QFrame {{ border: 1px solid {self.accent_color}; border-radius: 5px; }} QLabel {base_style} QLineEdit {{ {base_style} background-color: #3B4252; border: 1px solid #4C566A; border-radius: 3px; padding: 2px; min-height: 24px; }} """) self.params_layout = QGridLayout(self.params_frame) self.params_layout.setVerticalSpacing(3) self.params_layout.setHorizontalSpacing(5) self.params_layout.setContentsMargins(8, 5, 8, 5) # Add parameter inputs row = 0 # Battery Capacity self.capacity_label_1 = QLabel("Capacity (Ah):") self.params_layout.addWidget(self.capacity_label_1, row, 0) self.capacity_input = QLineEdit("1.0") self.capacity_input.setValidator(QDoubleValidator(0.001, 100, 3)) self.params_layout.addWidget(self.capacity_input, row, 1) row += 1 # C-Rate self.c_rate_label = QLabel("C-Rate:") self.params_layout.addWidget(self.c_rate_label, row, 0) self.c_rate_input = QLineEdit("0.1") self.c_rate_input.setValidator(QDoubleValidator(0.01, 1, 2)) self.params_layout.addWidget(self.c_rate_input, row, 1) row += 1 # Charge Cutoff Voltage self.charge_cutoff_label = QLabel("Charge Cutoff (V):") self.params_layout.addWidget(self.charge_cutoff_label, row, 0) self.charge_cutoff_input = QLineEdit("1.43") self.charge_cutoff_input.setValidator(QDoubleValidator(0.1, 5.0, 3)) self.params_layout.addWidget(self.charge_cutoff_input, row, 1) row += 1 # Discharge Cutoff Voltage self.discharge_cutoff_label = QLabel("Discharge Cutoff (V):") self.params_layout.addWidget(self.discharge_cutoff_label, row, 0) self.discharge_cutoff_input = QLineEdit("0.01") self.discharge_cutoff_input.setValidator(QDoubleValidator(0.1, 5.0, 3)) self.params_layout.addWidget(self.discharge_cutoff_input, row, 1) row += 1 # Rest Time self.rest_time_label = QLabel("Rest Time (h):") self.params_layout.addWidget(self.rest_time_label, row, 0) self.rest_time_input = QLineEdit("0.5") self.rest_time_input.setValidator(QDoubleValidator(0.1, 24, 1)) self.params_layout.addWidget(self.rest_time_input, row, 1) row += 1 # Test Conditions self.test_conditions_label = QLabel("Test Conditions:") self.params_layout.addWidget(self.test_conditions_label, row, 0) self.test_conditions_input = QLineEdit("Room Temperature") self.params_layout.addWidget(self.test_conditions_input, row, 1) controls_layout.addWidget(self.params_frame, 1) # Button frame button_frame = QFrame() button_frame.setFrameShape(QFrame.NoFrame) button_layout = QVBoxLayout(button_frame) button_layout.setContentsMargins(5, 0, 0, 0) button_layout.setSpacing(5) # Button style button_style = f""" QPushButton {{ {base_style} font-weight: bold; padding: 4px 8px; border-radius: 4px; min-height: 28px; color: {self.fg_color}; }} QPushButton:checked {{ background-color: {self.warning_color} !important; color: {self.fg_color} !important; }} QPushButton:disabled {{ background-color: #4C566A; }} """ # Single toggle button (Start/Stop) self.toggle_button = QPushButton("START") self.toggle_button.setCheckable(True) self.toggle_button.clicked.connect(self.toggle_test) button_layout.addWidget(self.toggle_button) # Continuous mode checkbox (only for Cycle mode) self.continuous_mode_check = QCheckBox("Continuous Mode") self.continuous_mode_check.setChecked(True) self.continuous_mode_check.setStyleSheet(base_style) button_layout.addWidget(self.continuous_mode_check) self.continuous_mode_check.hide() # Record button for Live mode self.record_button = QPushButton("● Start Recording") self.record_button.setCheckable(True) self.record_button.setStyleSheet(button_style.replace( "background-color", "background-color", 1 ) + f"background-color: {self.success_color};") self.record_button.clicked.connect(self.toggle_recording) button_layout.addWidget(self.record_button) self.record_button.hide() controls_layout.addWidget(button_frame) self.main_layout.addWidget(controls_frame) # Plot area self.setup_plot() # Status bar self.status_bar = self.statusBar() self.status_bar.setStyleSheet(f"color: {self.fg_color}; font-size: 9pt;") self.status_bar.showMessage("Ready") # Apply dark theme self.setStyleSheet(f""" QMainWindow {{ background-color: {self.bg_color}; }} QWidget {{ {base_style} }} """) def setup_plot(self): """Configure the matplotlib plot""" self.fig = Figure(figsize=(8, 5), dpi=100, facecolor=self.bg_color) self.fig.subplots_adjust(left=0.1, right=0.88, top=0.9, bottom=0.15) self.ax = self.fig.add_subplot(111) self.ax.set_facecolor('#3B4252') # Set initial voltage range self.ax.set_ylim(0, 5.0) # Voltage plot self.line_voltage, = self.ax.plot([0], [0], 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.ax2.set_ylim(-1.0, 1.0) self.line_current, = self.ax2.plot([0], [0], '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', color=self.fg_color) self.ax.tick_params(axis='x', colors=self.fg_color) self.ax.grid(True, color='#4C566A') # Position legends self.ax.legend(loc='upper left') self.ax2.legend(loc='upper right') # Embed plot self.canvas = FigureCanvas(self.fig) self.canvas.setStyleSheet(f"background-color: {self.bg_color};") self.main_layout.addWidget(self.canvas, 1) def update_button_colors(self): """Update button colors based on state""" if self.toggle_button.isChecked(): self.toggle_button.setStyleSheet(f""" background-color: {self.warning_color}; color: {self.fg_color}; font-weight: bold; padding: 4px 8px; border-radius: 4px; min-height: 28px; min-width: 120px; """) else: self.toggle_button.setStyleSheet(f""" background-color: {self.success_color}; color: {self.fg_color}; font-weight: bold; padding: 4px 8px; border-radius: 4px; min-height: 28px; min-width: 120px; """) if self.record_button.isChecked(): self.record_button.setStyleSheet(f""" background-color: {self.warning_color}; color: {self.fg_color}; font-weight: bold; padding: 4px 8px; border-radius: 4px; min-height: 28px; """) else: self.record_button.setStyleSheet(f""" background-color: {self.success_color}; color: {self.fg_color}; font-weight: bold; padding: 4px 8px; border-radius: 4px; min-height: 28px; """) def change_mode(self, mode_name): """Change between different test modes""" self.current_mode = mode_name self.stop_test() # Stop any current operation # Show/hide mode-specific UI elements show_charge = mode_name in ["Cycle Test", "Charge Test"] show_discharge = mode_name in ["Cycle Test", "Discharge Test"] show_rest = mode_name == "Cycle Test" self.charge_cutoff_label.setVisible(show_charge) self.charge_cutoff_input.setVisible(show_charge) self.discharge_cutoff_label.setVisible(show_discharge) self.discharge_cutoff_input.setVisible(show_discharge) self.rest_time_label.setVisible(show_rest) self.rest_time_input.setVisible(show_rest) # Continuous mode checkbox only for cycle test self.continuous_mode_check.setVisible(mode_name == "Cycle Test") # Record button only for live monitoring self.record_button.setVisible(mode_name == "Live Monitoring") # Set button text based on mode if mode_name == "Cycle Test": self.toggle_button.setText("START CYCLE TEST") self.toggle_button.show() elif mode_name == "Discharge Test": self.toggle_button.setText("START DISCHARGE") self.toggle_button.show() elif mode_name == "Charge Test": self.toggle_button.setText("START CHARGE") self.toggle_button.show() elif mode_name == "Live Monitoring": self.toggle_button.hide() # Reset button state self.toggle_button.setChecked(False) self.toggle_button.setEnabled(True) # Reset measurement state self.time_data.clear() self.voltage_data.clear() self.current_data.clear() self.display_time_data.clear() self.display_voltage_data.clear() self.display_current_data.clear() # Reset UI displays self.capacity_label.setText("0.0000") self.energy_label.setText("0.0000") self.cycle_label.setText("0") self.phase_label.setText("Idle") # Reset plot self.reset_plot() self.status_bar.showMessage(f"Mode changed to {mode_name}") def device_changed(self, index): """Handle device selection change""" # Disable measurement for previous device if self.current_device_idx in self.workers: self.workers[self.current_device_idx].measuring = False # Update current device index self.current_device_idx = index # Enable measurement for new device if self.current_device_idx in self.workers: self.workers[self.current_device_idx].measuring = True # Reset data buffers self.time_data.clear() self.voltage_data.clear() self.current_data.clear() self.display_time_data.clear() self.display_voltage_data.clear() self.display_current_data.clear() # Reset plot self.reset_plot() self.status_bar.showMessage(f"Switched to device {index}") @pyqtSlot(float, float, float, str) def update_data(self, elapsed, voltage, current, phase): """Update data from worker thread""" # Only update if a test is running or in live monitoring if elapsed > 0 or self.current_mode == "Live Monitoring": # Store data self.time_data.append(elapsed) self.voltage_data.append(voltage) self.current_data.append(current) # Update display buffers self.display_time_data.append(elapsed) self.display_voltage_data.append(voltage) self.display_current_data.append(current) # Update UI self.voltage_label.setText(f"{voltage:.4f}") self.current_label.setText(f"{current:.4f}") self.phase_label.setText(phase) @pyqtSlot(float, float, int) def update_capacity(self, capacity_ah, energy_wh, cycle_count): """Update capacity data from worker thread""" self.capacity_ah = capacity_ah self.energy_wh = energy_wh self.cycle_count = cycle_count self.capacity_label.setText(f"{capacity_ah:.4f}") self.energy_label.setText(f"{energy_wh:.4f}") self.cycle_label.setText(str(cycle_count)) @pyqtSlot(str, str) def update_status(self, device, status): """Update status from worker thread""" self.status_bar.showMessage(f"{device}: {status}") if device == f"Device {self.current_device_idx}": self.phase_label.setText(status.split(":")[-1].strip()) @pyqtSlot(str) def test_completed(self, device): """Handle test completion""" if device == f"Device {self.current_device_idx}": self.toggle_button.setChecked(False) self.update_button_colors() self.status_bar.showMessage(f"{device}: Test completed") @pyqtSlot(str, str) def show_error(self, device, error): """Show error message from worker thread""" QMessageBox.critical(self, f"{device} Error", error) self.status_bar.showMessage(f"{device}: {error}") def toggle_test(self, checked): """Toggle test start/stop""" self.toggle_button.setChecked(checked) self.update_button_colors() if checked: self.start_test() else: self.stop_test() def start_test(self): """Start the selected test mode""" if self.current_device_idx not in self.workers: QMessageBox.warning(self, "Error", "No active device selected!") return try: # Get parameters from UI params = { 'capacity': float(self.capacity_input.text()), 'c_rate': float(self.c_rate_input.text()), 'charge_cutoff': float(self.charge_cutoff_input.text()), 'discharge_cutoff': float(self.discharge_cutoff_input.text()), 'rest_time': float(self.rest_time_input.text()), 'continuous': self.continuous_mode_check.isChecked() } # Start test on worker self.workers[self.current_device_idx].start_test( self.current_mode, params ) # Update UI self.toggle_button.setText("STOP") self.status_bar.showMessage(f"{self.current_mode} started on device {self.current_device_idx}") except Exception as e: QMessageBox.critical(self, "Error", f"Invalid parameters: {str(e)}") self.stop_test() def stop_test(self): """Stop the current test""" if self.current_device_idx in self.workers: self.workers[self.current_device_idx].stop_test() # Update UI if self.current_mode == "Cycle Test": self.toggle_button.setText("START CYCLE TEST") elif self.current_mode == "Discharge Test": self.toggle_button.setText("START DISCHARGE") elif self.current_mode == "Charge Test": self.toggle_button.setText("START CHARGE") else: self.toggle_button.setText("START") self.toggle_button.setChecked(False) self.update_button_colors() self.status_bar.showMessage("Test stopped") def toggle_recording(self, checked): """Toggle data recording""" self.record_button.setChecked(checked) self.update_button_colors() if checked: try: # Create log file timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = os.path.join(self.log_dir, f"device{self.current_device_idx}_test_{timestamp}.csv") # Start logging on worker if self.current_device_idx in self.workers: if self.workers[self.current_device_idx].start_logging(filename): self.record_button.setText("■ Stop Recording") self.status_bar.showMessage("Recording started") else: self.record_button.setChecked(False) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to start recording: {str(e)}") self.record_button.setChecked(False) else: if self.current_device_idx in self.workers: self.workers[self.current_device_idx].stop_logging() self.record_button.setText("● Start Recording") self.status_bar.showMessage("Recording stopped") def update_plot(self): """Update the plot with current data""" if not self.display_time_data: return self.line_voltage.set_data(self.display_time_data, self.display_voltage_data) self.line_current.set_data(self.display_time_data, self.display_current_data) # Auto-scale axes if len(self.display_time_data) > 1: self.ax.set_xlim(0, max(10, self.display_time_data[-1] * 1.05)) min_v = max(MIN_VOLTAGE, min(self.display_voltage_data) * 0.95) max_v = min(MAX_VOLTAGE, max(self.display_voltage_data) * 1.05) self.ax.set_ylim(min_v, max_v) min_c = min(self.display_current_data) * 1.1 max_c = max(self.display_current_data) * 1.1 self.ax2.set_ylim(min_c, max_c) self.canvas.draw_idle() def update_status_and_plot(self): """Periodic status update""" if self.time_data: elapsed = self.time_data[-1] self.time_label.setText(self.format_time(elapsed)) self.update_plot() # Update connection status if self.session_active: self.status_light.setStyleSheet("background-color: green; border-radius: 8px;") self.connection_label.setText(f"Connected ({len(self.devices)} devices)") else: self.status_light.setStyleSheet("background-color: red; border-radius: 8px;") self.connection_label.setText("Disconnected") def format_time(self, 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 reset_plot(self): """Reset the plot to initial state""" self.line_voltage.set_data([], []) self.line_current.set_data([], []) self.ax.set_xlim(0, 10) self.ax.set_ylim(0, 5.0) self.ax2.set_ylim(-1.0, 1.0) self.canvas.draw_idle() def closeEvent(self, event): """Clean up on window close""" # Stop all workers for worker in self.workers.values(): worker.stop() # Stop all threads for thread in self.threads.values(): thread.quit() thread.wait() # End session if self.session: try: self.session.end() except: pass event.accept() if __name__ == "__main__": app = QApplication([]) window = BatteryTesterGUI() window.show() app.exec_()