Adalm1000_Battery_SMU/MainCode/adalm1000_logger.py
2025-08-10 15:17:22 +02:00

1141 lines
42 KiB
Python

# -*- 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_()