Files
ReinLoopTest/ReinLoop/ui/control_tab.py
2026-07-30 11:12:31 +08:00

594 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# control_tab.py
"""页面2:系统状态栏(三卡片) + 控制参数(Section 卡片)
重构要点:
- 状态栏:三张横向并排卡片,每张含圆形图标 + 大字数值 + 右上角色点
- 控制参数区:Section 卡片(蓝竖线装饰),QGridLayout 双列布局,输入列拉伸占满约 2/3 页宽
"""
import os
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
QLabel, QLineEdit, QComboBox, QPushButton,
QRadioButton, QCheckBox, QFrame, QButtonGroup, QSizePolicy,
QGraphicsDropShadowEffect,
)
from PySide6.QtCore import Qt, Signal, QSize
from PySide6.QtGui import QColor, QIcon
from PySide6.QtSvgWidgets import QSvgWidget
from ui.connection_tab import _make_section_card
_SRC_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "src"))
# ==========================================
# 工具函数:透明容器
# ==========================================
def _transparent_widget() -> QWidget:
"""创建一个透明的空容器(用于包裹多个控件)。"""
w = QWidget()
w.setProperty("cssClass", "transparentBg")
w.style().unpolish(w)
w.style().polish(w)
return w
# ==========================================
# 工具函数:三点状态栏卡片
# ==========================================
def _make_status_card(parent, title: str, value: str, unit: str,
value_color: str, circle_bg: str,
icon_path: str, dot_color: str):
"""创建单张状态卡片(圆形图标 + 大字数值 + 右上角圆点)。
返回 (card, value_label)。
"""
card = QFrame(parent)
card.setProperty("cssClass", "sectionCard")
card.style().unpolish(card)
card.style().polish(card)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
shadow = QGraphicsDropShadowEffect(card)
shadow.setColor(QColor(0, 0, 0, 10))
shadow.setBlurRadius(14)
shadow.setOffset(0, 2)
card.setGraphicsEffect(shadow)
inner = QVBoxLayout(card)
inner.setContentsMargins(16, 12, 16, 14)
inner.setSpacing(0)
# ---- 右上角圆点 ----
dot_row = QHBoxLayout()
dot_row.setContentsMargins(0, 0, 0, 6)
dot_row.addStretch()
dot = QWidget()
dot.setFixedSize(8, 8)
dot.setStyleSheet(f"background: {dot_color}; border-radius: 4px;")
dot_row.addWidget(dot)
inner.addLayout(dot_row)
# ---- 主体:圆形图标 + 文本 ----
body = QHBoxLayout()
body.setSpacing(30)
# 圆形图标容器
icon_circle = QWidget()
icon_circle.setFixedSize(82, 82)
icon_circle.setStyleSheet(
f"background: {circle_bg}; border-radius: 41px;"
)
icon_inner = QVBoxLayout(icon_circle)
icon_inner.setContentsMargins(0, 0, 0, 0)
icon_inner.setAlignment(Qt.AlignCenter)
svg = QSvgWidget(icon_path)
svg.setFixedSize(48, 48)
icon_inner.addWidget(svg, alignment=Qt.AlignCenter)
body.addWidget(icon_circle)
# 文本列
text_col = QVBoxLayout()
text_col.setSpacing(4)
title_lbl = QLabel(title)
title_lbl.setStyleSheet(
"color: #555555; font-size: 15px; background: transparent; border: none;"
)
text_col.addWidget(title_lbl)
value_row = QHBoxLayout()
value_row.setSpacing(4)
val_lbl = QLabel(value)
val_lbl.setStyleSheet(
f"color: {value_color}; font-size: 56px; font-weight: bold;"
"background: transparent; border: none;"
)
value_row.addWidget(val_lbl)
unit_lbl = QLabel(unit)
unit_lbl.setStyleSheet(
f"color: {value_color}; font-size: 24px; background: transparent;"
"border: none; padding-top: 14px;"
)
value_row.addWidget(unit_lbl)
value_row.addStretch()
text_col.addLayout(value_row)
body.addLayout(text_col, 1)
inner.addLayout(body, 1)
return card, val_lbl
# ==========================================
# 工具函数:行级标签
# ==========================================
def _label(text: str, parent=None) -> QLabel:
"""紧凑表单标签。"""
lbl = QLabel(text, parent)
lbl.setStyleSheet(
"color: #333333; font-size: 14px; font-weight: bold; background: transparent;"
)
lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
return lbl
def _unit_label(unit: str, parent=None) -> QLabel:
"""单位标签(灰色小字)。"""
lbl = QLabel(unit, parent)
lbl.setStyleSheet(
"color: #94A3B8; font-size: 12px; background: transparent;"
)
return lbl
# ==========================================
# 主类
# ==========================================
class ControlTab(QWidget):
"""控制设置页面"""
# ---- 信号 ----
target_set_requested = Signal(float)
mode_changed = Signal(str)
pid_update_requested = Signal(float, float, float)
model_load_requested = Signal(str)
models_refresh_requested = Signal()
control_toggle_requested = Signal()
plot_requested = Signal()
manual_valve_set_requested = Signal(float)
log_message_requested = Signal(str)
def __init__(self, colors: dict, parent=None):
super().__init__(parent)
self.setProperty("cssClass", "tabPage")
self.colors = colors
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(20, 16, 20, 16)
main_layout.setSpacing(14)
# ==========================================
# A. 系统状态栏(三卡片)
# ==========================================
status_bar = QHBoxLayout()
status_bar.setSpacing(14)
self._pressure_card, self.current_pressure_lbl = _make_status_card(
self,
title="当前系统压力",
value="0.0",
unit="kPa",
value_color="#0F955D",
circle_bg="#E2F5ED",
icon_path=os.path.join(_SRC_DIR, "pressure.svg"),
dot_color="#0F955D",
)
self._target_card, self.target_pressure_lbl = _make_status_card(
self,
title="设置目标压力",
value="0.0",
unit="kPa",
value_color="#0960D1",
circle_bg="#EBF3FE",
icon_path=os.path.join(_SRC_DIR, "target.svg"),
dot_color="#0960D1",
)
self._valve_card, self.valve_opening_lbl = _make_status_card(
self,
title="控制阀门开度",
value="0.0",
unit="%",
value_color="#E67E22",
circle_bg="#FFF2E8",
icon_path=os.path.join(_SRC_DIR, "valve.svg"),
dot_color="#E67E22",
)
status_bar.addWidget(self._pressure_card)
status_bar.addWidget(self._target_card)
status_bar.addWidget(self._valve_card)
main_layout.addLayout(status_bar)
# ==========================================
# B. 控制参数设置区(Section 卡片)
# ==========================================
ctrl_card, ctrl_grid = _make_section_card(self, "控制设置", colors)
self._build_control_section(ctrl_grid)
main_layout.addWidget(ctrl_card)
main_layout.addStretch()
# 信号连接
self.mode_group.buttonClicked.connect(self._on_mode_changed_internal)
# ==========================================
# 控制设置 — QGridLayout 双列布局,输入列拉伸占满 2/3 页宽
# ==========================================
def _build_control_section(self, grid: QGridLayout):
# 沿用 _make_section_card 的列配置:col 0 标签固定 148px,col 1 输入区拉伸
grid.setVerticalSpacing(16)
# --- B1: 物理工况 (容积 + 流量) ---
row = 0
grid.addWidget(_label("物理工况:"), row, 0)
b1 = _transparent_widget()
b1h = QHBoxLayout(b1)
b1h.setContentsMargins(0, 0, 0, 0)
b1h.setSpacing(6)
b1h.addWidget(_label("容积"))
self.volume_entry = QLineEdit()
self.volume_entry.setFixedWidth(120)
b1h.addWidget(self.volume_entry)
b1h.addWidget(_unit_label("L"))
b1h.addSpacing(32)
b1h.addWidget(_label("流量"))
self.flow_entry = QLineEdit("100")
self.flow_entry.setFixedWidth(120)
b1h.addWidget(self.flow_entry)
b1h.addWidget(_unit_label("L/min"))
b1h.addStretch()
grid.addWidget(b1, row, 1)
# --- B2: 目标压力 + 按钮 ---
row = 1
grid.addWidget(_label("目标压力:"), row, 0)
b2 = _transparent_widget()
b2h = QHBoxLayout(b2)
b2h.setContentsMargins(0, 0, 0, 0)
b2h.setSpacing(6)
self.target_entry = QLineEdit("80.0")
self.target_entry.setFixedWidth(200)
b2h.addWidget(self.target_entry)
b2h.addWidget(_unit_label("kPa"))
b2h.addSpacing(10)
self.set_target_btn = QPushButton("设置目标")
self.set_target_btn.setIcon(QIcon(os.path.join(_SRC_DIR, "target.svg")))
self.set_target_btn.setIconSize(QSize(18, 18))
self.set_target_btn.setStyleSheet(
"QPushButton { background: white; color: #0960D1; border: 1.5px solid #0960D1;"
"border-radius: 6px; padding: 9px 20px; font-weight: bold; font-size: 14px; }"
"QPushButton:hover { background: #EBF3FE; }"
)
self.set_target_btn.setCursor(Qt.PointingHandCursor)
self.set_target_btn.clicked.connect(self._on_set_target)
b2h.addWidget(self.set_target_btn)
b2h.addStretch()
grid.addWidget(b2, row, 1)
# --- B3: 控制模式单选 ---
row = 2
grid.addWidget(_label("控制方式:"), row, 0)
b3 = _transparent_widget()
b3h = QHBoxLayout(b3)
b3h.setContentsMargins(0, 0, 0, 0)
b3h.setSpacing(24)
self.mode_group = QButtonGroup(self)
self.radio_rl = QRadioButton("智能自动")
self.radio_pid = QRadioButton("手动PID")
self.radio_manual = QRadioButton("设置开度")
self.mode_group.addButton(self.radio_rl, 0)
self.mode_group.addButton(self.radio_pid, 1)
self.mode_group.addButton(self.radio_manual, 2)
self.radio_rl.setChecked(True)
b3h.addWidget(self.radio_rl)
b3h.addWidget(self.radio_pid)
b3h.addWidget(self.radio_manual)
b3h.addStretch()
grid.addWidget(b3, row, 1)
# --- B4: 模型面板(跨两列,内部标签固定148px与外层col0对齐) ---
row = 3
self.rl_panel = QWidget()
self.rl_panel.setStyleSheet("background: transparent;")
rl_layout = QHBoxLayout(self.rl_panel)
rl_layout.setContentsMargins(0, 0, 0, 0)
rl_layout.setSpacing(8)
rl_lbl = _label("决策模型:")
rl_lbl.setFixedWidth(148)
rl_layout.addWidget(rl_lbl)
self.model_combobox = QComboBox()
self.model_combobox.setFixedWidth(280)
self.model_combobox.setFixedHeight(36)
rl_layout.addWidget(self.model_combobox)
self.load_model_btn = QPushButton("加载模型")
self.load_model_btn.setStyleSheet(
"QPushButton { background: #0960D1; color: white; border: none;"
"border-radius: 6px; padding: 7px 16px; font-weight: bold; font-size: 14px; }"
"QPushButton:hover { background: #0856B8; }"
)
self.load_model_btn.setCursor(Qt.PointingHandCursor)
self.load_model_btn.clicked.connect(self._on_load_model)
rl_layout.addWidget(self.load_model_btn)
self.refresh_models_btn = QPushButton("🔄 刷新")
self.refresh_models_btn.setProperty("cssClass", "refresh")
self.refresh_models_btn.setCursor(Qt.PointingHandCursor)
self.refresh_models_btn.clicked.connect(self._on_refresh_models)
rl_layout.addWidget(self.refresh_models_btn)
rl_layout.addStretch()
grid.addWidget(self.rl_panel, row, 0, 1, 2)
# --- B5: PID 面板(跨两列,内部标签固定148px) ---
row = 4
self.pid_panel = QWidget()
self.pid_panel.setStyleSheet("background: transparent;")
self.pid_panel.hide()
pid_layout = QHBoxLayout(self.pid_panel)
pid_layout.setContentsMargins(0, 0, 0, 0)
pid_layout.setSpacing(6)
pid_lbl = _label("PID 调节:")
pid_lbl.setFixedWidth(148)
pid_layout.addWidget(pid_lbl)
pid_layout.addWidget(_label("Kp:"))
self.Kp_entry = QLineEdit("1.0")
self.Kp_entry.setFixedWidth(80)
pid_layout.addWidget(self.Kp_entry)
pid_layout.addWidget(_label("Ki:"))
self.Ki_entry = QLineEdit("0.4")
self.Ki_entry.setFixedWidth(80)
pid_layout.addWidget(self.Ki_entry)
pid_layout.addWidget(_label("Kd:"))
self.Kd_entry = QLineEdit("0")
self.Kd_entry.setFixedWidth(80)
pid_layout.addWidget(self.Kd_entry)
self.update_pid_btn = QPushButton("更新PID参数")
self.update_pid_btn.setStyleSheet(
"QPushButton { background: #0960D1; color: white; border: none;"
"border-radius: 6px; padding: 7px 16px; font-weight: bold; font-size: 14px; }"
"QPushButton:hover { background: #0856B8; }"
)
self.update_pid_btn.setCursor(Qt.PointingHandCursor)
self.update_pid_btn.clicked.connect(self._on_update_pid)
pid_layout.addWidget(self.update_pid_btn)
pid_layout.addStretch()
grid.addWidget(self.pid_panel, row, 0, 1, 2)
# --- B6: 手动开度面板(跨两列,内部标签固定148px) ---
row = 5
self.manual_panel = QWidget()
self.manual_panel.setStyleSheet("background: transparent;")
self.manual_panel.hide()
man_layout = QHBoxLayout(self.manual_panel)
man_layout.setContentsMargins(0, 0, 0, 0)
man_layout.setSpacing(6)
man_lbl = _label("设置开度:")
man_lbl.setFixedWidth(148)
man_layout.addWidget(man_lbl)
self.valve_entry = QLineEdit()
self.valve_entry.setFixedWidth(160)
man_layout.addWidget(self.valve_entry)
man_layout.addWidget(_unit_label("%"))
self.set_valve_btn = QPushButton("设置")
self.set_valve_btn.setStyleSheet(
"QPushButton { background: #0960D1; color: white; border: none;"
"border-radius: 6px; padding: 7px 16px; font-weight: bold; font-size: 14px; }"
"QPushButton:hover { background: #0856B8; }"
)
self.set_valve_btn.setCursor(Qt.PointingHandCursor)
self.set_valve_btn.clicked.connect(self._on_set_valve)
man_layout.addWidget(self.set_valve_btn)
man_layout.addStretch()
grid.addWidget(self.manual_panel, row, 0, 1, 2)
# --- B7: 控制启停行(跨两列) ---
row = 6
b7 = _transparent_widget()
b7h = QHBoxLayout(b7)
b7h.setContentsMargins(0, 0, 0, 0)
b7h.setSpacing(12)
self.start_btn = QPushButton("开始控制")
self.start_btn.setIcon(QIcon(os.path.join(_SRC_DIR, "start_control.svg")))
self.start_btn.setIconSize(QSize(18, 18))
self.start_btn.setProperty("cssClass", "action")
self.start_btn.setStyleSheet(
"QPushButton { background-color: #0F955D; color: white; border: none;"
"border-radius: 6px; padding: 9px 24px; font-weight: bold; font-size: 14px; }"
"QPushButton:hover { background-color: #0D8250; }"
)
self.start_btn.setCursor(Qt.PointingHandCursor)
self.start_btn.clicked.connect(self._on_toggle_control)
self.plot_btn = QPushButton("绘制图线")
self.plot_btn.setIcon(QIcon(os.path.join(_SRC_DIR, "plot.svg")))
self.plot_btn.setIconSize(QSize(18, 18))
self.plot_btn.setStyleSheet(
"QPushButton { background: white; color: #0960D1; border: 1.5px solid #0960D1;"
"border-radius: 6px; padding: 9px 20px; font-weight: bold; font-size: 14px; }"
"QPushButton:hover { background: #EBF3FE; }"
)
self.plot_btn.setCursor(Qt.PointingHandCursor)
self.plot_btn.clicked.connect(self._on_plot)
self.collect_data_cb = QCheckBox("同步收集数据集")
b7h.addWidget(self.start_btn)
b7h.addWidget(self.plot_btn)
b7h.addWidget(self.collect_data_cb)
grid.addWidget(b7, row, 0, 1, 2)
# ============================================================
# 以下方法完全兼容旧版 APImain_window.py 无需变动
# ============================================================
# ---- 模式切换 ----
def _on_mode_changed_internal(self, btn):
if btn == self.radio_pid:
mode = "PID"
self.rl_panel.hide()
self.manual_panel.hide()
self.pid_panel.show()
self.collect_data_cb.setEnabled(True)
self.model_combobox.setEnabled(False)
self.load_model_btn.setEnabled(False)
self.refresh_models_btn.setEnabled(False)
elif btn == self.radio_rl:
mode = "RL"
self.pid_panel.hide()
self.manual_panel.hide()
self.rl_panel.show()
self.collect_data_cb.setEnabled(True)
self.model_combobox.setEnabled(True)
self.load_model_btn.setEnabled(True)
self.refresh_models_btn.setEnabled(True)
elif btn == self.radio_manual:
mode = "MANUAL"
self.rl_panel.hide()
self.pid_panel.hide()
self.manual_panel.show()
self.collect_data_cb.setChecked(False)
self.collect_data_cb.setEnabled(False)
else:
mode = "RL"
self.mode_changed.emit(mode)
def init_mode_ui(self):
self.rl_panel.show()
self.pid_panel.hide()
self.manual_panel.hide()
def set_mode_switch_enabled(self, enabled: bool):
self.radio_pid.setEnabled(enabled)
self.radio_rl.setEnabled(enabled)
self.radio_manual.setEnabled(enabled)
# ---- 信号处理 ----
def _on_set_target(self):
try:
target = float(self.target_entry.text())
if 0 <= target <= 3000:
self.target_set_requested.emit(target)
else:
self.target_set_requested.emit(-1)
except ValueError:
self.target_set_requested.emit(-1)
def _on_load_model(self):
selected = self.model_combobox.currentText()
self.model_load_requested.emit(selected)
def _on_refresh_models(self):
self.models_refresh_requested.emit()
def _on_update_pid(self):
try:
kp = float(self.Kp_entry.text())
ki = float(self.Ki_entry.text())
kd = float(self.Kd_entry.text())
self.pid_update_requested.emit(kp, ki, kd)
except ValueError:
self.log_message_requested.emit("错误: PID参数输入无效,请输入有效数字")
def _on_set_valve(self):
try:
valve = float(self.valve_entry.text())
if 0 <= valve <= 120:
self.manual_valve_set_requested.emit(valve)
else:
self.manual_valve_set_requested.emit(-1)
except ValueError:
self.manual_valve_set_requested.emit(-2)
def _on_toggle_control(self):
self.control_toggle_requested.emit()
def _on_plot(self):
self.plot_requested.emit()
# ---- 公开方法 (由 main_window 调用) ----
def set_control_running(self, running: bool):
if running:
self.start_btn.setText("停止控制")
self.start_btn.setIcon(QIcon())
self.start_btn.setProperty("cssClass", "danger")
self.start_btn.setStyleSheet(
"QPushButton { background-color: #EF4444; color: white; border: none;"
"border-radius: 6px; padding: 9px 24px; font-weight: bold; font-size: 14px; }"
"QPushButton:hover { background-color: #DC2626; }"
)
else:
self.start_btn.setText("开始控制")
self.start_btn.setIcon(QIcon(os.path.join(_SRC_DIR, "start_control.svg")))
self.start_btn.setIconSize(QSize(18, 18))
self.start_btn.setProperty("cssClass", "action")
self.start_btn.setStyleSheet(
"QPushButton { background-color: #0F955D; color: white; border: none;"
"border-radius: 6px; padding: 9px 24px; font-weight: bold; font-size: 14px; }"
"QPushButton:hover { background-color: #0D8250; }"
)
self.start_btn.style().unpolish(self.start_btn)
self.start_btn.style().polish(self.start_btn)
def update_display(self, pressure: float, target: float, valve: float):
self.current_pressure_lbl.setText(f"{pressure:.1f}")
self.target_pressure_lbl.setText(f"{target:.1f}")
self.valve_opening_lbl.setText(f"{valve:.1f}")
def update_pid_entries(self, kp: float, ki: float, kd: float):
self.Kp_entry.setText(f"{kp:.3f}")
self.Ki_entry.setText(f"{ki:.3f}")
self.Kd_entry.setText(f"{kd:.3f}")
def update_model_list(self, files: list):
self.model_combobox.clear()
if files:
self.model_combobox.addItems(files)
else:
self.model_combobox.addItem("无模型文件")
def get_mode(self) -> str:
if self.radio_pid.isChecked():
return "PID"
elif self.radio_manual.isChecked():
return "MANUAL"
return "RL"
def get_collect_data(self) -> bool:
return self.collect_data_cb.isChecked()
def get_control_params(self) -> dict:
return {
"volume": float(self.volume_entry.text() or "0"),
"flow": float(self.flow_entry.text() or "100"),
}
def get_pid_params(self) -> tuple:
return (
float(self.Kp_entry.text() or "1.0"),
float(self.Ki_entry.text() or "0.4"),
float(self.Kd_entry.text() or "0"),
)
def get_manual_valve(self) -> float:
return float(self.valve_entry.text() or "0")
def enable_plot_button(self, enable: bool):
self.plot_btn.setEnabled(enable)
def set_pid_entries_text(self, kp, ki, kd):
self.Kp_entry.setText(str(kp))
self.Ki_entry.setText(str(ki))
self.Kd_entry.setText(str(kd))