1113 lines
44 KiB
Python
1113 lines
44 KiB
Python
# main_window.py
|
||
"""主窗口:组装所有 UI 组件,连接 Core 层业务逻辑"""
|
||
|
||
import time
|
||
import os
|
||
import threading
|
||
from collections import deque
|
||
from PySide6.QtWidgets import (
|
||
QApplication,
|
||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||
QStackedWidget, QLabel, QPushButton, QTabBar, QFrame
|
||
)
|
||
from PySide6.QtCore import Qt, QTimer, Signal, QObject, QSize
|
||
from PySide6.QtGui import QFont, QIcon
|
||
from PySide6.QtSvgWidgets import QSvgWidget
|
||
|
||
from ui.status_bar import StatusBar
|
||
from ui.connection_tab import ConnectionTab
|
||
from ui.control_tab import ControlTab
|
||
from ui.debug_tab import DebugTab
|
||
from ui.plot_window import PlotWindow
|
||
|
||
from core.connection_manager import ConnectionManager
|
||
from core.control_engine import ControlEngine
|
||
from core.model_manager import ModelManager
|
||
from core.data_collector import DataCollector
|
||
from core.identification import IdentificationManager
|
||
from core.identification_config import download_identification_config
|
||
from core.identification_feedback import (
|
||
acknowledge_identification_feedback,
|
||
get_identification_feedback,
|
||
register_identification_result,
|
||
)
|
||
from core.volume_config import (
|
||
acknowledge_volume_config_request,
|
||
create_volume_config_request,
|
||
poll_volume_config_request,
|
||
)
|
||
from core.device_heartbeat import heartbeat_device
|
||
|
||
from controllers import IncrementalPID
|
||
|
||
import PcControl
|
||
|
||
|
||
IDENTIFICATION_FEEDBACK_POLL_INTERVAL_MS = 2000
|
||
IDENTIFICATION_CONFIG_RETRY_INTERVAL_MS = 2000
|
||
VOLUME_REQUEST_STATUS_POLL_INTERVAL_MS = 2000
|
||
DEVICE_HEARTBEAT_INTERVAL_MS = 10_000
|
||
|
||
|
||
class _Bridge(QObject):
|
||
"""线程安全的信号桥:后台线程通过 signal.emit() → 主线程 slot 更新 UI"""
|
||
log_signal = Signal(str)
|
||
status_signal = Signal(bool, str)
|
||
display_signal = Signal(float, float, float)
|
||
pid_ui_signal = Signal(float, float, float)
|
||
models_loaded_signal = Signal(object)
|
||
model_load_complete = Signal(bool, str)
|
||
sample_signal = Signal(object, object) # (valve_cmd, pressure)
|
||
volume_result_signal = Signal(float)
|
||
identification_config_loaded = Signal(int, object, object)
|
||
identification_upload_complete = Signal(bool, object, object)
|
||
identification_feedback_loaded = Signal(int, object, object)
|
||
volume_request_created = Signal(int, object, object)
|
||
volume_config_loaded = Signal(int, object, object)
|
||
heartbeat_completed = Signal(object)
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
"""主窗口:ReinLoop-V1.0"""
|
||
|
||
def __init__(self, colors: dict):
|
||
super().__init__()
|
||
self.colors = colors
|
||
self.setWindowTitle("ReinLoop-V1.0 - 收敛有界")
|
||
self.resize(900, 700)
|
||
|
||
# ---- 业务层初始化 ----
|
||
self.pid = IncrementalPID(kp=1.0, ki=0.4, kd=0, dt=0.1, out_min=0, out_max=100, xa_full=1062.5)
|
||
|
||
self.conn_mgr = ConnectionManager()
|
||
self.model_mgr = ModelManager()
|
||
self.data_collector = DataCollector()
|
||
self.ident_mgr = IdentificationManager()
|
||
self.engine = ControlEngine(self.pid)
|
||
|
||
# 注入依赖到控制引擎
|
||
self.engine.set_connection_manager(self.conn_mgr)
|
||
self.engine.set_model_manager(self.model_mgr)
|
||
self.engine.set_data_collector(self.data_collector)
|
||
|
||
# ---- 信号桥 (线程安全) ----
|
||
self._bridge = _Bridge()
|
||
self._ident_config_request_id = 0
|
||
self._ident_config_request_inflight = False
|
||
self._ident_workflow_active = False
|
||
self._ident_waiting_for_updated_config = False
|
||
self._ident_current_config = None
|
||
self._ident_feedback_run_id = None
|
||
self._ident_feedback_request_id = 0
|
||
self._ident_feedback_request_inflight = False
|
||
self._ident_feedback_registered = False
|
||
self._ident_feedback_last_error = None
|
||
self._volume_workflow_active = False
|
||
self._volume_measurement_running = False
|
||
self._volume_config_request_id = 0
|
||
self._volume_config_request_inflight = False
|
||
self._volume_cloud_request_id = None
|
||
self._volume_request_expires_at_ms = None
|
||
self._heartbeat_inflight = False
|
||
self._heartbeat_last_error = None
|
||
|
||
# 确认的目标压力
|
||
self.confirmed_target_pressure = 80.0
|
||
|
||
# 数据历史 (用于绘图,最多保留 10 小时 / 720000 点)
|
||
_MAX = 720_000
|
||
self.pressure_data = deque(maxlen=_MAX)
|
||
self.target_data = deque(maxlen=_MAX)
|
||
self.valve_data = deque(maxlen=_MAX)
|
||
self.time_data = deque(maxlen=_MAX)
|
||
self.start_time = None
|
||
|
||
# 绘图窗口引用
|
||
self._plot_window = None
|
||
|
||
# ---- 搭建 UI ----
|
||
self._setup_ui()
|
||
self._connect_signals()
|
||
|
||
# ---- 初始化回调 ----
|
||
self._setup_callbacks()
|
||
|
||
# 初始扫描模型列表
|
||
self.model_mgr.scan_models()
|
||
|
||
def _setup_ui(self):
|
||
"""构建主窗口布局:顶部两层结构 (Layer1: Logo标题行, Layer2: Tab栏)"""
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
main_layout = QVBoxLayout(central)
|
||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||
main_layout.setSpacing(0)
|
||
|
||
_src = os.path.join(os.path.dirname(__file__), "..", "src")
|
||
|
||
# ==========================================
|
||
# 1. 顶部区域 — Layer 1: 标题行(纯白背景)
|
||
# ==========================================
|
||
title_row = QWidget()
|
||
title_row.setProperty("cssClass", "titleRow")
|
||
title_layout = QHBoxLayout(title_row)
|
||
title_layout.setContentsMargins(24, 12, 24, 12)
|
||
title_layout.setSpacing(0)
|
||
|
||
# Logo SVG
|
||
logo_path = os.path.normpath(os.path.join(_src, "logo.svg"))
|
||
logo_widget = QSvgWidget(logo_path)
|
||
logo_widget.setFixedSize(100, 33)
|
||
title_layout.addWidget(logo_widget)
|
||
|
||
# 竖线分隔符
|
||
vdivider = QFrame()
|
||
vdivider.setFrameShape(QFrame.VLine)
|
||
vdivider.setFrameShadow(QFrame.Plain)
|
||
vdivider.setFixedWidth(1)
|
||
vdivider.setFixedHeight(28)
|
||
vdivider.setStyleSheet("background-color: #E2E8F0; border: none;")
|
||
title_layout.addSpacing(16)
|
||
title_layout.addWidget(vdivider)
|
||
title_layout.addSpacing(16)
|
||
|
||
# 标题 "ReinLoop V1.0"
|
||
title_lbl = QLabel("ReinLoop V1.0")
|
||
title_lbl.setProperty("cssClass", "navTitle")
|
||
title_layout.addWidget(title_lbl)
|
||
|
||
title_layout.addStretch()
|
||
|
||
main_layout.addWidget(title_row)
|
||
|
||
# -- 分割线 1:标题行与 Tab 行之间的灰色细线 --
|
||
div1 = QFrame()
|
||
div1.setFrameShape(QFrame.HLine)
|
||
div1.setFixedHeight(1)
|
||
div1.setStyleSheet("background-color: #E2E8F0; border: none;")
|
||
main_layout.addWidget(div1)
|
||
|
||
# ==========================================
|
||
# 1. 顶部区域 — Layer 2: Tab 栏行(浅灰背景 #F8FAFC)
|
||
# ==========================================
|
||
tab_container = QWidget()
|
||
tab_container.setProperty("cssClass", "tabRow")
|
||
tab_container_layout = QHBoxLayout(tab_container)
|
||
tab_container_layout.setContentsMargins(24, 0, 24, 0)
|
||
tab_container_layout.setSpacing(0)
|
||
|
||
# 使用 QTabBar(仅导航栏)+ 独立 QStackedWidget(内容区)
|
||
self.tab_bar = QTabBar()
|
||
self.tab_bar.setProperty("cssClass", "mainTab")
|
||
self.tab_bar.setIconSize(QSize(22, 22))
|
||
self.tab_bar.setCursor(Qt.PointingHandCursor)
|
||
self.tab_bar.setExpanding(False)
|
||
self.tab_bar.setDrawBase(False)
|
||
|
||
# Tab 图标路径(灰色/蓝色两套),保存为类属性方便动态切换
|
||
self._icons_gray = [
|
||
os.path.normpath(os.path.join(_src, "link_icon_gray.svg")),
|
||
os.path.normpath(os.path.join(_src, "control_icon_gray.svg")),
|
||
os.path.normpath(os.path.join(_src, "debug_icon_gray.svg"))
|
||
]
|
||
self._icons_blue = [
|
||
os.path.normpath(os.path.join(_src, "link_icon.svg")),
|
||
os.path.normpath(os.path.join(_src, "control_icon.svg")),
|
||
os.path.normpath(os.path.join(_src, "debug_icon.svg"))
|
||
]
|
||
|
||
# 初始默认全灰图标,文字前加半角空格确保图标与文字间距
|
||
self.tab_bar.addTab(QIcon(self._icons_gray[0]), " 连接设置")
|
||
self.tab_bar.addTab(QIcon(self._icons_gray[1]), " 控制设置")
|
||
self.tab_bar.addTab(QIcon(self._icons_gray[2]), " 模型调试")
|
||
|
||
tab_container_layout.addWidget(self.tab_bar)
|
||
tab_container_layout.addStretch()
|
||
main_layout.addWidget(tab_container)
|
||
|
||
# -- 分割线 2:Tab 行与主体内容之间的灰色细线 --
|
||
div2 = QFrame()
|
||
div2.setFrameShape(QFrame.HLine)
|
||
div2.setFixedHeight(1)
|
||
div2.setStyleSheet("background-color: #E2E8F0; border: none;")
|
||
main_layout.addWidget(div2)
|
||
|
||
# ==========================================
|
||
# 2. 页面堆栈
|
||
# ==========================================
|
||
self.stack = QStackedWidget()
|
||
|
||
self.tab_connection = ConnectionTab(self.colors)
|
||
self.tab_control = ControlTab(self.colors)
|
||
self.tab_debug = DebugTab(self.colors)
|
||
|
||
self.stack.addWidget(self.tab_connection)
|
||
self.stack.addWidget(self.tab_control)
|
||
self.stack.addWidget(self.tab_debug)
|
||
|
||
main_layout.addWidget(self.stack, stretch=1)
|
||
|
||
# ==========================================
|
||
# 3. 底部状态栏
|
||
# ==========================================
|
||
self.status_bar = StatusBar(self.colors)
|
||
main_layout.addWidget(self.status_bar)
|
||
|
||
# 默认选中第一个标签
|
||
self.tab_bar.setCurrentIndex(0)
|
||
self.stack.setCurrentIndex(0)
|
||
|
||
def _switch_tab(self, index: int):
|
||
"""切换标签页,并动态刷新图标与 QSS 状态"""
|
||
self.stack.setCurrentIndex(index)
|
||
|
||
# 阻断信号,防止改变时死循环
|
||
self.tab_bar.blockSignals(True)
|
||
self.tab_bar.setCurrentIndex(index)
|
||
self.tab_bar.blockSignals(False)
|
||
|
||
# 动态轮询,更新图标:选中项换蓝色高亮图标,其余换回灰色
|
||
for i in range(self.tab_bar.count()):
|
||
if i == index:
|
||
self.tab_bar.setTabIcon(i, QIcon(self._icons_blue[i]))
|
||
else:
|
||
self.tab_bar.setTabIcon(i, QIcon(self._icons_gray[i]))
|
||
|
||
# 强制让控件重新 polish 样式,确保 QSS 中的 :selected 样式立即生效
|
||
self.tab_bar.style().unpolish(self.tab_bar)
|
||
self.tab_bar.style().polish(self.tab_bar)
|
||
|
||
# ==========================================
|
||
# 回调设置(Core → UI 通过信号桥)
|
||
# ==========================================
|
||
def _setup_callbacks(self):
|
||
"""将 Core 层的回调全部桥接到主线程信号"""
|
||
bridge = self._bridge
|
||
|
||
# 日志 → status_bar
|
||
def on_log(msg):
|
||
bridge.log_signal.emit(msg)
|
||
|
||
self.conn_mgr.set_log_callback(on_log)
|
||
self.model_mgr.set_log_callback(on_log)
|
||
self.data_collector.set_log_callback(on_log)
|
||
self.ident_mgr.set_log_callback(on_log)
|
||
self.engine.set_log_callback(on_log)
|
||
|
||
# 连接状态 → status_bar
|
||
self.conn_mgr.set_status_callback(
|
||
lambda connected, text: bridge.status_signal.emit(connected, text)
|
||
)
|
||
|
||
# 控制显示更新 → control_tab
|
||
self.engine.set_display_update_callback(
|
||
lambda p, t, v: bridge.display_signal.emit(p, t, v)
|
||
)
|
||
|
||
# PID UI 更新 → control_tab
|
||
self.engine.set_pid_ui_update_callback(
|
||
lambda kp, ki, kd: bridge.pid_ui_signal.emit(kp, ki, kd)
|
||
)
|
||
|
||
# 控制启停 → control_tab 按钮状态 + 模式锁定
|
||
self.engine.set_started_callback(
|
||
self._on_engine_started
|
||
)
|
||
self.engine.set_stopped_callback(
|
||
self._on_engine_stopped
|
||
)
|
||
|
||
# 模型列表加载完成
|
||
self.model_mgr.set_models_loaded_callback(
|
||
lambda files: bridge.models_loaded_signal.emit(files)
|
||
)
|
||
|
||
# 模型加载完成
|
||
self.model_mgr.set_load_complete_callback(
|
||
lambda success, msg: bridge.model_load_complete.emit(success, msg)
|
||
)
|
||
|
||
# 辨识/容积采样
|
||
self.ident_mgr.set_sample_callback(
|
||
lambda u, p: bridge.sample_signal.emit(u, p)
|
||
)
|
||
|
||
# 容积结果
|
||
self.ident_mgr.set_volume_result_callback(
|
||
lambda vol: bridge.volume_result_signal.emit(vol)
|
||
)
|
||
|
||
self.ident_mgr.set_identification_upload_callback(
|
||
lambda success, filename, error:
|
||
bridge.identification_upload_complete.emit(
|
||
success, filename, error
|
||
)
|
||
)
|
||
|
||
def _connect_signals(self):
|
||
"""连接信号桥到各 UI 组件槽函数"""
|
||
bridge = self._bridge
|
||
|
||
bridge.log_signal.connect(self.status_bar.set_log)
|
||
bridge.status_signal.connect(self.status_bar.set_connection_status)
|
||
bridge.display_signal.connect(self._on_display_update)
|
||
bridge.pid_ui_signal.connect(self.tab_control.update_pid_entries)
|
||
bridge.models_loaded_signal.connect(self.tab_control.update_model_list)
|
||
bridge.model_load_complete.connect(self._on_model_load_complete)
|
||
bridge.sample_signal.connect(self._on_sample_update)
|
||
bridge.volume_result_signal.connect(self.tab_debug.set_volume_text)
|
||
bridge.identification_config_loaded.connect(
|
||
self._on_identification_config_loaded
|
||
)
|
||
bridge.identification_upload_complete.connect(
|
||
self._on_identification_upload_complete
|
||
)
|
||
bridge.identification_feedback_loaded.connect(
|
||
self._on_identification_feedback_loaded
|
||
)
|
||
bridge.volume_request_created.connect(self._on_volume_request_created)
|
||
bridge.volume_config_loaded.connect(self._on_volume_config_loaded)
|
||
bridge.heartbeat_completed.connect(self._on_heartbeat_completed)
|
||
|
||
# ---- UI 按钮 → Core 方法 ----
|
||
# 连接设置页
|
||
self.tab_connection.connect_btn.clicked.connect(self._on_connect_toggle)
|
||
|
||
# 控制设置页
|
||
self.tab_control.target_set_requested.connect(self._on_set_target)
|
||
self.tab_control.mode_changed.connect(self._on_mode_changed)
|
||
self.tab_control.pid_update_requested.connect(self._on_update_pid)
|
||
self.tab_control.model_load_requested.connect(self.model_mgr.load_model)
|
||
self.tab_control.models_refresh_requested.connect(self.model_mgr.scan_models)
|
||
self.tab_control.control_toggle_requested.connect(self._on_control_toggle)
|
||
self.tab_control.plot_requested.connect(self._on_plot)
|
||
self.tab_control.manual_valve_set_requested.connect(self._on_set_manual_valve)
|
||
self.tab_control.log_message_requested.connect(self.status_bar.set_log)
|
||
|
||
# 模型调试页
|
||
self.tab_debug.identify_start_requested.connect(self._on_identify_start)
|
||
self.tab_debug.identify_stop_requested.connect(self._on_identify_stop)
|
||
self.tab_debug.volume_measure_requested.connect(self._on_volume_measure)
|
||
self.tab_debug.volume_stop_requested.connect(self._on_volume_stop)
|
||
|
||
# 轮询辨识任务完成状态(100ms),任务结束后恢复按钮
|
||
self._ident_poll_timer = QTimer(self)
|
||
self._ident_poll_timer.setInterval(100)
|
||
self._ident_poll_timer.timeout.connect(self._poll_ident_done)
|
||
|
||
self._ident_feedback_timer = QTimer(self)
|
||
self._ident_feedback_timer.setInterval(
|
||
IDENTIFICATION_FEEDBACK_POLL_INTERVAL_MS
|
||
)
|
||
self._ident_feedback_timer.timeout.connect(
|
||
self._request_identification_feedback
|
||
)
|
||
|
||
self._ident_config_retry_timer = QTimer(self)
|
||
self._ident_config_retry_timer.setInterval(
|
||
IDENTIFICATION_CONFIG_RETRY_INTERVAL_MS
|
||
)
|
||
self._ident_config_retry_timer.timeout.connect(
|
||
self._request_identification_config
|
||
)
|
||
|
||
self._volume_request_status_timer = QTimer(self)
|
||
self._volume_request_status_timer.setInterval(
|
||
VOLUME_REQUEST_STATUS_POLL_INTERVAL_MS
|
||
)
|
||
self._volume_request_status_timer.timeout.connect(
|
||
self._request_volume_config
|
||
)
|
||
|
||
self._heartbeat_timer = QTimer(self)
|
||
self._heartbeat_timer.setInterval(DEVICE_HEARTBEAT_INTERVAL_MS)
|
||
self._heartbeat_timer.timeout.connect(self._request_device_heartbeat)
|
||
self._heartbeat_timer.start()
|
||
self._request_device_heartbeat()
|
||
|
||
# ---- 导航栏 Tab 切换 → 页面切换 ----
|
||
self.tab_bar.currentChanged.connect(self._switch_tab)
|
||
|
||
def _request_device_heartbeat(self):
|
||
if self._heartbeat_inflight:
|
||
return
|
||
self._heartbeat_inflight = True
|
||
|
||
def heartbeat_thread():
|
||
try:
|
||
heartbeat_device()
|
||
except Exception as exc:
|
||
self._bridge.heartbeat_completed.emit(str(exc))
|
||
else:
|
||
self._bridge.heartbeat_completed.emit(None)
|
||
|
||
threading.Thread(
|
||
target=heartbeat_thread,
|
||
name="device-heartbeat",
|
||
daemon=True,
|
||
).start()
|
||
|
||
def _on_heartbeat_completed(self, error):
|
||
self._heartbeat_inflight = False
|
||
if error and error != self._heartbeat_last_error:
|
||
self.status_bar.set_log(f"设备在线状态未上报: {error}")
|
||
self._heartbeat_last_error = error
|
||
|
||
# ==========================================
|
||
# 槽函数
|
||
# ==========================================
|
||
def _on_connect_toggle(self):
|
||
if self.conn_mgr.is_connected():
|
||
self.status_bar.set_log("正在断开连接...")
|
||
self.conn_mgr.disconnect()
|
||
self.status_bar.set_connection_status(False, "● 未连接")
|
||
self.tab_connection._connect_text_lbl.setText(" 连接设备")
|
||
self.status_bar.set_log("已断开设备连接")
|
||
else:
|
||
self.status_bar.set_log("正在连接设备,请稍候...")
|
||
QApplication.processEvents() # 强制刷新 UI,让日志立即可见
|
||
params = self.tab_connection.get_connection_params()
|
||
success = self.conn_mgr.connect(**params)
|
||
if success:
|
||
self.status_bar.set_connection_status(True, "● 已连接")
|
||
self.tab_connection._connect_text_lbl.setText(" 断开连接")
|
||
self.status_bar.set_log("设备连接成功")
|
||
else:
|
||
self.status_bar.set_connection_status(False, "● 未连接")
|
||
self.status_bar.set_log("设备连接失败,请检查参数和硬件连接")
|
||
|
||
def _on_set_target(self, target: float):
|
||
if target < 0 or target > 3000:
|
||
self.status_bar.set_log("错误: 目标压力必须在0-3000 kPa范围内")
|
||
return
|
||
self.confirmed_target_pressure = target
|
||
self.engine.target_pressure = target # 运行中实时同步到控制引擎
|
||
|
||
# 智能自动模式(RL):每次设置目标时自动从 PLC 读取流量
|
||
if self.engine.mode == "RL":
|
||
if self.conn_mgr.is_connected():
|
||
q = self.conn_mgr.read_flow()
|
||
if q is not None:
|
||
self.engine.flow = q
|
||
self.tab_control.flow_entry.setText(f"{q:.1f}")
|
||
self.status_bar.set_log(
|
||
f"目标压力已设置为: {target} kPa | 已从模块读取流量: {q:.1f} L/min")
|
||
return
|
||
|
||
self.status_bar.set_log(f"目标压力已设置为: {target} kPa")
|
||
|
||
def _on_mode_changed(self, mode: str):
|
||
self.engine.mode = mode
|
||
|
||
def _on_update_pid(self, kp: float, ki: float, kd: float):
|
||
self.pid.kp = kp
|
||
self.pid.ki = ki
|
||
self.pid.kd = kd
|
||
self.pid._calculate_coefficients()
|
||
self.status_bar.set_log(f"PID参数已更新: Kp={kp:.3f}, Ki={ki:.3f}, Kd={kd:.3f}")
|
||
|
||
def _on_engine_started(self):
|
||
self.tab_control.set_control_running(True)
|
||
self.tab_control.set_mode_switch_enabled(False)
|
||
|
||
def _on_engine_stopped(self):
|
||
self.tab_control.set_control_running(False)
|
||
self.tab_control.set_mode_switch_enabled(True)
|
||
|
||
def _on_control_toggle(self):
|
||
if self.engine.is_running:
|
||
self.engine.stop()
|
||
self.status_bar.set_log("控制已停止")
|
||
else:
|
||
try:
|
||
self._do_start_control()
|
||
except Exception as e:
|
||
import traceback
|
||
err = traceback.format_exc()
|
||
self.status_bar.set_log(f"启动控制失败: {e}")
|
||
# 同时写入日志文件(打包后无控制台)
|
||
try:
|
||
import os, sys
|
||
from datetime import datetime
|
||
log_dir = os.path.join(os.path.dirname(sys.executable), "logs")
|
||
os.makedirs(log_dir, exist_ok=True)
|
||
log_file = os.path.join(log_dir, "reinloop_error.log")
|
||
with open(log_file, "a", encoding="utf-8") as f:
|
||
f.write(f"[{datetime.now().strftime('%Y%m%d_%H%M%S')}] _on_control_toggle 异常:\n{err}\n")
|
||
except Exception:
|
||
pass
|
||
|
||
def _do_start_control(self):
|
||
"""实际启动控制的逻辑(从 _on_control_toggle 拆出,便于异常隔离)"""
|
||
# 自动从 PLC 读取流量值并填入输入框
|
||
if self.conn_mgr.is_connected():
|
||
q = self.conn_mgr.read_flow()
|
||
if q is not None:
|
||
self.tab_control.flow_entry.setText(f"{q:.1f}")
|
||
self.status_bar.set_log(f"已从模块读取流量: {q:.1f} L/min")
|
||
|
||
# 收集参数
|
||
params = self.tab_control.get_control_params()
|
||
pid_params = self.tab_control.get_pid_params()
|
||
adv = self.tab_debug.get_advanced_params()
|
||
|
||
# 同步电机限幅参数到 PcControl(总限幅→x_max, 模拟量映射→volthege_min/max)
|
||
PcControl.set_motor_limits(
|
||
volthege_min_val=adv.get("volthege_min"),
|
||
volthege_max_val=adv.get("volthege_max"),
|
||
x_max_val=adv.get("xa_full")
|
||
)
|
||
|
||
self.engine.mode = self.tab_control.get_mode()
|
||
self.engine.flow = params["flow"]
|
||
self.engine.volume = params["volume"]
|
||
self.engine.target_pressure = self.confirmed_target_pressure
|
||
self.engine.dz = adv["dz"]
|
||
self.engine.motor_max = adv["motor_max"]
|
||
xa_full_val = adv.get("xa_full")
|
||
self.engine.xa_full = float(xa_full_val) if xa_full_val is not None else 1062.5
|
||
self.pid.xa_full = float(xa_full_val) if xa_full_val is not None else 1062.5
|
||
self.engine.collect_data = self.tab_control.get_collect_data()
|
||
|
||
if self.engine.mode == "MANUAL":
|
||
self.engine.manual_valve = self.tab_control.get_manual_valve()
|
||
|
||
# 重置数据
|
||
_MAX = 720_000
|
||
self.pressure_data = deque(maxlen=_MAX)
|
||
self.target_data = deque(maxlen=_MAX)
|
||
self.valve_data = deque(maxlen=_MAX)
|
||
self.time_data = deque(maxlen=_MAX)
|
||
self.start_time = time.time()
|
||
|
||
# 启动定时器(周期 = PID dt,与 control_tick 内的 sleep 配合保证精确周期)
|
||
# 先停止旧 timer(防止重复启动累积多个 timer)
|
||
if hasattr(self, '_ui_timer') and self._ui_timer is not None:
|
||
self._ui_timer.stop()
|
||
self._ui_timer = QTimer(self)
|
||
self._ui_timer.timeout.connect(self._poll_display)
|
||
timer_ms = max(1, int(self.pid.dt * 1000))
|
||
self._ui_timer.start(timer_ms)
|
||
|
||
self.engine.start()
|
||
|
||
def _poll_display(self):
|
||
"""定时轮询:在主线程执行一个控制周期(读压力→计算→写电机→更新UI)
|
||
|
||
全部在主线程运行,避免 Cython 编译后在 PyInstaller 子线程中 segfault。
|
||
"""
|
||
self.engine.control_tick()
|
||
|
||
def _on_display_update(self, pressure: float, target: float, valve: float):
|
||
"""更新 UI 显示 + 记录数据(deque 自动滚动,最多保留 10 小时)"""
|
||
self.tab_control.update_display(pressure, target, valve)
|
||
|
||
if self.start_time is not None:
|
||
elapsed = time.time() - self.start_time
|
||
self.time_data.append(elapsed)
|
||
self.pressure_data.append(pressure)
|
||
self.target_data.append(target)
|
||
self.valve_data.append(valve)
|
||
|
||
def _on_model_load_complete(self, success: bool, message: str):
|
||
self.status_bar.set_log(message)
|
||
|
||
def _on_sample_update(self, valve_cmd, pressure):
|
||
if valve_cmd is not None:
|
||
self.tab_control.valve_opening_lbl.setText(f"{valve_cmd:.1f}")
|
||
if pressure is not None:
|
||
self.tab_control.current_pressure_lbl.setText(f"{pressure:.1f}")
|
||
|
||
def _on_set_manual_valve(self, valve: float):
|
||
if valve == -1:
|
||
self.status_bar.set_log("错误: 目标阀开度超出范围")
|
||
elif valve == -2:
|
||
self.status_bar.set_log("错误: 请输入有效的数字")
|
||
else:
|
||
self.engine.manual_valve = valve
|
||
self.status_bar.set_log(f"阀门开度已设置为: {valve}%")
|
||
|
||
def _on_plot(self):
|
||
if not self.pressure_data:
|
||
self.status_bar.set_log("没有可绘制的数据")
|
||
return
|
||
|
||
# 关闭已有窗口(先断开 finished 信号,防止回调中 enable_plot_button(True)
|
||
# 与下面紧跟的 enable_plot_button(False) 产生竞争,导致按钮状态闪烁)
|
||
if self._plot_window is not None:
|
||
try:
|
||
self._plot_window.finished.disconnect(self._on_plot_closed)
|
||
except RuntimeError:
|
||
pass # 信号可能已被断开
|
||
self._plot_window.close()
|
||
self._plot_window = None
|
||
|
||
self.tab_control.enable_plot_button(False)
|
||
try:
|
||
self._plot_window = PlotWindow(
|
||
self.time_data, self.pressure_data,
|
||
self.target_data, self.valve_data,
|
||
parent=self
|
||
)
|
||
except Exception as e:
|
||
import traceback
|
||
self.status_bar.set_log(f"绘图失败: {e}")
|
||
traceback.print_exc()
|
||
self.tab_control.enable_plot_button(True)
|
||
return
|
||
|
||
self._plot_window.finished.connect(self._on_plot_closed)
|
||
self._plot_window.show()
|
||
self.status_bar.set_log("绘图窗口已打开")
|
||
|
||
def _on_plot_closed(self):
|
||
self._plot_window = None
|
||
self.tab_control.enable_plot_button(True)
|
||
|
||
def _on_identify_start(self):
|
||
if self._volume_workflow_active:
|
||
self.status_bar.set_log("请先停止容积测试")
|
||
self.tab_debug.set_identify_finished()
|
||
return
|
||
|
||
self._ident_workflow_active = True
|
||
self._ident_waiting_for_updated_config = False
|
||
self._ident_current_config = None
|
||
self._ident_config_request_id += 1
|
||
self._ident_config_request_inflight = False
|
||
self._ident_feedback_request_id += 1
|
||
self._ident_feedback_request_inflight = False
|
||
self._ident_feedback_registered = False
|
||
self._ident_feedback_run_id = None
|
||
self._ident_feedback_last_error = None
|
||
self._ident_feedback_timer.stop()
|
||
self._ident_config_retry_timer.stop()
|
||
self.tab_debug.set_identification_feedback("正在获取参数", "pending")
|
||
self.status_bar.set_log("正在从云端获取辨识参数...")
|
||
self._request_identification_config()
|
||
|
||
def _request_identification_config(self):
|
||
"""异步获取 9 参数 CSV;失败重试时禁止并发请求。"""
|
||
if (not self._ident_workflow_active
|
||
or self._ident_config_request_inflight):
|
||
return
|
||
|
||
request_id = self._ident_config_request_id
|
||
self._ident_config_request_inflight = True
|
||
|
||
def download_thread():
|
||
try:
|
||
config = download_identification_config()
|
||
except Exception as exc:
|
||
self._bridge.identification_config_loaded.emit(
|
||
request_id, None, str(exc)
|
||
)
|
||
else:
|
||
self._bridge.identification_config_loaded.emit(
|
||
request_id, config, None
|
||
)
|
||
|
||
threading.Thread(
|
||
target=download_thread,
|
||
name="identification-config-download",
|
||
daemon=True,
|
||
).start()
|
||
|
||
def _on_identification_config_loaded(self, request_id, config, error):
|
||
"""在主线程使用云端参数启动辨识,忽略已被停止的旧请求。"""
|
||
if (request_id != self._ident_config_request_id
|
||
or not self._ident_workflow_active):
|
||
return
|
||
self._ident_config_request_inflight = False
|
||
|
||
if error:
|
||
if self._ident_waiting_for_updated_config:
|
||
self.status_bar.set_log(
|
||
f"等待新辨识参数,将继续重试: {error}"
|
||
)
|
||
else:
|
||
self.status_bar.set_log(f"无法启动辨识: {error}")
|
||
self.tab_debug.set_identification_feedback(
|
||
"参数获取失败", "failed"
|
||
)
|
||
self._ident_workflow_active = False
|
||
self.tab_debug.set_identify_finished()
|
||
return
|
||
|
||
if (self._ident_waiting_for_updated_config
|
||
and config == self._ident_current_config):
|
||
self.status_bar.set_log(
|
||
"辨识未通过,正在等待云端更新 9 参数 CSV..."
|
||
)
|
||
return
|
||
|
||
self._ident_waiting_for_updated_config = False
|
||
self._ident_config_retry_timer.stop()
|
||
self._ident_current_config = dict(config)
|
||
|
||
# xa_full 同时参与 PRBS 行程换算和 PcControl 电机限幅。
|
||
PcControl.set_motor_limits(x_max_val=config["xa_full"])
|
||
self.status_bar.set_log("云端参数已加载,正在启动辨识数据采集...")
|
||
self.tab_debug.set_identification_feedback("正在辨识", "pending")
|
||
started = self.ident_mgr.start_identification(
|
||
conn_mgr=self.conn_mgr,
|
||
running_flag_check=lambda: self.engine.is_running,
|
||
**config,
|
||
)
|
||
if not started:
|
||
self._ident_workflow_active = False
|
||
self.tab_debug.set_identification_feedback("启动失败", "failed")
|
||
self.tab_debug.set_identify_finished()
|
||
return
|
||
self._ident_poll_timer.start()
|
||
|
||
def _on_identification_upload_complete(self, success, filename, error):
|
||
"""辨识 CSV 上传后登记本轮审核任务。"""
|
||
if not self._ident_workflow_active:
|
||
return
|
||
if not success or not filename:
|
||
self._ident_workflow_active = False
|
||
self.tab_debug.set_identification_feedback("上传失败", "failed")
|
||
self.status_bar.set_log(f"辨识结果上传失败: {error or '未知错误'}")
|
||
self.tab_debug.set_identify_finished()
|
||
return
|
||
|
||
self._ident_feedback_run_id = filename
|
||
self._ident_feedback_request_id += 1
|
||
self._ident_feedback_request_inflight = False
|
||
self._ident_feedback_registered = False
|
||
self._ident_feedback_last_error = None
|
||
self.tab_debug.set_identification_feedback("等待反馈", "pending")
|
||
self.status_bar.set_log("辨识 CSV 已上传,正在等待云端返回 0/1...")
|
||
self._request_identification_feedback()
|
||
self._ident_feedback_timer.start()
|
||
|
||
def _request_identification_feedback(self):
|
||
"""登记本轮 CSV,并轮询服务器返回的数字 0/1。"""
|
||
if (not self._ident_workflow_active
|
||
or not self._ident_feedback_run_id
|
||
or self._ident_feedback_request_inflight):
|
||
return
|
||
|
||
request_id = self._ident_feedback_request_id
|
||
run_id = self._ident_feedback_run_id
|
||
registered = self._ident_feedback_registered
|
||
self._ident_feedback_request_inflight = True
|
||
|
||
def feedback_thread():
|
||
try:
|
||
if not registered:
|
||
register_identification_result(run_id)
|
||
payload = {"registered": True}
|
||
else:
|
||
payload = {
|
||
"registered": True,
|
||
"feedback": get_identification_feedback(run_id),
|
||
}
|
||
except Exception as exc:
|
||
self._bridge.identification_feedback_loaded.emit(
|
||
request_id, None, str(exc)
|
||
)
|
||
else:
|
||
self._bridge.identification_feedback_loaded.emit(
|
||
request_id, payload, None
|
||
)
|
||
|
||
threading.Thread(
|
||
target=feedback_thread,
|
||
name="identification-feedback-poll",
|
||
daemon=True,
|
||
).start()
|
||
|
||
def _on_identification_feedback_loaded(self, request_id, payload, error):
|
||
if (request_id != self._ident_feedback_request_id
|
||
or not self._ident_workflow_active):
|
||
return
|
||
self._ident_feedback_request_inflight = False
|
||
|
||
if error:
|
||
if error != self._ident_feedback_last_error:
|
||
self.status_bar.set_log(
|
||
f"辨识反馈查询失败,将继续重试: {error}"
|
||
)
|
||
self._ident_feedback_last_error = error
|
||
return
|
||
|
||
self._ident_feedback_last_error = None
|
||
if payload.get("registered") and not self._ident_feedback_registered:
|
||
self._ident_feedback_registered = True
|
||
self._request_identification_feedback()
|
||
return
|
||
|
||
feedback = payload.get("feedback")
|
||
if feedback is None:
|
||
return
|
||
|
||
self._ident_feedback_timer.stop()
|
||
run_id = self._ident_feedback_run_id
|
||
self._ident_feedback_request_id += 1
|
||
self._ident_feedback_request_inflight = False
|
||
self._ident_feedback_registered = False
|
||
self._ident_feedback_run_id = None
|
||
self._ack_identification_feedback(run_id)
|
||
|
||
if feedback == 1:
|
||
self._ident_workflow_active = False
|
||
self.tab_debug.set_identification_feedback("已通过", "passed")
|
||
self.status_bar.set_log("辨识结果:已通过")
|
||
self.tab_debug.set_identify_finished()
|
||
return
|
||
|
||
self.tab_debug.set_identification_feedback("未通过", "failed")
|
||
self.status_bar.set_log(
|
||
"辨识结果:未通过,等待重新制定并上传 9 参数 CSV"
|
||
)
|
||
self._ident_waiting_for_updated_config = True
|
||
self._ident_config_request_inflight = False
|
||
self._request_identification_config()
|
||
self._ident_config_retry_timer.start()
|
||
|
||
def _ack_identification_feedback(self, run_id):
|
||
def ack_thread():
|
||
try:
|
||
acknowledge_identification_feedback(run_id)
|
||
except Exception as exc:
|
||
self._bridge.log_signal.emit(f"确认辨识反馈失败: {exc}")
|
||
|
||
threading.Thread(
|
||
target=ack_thread,
|
||
name="identification-feedback-ack",
|
||
daemon=True,
|
||
).start()
|
||
|
||
def _on_identify_stop(self):
|
||
self._ident_workflow_active = False
|
||
self._ident_waiting_for_updated_config = False
|
||
self._ident_config_request_id += 1
|
||
self._ident_config_request_inflight = False
|
||
self._ident_feedback_request_id += 1
|
||
self._ident_feedback_request_inflight = False
|
||
self._ident_feedback_run_id = None
|
||
self._ident_feedback_timer.stop()
|
||
self._ident_config_retry_timer.stop()
|
||
self.ident_mgr.stop()
|
||
self.tab_debug.set_identification_feedback("已停止", "neutral")
|
||
self.tab_debug.set_identify_finished()
|
||
self.status_bar.set_log("已停止辨识及反馈监听")
|
||
|
||
def _on_volume_measure(self):
|
||
if self._ident_workflow_active:
|
||
self.status_bar.set_log("请先结束辨识及反馈流程")
|
||
self.tab_debug.set_volume_finished()
|
||
return
|
||
if self._volume_workflow_active:
|
||
return
|
||
|
||
self._volume_workflow_active = True
|
||
self._volume_config_request_id += 1
|
||
self._volume_config_request_inflight = False
|
||
self._volume_cloud_request_id = None
|
||
self._volume_request_expires_at_ms = None
|
||
self.status_bar.set_log("正在向云端发送容积参数请求指令...")
|
||
self._create_volume_config_request()
|
||
|
||
def _create_volume_config_request(self):
|
||
"""点击测试后只创建一次请求指令。"""
|
||
generation = self._volume_config_request_id
|
||
self._volume_config_request_inflight = True
|
||
|
||
def create_thread():
|
||
try:
|
||
request_info = create_volume_config_request()
|
||
except Exception as exc:
|
||
self._bridge.volume_request_created.emit(
|
||
generation, None, str(exc)
|
||
)
|
||
else:
|
||
self._bridge.volume_request_created.emit(
|
||
generation, request_info, None
|
||
)
|
||
|
||
threading.Thread(
|
||
target=create_thread,
|
||
name="volume-request-create",
|
||
daemon=True,
|
||
).start()
|
||
|
||
def _on_volume_request_created(self, generation, request_info, error):
|
||
if (generation != self._volume_config_request_id
|
||
or not self._volume_workflow_active):
|
||
if request_info and request_info.get("request_id"):
|
||
self._ack_volume_config_request(request_info["request_id"])
|
||
return
|
||
self._volume_config_request_inflight = False
|
||
if error:
|
||
self._volume_workflow_active = False
|
||
self.status_bar.set_log(f"发送容积参数请求失败: {error}")
|
||
self.tab_debug.set_volume_finished()
|
||
return
|
||
|
||
self._volume_cloud_request_id = request_info["request_id"]
|
||
self._volume_request_expires_at_ms = request_info["expires_at_ms"]
|
||
self.status_bar.set_log(
|
||
"容积参数请求已发送,等待公司端上传本次 JSON..."
|
||
)
|
||
self._volume_request_status_timer.start()
|
||
self._request_volume_config()
|
||
|
||
def _request_volume_config(self):
|
||
"""只查询已创建 requestId 的状态,不重复创建请求指令。"""
|
||
if (not self._volume_workflow_active
|
||
or self._volume_config_request_inflight
|
||
or not self._volume_cloud_request_id):
|
||
return
|
||
|
||
generation = self._volume_config_request_id
|
||
cloud_request_id = self._volume_cloud_request_id
|
||
self._volume_config_request_inflight = True
|
||
|
||
def download_thread():
|
||
try:
|
||
result = poll_volume_config_request(cloud_request_id)
|
||
except Exception as exc:
|
||
self._bridge.volume_config_loaded.emit(
|
||
generation, None, str(exc)
|
||
)
|
||
else:
|
||
self._bridge.volume_config_loaded.emit(
|
||
generation, result, None
|
||
)
|
||
|
||
threading.Thread(
|
||
target=download_thread,
|
||
name="volume-request-status",
|
||
daemon=True,
|
||
).start()
|
||
|
||
def _on_volume_config_loaded(self, generation, result, error):
|
||
"""检测本次请求关联的几分钟内 JSON,成功后加载一次。"""
|
||
if (generation != self._volume_config_request_id
|
||
or not self._volume_workflow_active):
|
||
return
|
||
self._volume_config_request_inflight = False
|
||
|
||
if error:
|
||
if (self._volume_request_expires_at_ms is not None
|
||
and time.time() * 1000 >= self._volume_request_expires_at_ms):
|
||
self._finish_volume_request("等待公司端参数超时")
|
||
else:
|
||
self.status_bar.set_log(
|
||
f"查询本次容积参数请求失败,将继续等待: {error}"
|
||
)
|
||
return
|
||
|
||
if result.get("expired"):
|
||
self._finish_volume_request("等待公司端参数超时")
|
||
return
|
||
if not result.get("ready"):
|
||
return
|
||
|
||
self._volume_request_status_timer.stop()
|
||
cloud_request_id = self._volume_cloud_request_id
|
||
self._volume_cloud_request_id = None
|
||
self._volume_request_expires_at_ms = None
|
||
self._ack_volume_config_request(cloud_request_id)
|
||
config = result["config"]
|
||
|
||
PcControl.set_motor_limits(x_max_val=config["xa_full"])
|
||
started = self.ident_mgr.start_volume_measurement(
|
||
conn_mgr=self.conn_mgr,
|
||
running_flag_check=lambda: self.engine.is_running,
|
||
**config,
|
||
)
|
||
if not started:
|
||
self._volume_workflow_active = False
|
||
self.tab_debug.set_volume_finished()
|
||
return
|
||
|
||
self._volume_measurement_running = True
|
||
self.status_bar.set_log("已获取云端 8 参数,开始容积测量")
|
||
self._ident_poll_timer.start()
|
||
|
||
def _finish_volume_request(self, message):
|
||
cloud_request_id = self._volume_cloud_request_id
|
||
self._volume_workflow_active = False
|
||
self._volume_config_request_inflight = False
|
||
self._volume_cloud_request_id = None
|
||
self._volume_request_expires_at_ms = None
|
||
self._volume_request_status_timer.stop()
|
||
if cloud_request_id:
|
||
self._ack_volume_config_request(cloud_request_id)
|
||
self.status_bar.set_log(message)
|
||
self.tab_debug.set_volume_finished()
|
||
|
||
def _ack_volume_config_request(self, request_id):
|
||
def ack_thread():
|
||
try:
|
||
acknowledge_volume_config_request(request_id)
|
||
except Exception as exc:
|
||
self._bridge.log_signal.emit(f"清理容积参数请求失败: {exc}")
|
||
|
||
threading.Thread(
|
||
target=ack_thread,
|
||
name="volume-request-ack",
|
||
daemon=True,
|
||
).start()
|
||
|
||
def _on_volume_stop(self):
|
||
cloud_request_id = self._volume_cloud_request_id
|
||
self._volume_workflow_active = False
|
||
self._volume_config_request_id += 1
|
||
self._volume_config_request_inflight = False
|
||
self._volume_cloud_request_id = None
|
||
self._volume_request_expires_at_ms = None
|
||
self._volume_request_status_timer.stop()
|
||
if cloud_request_id:
|
||
self._ack_volume_config_request(cloud_request_id)
|
||
if self._volume_measurement_running:
|
||
self.ident_mgr.stop()
|
||
self._volume_measurement_running = False
|
||
self.tab_debug.set_volume_finished()
|
||
self.status_bar.set_log("已停止容积测试")
|
||
|
||
def _poll_ident_done(self):
|
||
"""100ms 轮询:检测辨识/测量任务是否自然结束,恢复按钮状态"""
|
||
if self.ident_mgr.is_running:
|
||
return
|
||
|
||
self._ident_poll_timer.stop()
|
||
if not self._ident_workflow_active:
|
||
self.tab_debug.set_identify_finished()
|
||
if self._volume_measurement_running:
|
||
self._volume_measurement_running = False
|
||
self._volume_workflow_active = False
|
||
self.status_bar.set_log("容积测量完成")
|
||
self.tab_debug.set_volume_finished()
|
||
elif not self._volume_workflow_active:
|
||
self.tab_debug.set_volume_finished()
|
||
|
||
def closeEvent(self, event):
|
||
"""窗口关闭时清理资源"""
|
||
self._ident_config_request_id += 1
|
||
self._ident_config_request_inflight = False
|
||
self._ident_workflow_active = False
|
||
self._ident_feedback_request_id += 1
|
||
self._ident_feedback_request_inflight = False
|
||
self._ident_feedback_timer.stop()
|
||
self._ident_config_retry_timer.stop()
|
||
self._volume_workflow_active = False
|
||
self._volume_config_request_id += 1
|
||
self._volume_request_status_timer.stop()
|
||
self._heartbeat_timer.stop()
|
||
if self._volume_cloud_request_id:
|
||
self._ack_volume_config_request(self._volume_cloud_request_id)
|
||
self._volume_cloud_request_id = None
|
||
self.ident_mgr.stop()
|
||
if self.engine.is_running:
|
||
self.engine.stop()
|
||
if self.conn_mgr.is_connected():
|
||
self.conn_mgr.disconnect()
|
||
if self._plot_window is not None:
|
||
self._plot_window.close()
|
||
event.accept()
|