1141 lines
42 KiB
Python
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_() |