LeakTest_HMI/HMI/python_hmi/ehg_leaktest_bkp.py

518 lines
19 KiB
Python

# leak_test_hmi_threaded.py
import sys
import os
import json
import struct
import time
import serial
import serial.tools.list_ports
import pandas as pd
from PyQt6 import QtCore, QtGui, QtWidgets
import pyqtgraph as pg
# ---------------------------
# Serial helper: auto-detect
# ---------------------------
def find_arduino_port():
ports = list(serial.tools.list_ports.comports())
for port in ports:
desc = (port.description or "").lower()
if any(kw in desc for kw in ("arduino", "ch340", "usb serial", "ftdi")):
return port.device
return None
# ---------------------------
# Config JSON helpers
# ---------------------------
CONFIG_FILE = "config.json"
DEFAULT_CONFIG = {
"setpoint_start": 25.0,
"setpoint_end": 12.0,
"setwindow": 900.0,
"marker_interval": 20.0
}
def load_config():
if not os.path.exists(CONFIG_FILE):
return DEFAULT_CONFIG.copy()
try:
with open(CONFIG_FILE, "r") as f:
cfg = json.load(f)
# ensure defaults for missing keys
for k, v in DEFAULT_CONFIG.items():
cfg.setdefault(k, v)
return cfg
except Exception:
return DEFAULT_CONFIG.copy()
def save_config(cfg: dict):
with open(CONFIG_FILE, "w") as f:
json.dump(cfg, f, indent=4)
# ---------------------------
# Serial reading thread
# ---------------------------
class SerialReaderThread(QtCore.QThread):
# emits elapsed_time (s), eng_pressure (float)
new_sample = QtCore.pyqtSignal(float, float)
error = QtCore.pyqtSignal(str)
def __init__(self, ser: serial.Serial | None, start_time: float, parent=None):
super().__init__(parent)
self.ser = ser
self._running = False
self.start_time = start_time
def run(self):
self._running = True
# small sleep to avoid busy-wait burning CPU
while self._running:
try:
if not self.ser:
# no serial, sleep and continue
self.msleep(200)
continue
# If binary 2-byte packets are expected, read when available
if self.ser.in_waiting >= 2:
data = self.ser.read(2)
if len(data) < 2:
continue
try:
raw = struct.unpack('<H', data)[0]
except Exception as e:
# malformed packet; skip
continue
# calibration: adapt as needed
eng_pressure = raw * 1.219 - 246.676
elapsed = time.time() - self.start_time
# emit to GUI thread
self.new_sample.emit(elapsed, eng_pressure)
else:
# nothing to read, small sleep
self.msleep(5)
except Exception as e:
# emit error once and sleep to avoid spamming
try:
self.error.emit(str(e))
except Exception:
pass
self.msleep(200)
def stop(self):
self._running = False
self.wait()
# ---------------------------
# Main HMI
# ---------------------------
class HMI(QtWidgets.QWidget):
def __init__(self, ser):
super().__init__()
self.ser = ser # pyserial Serial or None
self.setWindowTitle("Leak Test")
# optional: use a local icon file if present
if os.path.exists("ehg_icon.png"):
self.setWindowIcon(QtGui.QIcon("ehg_icon.png"))
self.resize(900, 560)
# load config
cfg = load_config()
self.setpoint_start = float(cfg["setpoint_start"])
self.setpoint_end = float(cfg["setpoint_end"])
self.setwindow = float(cfg["setwindow"])
self.marker_interval = float(cfg["marker_interval"])
# data buffers
self.start_time = time.time()
self.time_data = []
self.pressure_data = []
self.max_points = 5000 # circular buffer limit
# layout
main_layout = QtWidgets.QVBoxLayout(self)
# date/time label top-right
date_layout = QtWidgets.QHBoxLayout()
date_layout.addStretch()
self.date_label = QtWidgets.QLabel()
self.date_label.setStyleSheet("color: gray; font-size: 12px;")
date_layout.addWidget(self.date_label)
main_layout.addLayout(date_layout)
self._dt_timer = QtCore.QTimer(self)
self._dt_timer.timeout.connect(self._update_date_label)
self._dt_timer.start(1000)
self._update_date_label()
# plot
self.plot_widget = pg.PlotWidget()
self.plot_widget.setBackground("w")
self.plot_widget.setTitle("Pressure")
self.plot_widget.setLabel("left", "Pressure [kPa]")
self.plot_widget.setLabel("bottom", "Time [s]")
main_layout.addWidget(self.plot_widget)
self.plot = self.plot_widget.plot([], [], pen=pg.mkPen(color="k", width=2))
# setpoint lines
self.setpoint_start_line = pg.InfiniteLine(pos=self.setpoint_start, angle=0, pen=pg.mkPen(color="r", width=2, style=pg.QtCore.Qt.PenStyle.DashLine))
self.setpoint_end_line = pg.InfiniteLine(pos=self.setpoint_end, angle=0, pen=pg.mkPen(color="b", width=2, style=pg.QtCore.Qt.PenStyle.DashLine))
self.plot_widget.addItem(self.setpoint_start_line)
self.plot_widget.addItem(self.setpoint_end_line)
# cursors
self.cursor_start = pg.InfiniteLine(pos=10, angle=90, movable=True, pen=pg.mkPen(color="g", width=2))
self.cursor_end = pg.InfiniteLine(pos=10 + self.setwindow, angle=90, movable=True, pen=pg.mkPen(color="g", width=2))
self.plot_widget.addItem(self.cursor_start)
self.plot_widget.addItem(self.cursor_end)
self.cursor_start.sigPositionChanged.connect(lambda: self._sync_cursors(self.cursor_start, self.cursor_end))
self.cursor_end.sigPositionChanged.connect(lambda: self._sync_cursors(self.cursor_end, self.cursor_start))
# control row
controls = QtWidgets.QHBoxLayout()
controls.addWidget(QtWidgets.QLabel("Setpoint_start [kPa]:"))
self.setpoint_start_input = QtWidgets.QLineEdit(str(self.setpoint_start))
self.setpoint_start_input.setFixedWidth(80)
self.setpoint_start_input.editingFinished.connect(self.update_setpoint_start)
controls.addWidget(self.setpoint_start_input)
controls.addWidget(QtWidgets.QLabel("Setpoint_end [kPa]:"))
self.setpoint_end_input = QtWidgets.QLineEdit(str(self.setpoint_end))
self.setpoint_end_input.setFixedWidth(80)
self.setpoint_end_input.editingFinished.connect(self.update_setpoint_end)
controls.addWidget(self.setpoint_end_input)
controls.addWidget(QtWidgets.QLabel("dT [sec]:"))
self.setwindow_input = QtWidgets.QLineEdit(str(self.setwindow))
self.setwindow_input.setFixedWidth(80)
self.setwindow_input.editingFinished.connect(self.update_setwindow)
controls.addWidget(self.setwindow_input)
# save configuration button
self.save_config_btn = QtWidgets.QPushButton("Save Configuration")
self.save_config_btn.clicked.connect(self.save_configuration)
controls.addWidget(self.save_config_btn)
controls.addStretch()
# analyze button
self.analyze_btn = QtWidgets.QPushButton("Analyze")
self.analyze_btn.clicked.connect(self.analyze_and_save)
controls.addWidget(self.analyze_btn)
main_layout.addLayout(controls)
# last value label (TextItem on plot)
self.last_value_label = pg.TextItem("", anchor=(0, 1), fill=pg.mkBrush(255, 255, 255, 200))
self.plot_widget.addItem(self.last_value_label)
# result label
self.result_label = QtWidgets.QLabel("Test Result: N/A")
self.result_label.setStyleSheet("font-weight: bold; font-size: 14px;")
main_layout.addWidget(self.result_label)
# stack and comments row
meta_layout = QtWidgets.QHBoxLayout()
meta_layout.addWidget(QtWidgets.QLabel("Stack No [ST#]:"))
self.stack_input = QtWidgets.QLineEdit("0")
self.stack_input.setFixedWidth(120)
meta_layout.addWidget(self.stack_input)
meta_layout.addWidget(QtWidgets.QLabel("Comments:"))
self.comments_input = QtWidgets.QLineEdit("")
self.comments_input.setFixedWidth(360)
meta_layout.addWidget(self.comments_input)
meta_layout.addStretch()
main_layout.addLayout(meta_layout)
# marker interval row
marker_layout = QtWidgets.QHBoxLayout()
marker_layout.addStretch()
marker_layout.addWidget(QtWidgets.QLabel("Sample Time [sec]:"))
self.marker_input = QtWidgets.QLineEdit(str(self.marker_interval))
self.marker_input.setFixedWidth(80)
self.marker_input.editingFinished.connect(self.update_marker_interval)
marker_layout.addWidget(self.marker_input)
main_layout.addLayout(marker_layout)
# marker timer
self.marker_timer = QtCore.QTimer(self)
self.marker_timer.timeout.connect(self.add_marker)
self.marker_timer.start(int(self.marker_interval * 1000))
# plotting update timer (only used to refresh UI if data appended from thread)
self.plot_refresh_timer = QtCore.QTimer(self)
self.plot_refresh_timer.timeout.connect(self._refresh_plot)
self.plot_refresh_timer.start(200) # 5 Hz UI refresh
# start background serial thread
self.serial_thread = SerialReaderThread(self.ser, self.start_time)
self.serial_thread.new_sample.connect(self.handle_new_sample)
self.serial_thread.error.connect(self._handle_serial_error)
self.serial_thread.start()
# ensure the plot lines reflect the loaded config
self.setpoint_start_line.setValue(self.setpoint_start)
self.setpoint_end_line.setValue(self.setpoint_end)
self._sync_cursors(self.cursor_start, self.cursor_end)
# ----- core helpers -----
def _update_date_label(self):
self.date_label.setText(time.strftime("%Y-%m-%d %H:%M:%S"))
def _handle_serial_error(self, msg):
# you can log the message
print("Serial thread error:", msg)
def handle_new_sample(self, elapsed, pressure):
# Called in GUI thread via signal
# append to buffers with circular behavior
self.time_data.append(elapsed)
self.pressure_data.append(pressure)
if len(self.time_data) > self.max_points:
# keep only last max_points
self.time_data = self.time_data[-self.max_points:]
self.pressure_data = self.pressure_data[-self.max_points:]
# update last value label immediately
try:
self.last_value_label.setText(f"{pressure:.2f} kPa")
self.last_value_label.setPos(self.time_data[-1], self.pressure_data[-1])
except Exception:
pass
def _refresh_plot(self):
self.time_window = 100.0 # seconds of visible data
# update plot from latest buffers
if not self.time_data:
return
try:
# plot raw values
self.plot.setData(self.time_data, self.pressure_data)
if len(self.time_data) > 0:
latest_time = self.time_data[-1]
self.plot_widget.setXRange(latest_time - self.time_window, latest_time)
except Exception as e:
# ignore transient errors
pass
def _sync_cursors(self, moved_cursor, other_cursor):
# keep the two cursors setwindow apart
try:
if moved_cursor is self.cursor_start:
other_val = moved_cursor.value() + self.setwindow
else:
other_val = moved_cursor.value() - self.setwindow
other_cursor.blockSignals(True)
other_cursor.setValue(other_val)
other_cursor.blockSignals(False)
except Exception:
pass
# ----- update fields -----
def update_setpoint_start(self):
try:
self.setpoint_start = float(self.setpoint_start_input.text())
self.setpoint_start_line.setValue(self.setpoint_start)
except ValueError:
# ignore invalid
pass
def update_setpoint_end(self):
try:
self.setpoint_end = float(self.setpoint_end_input.text())
self.setpoint_end_line.setValue(self.setpoint_end)
except ValueError:
pass
def update_setwindow(self):
try:
self.setwindow = float(self.setwindow_input.text())
# sync cursors
self._sync_cursors(self.cursor_start, self.cursor_end)
except ValueError:
pass
def update_marker_interval(self):
try:
val = float(self.marker_input.text())
if val <= 0:
return
self.marker_interval = val
self.marker_timer.setInterval(int(self.marker_interval * 1000))
except ValueError:
pass
def add_marker(self):
t = time.time() - self.start_time
marker = pg.InfiniteLine(pos=t, angle=90,
pen=pg.mkPen(color="m", width=1, style=pg.QtCore.Qt.PenStyle.DashLine))
self.plot_widget.addItem(marker)
# ----- save / load config -----
def save_configuration(self):
try:
cfg = {
"setpoint_start": float(self.setpoint_start_input.text()),
"setpoint_end": float(self.setpoint_end_input.text()),
"setwindow": float(self.setwindow_input.text()),
"marker_interval": float(self.marker_input.text())
}
save_config(cfg)
# update current runtime values too
self.setpoint_start = cfg["setpoint_start"]
self.setpoint_end = cfg["setpoint_end"]
self.setwindow = cfg["setwindow"]
self.marker_interval = cfg["marker_interval"]
# apply to UI
self.setpoint_start_line.setValue(self.setpoint_start)
self.setpoint_end_line.setValue(self.setpoint_end)
self._sync_cursors(self.cursor_start, self.cursor_end)
self.marker_timer.setInterval(int(self.marker_interval * 1000))
QtWidgets.QMessageBox.information(self, "Configuration", "Configuration saved.")
except Exception as e:
QtWidgets.QMessageBox.warning(self, "Configuration", f"Could not save configuration: {e}")
# ----- analyze & save -----
def analyze_and_save(self):
try:
start_time = float(self.cursor_start.value())
end_time = float(self.cursor_end.value())
if start_time >= end_time:
self.result_label.setText("Invalid dT")
return
# build marker times from start_time to end_time
marker_times = []
t = start_time
# avoid infinite loops if marker_interval invalid
if self.marker_interval <= 0:
self.result_label.setText("Invalid marker interval")
return
while t <= end_time:
marker_times.append(t)
t += self.marker_interval
# find nearest sample for each marker
sampled_times = []
sampled_pressures = []
for mt in marker_times:
if not self.time_data:
break
idx = min(range(len(self.time_data)), key=lambda i: abs(self.time_data[i] - mt))
sampled_times.append(self.time_data[idx])
sampled_pressures.append(self.pressure_data[idx])
if not sampled_pressures:
self.result_label.setText("No data at markers")
return
start_pressure = sampled_pressures[0]
end_pressure = sampled_pressures[-1]
pressure_drop = start_pressure - end_pressure
dt = end_time - start_time
drop_rate = pressure_drop / dt if dt != 0 else 0.0
threshold = (self.setpoint_start - self.setpoint_end) / dt if dt != 0 else 0.0
test_result = "PASS" if drop_rate <= threshold else "FAIL"
self.result_label.setText(f"Test Result: {test_result} (Drop Rate: {drop_rate:.3f} kPa/s)")
self.result_label.setStyleSheet("color: green;" if test_result == "PASS" else "color: red;")
# timestamp
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
# detailed CSV (sampled points) rounded to 1 decimal
detailed_df = pd.DataFrame({
"Time (s)": [round(t, 1) for t in sampled_times],
"Pressure (kPa)": [round(p, 1) for p in sampled_pressures]
})
detailed_filename = f"leak_test_data_ST{self.stack_input.text()}_{timestamp.replace(':', '-').replace(' ', '_')}.csv"
detailed_df.to_csv(detailed_filename, index=False)
# screenshot of whole window
screenshot = self.grab()
screenshot_filename = f"screenshot_{detailed_filename}.png"
screenshot.save(screenshot_filename)
# summary CSV (append)
summary_df = pd.DataFrame([[
timestamp,
round(start_pressure, 1),
round(end_pressure, 1),
round(drop_rate, 3),
test_result,
self.stack_input.text(),
self.comments_input.text(),
screenshot_filename
]], columns=["Time", "Start Pressure", "End Pressure", "Drop Rate (kPa/s)", "Result", "Stack", "Comments", "Screenshot"])
summary_file = "leak_test_summary.csv"
header = not os.path.exists(summary_file)
summary_df.to_csv(summary_file, mode="a", header=header, index=False)
QtWidgets.QMessageBox.information(self, "Analysis", f"Saved summary and data:\n{summary_file}\n{detailed_filename}\n{screenshot_filename}")
except Exception as e:
self.result_label.setText("Invalid Criteria Input")
print("analyze_and_save error:", e)
# ----- cleanup -----
def closeEvent(self, event: QtGui.QCloseEvent):
# stop serial thread
try:
if hasattr(self, "serial_thread") and self.serial_thread is not None:
self.serial_thread.stop()
except Exception:
pass
# close serial
try:
if self.ser and self.ser.is_open:
self.ser.close()
except Exception:
pass
# stop timers
try:
self._dt_timer.stop()
self.marker_timer.stop()
self.plot_refresh_timer.stop()
except Exception:
pass
event.accept()
# ---------------------------
# run app
# ---------------------------
def main():
# find Arduino and open serial
port = find_arduino_port()
ser = None
if port:
try:
ser = serial.Serial(port, 9600, timeout=0.1)
# flush at start
ser.reset_input_buffer()
print("Opened serial", port)
except Exception as e:
print("Could not open serial:", e)
ser = None
else:
print("Arduino port not found; running without serial input")
app = QtWidgets.QApplication(sys.argv)
win = HMI(ser)
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()