update server
@@ -0,0 +1,80 @@
|
||||
.lic
|
||||
|
||||
# =====================
|
||||
# Python
|
||||
# =====================
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
*.egg
|
||||
dist/
|
||||
build/
|
||||
*.whl
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# =====================
|
||||
# Virtual environments
|
||||
# =====================
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
.env/
|
||||
|
||||
# =====================
|
||||
# C extensions / Cython
|
||||
# =====================
|
||||
*.pyd
|
||||
*.so
|
||||
*.c
|
||||
*.exp
|
||||
*.lib
|
||||
*.obj
|
||||
|
||||
# =====================
|
||||
# IDE / Editor
|
||||
# =====================
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# =====================
|
||||
# macOS
|
||||
# =====================
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
|
||||
# =====================
|
||||
# Build artifacts
|
||||
# =====================
|
||||
build_libs/temp/
|
||||
|
||||
# =====================
|
||||
# Secrets & keys
|
||||
# =====================
|
||||
license_private.pem
|
||||
*.key
|
||||
.env.local
|
||||
*.local
|
||||
|
||||
# =====================
|
||||
# Logs & runtime data
|
||||
# =====================
|
||||
*.log
|
||||
data_record/
|
||||
ind_data/
|
||||
model_config/
|
||||
|
||||
# =====================
|
||||
# Distribution / packaging
|
||||
# =====================
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.dmg
|
||||
*.app
|
||||
installer/
|
||||
@@ -0,0 +1,818 @@
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
from pymodbus.client import ModbusTcpClient, ModbusSerialClient
|
||||
from pymodbus.exceptions import ModbusException
|
||||
from pymodbus.payload import BinaryPayloadDecoder, BinaryPayloadBuilder
|
||||
from pymodbus.constants import Endian
|
||||
|
||||
|
||||
def _motor_log(msg: str):
|
||||
"""电机操作日志,直接写文件 + 刷盘"""
|
||||
try:
|
||||
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, "control_debug.log")
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(f"[{time.strftime('%H:%M:%S.%f')[:-3]}] [MOTOR] {msg}\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
volthege_min = 819 # 模拟量映射最小值(对应 0V/4mA)
|
||||
volthege_max = 4095 # 模拟量映射最大值(对应 10V/20mA)
|
||||
x_max = 1000 # 最大行程
|
||||
impulse_max = 163840 # 电机脉冲最大值(对应 x_max)
|
||||
|
||||
|
||||
def set_motor_limits(volthege_min_val=None, volthege_max_val=None, x_max_val=None):
|
||||
"""更新电机限幅参数(由 GUI 高级设置页面调用)
|
||||
|
||||
Args:
|
||||
volthege_min_val: 模拟量映射最小值,None 表示不更新
|
||||
volthege_max_val: 模拟量映射最大值,None 表示不更新
|
||||
x_max_val: 最大行程(对应 GUI 总限幅),None 表示不更新
|
||||
"""
|
||||
global volthege_min, volthege_max, x_max
|
||||
if volthege_min_val is not None:
|
||||
volthege_min = volthege_min_val
|
||||
if volthege_max_val is not None:
|
||||
volthege_max = volthege_max_val
|
||||
if x_max_val is not None:
|
||||
x_max = x_max_val
|
||||
|
||||
# ---------- 原有的 PLC Modbus TCP 客户端类 ----------
|
||||
class Easy521ModbusClient:
|
||||
# 参数来源(GUI 页面1 Modbus TCP 区):
|
||||
# host <- PLC地址 (默认 192.168.1.88)
|
||||
# port <- 端口 (默认 502)
|
||||
# current_p_addr <- 读取压力寄存器地址 (默认 504)
|
||||
def __init__(self, host="192.168.1.88", port=502, slave_id=1, current_p_addr=504):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.slave_id = slave_id
|
||||
self.client = ModbusTcpClient(
|
||||
host=host,
|
||||
port=port,
|
||||
timeout=3,
|
||||
retries=3
|
||||
)
|
||||
self.connected = False
|
||||
self.current_p_addr0 = current_p_addr
|
||||
self.current_p_addr = current_p_addr
|
||||
# self.current_p_addr = 18
|
||||
self.target_p_addr = 42
|
||||
self.u_addr = 514
|
||||
# self.u_addr = 40
|
||||
# self.output_postion = 514
|
||||
self.control_flag_addr = 100
|
||||
self.M901_ADDR = 901
|
||||
self.M902_ADDR = 902
|
||||
self.M903_ADDR = 903
|
||||
self.M904_ADDR = 904
|
||||
self.M905_ADDR = 905
|
||||
self.M906_ADDR = 906
|
||||
self.q_addr = 512
|
||||
|
||||
def connect(self):
|
||||
try:
|
||||
connection = self.client.connect()
|
||||
if connection:
|
||||
print(f"成功连接到 {self.host}:{self.port}")
|
||||
self.connected = True
|
||||
else:
|
||||
print(f"无法连接到 {self.host}:{self.port}")
|
||||
self.connected = False
|
||||
return connection
|
||||
except Exception as e:
|
||||
print(f"连接错误: {e}")
|
||||
self.connected = False
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
self.client.close()
|
||||
self.connected = False
|
||||
print("连接已关闭")
|
||||
|
||||
def read_float(self, address):
|
||||
try:
|
||||
address = int(address)
|
||||
result = self.client.read_input_registers(
|
||||
address=address,
|
||||
count=2,
|
||||
slave=self.slave_id
|
||||
)
|
||||
if not result.isError():
|
||||
decoder = BinaryPayloadDecoder.fromRegisters(
|
||||
result.registers,
|
||||
byteorder=Endian.BIG,
|
||||
wordorder=Endian.LITTLE
|
||||
)
|
||||
return decoder.decode_32bit_float()
|
||||
else:
|
||||
print(f"读取寄存器错误: {result}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"读取浮点数时发生错误: {e}")
|
||||
return None
|
||||
|
||||
def write_float(self, address, float_value):
|
||||
try:
|
||||
address = int(address)
|
||||
builder = BinaryPayloadBuilder(byteorder=Endian.LITTLE, wordorder=Endian.BIG)
|
||||
builder.add_32bit_float(float_value)
|
||||
payload = builder.to_registers()
|
||||
result = self.client.write_registers(
|
||||
address=address,
|
||||
values=payload,
|
||||
slave=self.slave_id
|
||||
)
|
||||
return not result.isError()
|
||||
except Exception as e:
|
||||
print(f"写入浮点数时发生错误: {e}")
|
||||
return False
|
||||
|
||||
def write_coil(self, address, value):
|
||||
try:
|
||||
address = int(address)
|
||||
result = self.client.write_coil(address=address, value=value, slave=self.slave_id)
|
||||
return not result.isError()
|
||||
except Exception as e:
|
||||
print(f"写入线圈时发生错误: {e}")
|
||||
return False
|
||||
|
||||
def get_current_p(self):
|
||||
return self.read_float(self.current_p_addr)
|
||||
|
||||
def get_current_p0(self):
|
||||
return self.read_float(self.current_p_addr0)
|
||||
|
||||
def get_target_p(self):
|
||||
return self.read_float(self.target_p_addr)
|
||||
|
||||
def get_current_q(self):
|
||||
return self.read_float(self.q_addr)
|
||||
|
||||
def write_u(self, float_value):
|
||||
try:
|
||||
address = int(self.u_addr)
|
||||
builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.LITTLE)
|
||||
builder.add_32bit_float(float_value)
|
||||
payload = builder.to_registers()
|
||||
result = self.client.write_registers(
|
||||
address=address,
|
||||
values=payload,
|
||||
slave=self.slave_id
|
||||
)
|
||||
return not result.isError()
|
||||
except Exception as e:
|
||||
print(f"写入浮点数时发生错误: {e}")
|
||||
return False
|
||||
|
||||
def start_control(self):
|
||||
success = self.write_coil(self.control_flag_addr, True)
|
||||
if success:
|
||||
print("成功写入控制标志位True")
|
||||
else:
|
||||
print("写入控制标志位失败")
|
||||
|
||||
def stop_control(self):
|
||||
success = self.write_coil(self.control_flag_addr, False)
|
||||
if success:
|
||||
print("成功写入控制标志位False")
|
||||
else:
|
||||
print("写入控制标志位失败")
|
||||
|
||||
def read_rtu_flow(self, port='COM3', slave_id=2, baudrate=9600, bytesize=8, parity='N', stopbits=1):
|
||||
client = ModbusSerialClient(
|
||||
port=port,
|
||||
baudrate=baudrate,
|
||||
bytesize=bytesize,
|
||||
parity=parity,
|
||||
stopbits=stopbits,
|
||||
timeout=3
|
||||
)
|
||||
if not client.connect():
|
||||
print(f"无法连接到串口 {port}")
|
||||
return None
|
||||
|
||||
try:
|
||||
result = client.read_holding_registers(address=22, count=2, slave=slave_id)
|
||||
if result.isError():
|
||||
print(f"RTU 读取寄存器错误: {result}")
|
||||
return None
|
||||
decoder = BinaryPayloadDecoder.fromRegisters(
|
||||
result.registers,
|
||||
byteorder=Endian.BIG,
|
||||
wordorder=Endian.BIG
|
||||
)
|
||||
value = decoder.decode_32bit_uint() / 100
|
||||
return value
|
||||
except Exception as e:
|
||||
return None
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def start_up(self):
|
||||
if self.write_coil(self.M901_ADDR, True):
|
||||
print("M901 置位 TRUE")
|
||||
time.sleep(1)
|
||||
if self.write_coil(self.M901_ADDR, False):
|
||||
print("M901 复位 FALSE")
|
||||
else:
|
||||
print("警告:M901 复位失败")
|
||||
else:
|
||||
print("警告:M901 置位失败")
|
||||
time.sleep(1)
|
||||
|
||||
if self.write_coil(self.M902_ADDR, True):
|
||||
print("M902 置位 TRUE")
|
||||
time.sleep(1)
|
||||
if self.write_coil(self.M902_ADDR, False):
|
||||
print("M902 复位 FALSE")
|
||||
else:
|
||||
print("警告:M902 复位失败")
|
||||
else:
|
||||
print("警告:M902 置位失败")
|
||||
time.sleep(1)
|
||||
|
||||
if self.write_coil(self.M905_ADDR, True):
|
||||
print("M905 (初始开度) 置位 TRUE")
|
||||
time.sleep(1)
|
||||
if self.write_coil(self.M905_ADDR, False):
|
||||
print("M905 (初始开度) 复位 FALSE")
|
||||
else:
|
||||
print("警告:M905 (初始开度) 复位失败")
|
||||
else:
|
||||
print("警告:M905 (初始开度) 置位失败")
|
||||
time.sleep(1)
|
||||
|
||||
if self.write_coil(self.M903_ADDR, True):
|
||||
print("M903 置位 TRUE(持续)")
|
||||
else:
|
||||
print("警告:M903 置位失败")
|
||||
|
||||
if self.write_coil(self.M904_ADDR, True):
|
||||
print("M904 置位 TRUE(持续)")
|
||||
else:
|
||||
print("警告:M904 置位失败")
|
||||
time.sleep(1)
|
||||
|
||||
if self.write_coil(self.M906_ADDR, True):
|
||||
print("M906 (归零) 置位 TRUE")
|
||||
time.sleep(1)
|
||||
if self.write_coil(self.M906_ADDR, False):
|
||||
print("M906 (归零) 复位 FALSE")
|
||||
else:
|
||||
print("警告:M906 (归零) 复位失败")
|
||||
else:
|
||||
print("警告:M906 (归零) 置位失败")
|
||||
|
||||
print("初始化完成,M903 和 M904 已保持为 TRUE。")
|
||||
|
||||
|
||||
# ---------- 新增:独立的电机 Modbus RTU 客户端类(含报文打印) ----------
|
||||
class MotorModbusRTUClient:
|
||||
"""电机 Modbus RTU 通讯客户端(增强调试报文打印)"""
|
||||
# 参数来源(GUI 页面1 Modbus RTU 区):
|
||||
# port <- 端口号 (下拉)
|
||||
# baudrate <- 波特率 (默认 115200)
|
||||
# slave_id <- 站号 (默认 4)
|
||||
# bytesize <- 数据位 (默认 8)
|
||||
# stopbits <- 停止位 (默认 1)
|
||||
# parity <- 校验位 None/Odd/Even -> 'N'/'O'/'E' (默认 'N')
|
||||
def __init__(self, port='/dev/cu.usbserial-BG02B0IX', slave_id=4, baudrate=115200,
|
||||
bytesize=8, parity='N', stopbits=1):
|
||||
# def __init__(self, port='/dev/cu.usbserial-D30JITMY', slave_id=4, baudrate=115200):
|
||||
self.port = port
|
||||
self.slave_id = slave_id
|
||||
self.baudrate = baudrate
|
||||
self.bytesize = bytesize
|
||||
self.parity = parity
|
||||
self.stopbits = stopbits
|
||||
self.client = None
|
||||
|
||||
def connect(self):
|
||||
"""连接电机串口"""
|
||||
self.client = ModbusSerialClient(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
bytesize=self.bytesize,
|
||||
parity=self.parity,
|
||||
stopbits=self.stopbits,
|
||||
timeout=5 # 超时时间延长,便于观察
|
||||
)
|
||||
if self.client.connect():
|
||||
print(f"电机串口 {self.port} 连接成功")
|
||||
return True
|
||||
else:
|
||||
print(f"电机串口 {self.port} 连接失败")
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""断开电机串口"""
|
||||
if self.client:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
print("电机串口已关闭")
|
||||
|
||||
@staticmethod
|
||||
def _compute_crc(data: bytes) -> bytes:
|
||||
"""计算 Modbus CRC-16"""
|
||||
crc = 0xFFFF
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
if crc & 1:
|
||||
crc = (crc >> 1) ^ 0xA001
|
||||
else:
|
||||
crc >>= 1
|
||||
return crc.to_bytes(2, byteorder='little')
|
||||
|
||||
def _print_sent_message(self, address, function_code, data_bytes):
|
||||
"""构造完整报文并打印(含CRC)"""
|
||||
raw = bytes([self.slave_id, function_code]) + address.to_bytes(2, byteorder='big') + data_bytes
|
||||
crc = self._compute_crc(raw)
|
||||
full_msg = raw + crc
|
||||
hex_str = ' '.join(f'{b:02X}' for b in full_msg)
|
||||
# print(f"[发送] {hex_str}")
|
||||
|
||||
def _write_single_register(self, address, value):
|
||||
"""写单个寄存器(功能码 06)"""
|
||||
data_bytes = value.to_bytes(2, byteorder='big')
|
||||
self._print_sent_message(address, 0x06, data_bytes)
|
||||
|
||||
try:
|
||||
result = self.client.write_register(address, value, slave=self.slave_id)
|
||||
if result.isError():
|
||||
print(f"[接收] 错误响应: {result}")
|
||||
return False
|
||||
else:
|
||||
# print(f"[接收] 成功")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[接收] 异常: {e}")
|
||||
return False
|
||||
|
||||
def _write_32bit(self, address, value):
|
||||
"""
|
||||
写 32 位值到两个连续寄存器(功能码 10)
|
||||
字节序:大端(高字节在前,高字在前)
|
||||
"""
|
||||
builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.BIG)
|
||||
builder.add_32bit_uint(value)
|
||||
payload = builder.to_registers()
|
||||
# 构造数据部分:字节计数 + 各寄存器大端两字节
|
||||
data_bytes = bytes([len(payload) * 2])
|
||||
for reg in payload:
|
||||
data_bytes += reg.to_bytes(2, byteorder='big')
|
||||
self._print_sent_message(address, 0x10, data_bytes)
|
||||
|
||||
try:
|
||||
result = self.client.write_registers(address, payload, slave=self.slave_id)
|
||||
if result.isError():
|
||||
print(f"[接收] 错误响应: {result}")
|
||||
return False
|
||||
else:
|
||||
# print(f"[接收] 成功")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[接收] 异常: {e}")
|
||||
return False
|
||||
|
||||
def init(self):
|
||||
"""初始化电机参数(仅需调用一次)"""
|
||||
if not self.client or not self.client.connected:
|
||||
print("电机未连接,请先调用 connect()")
|
||||
return False
|
||||
|
||||
print("开始初始化电机参数...")
|
||||
success = True
|
||||
|
||||
# 1. 写模式 4 → 0x6007
|
||||
print("--- 步骤1: 写模式 4 到 0x6007 ---")
|
||||
if not self._write_single_register(0x6007, 4):
|
||||
success = False
|
||||
print("模式写入失败")
|
||||
else:
|
||||
print("模式写入成功")
|
||||
|
||||
# 2. 写速度 64000 → 0x6072
|
||||
print("--- 步骤2: 写速度 64000 到 0x6072 ---")
|
||||
if not self._write_32bit(0x6072, 64000):
|
||||
success = False
|
||||
print("速度写入失败")
|
||||
else:
|
||||
print("速度写入成功")
|
||||
|
||||
# 3. 写加速度 2400000 → 0x6067
|
||||
print("--- 步骤3: 写加速度 96000 到 0x6067 ---")
|
||||
if not self._write_32bit(0x6067, 96000):
|
||||
success = False
|
||||
print("加速度写入失败")
|
||||
else:
|
||||
print("加速度写入成功")
|
||||
|
||||
# 4. 写减速度 240000 → 0x6069
|
||||
print("--- 步骤4: 写减速度 96000 到 0x6069 ---")
|
||||
if not self._write_32bit(0x6069, 96000):
|
||||
success = False
|
||||
print("减速度写入失败")
|
||||
else:
|
||||
print("减速度写入成功")
|
||||
|
||||
if success:
|
||||
print("电机初始化完成。")
|
||||
else:
|
||||
print("电机初始化过程中出现错误。")
|
||||
return success
|
||||
|
||||
def _read_single_register(self, address):
|
||||
"""读取单个16位寄存器"""
|
||||
try:
|
||||
result = self.client.read_holding_registers(address, 1, slave=self.slave_id)
|
||||
if not result.isError():
|
||||
return result.registers[0]
|
||||
else:
|
||||
print(f"读取寄存器 0x{address:X} 失败: {result}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"读取寄存器 0x{address:X} 异常: {e}")
|
||||
return None
|
||||
|
||||
def set_position(self, position):
|
||||
"""设置目标位置并立即启动(位置 0~impulse_max"""
|
||||
if not self.client or not self.client.connected:
|
||||
print("电机未连接,请先调用 connect()")
|
||||
return False
|
||||
|
||||
position = int(position / x_max * impulse_max)
|
||||
|
||||
if not (0 <= position <= impulse_max):
|
||||
print(f"位置值 {position} 超出范围 (0~{impulse_max})")
|
||||
return False
|
||||
|
||||
self._write_32bit(0x6074, position)
|
||||
ret2 = self._write_single_register(0x6070, 112)
|
||||
if not ret2:
|
||||
print("第一次写入控制字失败,1ms后重试...")
|
||||
time.sleep(0.001)
|
||||
ret2 = self._write_single_register(0x6070, 112)
|
||||
if ret2:
|
||||
print("第二次重试成功")
|
||||
else:
|
||||
print("第二次重试仍然失败")
|
||||
|
||||
return True
|
||||
|
||||
def read_current_position(self):
|
||||
"""
|
||||
读取电机当前位置(INT 型,32位有符号整数)
|
||||
从寄存器 0x600E 开始,连续读取 2 个保持寄存器
|
||||
字节序:大端(与写操作一致)
|
||||
:return: 当前位置值(int),读取失败返回 None
|
||||
"""
|
||||
if not self.client or not self.client.connected:
|
||||
print("电机未连接,无法读取位置")
|
||||
return None
|
||||
|
||||
try:
|
||||
result = self.client.read_holding_registers(
|
||||
address=0x600E,
|
||||
count=2,
|
||||
slave=self.slave_id
|
||||
)
|
||||
if result.isError():
|
||||
print(f"读取位置寄存器失败: {result}")
|
||||
return None
|
||||
|
||||
# 解码为 32 位有符号整数,使用与写操作相同的大端字节序
|
||||
decoder = BinaryPayloadDecoder.fromRegisters(
|
||||
result.registers,
|
||||
byteorder=Endian.BIG,
|
||||
wordorder=Endian.BIG
|
||||
)
|
||||
position = decoder.decode_32bit_int()
|
||||
print(f"当前位置position: {position}")
|
||||
position_x = position / impulse_max * x_max
|
||||
return position_x
|
||||
except Exception as e:
|
||||
print(f"读取当前位置异常: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ---------- PC读取压力值 ----------
|
||||
class PressureModbusRTUClient:
|
||||
"""压力变送器 Modbus RTU 通讯客户端(读取16位压力值)"""
|
||||
def __init__(self, port='/dev/cu.usbserial-D30JITMY', slave_id=1, baudrate=9600, bytesize=8, parity='N', stopbits=1, timeout=3):
|
||||
"""
|
||||
初始化压力客户端
|
||||
:param port: 串口端口,如 COM3、/dev/ttyUSB0
|
||||
:param slave_id: 从站地址(站号),默认 1
|
||||
:param baudrate: 波特率,默认 9600
|
||||
:param bytesize: 数据位,默认 8
|
||||
:param parity: 校验位,默认 'N'(无校验)
|
||||
:param stopbits: 停止位,默认 1
|
||||
:param timeout: 通讯超时时间(秒),默认 3
|
||||
"""
|
||||
self.port = port
|
||||
self.slave_id = slave_id
|
||||
self.baudrate = baudrate
|
||||
self.bytesize = bytesize
|
||||
self.parity = parity
|
||||
self.stopbits = stopbits
|
||||
self.timeout = timeout
|
||||
self.client = None
|
||||
self.pressure_register_addr = 4 # 压力寄存器地址(04)
|
||||
|
||||
def connect(self):
|
||||
"""连接压力变送器串口"""
|
||||
self.client = ModbusSerialClient(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
bytesize=self.bytesize,
|
||||
parity=self.parity,
|
||||
stopbits=self.stopbits,
|
||||
timeout=self.timeout
|
||||
)
|
||||
if self.client.connect():
|
||||
print(f"压力串口 {self.port} 连接成功 (站号 {self.slave_id})")
|
||||
return True
|
||||
else:
|
||||
print(f"压力串口 {self.port} 连接失败")
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""断开压力串口"""
|
||||
if self.client:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
print("压力串口已关闭")
|
||||
|
||||
def get_current_p(self):
|
||||
"""
|
||||
读取压力值
|
||||
:return: 压力值(整数),若读取失败返回 None
|
||||
"""
|
||||
if not self.client or not self.client.connected:
|
||||
print("压力客户端未连接,请先调用 connect()")
|
||||
return None
|
||||
|
||||
try:
|
||||
t1 = time.perf_counter()
|
||||
# 读取保持寄存器(功能码03),地址4,个数1
|
||||
result = self.client.read_holding_registers(
|
||||
address=self.pressure_register_addr,
|
||||
count=1,
|
||||
slave=self.slave_id
|
||||
)
|
||||
if result.isError():
|
||||
print(f"压力读取错误: {result}")
|
||||
return None
|
||||
# 返回寄存器的第一个值(16位整数)
|
||||
pressure_raw = result.registers[0]
|
||||
# print(f"读压力用时:{time.perf_counter() - t1:.3f}s")
|
||||
return pressure_raw
|
||||
except ModbusException as e:
|
||||
print(f"压力读取 Modbus 异常: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"压力读取未知异常: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ---------- 新增:MT2-AM8 模块 Modbus TCP 客户端 ----------
|
||||
class MT2AM8Client:
|
||||
"""
|
||||
艾莫迅 MT2-AM8 模块的 Modbus TCP 通讯类
|
||||
- 默认 IP:192.168.1.12,端口 502,模块地址(站号)默认为 1
|
||||
- 输入寄存器(AI):地址 0x00~0x03(对应 PLC 地址 30001~30004)
|
||||
- 保持寄存器(AO):地址 0x00~0x03(对应 PLC 地址 40001~40004)
|
||||
- 模拟量值范围:0~4095(对应 0~10V 或 0~20mA)
|
||||
"""
|
||||
def __init__(self, host="192.168.1.12", port=502, slave_id=1,
|
||||
pressure_range=400, flow_range=300):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.slave_id = slave_id
|
||||
self.pressure_range = pressure_range # 压力表量程上限
|
||||
self.flow_range = flow_range # 流量计量程上限
|
||||
self.client = ModbusTcpClient(
|
||||
host=host,
|
||||
port=port,
|
||||
timeout=3,
|
||||
retries=3
|
||||
)
|
||||
self.connected = False
|
||||
|
||||
def connect(self):
|
||||
"""连接模块"""
|
||||
try:
|
||||
conn = self.client.connect()
|
||||
if conn:
|
||||
print(f"成功连接到 MT2-AM8 模块 {self.host}:{self.port}")
|
||||
self.connected = True
|
||||
else:
|
||||
print(f"无法连接到 {self.host}:{self.port}")
|
||||
self.connected = False
|
||||
return conn
|
||||
except Exception as e:
|
||||
print(f"连接错误: {e}")
|
||||
self.connected = False
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""断开连接"""
|
||||
self.client.close()
|
||||
self.connected = False
|
||||
print("连接已关闭")
|
||||
|
||||
def read_analog_input(self, channel):
|
||||
"""
|
||||
读取单路模拟量输入原始值(16位无符号整数)
|
||||
:param channel: 通道号 0~3(对应 AI1~AI4)
|
||||
:return: 0~4095 的整数值,失败返回 None
|
||||
"""
|
||||
try:
|
||||
result = self.client.read_input_registers(
|
||||
address=channel,
|
||||
count=1,
|
||||
slave=self.slave_id
|
||||
)
|
||||
if not result.isError():
|
||||
return result.registers[0]
|
||||
else:
|
||||
print(f"读取输入寄存器错误: {result}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"读取模拟量输入异常: {e}")
|
||||
return None
|
||||
|
||||
# def read_all_analog_inputs(self):
|
||||
# """
|
||||
# 一次性读取全部 4 路模拟量输入
|
||||
# :return: 长度为4的列表(int),失败返回 None
|
||||
# """
|
||||
# try:
|
||||
# result = self.client.read_input_registers(
|
||||
# address=0,
|
||||
# count=4,
|
||||
# slave=self.slave_id
|
||||
# )
|
||||
# if not result.isError():
|
||||
# return result.registers
|
||||
# else:
|
||||
# print(f"读取全部输入寄存器错误: {result}")
|
||||
# return None
|
||||
# except Exception as e:
|
||||
# print(f"读取全部模拟量输入异常: {e}")
|
||||
# return None
|
||||
|
||||
def write_analog_output(self, channel, value):
|
||||
"""
|
||||
写入单路模拟量输出(保持寄存器)
|
||||
:param channel: 通道号 0~3(对应 AO1~AO4)
|
||||
:param value: {volthege_min}~{volthege_max} 的整数值
|
||||
:return: True 成功,False 失败
|
||||
"""
|
||||
# if not 0 <= channel <= 3:
|
||||
# print("通道号必须为 0~3")
|
||||
# return False
|
||||
if not 0 <= value <= volthege_max:
|
||||
print(f"值 {value} 超出范围 ({volthege_min}~{volthege_max})")
|
||||
return False
|
||||
try:
|
||||
result = self.client.write_register(
|
||||
address=channel,
|
||||
value=value,
|
||||
slave=self.slave_id
|
||||
)
|
||||
return not result.isError()
|
||||
except Exception as e:
|
||||
print(f"写入模拟量输出异常: {e}")
|
||||
return False
|
||||
|
||||
# def write_all_analog_outputs(self, values):
|
||||
# """
|
||||
# 一次性写入全部 4 路模拟量输出(用于批量设置)
|
||||
# :param values: 长度为4的列表或元组,每个元素为 0~4095
|
||||
# :return: True 成功,False 失败
|
||||
# """
|
||||
# if len(values) != 4:
|
||||
# print("需提供 4 个输出值")
|
||||
# return False
|
||||
# for v in values:
|
||||
# if not 0 <= v <= 4095:
|
||||
# print(f"值 {v} 超出范围 (0~4095)")
|
||||
# return False
|
||||
# try:
|
||||
# result = self.client.write_registers(
|
||||
# address=0,
|
||||
# values=list(values),
|
||||
# slave=self.slave_id
|
||||
# )
|
||||
# return not result.isError()
|
||||
# except Exception as e:
|
||||
# print(f"批量写入模拟量输出异常: {e}")
|
||||
# return False
|
||||
|
||||
def get_pressure(self, channel):
|
||||
"""
|
||||
读取压力值并转换为实际物理量
|
||||
转换公式:raw / (volthege_max - volthege_min) * pressure_range
|
||||
:param channel: 压力传感器模拟量通道地址
|
||||
:return: 实际压力值(kPa),失败返回 None
|
||||
"""
|
||||
raw = self.read_analog_input(channel)
|
||||
if raw is None:
|
||||
return None
|
||||
pressure = (raw - volthege_min) / (volthege_max - volthege_min) * self.pressure_range
|
||||
# print(f"读取压力通道 {channel} 原始值: {raw}, 转换后压力: {pressure:.2f} kPa")
|
||||
return pressure
|
||||
|
||||
def get_flow(self, channel):
|
||||
"""
|
||||
读取流量值并转换为实际物理量
|
||||
转换公式:raw / (volthege_max - volthege_min) * flow_range
|
||||
:param channel: 流量计模拟量通道地址
|
||||
:return: 实际流量值(L/min),失败返回 None
|
||||
"""
|
||||
raw = self.read_analog_input(channel)
|
||||
if raw is None:
|
||||
return None
|
||||
flow = (raw - volthege_min) / (volthege_max - volthege_min) * self.flow_range
|
||||
# print(f"读取流量通道 {channel} 原始值: {raw}, 转换后流量: {flow:.2f} L/min")
|
||||
return flow
|
||||
|
||||
|
||||
# def set_motor_speed(self, voltage_percent):
|
||||
# """
|
||||
# 通过模拟量输出控制电机(例如 0~100% 对应 0~10V)
|
||||
# :param voltage_percent: 0~100 的浮点数,表示百分比
|
||||
# """
|
||||
# if not 0 <= voltage_percent <= 100:
|
||||
# print("百分比需在 0~100 之间")
|
||||
# return False
|
||||
# # 将百分比映射到 0~4095
|
||||
# raw_value = int(voltage_percent / 100.0 * 4095)
|
||||
# return self.write_analog_output(0, raw_value) # 假设电机接在 AO1
|
||||
|
||||
def set_motor_position(self, voltage_distance, channel=0):
|
||||
"""
|
||||
通过模拟量输出控制电机(例如 0~1000 对应 0~10V)
|
||||
:param voltage_distance: 0~1000 的浮点数,表示行程
|
||||
:param channel: 模拟量输出通道号,默认 0(AO1)
|
||||
"""
|
||||
if not (0 <= voltage_distance <= x_max):
|
||||
print(f"行程需在 0~{x_max} 之间")
|
||||
return False
|
||||
# 将行程映射到 0~4095
|
||||
raw_value = int(voltage_distance / x_max * volthege_max ) # 假设最小值对应 volthege_min
|
||||
# print(f"设置电机行程为 {voltage_distance},模拟量输出值 {raw_value}")
|
||||
return self.write_analog_output(channel, raw_value)
|
||||
|
||||
|
||||
# ---------- 主函数:测试示例 ----------
|
||||
# if __name__ == "__main__":
|
||||
# # (可选)启用 pymodbus 详细日志,可观察底层收发帧
|
||||
# # logging.basicConfig()
|
||||
# # logging.getLogger('pymodbus').setLevel(logging.DEBUG)
|
||||
#
|
||||
# motor = MotorModbusRTUClient()
|
||||
#
|
||||
#
|
||||
# print("连接电机 (Modbus RTU)...")
|
||||
# if not motor.connect():
|
||||
# print("电机连接失败,退出。")
|
||||
# exit(1)
|
||||
#
|
||||
# # 增加短暂延时,等待驱动器接口就绪
|
||||
# time.sleep(1)
|
||||
#
|
||||
# print("初始化电机参数...")
|
||||
# if not motor.init():
|
||||
# print("电机初始化失败,退出。")
|
||||
# motor.disconnect()
|
||||
# exit(1)
|
||||
#
|
||||
# print("\n========== 电机位置控制测试 ==========")
|
||||
# print("输入目标位置 (0~60000),输入 'q' 退出。函数已修改,只需输入开度。\n")
|
||||
#
|
||||
# try:
|
||||
# while True:
|
||||
# user_input = input("目标位置: ").strip()
|
||||
# if user_input.lower() in ('q', 'quit', 'exit'):
|
||||
# break
|
||||
# if not user_input:
|
||||
# continue
|
||||
# try:
|
||||
# pos = int(user_input)
|
||||
# motor.set_position(pos)
|
||||
# except ValueError:
|
||||
# print("错误:请输入有效的整数。")
|
||||
# except KeyboardInterrupt:
|
||||
# print("\n用户中断测试。")
|
||||
# finally:
|
||||
# motor.disconnect()
|
||||
# print("程序结束。")
|
||||
@@ -0,0 +1,85 @@
|
||||
# ReinLoop V1.0 — 收敛有界
|
||||
|
||||
基于 Modbus 通讯的压力控制 GUI,支持 PID / 强化学习 / 手动三种控制模式。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
pressure_control_gui/
|
||||
├── main.py # 应用入口
|
||||
├── PcControl.py # Modbus 通讯类
|
||||
│ # MT2AM8Client - MT2-AM8 模块 TCP(AI 读压力/流量,AO 写电机)
|
||||
│ # Easy521ModbusClient - PLC TCP(读压力/流量,备用)
|
||||
│ # MotorModbusRTUClient - 电机 RTU(直接写位置,备用)
|
||||
│ # PressureModbusRTUClient - 压力变送器 RTU(备用)
|
||||
├── controllers.py # 增量式 PID 控制器
|
||||
├── api.py # Express Server API 配置
|
||||
├── styles.py # 全局 QSS 样式表
|
||||
├── ind_collector.py # PRBS 辨识数据采集
|
||||
├── get_V.py # 容积测量
|
||||
├── license_utils.py # 许可证签发与校验
|
||||
├── core/
|
||||
│ ├── control_engine.py # 控制主循环(PID / RL / 手动),含 EMA 压力滤波
|
||||
│ ├── connection_manager.py# 连接管理器(封装 MT2AM8Client)
|
||||
│ ├── model_manager.py # RL 模型管理
|
||||
│ ├── data_collector.py # 数据采集与云端上传
|
||||
│ └── identification.py # 系统辨识与容积测量管理
|
||||
├── ui/
|
||||
│ ├── main_window.py # 主窗口(布局与信号槽绑定)
|
||||
│ ├── connection_tab.py # 连接设置页(Modbus TCP)
|
||||
│ ├── control_tab.py # 控制设置页
|
||||
│ ├── debug_tab.py # 模型调试页(高级参数:死区/限幅/模拟量映射)
|
||||
│ ├── status_bar.py # 底部状态栏
|
||||
│ └── plot_window.py # 数据绘图窗口
|
||||
├── src/ # SVG 图标资产
|
||||
├── model_config/ # RL 模型配置文件
|
||||
├── ind_data/ # 辨识数据本地输出目录
|
||||
└── tool/ # 本地调试与诊断工具
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
```bash
|
||||
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
## 控制模式
|
||||
|
||||
| 模式 | 说明 |
|
||||
|------|------|
|
||||
| **PID** | 增量式 PID,参数可在线调整,死区/限幅可配置 |
|
||||
| **RL** | 强化学习模型实时预测 Kp/Ki,需先加载模型 |
|
||||
| **手动** | 直接设定阀门开度百分比 |
|
||||
|
||||
控制周期由 PID 的 `dt` 参数决定(默认 0.1s),QTimer 驱动主线程执行,每周期末自动 sleep 补足时长保证精确周期。
|
||||
|
||||
## 硬件连接
|
||||
|
||||
GUI 主程序使用 **MT2-AM8 模拟量模块**(艾莫迅),通过 Modbus TCP 统一 IO:
|
||||
|
||||
- **MT2-AM8 模块**:Modbus TCP,默认 `192.168.1.12:502`,模块地址 1
|
||||
- AI(输入寄存器 0x00~0x03):读压力传感器(4-20mA → 0-4095 → kPa)、读流量计
|
||||
- AO(保持寄存器 0x00~0x03):写电机伺服驱动器(0-10V 模拟量控制行程)
|
||||
- 模拟量映射范围、压力/流量量程可在界面中配置
|
||||
|
||||
### PcControl.py 中其他可用通讯类
|
||||
|
||||
以下类在 GUI 主循环中**未直接使用**,但可供独立脚本(如 `get_V.py` 的 `main()`)或调试调用:
|
||||
|
||||
| 类 | 协议 | 默认参数 | 用途 |
|
||||
|---|---|---|---|
|
||||
| `Easy521ModbusClient` | Modbus TCP | `192.168.1.88:502` | 读 PLC 压力寄存器 504(32-bit float)、写线圈控制 |
|
||||
| `MotorModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-BG02B0IX`,站号 4,115200 | 通过 RS-485 直接读写电机驱动器寄存器 |
|
||||
| `PressureModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-D30JITMY`,站号 1,9600 | 读压力变送器保持寄存器(备用) |
|
||||
|
||||
## 数据上传
|
||||
|
||||
控制数据(Episode)、辨识数据和容积测量结果通过 Express Server 的上传接口保存,
|
||||
服务地址与设备目录配置在 `api.py`。许可证、模型和公司/产线等管理操作由
|
||||
ControlPanel 完成,不由客户端工具执行。
|
||||
@@ -0,0 +1,26 @@
|
||||
"""ReinLoop server endpoint configuration shared by core modules."""
|
||||
|
||||
import os
|
||||
|
||||
from license_utils import get_verified_license
|
||||
|
||||
|
||||
base_url = os.environ.get(
|
||||
"REINLOOP_SERVER_URL",
|
||||
"http://ReinLoop.dominatedconvergence.com",
|
||||
).rstrip("/")
|
||||
data_record_url = os.environ.get(
|
||||
"REINLOOP_API_URL",
|
||||
f"{base_url}/api",
|
||||
)
|
||||
_license = get_verified_license()
|
||||
_license_device_id = (_license or {}).get("device_id", "").strip()
|
||||
_environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip()
|
||||
|
||||
if _license_device_id and _environment_device_id and _license_device_id != _environment_device_id:
|
||||
raise RuntimeError("REINLOOP_DEVICE_ID 与许可证 device_id 不一致")
|
||||
|
||||
the_folder = _license_device_id or _environment_device_id or "local-test-device"
|
||||
|
||||
if not the_folder:
|
||||
raise RuntimeError("设备 ID 不能为空")
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"notice": "Legacy notice only. The client reads identification_config.csv from cloud storage and never reads this file."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"notice": "This file is not used by the client. Test creates one cloud request; the client waits for the company to upload a request-specific JSON file."
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
# controllers.py
|
||||
import os, sys, time
|
||||
|
||||
|
||||
def _pid_log(msg: str):
|
||||
"""PID 内部日志,直接写文件 + 刷盘"""
|
||||
try:
|
||||
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, "control_debug.log")
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(f"[{time.strftime('%H:%M:%S.%f')[:-3]}] [PID] {msg}\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class IncrementalPID:
|
||||
"""增量式PID控制器"""
|
||||
|
||||
def __init__(self, kp: float, ki: float, kd: float, dt: float,
|
||||
out_min: float, out_max: float, xa_full: float = 1062.5):
|
||||
# PID参数
|
||||
self.kp = kp
|
||||
self.ki = ki
|
||||
self.kd = kd
|
||||
self.dt = dt # 默认50HZ执行周期防止除零
|
||||
self.motor_max = 300.0
|
||||
self.du_max = self.motor_max * self.dt
|
||||
self.dead_area = 240.0
|
||||
self.xa_full = xa_full # 总限幅(最大行程)
|
||||
|
||||
# self.kp = 1.392
|
||||
# self.ki = 30.2
|
||||
# self.kd = 0.000485
|
||||
# self.dt = 0.0059
|
||||
|
||||
# 限幅设置
|
||||
self.out_min = out_min
|
||||
self.out_max = out_max
|
||||
|
||||
# 输入输出
|
||||
self.target_pressure = 0.0 # 参考值(设定压力大小)
|
||||
self.current_pressure = 0.0 # 反馈值
|
||||
self.error = 0.0 # 当前误差
|
||||
|
||||
# 计算系数
|
||||
self.a0 = 0.0
|
||||
self.a1 = 0.0
|
||||
self.a2 = 0.0
|
||||
self._calculate_coefficients()
|
||||
|
||||
# 控制器状态
|
||||
self.prev_error = 0.0 # 前次误差 e(k-1)
|
||||
self.prev_error2 = 0.0 # 前前次误差 e(k-2)
|
||||
self.output = 0.0 # 控制器总输出
|
||||
|
||||
def _calculate_coefficients(self):
|
||||
"""重新计算增量式PID系数"""
|
||||
if self.dt <= 0:
|
||||
return
|
||||
self.a0 = self.kp + (self.ki * self.dt / 2.0) + (2.0 * self.kd / self.dt)
|
||||
self.a1 = -self.kp + (self.ki * self.dt / 2.0) - (4.0 * self.kd / self.dt)
|
||||
self.a2 = (2.0 * self.kd) / self.dt
|
||||
|
||||
def update_pressure_values(self, current_pressure, target_pressure):
|
||||
"""更新当前压力和目标压力值"""
|
||||
self.current_pressure = current_pressure
|
||||
self.target_pressure = target_pressure
|
||||
|
||||
def update(self, du_max=None):
|
||||
# 计算当前误差
|
||||
self.error = -(self.target_pressure - self.current_pressure)
|
||||
|
||||
# if abs(self.error) < 1:
|
||||
# return self.output # 误差过小,直接返回当前输出
|
||||
|
||||
# 计算控制增量
|
||||
delta = (self.a0 * self.error + self.a1 * self.prev_error + self.a2 * self.prev_error2)
|
||||
|
||||
if du_max is not None:
|
||||
self.du_max = du_max
|
||||
else:
|
||||
self.du_max = self.get_du_max(self.target_pressure)
|
||||
|
||||
# 纯 Python 限幅(替代 np.clip)
|
||||
if delta > self.du_max:
|
||||
delta = self.du_max
|
||||
elif delta < -self.du_max:
|
||||
delta = -self.du_max
|
||||
|
||||
# 计算新输出
|
||||
new_output = self.output + delta
|
||||
# print(f"output:{self.output}, delta:{delta}, new_output:{new_output}")
|
||||
|
||||
# 应用输出限幅
|
||||
new_output = max(self.out_min, min(self.out_max, new_output))
|
||||
|
||||
# 更新历史状态
|
||||
self.prev_error2 = self.prev_error
|
||||
self.prev_error = self.error
|
||||
self.output = new_output
|
||||
return new_output
|
||||
|
||||
def reset(self):
|
||||
"""重置PID控制器状态(保留参数)"""
|
||||
self.prev_error = 0.0
|
||||
self.prev_error2 = 0.0
|
||||
self.output = 0.0
|
||||
|
||||
def update_parameters(self, kp: float, ki: float, kd: float):
|
||||
self.kp = kp
|
||||
self.ki = ki
|
||||
self.kd = kd
|
||||
self._calculate_coefficients()
|
||||
|
||||
def set_du_max(self, value):
|
||||
"""设置 du_max(供外部模块通过方法调用设置,避免跨 .pyd 属性写入 crash)"""
|
||||
self.du_max = value
|
||||
|
||||
def get_du_max(self, target_pressure):
|
||||
"""根据目标压力计算 PID 最大增量限幅(纯 Python 线性插值)"""
|
||||
x = float(target_pressure)
|
||||
dt = float(self.dt)
|
||||
|
||||
if x <= 0.0:
|
||||
val = 500.0 * dt
|
||||
|
||||
elif x >= 200.0:
|
||||
val = 250.0 * dt
|
||||
|
||||
elif x <= 100.0:
|
||||
# 0~100:从 500 线性下降到 300
|
||||
val = (500.0 - 2.0 * x) * dt
|
||||
|
||||
else:
|
||||
# 100~200:从 300 线性下降到 250
|
||||
val = (350.0 - 0.5 * x) * dt
|
||||
|
||||
return val
|
||||
|
||||
def init_v(self, position_x):
|
||||
v = (self.xa_full - position_x) / (self.xa_full - self.dead_area) * 100
|
||||
return v
|
||||
@@ -0,0 +1,39 @@
|
||||
# core/__init__.py
|
||||
"""core 包 —— 业务逻辑层。
|
||||
|
||||
模块级许可证校验:import 此包的瞬间自动执行验签。
|
||||
两个文件均编译为 .pyd → 无法被篡改绕过。
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
# ============================================================
|
||||
# 模块级验签 —— 每次 import core.xxx 必然触发
|
||||
# 效果等同于在 main.py 中调用 check_license(),
|
||||
# 但此文件编译进 .pyd,攻击者无法删除或修改。
|
||||
# ============================================================
|
||||
_LICENSE_CHECKED = False
|
||||
|
||||
|
||||
def _init_license():
|
||||
"""在模块加载时自动调用一次,验证许可证。"""
|
||||
global _LICENSE_CHECKED
|
||||
if _LICENSE_CHECKED:
|
||||
return
|
||||
|
||||
# 开发环境(非 PyInstaller 打包)→ 直接跳过,不打扰
|
||||
# if not getattr(sys, 'frozen', False):
|
||||
# print("[core] 开发环境:跳过许可证校验")
|
||||
# _LICENSE_CHECKED = True
|
||||
# return
|
||||
|
||||
# 生产环境(exe 打包)→ 严格执行验签
|
||||
from license_utils import check_license
|
||||
|
||||
check_license() # 验签并启动唯一的后台巡检线程,失败直接退出
|
||||
|
||||
_LICENSE_CHECKED = True
|
||||
|
||||
|
||||
# 导入时立即执行
|
||||
_init_license()
|
||||
@@ -0,0 +1,132 @@
|
||||
# connection_manager.py
|
||||
"""连接管理器:负责 MT2-AM8 模块 (Modbus TCP) 的连接/断开。
|
||||
|
||||
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
||||
"""
|
||||
|
||||
import time
|
||||
from PcControl import MT2AM8Client
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""管理 MT2-AM8 模块连接的生命周期"""
|
||||
|
||||
def __init__(self):
|
||||
self.modbus_client = None # MT2AM8Client (TCP 读压力/流量,控电机)
|
||||
self._pressure_addr = 0 # 压力传感器模拟量通道地址
|
||||
self._flowmeter_addr = None # 流量计模拟量通道地址(None=使用手动输入)
|
||||
self._motor_addr = 0 # 电机模拟量输出通道地址
|
||||
self._on_log = None # 日志回调
|
||||
self._on_status_change = None # 状态变化回调
|
||||
|
||||
def set_log_callback(self, callback):
|
||||
"""设置日志回调: callback(message: str)"""
|
||||
self._on_log = callback
|
||||
|
||||
def set_status_callback(self, callback):
|
||||
"""设置状态变化回调: callback(connected: bool, status_text: str)"""
|
||||
self._on_status_change = callback
|
||||
|
||||
def log(self, message):
|
||||
"""内部日志输出"""
|
||||
if self._on_log:
|
||||
self._on_log(message)
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""检查是否已连接"""
|
||||
return self.modbus_client is not None and self.modbus_client.connected
|
||||
|
||||
def connect(self, tcp_ip: str, tcp_port: int, pressure_addr: int,
|
||||
motor_addr: int, flowmeter_addr: int,
|
||||
pressure_range: float = 400, flow_range: float = 100) -> bool:
|
||||
"""连接到 MT2-AM8 模块
|
||||
|
||||
Args:
|
||||
tcp_ip: 模块 IP 地址
|
||||
tcp_port: TCP 端口
|
||||
pressure_addr: 压力传感器模拟量通道地址
|
||||
motor_addr: 电机模拟量输出通道地址
|
||||
flowmeter_addr: 流量计模拟量通道地址
|
||||
pressure_range: 压力表量程上限
|
||||
flow_range: 流量计量程上限
|
||||
|
||||
Returns:
|
||||
是否连接成功
|
||||
"""
|
||||
try:
|
||||
self.log("正在连接设备...")
|
||||
|
||||
# 保存地址配置
|
||||
self._pressure_addr = pressure_addr
|
||||
self._motor_addr = motor_addr
|
||||
self._flowmeter_addr = flowmeter_addr
|
||||
|
||||
# 创建 MT2-AM8 客户端
|
||||
self.modbus_client = MT2AM8Client(
|
||||
host=tcp_ip,
|
||||
port=tcp_port,
|
||||
pressure_range=pressure_range,
|
||||
flow_range=flow_range,
|
||||
)
|
||||
|
||||
if not self.modbus_client.connect():
|
||||
self.log(f"连接 MT2-AM8 模块失败: {tcp_ip}:{tcp_port}")
|
||||
return False
|
||||
|
||||
self.log(f"成功连接到 MT2-AM8 模块: {tcp_ip}:{tcp_port}")
|
||||
self.log(f"压力地址: {pressure_addr}, 电机地址: {motor_addr}, 流量计地址: {flowmeter_addr}")
|
||||
|
||||
if self._on_status_change:
|
||||
self._on_status_change(True, "已连接")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"连接异常: {str(e)}")
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""断开连接"""
|
||||
if self.modbus_client:
|
||||
self.modbus_client.disconnect()
|
||||
self.modbus_client = None
|
||||
|
||||
self.log("已断开连接")
|
||||
|
||||
if self._on_status_change:
|
||||
self._on_status_change(False, "未连接")
|
||||
|
||||
def read_pressure(self):
|
||||
"""读取当前压力值(转换为实际物理量)
|
||||
|
||||
Returns:
|
||||
实际压力值 (kPa), 读取失败返回 None
|
||||
"""
|
||||
if not self.is_connected():
|
||||
return None
|
||||
return self.modbus_client.get_pressure(self._pressure_addr)
|
||||
|
||||
def read_flow(self):
|
||||
"""读取当前流量值(转换为实际物理量)
|
||||
|
||||
若未配置流量计地址,直接返回 None,由调用方使用控制栏手动输入值。
|
||||
|
||||
Returns:
|
||||
实际流量值 (L/min), 未配置地址或读取失败返回 None
|
||||
"""
|
||||
if not self.is_connected() or self._flowmeter_addr is None:
|
||||
return None
|
||||
return self.modbus_client.get_flow(self._flowmeter_addr)
|
||||
|
||||
def set_motor_position(self, xa: float) -> bool:
|
||||
"""设置电机位置(通过模拟量输出控制阀门开度)
|
||||
|
||||
Args:
|
||||
xa: 目标行程 (0~x_max)
|
||||
|
||||
Returns:
|
||||
是否设置成功
|
||||
"""
|
||||
if not self.is_connected():
|
||||
return False
|
||||
return self.modbus_client.set_motor_position(xa, channel=self._motor_addr)
|
||||
@@ -0,0 +1,329 @@
|
||||
# control_engine.py
|
||||
"""控制引擎:管理控制主循环,支持 PID / RL / MANUAL 三种模式。
|
||||
|
||||
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
||||
|
||||
设计原则:所有操作在主线程执行(QTimer 驱动),避免 Cython 编译后
|
||||
在 PyInstaller 子线程中 segfault。
|
||||
"""
|
||||
|
||||
import time
|
||||
import os
|
||||
import traceback
|
||||
import sys
|
||||
import logging
|
||||
import numpy as np
|
||||
from controllers import IncrementalPID
|
||||
|
||||
|
||||
logger = logging.getLogger("ReinLoop.ControlEngine")
|
||||
|
||||
|
||||
class ControlEngine:
|
||||
"""控制主引擎"""
|
||||
|
||||
def __init__(self, pid: IncrementalPID):
|
||||
self.pid = pid
|
||||
self._running = False
|
||||
self._cycle_count = 0
|
||||
self._last_tick = 0.0
|
||||
|
||||
# 缓存参数(start 时从 UI 获取)
|
||||
self.mode = "PID"
|
||||
self.flow = 0.0
|
||||
self.volume = 0.0
|
||||
self.target_pressure = 80.0
|
||||
self.manual_valve = 0.0
|
||||
self.dz = None
|
||||
self.motor_max = None
|
||||
self.xa_full = 1062.5
|
||||
self.collect_data = False
|
||||
|
||||
# 压力 EMA 滤波
|
||||
self.pressure_alpha = 1 # 平滑系数(0~1),越小越平滑
|
||||
self._pressure_filtered = None # 滤波后的压力值
|
||||
|
||||
# 外部依赖
|
||||
self._conn_mgr = None
|
||||
self._model_mgr = None
|
||||
self._data_collector = None
|
||||
|
||||
# RL 模式相关
|
||||
self.last_target_rl = None
|
||||
self.Kp_0 = 1.0
|
||||
self.Ki_0 = 0.4
|
||||
|
||||
# 回调
|
||||
self._on_log = None
|
||||
self._on_display_update = None
|
||||
self._on_pid_ui_update = None
|
||||
self._on_started = None
|
||||
self._on_stopped = None
|
||||
|
||||
# ---- 依赖注入 ----
|
||||
def set_connection_manager(self, mgr):
|
||||
self._conn_mgr = mgr
|
||||
|
||||
def set_model_manager(self, mgr):
|
||||
self._model_mgr = mgr
|
||||
|
||||
def set_data_collector(self, collector):
|
||||
self._data_collector = collector
|
||||
|
||||
# ---- 回调设置 ----
|
||||
def set_log_callback(self, cb):
|
||||
self._on_log = cb
|
||||
|
||||
def set_display_update_callback(self, cb):
|
||||
self._on_display_update = cb
|
||||
|
||||
def set_pid_ui_update_callback(self, cb):
|
||||
self._on_pid_ui_update = cb
|
||||
|
||||
def set_started_callback(self, cb):
|
||||
self._on_started = cb
|
||||
|
||||
def set_stopped_callback(self, cb):
|
||||
self._on_stopped = cb
|
||||
|
||||
def log(self, message):
|
||||
if self._on_log:
|
||||
self._on_log(message)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
# ---- 启动/停止 ----
|
||||
def start(self):
|
||||
"""启动控制循环(主线程调用)"""
|
||||
self.log("正在启动控制循环...")
|
||||
|
||||
if not self._conn_mgr or not self._conn_mgr.is_connected():
|
||||
self.log("启动失败: 请先连接压力表")
|
||||
return
|
||||
|
||||
if self.mode == "RL":
|
||||
if not self._model_mgr or not self._model_mgr.is_model_loaded():
|
||||
self.log("启动失败: 模型未加载,请先选择工况并点击【加载模型】按钮")
|
||||
return
|
||||
|
||||
# 预置初始阀位(读取当前电机位置)
|
||||
# try:
|
||||
# position_x = self._conn_mgr.read_motor_position()
|
||||
# initial_valve = self.pid.init_v(position_x)
|
||||
# self.pid.output = initial_valve
|
||||
# self.log(f"预置初始阀位 {initial_valve:.1f}%")
|
||||
# except Exception as e:
|
||||
# self.log(f"读取初始开度失败,将使用 80% 启动: {e}")
|
||||
# self.pid.output = 80.0
|
||||
|
||||
self.pid.output = 100.0
|
||||
|
||||
# 设置死区
|
||||
if self.dz is not None:
|
||||
self.pid.dead_area = self.dz
|
||||
|
||||
# RL 模式:在主线程预先完成模型预测
|
||||
if self.mode == "RL":
|
||||
try:
|
||||
current_p = self._conn_mgr.read_pressure()
|
||||
if current_p is None:
|
||||
current_p = 0.0
|
||||
self._rl_predict(current_p, self.target_pressure)
|
||||
self.log(f"RL 初始预测: Kp={self.pid.kp:.4f}, Ki={self.pid.ki:.4f}")
|
||||
except Exception as e:
|
||||
self.log(f"模型调用异常,使用默认pid: {e}")
|
||||
|
||||
# 重置状态
|
||||
self.last_target_rl = None
|
||||
self._pressure_filtered = None # 复位滤波器
|
||||
self._cycle_count = 0
|
||||
self._last_tick = time.perf_counter()
|
||||
|
||||
if self._data_collector:
|
||||
self._data_collector.reset()
|
||||
|
||||
self._running = True
|
||||
|
||||
if self._on_started:
|
||||
self._on_started()
|
||||
|
||||
self.log(f"控制循环已启动 (模式: {self.mode}, 目标: {self.target_pressure} kPa)")
|
||||
|
||||
def control_tick(self):
|
||||
"""主线程 QTimer 每次触发时调用——执行一个控制周期"""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
cycle_start = time.perf_counter()
|
||||
|
||||
try:
|
||||
# 1. 读取当前压力(原始值)
|
||||
raw_pressure = self._conn_mgr.read_pressure()
|
||||
if raw_pressure is None:
|
||||
self.log("读取当前压力失败,检查地址和连接")
|
||||
return
|
||||
|
||||
# EMA 低通滤波:平滑毛刺
|
||||
if self._pressure_filtered is None:
|
||||
self._pressure_filtered = raw_pressure
|
||||
else:
|
||||
self._pressure_filtered = (self.pressure_alpha * raw_pressure
|
||||
+ (1 - self.pressure_alpha) * self._pressure_filtered)
|
||||
current_pressure = self._pressure_filtered
|
||||
|
||||
target_pressure = self.target_pressure
|
||||
mode = self.mode
|
||||
|
||||
# 2. 根据模式计算阀门开度
|
||||
if mode == "PID":
|
||||
valve_opening = self._pid_step(current_pressure, target_pressure)
|
||||
|
||||
elif mode == "RL":
|
||||
valve_opening = self._rl_step(current_pressure, target_pressure)
|
||||
|
||||
elif mode == "MANUAL":
|
||||
valve_opening = self._manual_step()
|
||||
|
||||
else:
|
||||
self.log("错误!未知控制模式")
|
||||
valve_opening = 0.0
|
||||
|
||||
# 3. 数据采集
|
||||
if self.collect_data and self._data_collector:
|
||||
self._data_collector.record_step(
|
||||
cycle_count=self._cycle_count,
|
||||
current_pressure=current_pressure,
|
||||
target_pressure=target_pressure,
|
||||
valve_opening=valve_opening,
|
||||
kp=self.pid.kp, ki=self.pid.ki, kd=self.pid.kd,
|
||||
q_in=self.flow, v=self.volume
|
||||
)
|
||||
|
||||
# 4. 更新 UI 显示
|
||||
if self._on_display_update:
|
||||
self._on_display_update(current_pressure, target_pressure, valve_opening)
|
||||
|
||||
self._cycle_count += 1
|
||||
|
||||
# 5. 周期精确计时:若本周期用时不满 dt,sleep 补足
|
||||
elapsed = time.perf_counter() - cycle_start
|
||||
dt = self.pid.dt
|
||||
# dt = 0.2
|
||||
if elapsed < dt:
|
||||
time.sleep(dt - elapsed)
|
||||
|
||||
# 6. 记录实际周期时长
|
||||
now = time.perf_counter()
|
||||
tick_time = now - cycle_start
|
||||
# print(f"本周期用时 {tick_time*1000:.1f}ms (目标 {dt*1000:.0f}ms)")
|
||||
self._last_tick = now
|
||||
|
||||
except Exception as e:
|
||||
# control_tick 原本会捕获异常,因此异常不会进入 main.py 的
|
||||
# sys.excepthook。这里必须主动把完整 traceback 打到控制台。
|
||||
err_detail = traceback.format_exc()
|
||||
|
||||
print("\n" + "=" * 80, file=sys.stderr, flush=True)
|
||||
print("ControlEngine.control_tick 发生异常:", file=sys.stderr, flush=True)
|
||||
print(err_detail, file=sys.stderr, flush=True)
|
||||
print("=" * 80, file=sys.stderr, flush=True)
|
||||
|
||||
# main.py 已配置控制台和文件日志;这里会同步写入 logs 目录。
|
||||
logger.error("控制周期错误:\n%s", err_detail)
|
||||
|
||||
# UI 中保留一行简要信息,避免多行文本被控件截断。
|
||||
self.log(
|
||||
f"控制周期错误: {type(e).__name__}: {e};"
|
||||
f"完整 traceback 请看运行控制台或 logs 日志"
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
"""停止控制循环"""
|
||||
self._running = False
|
||||
|
||||
if self._data_collector:
|
||||
self._data_collector.finalize_and_upload(self.flow, self.volume)
|
||||
|
||||
self.log("控制循环已停止")
|
||||
|
||||
if self._on_stopped:
|
||||
self._on_stopped()
|
||||
|
||||
# ---- PID 模式 ----
|
||||
def _pid_step(self, current_pressure, target_pressure):
|
||||
"""PID 控制单步"""
|
||||
self.pid.update_pressure_values(current_pressure, target_pressure)
|
||||
valve_opening = self.pid.update()
|
||||
xa = self.xa_full * (100 - valve_opening) / 100
|
||||
self._conn_mgr.set_motor_position(xa)
|
||||
return valve_opening
|
||||
|
||||
# ---- RL 模式 ----
|
||||
def _rl_step(self, current_pressure, target_pressure):
|
||||
"""RL 增强控制单步"""
|
||||
# 跟踪目标压力变化,触发 RL 重预测
|
||||
if self.last_target_rl is None:
|
||||
self.last_target_rl = target_pressure
|
||||
elif self.last_target_rl != target_pressure:
|
||||
self.last_target_rl = target_pressure
|
||||
try:
|
||||
self._rl_predict(current_pressure, target_pressure)
|
||||
except Exception as e:
|
||||
self.log(f"模型调用异常,使用默认pid: {e}")
|
||||
|
||||
# 检查高级设置中的单步限幅是否有填入,如果有,使用填入的值;如果没有,使用默认函数
|
||||
# if self.motor_max is not None:
|
||||
# self.pid.set_du_max(self.motor_max * self.pid.dt)
|
||||
# else:
|
||||
# self.pid.get_du_max(target_pressure)
|
||||
self.pid.update_pressure_values(current_pressure, target_pressure)
|
||||
if self.motor_max is not None:
|
||||
du_max = self.motor_max * self.pid.dt
|
||||
else:
|
||||
du_max = None
|
||||
# PID 计算
|
||||
valve_opening = self.pid.update(du_max)
|
||||
|
||||
# 位置换算(考虑死区)
|
||||
xa = self.pid.dead_area + (100 - valve_opening) * (self.xa_full - self.pid.dead_area) / 100
|
||||
self._conn_mgr.set_motor_position(xa)
|
||||
|
||||
return valve_opening
|
||||
|
||||
def _rl_predict(self, current_p, target_p):
|
||||
"""执行 RL 模型预测并更新 PID 参数(只在主线程调用)"""
|
||||
model = self._model_mgr.rl_model
|
||||
if model is None:
|
||||
print("[RL] 错误: rl_model 为 None,跳过预测")
|
||||
return
|
||||
|
||||
obs = np.array([
|
||||
self.flow / 100,
|
||||
current_p / 100,
|
||||
(target_p - current_p) / 100
|
||||
], dtype=np.float32)
|
||||
print(f"[RL] 预测 obs={obs}", flush=True)
|
||||
action, _ = model.predict(obs, deterministic=True)
|
||||
print(f"[RL] model.predict 完成, action={action}")
|
||||
|
||||
action_space = model.action_space
|
||||
Kp_0 = float(action_space.high[0])
|
||||
Ki_0 = float(action_space.high[1])
|
||||
kp = float(Kp_0 + action[0])
|
||||
ki = float(Ki_0 + action[1])
|
||||
|
||||
self.Kp_0 = Kp_0
|
||||
self.Ki_0 = Ki_0
|
||||
self.pid.update_parameters(kp, ki, self.pid.kd)
|
||||
print(f"[RL] PID 参数已更新: Kp={kp:.4f}, Ki={ki:.4f}")
|
||||
if self._on_pid_ui_update:
|
||||
self._on_pid_ui_update(kp, ki, self.pid.kd)
|
||||
|
||||
# ---- MANUAL 模式 ----
|
||||
def _manual_step(self):
|
||||
"""手动模式单步"""
|
||||
xa = self.pid.dead_area + (100 - self.manual_valve) * (self.xa_full - self.pid.dead_area) / 100
|
||||
self._conn_mgr.set_motor_position(xa)
|
||||
return self.manual_valve
|
||||
@@ -0,0 +1,199 @@
|
||||
# data_collector.py
|
||||
"""数据采集器:管理 Episode 数据记录与上上传。
|
||||
|
||||
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import pickle
|
||||
import datetime
|
||||
import threading
|
||||
import requests
|
||||
|
||||
from api import base_url, data_record_url, the_folder
|
||||
|
||||
|
||||
class DataCollector:
|
||||
"""管理控制过程中的 Episode 数据采集与保存"""
|
||||
|
||||
def __init__(self):
|
||||
self.episode_data_raw = [] # 所有已完成的 Episode
|
||||
self.current_episode = None # 当前正在记录的 Episode
|
||||
self.last_target_record = None
|
||||
self._on_log = None
|
||||
|
||||
def set_log_callback(self, callback):
|
||||
"""设置日志回调"""
|
||||
self._on_log = callback
|
||||
|
||||
def log(self, message):
|
||||
if self._on_log:
|
||||
self._on_log(message)
|
||||
|
||||
def _upload_to_cos(self, data_bytes: bytes, filename: str, folder: str) -> bool:
|
||||
"""通过云函数获取直传凭证,再将数据直传到腾讯云 COS。"""
|
||||
try:
|
||||
resp = requests.post(data_record_url, json={
|
||||
"type": "uploadDataFile",
|
||||
"fileName": filename,
|
||||
"folder": folder,
|
||||
}, timeout=30)
|
||||
result = resp.json()
|
||||
except Exception as e:
|
||||
self.log(f"向云函数申请凭证异常: {e}")
|
||||
return False
|
||||
|
||||
if not result.get("success"):
|
||||
self.log(f"申请上传凭证失败: {result.get('errMsg', result)}")
|
||||
return False
|
||||
|
||||
meta = result.get("uploadMetadata")
|
||||
if not meta or "url" not in meta or "authorization" not in meta:
|
||||
self.log("云端未返回有效的上传元数据")
|
||||
return False
|
||||
|
||||
try:
|
||||
form_data = {
|
||||
"key": meta["cosFileId"],
|
||||
"Signature": meta["authorization"],
|
||||
"x-cos-security-token": meta["token"],
|
||||
"x-cos-meta-fileid": meta["fileId"],
|
||||
}
|
||||
files = {"file": (filename, io.BytesIO(data_bytes))}
|
||||
cos_resp = requests.post(meta["url"], data=form_data, files=files, timeout=60)
|
||||
|
||||
if cos_resp.status_code in [200, 204]:
|
||||
return True
|
||||
else:
|
||||
self.log(f"COS 直传失败,状态码: {cos_resp.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.log(f"COS 直传异常: {e}")
|
||||
return False
|
||||
|
||||
def reset(self):
|
||||
"""重置所有采集状态(控制启动时调用)"""
|
||||
self.episode_data_raw = []
|
||||
self.current_episode = None
|
||||
self.last_target_record = None
|
||||
|
||||
def record_step(self, cycle_count: int, current_pressure: float,
|
||||
target_pressure: float, valve_opening: float,
|
||||
kp: float, ki: float, kd: float,
|
||||
q_in: float, v: float):
|
||||
"""记录一个控制周期的数据点
|
||||
|
||||
Args:
|
||||
cycle_count: 控制周期计数
|
||||
current_pressure: 当前压力
|
||||
target_pressure: 目标压力
|
||||
valve_opening: 阀门开度
|
||||
kp, ki, kd: PID 参数
|
||||
q_in: 流量
|
||||
v: 容积
|
||||
"""
|
||||
# 目标压力变化时自动切分 Episode
|
||||
if self.current_episode is None or target_pressure != self.last_target_record:
|
||||
if self.current_episode is not None:
|
||||
self.episode_data_raw.append(self.current_episode)
|
||||
self.log(f"Episode 结束,已记录 {len(self.current_episode['pressures'])} 个点")
|
||||
|
||||
self.current_episode = {
|
||||
'pid': [float(kp), float(ki), float(kd)],
|
||||
'target_pressure': target_pressure,
|
||||
'Q_in': q_in,
|
||||
'V': v,
|
||||
'steps': [],
|
||||
'pressures': [],
|
||||
'errors': [],
|
||||
'valves': []
|
||||
}
|
||||
self.last_target_record = target_pressure
|
||||
|
||||
# 记录当前步数据
|
||||
error = -(target_pressure - current_pressure)
|
||||
self.current_episode['steps'].append(cycle_count)
|
||||
self.current_episode['pressures'].append(current_pressure)
|
||||
self.current_episode['errors'].append(error)
|
||||
self.current_episode['valves'].append(float(valve_opening))
|
||||
|
||||
def finalize_and_upload(self, flow: float, vol: float):
|
||||
"""停止控制时:闭合最后一个 Episode,分片上传到云存储。
|
||||
|
||||
单文件超过 5MB 时自动拆分为多个分片,
|
||||
同时上传一个 manifest.json 记录所有分片信息。
|
||||
|
||||
Args:
|
||||
flow: 流量值 (用于文件名/路径)
|
||||
vol: 容积值 (用于文件名/路径)
|
||||
"""
|
||||
# 闭合最后一个 Episode
|
||||
if self.current_episode and len(self.current_episode['pressures']) > 0:
|
||||
self.episode_data_raw.append(self.current_episode)
|
||||
self.current_episode = None
|
||||
|
||||
if not self.episode_data_raw:
|
||||
return
|
||||
|
||||
try:
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
base_folder = f"{the_folder}/data_record/data_{flow}SLM_{vol}L"
|
||||
|
||||
# 拆成每片尽量不超过 5MB 的 episode 分组
|
||||
MAX_CHUNK_BYTES = 5 * 1024 * 1024 # 5MB
|
||||
|
||||
chunks = [] # [(chunk_index, episodes_subset)]
|
||||
current_chunk = []
|
||||
for ep in self.episode_data_raw:
|
||||
current_chunk.append(ep)
|
||||
if pickle.dumps(current_chunk).__len__() >= MAX_CHUNK_BYTES:
|
||||
# 当前片已满,回退一个 episode 后保存
|
||||
current_chunk.pop()
|
||||
chunks.append(current_chunk)
|
||||
current_chunk = [ep]
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
|
||||
total_chunks = len(chunks)
|
||||
self.log(f"控制数据共 {len(self.episode_data_raw)} 个 Episode,"
|
||||
f"拆为 {total_chunks} 个分片上传")
|
||||
|
||||
def upload_all():
|
||||
part_files = []
|
||||
for idx, chunk_eps in enumerate(chunks):
|
||||
data_bytes = pickle.dumps(chunk_eps)
|
||||
size_kb = len(data_bytes) / 1024
|
||||
part_filename = f'episode_raw_data_{timestamp}_part{idx + 1}of{total_chunks}.pkl'
|
||||
self.log(f" 上传分片 {idx + 1}/{total_chunks} ({size_kb:.0f} KB)...")
|
||||
if self._upload_to_cos(data_bytes, part_filename, base_folder):
|
||||
part_files.append(part_filename)
|
||||
else:
|
||||
self.log(f" 分片 {idx + 1} 上传失败")
|
||||
|
||||
# 上传 manifest
|
||||
manifest = {
|
||||
"timestamp": timestamp,
|
||||
"total_chunks": total_chunks,
|
||||
"uploaded_chunks": len(part_files),
|
||||
"part_files": part_files,
|
||||
"total_episodes": len(self.episode_data_raw),
|
||||
"flow": flow,
|
||||
"volume": vol,
|
||||
}
|
||||
manifest_str = json.dumps(manifest, indent=2, ensure_ascii=False)
|
||||
manifest_bytes = manifest_str.encode('utf-8')
|
||||
manifest_filename = f'episode_raw_data_{timestamp}_manifest.json'
|
||||
self._upload_to_cos(manifest_bytes, manifest_filename, base_folder)
|
||||
|
||||
if len(part_files) == total_chunks:
|
||||
self.log(f"控制数据上传成功 ({total_chunks} 个分片)")
|
||||
else:
|
||||
self.log(f"控制数据部分上传失败 ({len(part_files)}/{total_chunks})")
|
||||
|
||||
threading.Thread(target=upload_all, daemon=True).start()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"保存收集数据时发生错误: {e}")
|
||||
finally:
|
||||
self.episode_data_raw = []
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Report the ReinLoop application's Server reachability for Panel status."""
|
||||
|
||||
|
||||
def heartbeat_device(timeout=5):
|
||||
"""Refresh the current device's Server heartbeat and return its timestamp."""
|
||||
import requests
|
||||
from api import data_record_url, the_folder
|
||||
|
||||
try:
|
||||
response = requests.post(data_record_url, json={
|
||||
"type": "deviceHeartbeat",
|
||||
"deviceId": the_folder,
|
||||
}, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except Exception as exc:
|
||||
raise ValueError(f"设备心跳请求失败: {exc}") from exc
|
||||
if not result.get("success"):
|
||||
raise ValueError(result.get("errMsg", "设备心跳被服务端拒绝"))
|
||||
return result.get("lastSeenAt")
|
||||
@@ -0,0 +1,487 @@
|
||||
# identification.py
|
||||
"""辨识与容积测量管理器。
|
||||
|
||||
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
||||
"""
|
||||
|
||||
import io
|
||||
import time
|
||||
import threading
|
||||
import datetime
|
||||
import json
|
||||
import traceback
|
||||
import requests
|
||||
from collections import deque
|
||||
|
||||
from get_V import measure_volume
|
||||
from ind_collector import collect_data_with_prbs
|
||||
|
||||
from api import base_url, data_record_url, the_folder
|
||||
|
||||
|
||||
class IdentificationManager:
|
||||
"""管理系统辨识与容积测量任务"""
|
||||
|
||||
def __init__(self):
|
||||
self._identifying = False
|
||||
self._task_thread = None
|
||||
self._on_log = None
|
||||
self._on_sample = None # 采样回调: (valve_cmd, pressure)
|
||||
self._on_volume_result = None # 容积结果回调: (volume_L: float)
|
||||
self._on_identification_upload = None
|
||||
|
||||
# ---- 回调设置 ----
|
||||
def set_log_callback(self, callback):
|
||||
"""设置日志回调"""
|
||||
self._on_log = callback
|
||||
|
||||
def set_sample_callback(self, callback):
|
||||
"""设置采样时段 UI 更新回调: callback(valve_cmd, pressure)"""
|
||||
self._on_sample = callback
|
||||
|
||||
def set_volume_result_callback(self, callback):
|
||||
"""设置容积测量结果回调: callback(volume_L: float)"""
|
||||
self._on_volume_result = callback
|
||||
|
||||
def set_identification_upload_callback(self, callback):
|
||||
"""设置辨识 CSV 上传结果回调: callback(success, filename, error)"""
|
||||
self._on_identification_upload = callback
|
||||
|
||||
def log(self, message):
|
||||
if self._on_log:
|
||||
self._on_log(message)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""当前是否正在辨识/测量中"""
|
||||
thread_alive = (
|
||||
self._task_thread is not None and self._task_thread.is_alive()
|
||||
)
|
||||
return self._identifying or thread_alive
|
||||
|
||||
def _upload_to_cos(self, content, filename: str, folder: str) -> bool:
|
||||
"""通过云函数获取直传凭证,再将文本或字节数据直传到 COS。
|
||||
|
||||
返回 True 表示上传成功,False 表示失败(已内部记 log)。
|
||||
"""
|
||||
# Step 1: 向云函数申请直传凭证(不传文件内容)
|
||||
try:
|
||||
resp = requests.post(data_record_url, json={
|
||||
"type": "uploadDataFile",
|
||||
"fileName": filename,
|
||||
"folder": folder,
|
||||
}, timeout=30)
|
||||
result = resp.json()
|
||||
except Exception as e:
|
||||
self.log(f"向云函数申请凭证异常: {e}")
|
||||
return False
|
||||
|
||||
if not result.get("success"):
|
||||
self.log(f"申请上传凭证失败: {result.get('errMsg', result)}")
|
||||
return False
|
||||
|
||||
meta = result.get("uploadMetadata")
|
||||
if not meta or "url" not in meta or "authorization" not in meta:
|
||||
self.log("云端未返回有效的上传元数据")
|
||||
return False
|
||||
|
||||
# Step 2: 直传到 COS
|
||||
try:
|
||||
form_data = {
|
||||
"key": meta["cosFileId"],
|
||||
"Signature": meta["authorization"],
|
||||
"x-cos-security-token": meta["token"],
|
||||
"x-cos-meta-fileid": meta["fileId"],
|
||||
}
|
||||
content_bytes = (
|
||||
content if isinstance(content, bytes)
|
||||
else str(content).encode("utf-8")
|
||||
)
|
||||
files = {"file": (filename, io.BytesIO(content_bytes))}
|
||||
cos_resp = requests.post(meta["url"], data=form_data, files=files, timeout=60)
|
||||
|
||||
if cos_resp.status_code in [200, 204]:
|
||||
return True
|
||||
else:
|
||||
self.log(f"COS 直传失败,状态码: {cos_resp.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.log(f"COS 直传异常: {e}")
|
||||
return False
|
||||
|
||||
def _run_initial_travel_scan(self, conn_mgr):
|
||||
"""Scan 1000..0 and upload each travel's stable pressure as JSON.
|
||||
|
||||
This is an independent pre-scan. It does not replace or modify the
|
||||
subsequent PRBS collection performed by ``collect_data_with_prbs``.
|
||||
"""
|
||||
distances = list(range(1000, -1, -100))
|
||||
settings = {
|
||||
"min_wait_time": 5.0,
|
||||
"sample_interval": 0.1,
|
||||
"stable_window": 5.0,
|
||||
"pressure_tolerance": 0.5,
|
||||
"slope_tolerance": 0.05,
|
||||
"stable_duration": 3.0,
|
||||
"max_wait_time": 60.0,
|
||||
}
|
||||
stable_pressure_records = []
|
||||
stopped = False
|
||||
|
||||
def slope(points):
|
||||
mean_t = sum(point[0] for point in points) / len(points)
|
||||
mean_p = sum(point[1] for point in points) / len(points)
|
||||
denominator = sum((point[0] - mean_t) ** 2 for point in points)
|
||||
if denominator == 0:
|
||||
return 0.0
|
||||
return sum(
|
||||
(point[0] - mean_t) * (point[1] - mean_p)
|
||||
for point in points
|
||||
) / denominator
|
||||
|
||||
try:
|
||||
for distance in distances:
|
||||
if not self._identifying:
|
||||
stopped = True
|
||||
break
|
||||
if not conn_mgr.set_motor_position(float(distance)):
|
||||
self.log(f"行程 {distance} 写入失败")
|
||||
continue
|
||||
|
||||
self.log(f"行程 {distance} 已写入,等待压力稳态")
|
||||
stage_start = time.monotonic()
|
||||
window = deque()
|
||||
stable_since = None
|
||||
stable_pressure = None
|
||||
pressure_range = None
|
||||
pressure_slope = None
|
||||
|
||||
while time.monotonic() - stage_start < settings["max_wait_time"]:
|
||||
if not self._identifying:
|
||||
stopped = True
|
||||
break
|
||||
|
||||
sample_start = time.monotonic()
|
||||
elapsed = sample_start - stage_start
|
||||
pressure = conn_mgr.read_pressure()
|
||||
if pressure is not None:
|
||||
pressure = float(pressure)
|
||||
if self._on_sample:
|
||||
# Do not expose the confidential travel command.
|
||||
self._on_sample(None, pressure)
|
||||
|
||||
if elapsed >= settings["min_wait_time"]:
|
||||
window.append([elapsed, pressure])
|
||||
cutoff = elapsed - settings["stable_window"]
|
||||
while window and window[0][0] < cutoff:
|
||||
window.popleft()
|
||||
|
||||
window_span = (
|
||||
window[-1][0] - window[0][0]
|
||||
if len(window) > 1 else 0
|
||||
)
|
||||
if window_span >= (
|
||||
settings["stable_window"] -
|
||||
settings["sample_interval"] * 1.5):
|
||||
pressures = [point[1] for point in window]
|
||||
pressure_range = max(pressures) - min(pressures)
|
||||
pressure_slope = slope(window)
|
||||
stable_now = (
|
||||
pressure_range <= settings["pressure_tolerance"] and
|
||||
abs(pressure_slope) <= settings["slope_tolerance"]
|
||||
)
|
||||
if stable_now:
|
||||
if stable_since is None:
|
||||
stable_since = sample_start
|
||||
elif sample_start - stable_since >= settings["stable_duration"]:
|
||||
stable_pressure = sum(pressures) / len(pressures)
|
||||
break
|
||||
else:
|
||||
stable_since = None
|
||||
|
||||
remaining = (
|
||||
settings["sample_interval"] -
|
||||
(time.monotonic() - sample_start)
|
||||
)
|
||||
if remaining > 0:
|
||||
time.sleep(remaining)
|
||||
|
||||
if stopped:
|
||||
break
|
||||
if stable_pressure is None:
|
||||
self.log(f"行程 {distance} 在 60 秒内未达到稳态")
|
||||
continue
|
||||
|
||||
stable_pressure_records.append({
|
||||
"distance": distance,
|
||||
"pressure": float(stable_pressure),
|
||||
})
|
||||
self.log(
|
||||
f"行程 {distance} 达到稳态,压力 {stable_pressure:.3f} kPa"
|
||||
)
|
||||
finally:
|
||||
# The requested sequence ends at zero; also return there on stop.
|
||||
conn_mgr.set_motor_position(0)
|
||||
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"travel_stability_pressures_{timestamp}.json"
|
||||
payload = {"stable_pressures": stable_pressure_records}
|
||||
uploaded = self._upload_to_cos(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
filename,
|
||||
f"{the_folder}/ind_data",
|
||||
)
|
||||
if uploaded:
|
||||
self.log("行程稳态压力 JSON 上传成功,继续执行 PRBS 辨识")
|
||||
else:
|
||||
self.log("行程稳态压力 JSON 上传失败,继续执行 PRBS 辨识")
|
||||
return payload
|
||||
|
||||
# ---- 系统辨识 ----
|
||||
def start_identification(self, *,
|
||||
conn_mgr,
|
||||
running_flag_check,
|
||||
q_in_val: float, dt: float,
|
||||
n_order: int, t_c: float,
|
||||
levels: list, dead_area: float,
|
||||
xa_full: float, V_val: float,
|
||||
repeat: int = 2):
|
||||
"""启动辨识数据采集(在后台线程中运行)
|
||||
|
||||
Args:
|
||||
conn_mgr: ConnectionManager 实例
|
||||
running_flag_check: 检查是否应该停止的可调用对象, 返回 bool
|
||||
q_in_val: 流量 (L/min)
|
||||
dt: 控制周期
|
||||
n_order: 阶数
|
||||
t_c: 周期 (s)
|
||||
levels: 序列 (阀门开度列表)
|
||||
dead_area: 死区
|
||||
xa_full: 总限幅
|
||||
V_val: 容积 (L)
|
||||
repeat: 整段复合序列重复次数,默认 2
|
||||
"""
|
||||
if running_flag_check():
|
||||
self.log("错误:请先停止控制再进行辨识")
|
||||
return False
|
||||
|
||||
if self.is_running:
|
||||
self.log("辨识正在进行中,请等待完成")
|
||||
return False
|
||||
|
||||
if not conn_mgr or not conn_mgr.is_connected():
|
||||
self.log("错误:请先连接设备")
|
||||
return False
|
||||
|
||||
self._identifying = True
|
||||
self.log("开始辨识数据采集...")
|
||||
|
||||
def _on_sample_point(t, u_cmd, p):
|
||||
if self._on_sample:
|
||||
self._on_sample(u_cmd, p)
|
||||
|
||||
def collect_thread():
|
||||
try:
|
||||
# Independent pre-scan. The PRBS call below is intentionally
|
||||
# left unchanged and starts after the travel scan completes.
|
||||
self._run_initial_travel_scan(conn_mgr)
|
||||
if not self._identifying:
|
||||
return
|
||||
|
||||
result = collect_data_with_prbs(
|
||||
conn_mgr,
|
||||
q_in_val=q_in_val, dt=dt,
|
||||
n_order=n_order, t_c=t_c,
|
||||
levels=levels, dead_area=dead_area,
|
||||
xa_full=xa_full,
|
||||
V_val=V_val,
|
||||
should_stop=lambda: not self._identifying,
|
||||
log=self.log,
|
||||
on_sample=_on_sample_point,
|
||||
repeat=repeat,
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
csv_data = result.get("csv_data")
|
||||
csv_filename = result.get("filename")
|
||||
if not csv_data or not csv_filename:
|
||||
error = "辨识采集结果缺少 CSV 数据或文件名"
|
||||
self.log(error)
|
||||
if self._on_identification_upload:
|
||||
self._on_identification_upload(False, None, error)
|
||||
elif self._upload_to_cos(
|
||||
csv_data, csv_filename, f"{the_folder}/ind_data"):
|
||||
self.log("辨识数据上传成功")
|
||||
if self._on_identification_upload:
|
||||
self._on_identification_upload(
|
||||
True, csv_filename, None
|
||||
)
|
||||
else:
|
||||
self.log("辨识数据上传失败")
|
||||
if self._on_identification_upload:
|
||||
self._on_identification_upload(
|
||||
False, csv_filename, "辨识 CSV 上传失败"
|
||||
)
|
||||
else:
|
||||
self.log("辨识未采集到数据")
|
||||
if self._on_identification_upload:
|
||||
self._on_identification_upload(
|
||||
False, None, "辨识未采集到数据"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"辨识数据采集详细错误: {traceback.format_exc()}")
|
||||
self.log(f"辨识数据采集失败: {e}")
|
||||
if self._on_identification_upload:
|
||||
self._on_identification_upload(False, None, str(e))
|
||||
finally:
|
||||
self._identifying = False
|
||||
# self.log("辨识结束")
|
||||
|
||||
thread = threading.Thread(target=collect_thread, daemon=True)
|
||||
self._task_thread = thread
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
# ---- 容积测量 ----
|
||||
def start_volume_measurement(self, *,
|
||||
conn_mgr,
|
||||
running_flag_check,
|
||||
q_in_val: float, dt: float,
|
||||
p_max: float, fit_low: float,
|
||||
fit_high: float, T_delta: float,
|
||||
xa_full: float = 1000,
|
||||
num_runs: int = 3):
|
||||
"""启动容积测量(在后台线程中运行)"""
|
||||
if running_flag_check():
|
||||
self.log("错误:请先停止控制再进行测试")
|
||||
return False
|
||||
|
||||
if self.is_running:
|
||||
self.log("测试正在进行中,请等待完成")
|
||||
return False
|
||||
|
||||
if not conn_mgr or not conn_mgr.is_connected():
|
||||
self.log("错误:请先连接设备")
|
||||
return False
|
||||
|
||||
self._identifying = True
|
||||
self.log("开始测量容积...")
|
||||
|
||||
def _on_vol_sample(t, p):
|
||||
if self._on_sample:
|
||||
self._on_sample(None, p)
|
||||
|
||||
def volume_thread():
|
||||
all_results = [] # 存储每次成功的结果
|
||||
|
||||
try:
|
||||
for run_idx in range(num_runs):
|
||||
if not self._identifying:
|
||||
break
|
||||
|
||||
print(f"--- 第 {run_idx + 1}/{num_runs} 次测量 ---")
|
||||
|
||||
# 非首次测量前,等待压力回落
|
||||
if run_idx > 0:
|
||||
print("等待压力回落...")
|
||||
wait_start = time.time()
|
||||
while time.time() - wait_start < 60: # 最多等 60 秒
|
||||
p = conn_mgr.read_pressure()
|
||||
if p is not None and p < fit_low:
|
||||
print(f"压力已回落至 {p:.1f} kPa,等待 10 秒稳定...")
|
||||
time.sleep(10)
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
print("等待压力回落超时,跳过剩余测量")
|
||||
break
|
||||
|
||||
result = measure_volume(
|
||||
conn_mgr,
|
||||
q_in_slm=q_in_val,
|
||||
dt=dt,
|
||||
xa=xa_full,
|
||||
p_max=p_max,
|
||||
fit_low=fit_low,
|
||||
fit_high=fit_high,
|
||||
T_delta=T_delta,
|
||||
should_stop=lambda: not self._identifying,
|
||||
log=self.log,
|
||||
on_sample=_on_vol_sample,
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
all_results.append(result)
|
||||
print(f"第 {run_idx + 1} 次测量成功,V = {result['volume_L']:.4f} L")
|
||||
else:
|
||||
print(f"第 {run_idx + 1} 次测量失败")
|
||||
|
||||
# ---- 汇总 ----
|
||||
if all_results:
|
||||
n = len(all_results)
|
||||
|
||||
# 平均关键参数
|
||||
avg_vol = sum(r['volume_L'] for r in all_results) / n
|
||||
avg_slope = sum(r['slope'] for r in all_results) / n
|
||||
avg_intercept = sum(r['intercept'] for r in all_results) / n
|
||||
avg_c1 = sum(r['c1'] for r in all_results) / n
|
||||
|
||||
# 构建上传数据
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
individual_runs = []
|
||||
for i, r in enumerate(all_results):
|
||||
individual_runs.append({
|
||||
"run": i + 1,
|
||||
"volume_L": r['volume_L'],
|
||||
"slope": r['slope'],
|
||||
"intercept": r['intercept'],
|
||||
"c1": r['c1'],
|
||||
"valid_points": r['valid_points'],
|
||||
"record_time": r.get('record_time', []),
|
||||
"p_actual": r.get('p_actual', []),
|
||||
})
|
||||
|
||||
full_data = {
|
||||
"num_runs_total": num_runs,
|
||||
"num_runs_successful": n,
|
||||
"averaged": {
|
||||
"volume_L": avg_vol,
|
||||
"slope": avg_slope,
|
||||
"intercept": avg_intercept,
|
||||
"c1": avg_c1,
|
||||
},
|
||||
"individual_runs": individual_runs,
|
||||
"q_in_slm": all_results[0]['payload_data'].get('q_in_slm'),
|
||||
"T_delta": T_delta,
|
||||
}
|
||||
json_str = json.dumps(full_data, indent=2, ensure_ascii=False)
|
||||
filename = f"volume_test_avg{avg_vol:.2f}L_{timestamp}.json"
|
||||
|
||||
if self._upload_to_cos(json_str, filename, f"{the_folder}/V_config"):
|
||||
self.log("体积测量数据上传成功")
|
||||
self.log(f"成功:{n}/{num_runs},平均等效体积 V = {avg_vol:.4f} L")
|
||||
|
||||
else:
|
||||
self.log("体积测量数据上传失败")
|
||||
|
||||
if self._on_volume_result:
|
||||
self._on_volume_result(avg_vol)
|
||||
else:
|
||||
self.log("所有测量均失败:有效数据点不足,无法计算体积")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"容积测量详细错误: {traceback.format_exc()}")
|
||||
self.log(f"容积测量失败: {e}")
|
||||
finally:
|
||||
self._identifying = False
|
||||
# self.log("测量结束")
|
||||
|
||||
thread = threading.Thread(target=volume_thread, daemon=True)
|
||||
self._task_thread = thread
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
"""停止当前辨识/测量任务"""
|
||||
self._identifying = False
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Download CSV and validate the nine PRBS identification parameters."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import math
|
||||
|
||||
|
||||
REQUIRED_FIELDS = {
|
||||
"q_in_val", "dt", "n_order", "t_c", "levels",
|
||||
"dead_area", "xa_full", "V_val", "repeat",
|
||||
}
|
||||
|
||||
|
||||
def validate_identification_config(config) -> dict:
|
||||
"""Validate a parsed config mapping and normalize numeric values."""
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError("辨识配置必须是参数映射")
|
||||
actual = set(config)
|
||||
if actual != REQUIRED_FIELDS:
|
||||
missing = sorted(REQUIRED_FIELDS - actual)
|
||||
extra = sorted(actual - REQUIRED_FIELDS)
|
||||
raise ValueError(f"辨识配置字段错误,缺少={missing},多余={extra}")
|
||||
|
||||
scalar_fields = {
|
||||
"q_in_val", "dt", "t_c", "dead_area", "xa_full", "V_val"
|
||||
}
|
||||
for field in scalar_fields:
|
||||
value = config[field]
|
||||
if (isinstance(value, bool) or not isinstance(value, (int, float))
|
||||
or not math.isfinite(float(value))):
|
||||
raise ValueError(f"辨识参数 {field} 必须是有限数字")
|
||||
|
||||
for field in ("n_order", "repeat"):
|
||||
value = config[field]
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"辨识参数 {field} 必须是整数")
|
||||
|
||||
levels = config["levels"]
|
||||
if not isinstance(levels, list) or len(levels) < 2:
|
||||
raise ValueError("levels 必须是至少包含 2 项的数组")
|
||||
if len(levels) & (len(levels) - 1):
|
||||
raise ValueError("levels 长度必须是 2 的整数次幂")
|
||||
normalized_levels = []
|
||||
for value in levels:
|
||||
if (isinstance(value, bool) or not isinstance(value, (int, float))
|
||||
or not math.isfinite(float(value)) or not 0 <= value <= 100):
|
||||
raise ValueError("levels 中的开度必须是 0 到 100 的有限数字")
|
||||
normalized_levels.append(float(value))
|
||||
|
||||
if config["q_in_val"] < 0:
|
||||
raise ValueError("q_in_val 不能小于 0")
|
||||
if config["dt"] <= 0 or config["t_c"] <= 0:
|
||||
raise ValueError("dt 和 t_c 必须大于 0")
|
||||
if config["t_c"] < config["dt"]:
|
||||
raise ValueError("t_c 必须大于等于 dt,确保每个码元至少采样一次")
|
||||
if config["n_order"] < 2:
|
||||
raise ValueError("n_order 必须大于等于 2")
|
||||
if config["repeat"] <= 0:
|
||||
raise ValueError("repeat 必须是正整数")
|
||||
if config["dead_area"] < 0 or config["xa_full"] <= config["dead_area"]:
|
||||
raise ValueError("必须满足 0 <= dead_area < xa_full")
|
||||
if config["xa_full"] < 1000:
|
||||
raise ValueError("xa_full 不能小于前置行程扫描上限 1000")
|
||||
if config["V_val"] <= 0:
|
||||
raise ValueError("V_val 必须大于 0")
|
||||
|
||||
return {
|
||||
"q_in_val": float(config["q_in_val"]),
|
||||
"dt": float(config["dt"]),
|
||||
"n_order": config["n_order"],
|
||||
"t_c": float(config["t_c"]),
|
||||
"levels": normalized_levels,
|
||||
"dead_area": float(config["dead_area"]),
|
||||
"xa_full": float(config["xa_full"]),
|
||||
"V_val": float(config["V_val"]),
|
||||
"repeat": config["repeat"],
|
||||
}
|
||||
|
||||
|
||||
def parse_identification_config_csv(csv_text: str) -> dict:
|
||||
"""Parse a two-column CSV into the validated identification config.
|
||||
|
||||
The CSV must use ``parameter,value`` as its header. ``levels`` is one
|
||||
quoted comma-separated value, for example ``"10,20,30,40"``.
|
||||
"""
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(csv_text))
|
||||
fieldnames = [name.strip() for name in (reader.fieldnames or [])]
|
||||
if fieldnames != ["parameter", "value"]:
|
||||
raise ValueError("CSV 表头必须为 parameter,value")
|
||||
|
||||
raw = {}
|
||||
for row in reader:
|
||||
if None in row:
|
||||
raise ValueError("CSV 每行只能包含 parameter 和 value 两列")
|
||||
parameter = (row.get("parameter") or "").strip()
|
||||
value = (row.get("value") or "").strip()
|
||||
if not parameter:
|
||||
raise ValueError("CSV 存在空参数名")
|
||||
if parameter in raw:
|
||||
raise ValueError(f"CSV 参数重复: {parameter}")
|
||||
raw[parameter] = value
|
||||
except csv.Error as exc:
|
||||
raise ValueError(f"辨识配置 CSV 格式错误: {exc}") from exc
|
||||
|
||||
actual = set(raw)
|
||||
if actual != REQUIRED_FIELDS:
|
||||
missing = sorted(REQUIRED_FIELDS - actual)
|
||||
extra = sorted(actual - REQUIRED_FIELDS)
|
||||
raise ValueError(f"辨识配置字段错误,缺少={missing},多余={extra}")
|
||||
|
||||
try:
|
||||
levels = [float(value.strip()) for value in raw["levels"].split(",")]
|
||||
config = {
|
||||
"q_in_val": float(raw["q_in_val"]),
|
||||
"dt": float(raw["dt"]),
|
||||
"n_order": int(raw["n_order"]),
|
||||
"t_c": float(raw["t_c"]),
|
||||
"levels": levels,
|
||||
"dead_area": float(raw["dead_area"]),
|
||||
"xa_full": float(raw["xa_full"]),
|
||||
"V_val": float(raw["V_val"]),
|
||||
"repeat": int(raw["repeat"]),
|
||||
}
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"辨识配置 CSV 参数值无效: {exc}") from exc
|
||||
return validate_identification_config(config)
|
||||
|
||||
|
||||
def download_identification_config(timeout=20) -> dict:
|
||||
"""Download the current customer's CSV config through the cloud function."""
|
||||
import requests
|
||||
from api import data_record_url, the_folder
|
||||
|
||||
try:
|
||||
response = requests.post(data_record_url, json={
|
||||
"type": "getIdentificationConfig",
|
||||
"deviceId": the_folder,
|
||||
}, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except Exception as exc:
|
||||
raise ValueError(f"连接云端辨识配置服务失败: {exc}") from exc
|
||||
|
||||
if not result.get("success"):
|
||||
raise ValueError(result.get("errMsg", "云端未返回辨识配置"))
|
||||
try:
|
||||
config_response = requests.get(result["url"], timeout=timeout)
|
||||
config_response.raise_for_status()
|
||||
return parse_identification_config_csv(config_response.text)
|
||||
except (KeyError, ValueError, requests.RequestException) as exc:
|
||||
raise ValueError(f"下载或解析辨识配置 CSV 失败: {exc}") from exc
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Register identification CSV results and poll the server for 0/1 review."""
|
||||
|
||||
|
||||
def _post(payload, timeout=10):
|
||||
import requests
|
||||
from api import data_record_url
|
||||
|
||||
try:
|
||||
response = requests.post(data_record_url, json=payload, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except Exception as exc:
|
||||
raise ValueError(f"辨识反馈服务请求失败: {exc}") from exc
|
||||
if not result.get("success"):
|
||||
raise ValueError(result.get("errMsg", "辨识反馈服务拒绝请求"))
|
||||
return result
|
||||
|
||||
|
||||
def register_identification_result(run_id: str, timeout=10) -> None:
|
||||
"""Register one uploaded CSV as the customer's current review target."""
|
||||
from api import the_folder
|
||||
|
||||
if not run_id:
|
||||
raise ValueError("辨识结果缺少 run_id")
|
||||
_post({
|
||||
"type": "registerIdentificationResult",
|
||||
"deviceId": the_folder,
|
||||
"runId": run_id,
|
||||
"fileName": run_id,
|
||||
}, timeout=timeout)
|
||||
|
||||
|
||||
def get_identification_feedback(run_id: str, timeout=10):
|
||||
"""Return None while pending, otherwise return the integer 0 or 1."""
|
||||
from api import the_folder
|
||||
|
||||
result = _post({
|
||||
"type": "getIdentificationFeedback",
|
||||
"deviceId": the_folder,
|
||||
"runId": run_id,
|
||||
}, timeout=timeout)
|
||||
if not result.get("ready"):
|
||||
return None
|
||||
feedback = result.get("result")
|
||||
if isinstance(feedback, bool) or feedback not in (0, 1):
|
||||
raise ValueError("云端辨识反馈必须是数字 0 或 1")
|
||||
return int(feedback)
|
||||
|
||||
|
||||
def acknowledge_identification_feedback(run_id: str, timeout=10) -> None:
|
||||
"""Delete the consumed review record so stale feedback cannot be reused."""
|
||||
from api import the_folder
|
||||
|
||||
_post({
|
||||
"type": "ackIdentificationFeedback",
|
||||
"deviceId": the_folder,
|
||||
"runId": run_id,
|
||||
}, timeout=timeout)
|
||||
@@ -0,0 +1,139 @@
|
||||
# model_manager.py
|
||||
"""RL 模型管理器:从云端扫描和加载强化学习模型。
|
||||
|
||||
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
||||
"""
|
||||
|
||||
import threading
|
||||
import io
|
||||
import requests
|
||||
import torch
|
||||
from stable_baselines3 import SAC
|
||||
|
||||
# 关键:禁用 PyTorch 内部多线程,防止在 PyInstaller daemon 线程中 segfault
|
||||
torch.set_num_threads(1)
|
||||
|
||||
from api import base_url, data_record_url, the_folder
|
||||
|
||||
|
||||
class ModelManager:
|
||||
"""管理 RL 模型的云端扫描与加载"""
|
||||
|
||||
def __init__(self):
|
||||
self.model_file_map = {} # 文件名 → fileID 映射
|
||||
self.rl_model = None # 加载的 SAC 模型实例
|
||||
self._on_log = None
|
||||
self._on_models_loaded = None
|
||||
self._on_load_complete = None
|
||||
|
||||
# ---- 回调设置 ----
|
||||
def set_log_callback(self, callback):
|
||||
"""设置日志回调: callback(message: str)"""
|
||||
self._on_log = callback
|
||||
|
||||
def set_models_loaded_callback(self, callback):
|
||||
"""设置模型列表加载完成回调: callback(file_names: list)"""
|
||||
self._on_models_loaded = callback
|
||||
|
||||
def set_load_complete_callback(self, callback):
|
||||
"""设置模型加载完成回调: callback(success: bool, message: str)"""
|
||||
self._on_load_complete = callback
|
||||
|
||||
def log(self, message):
|
||||
if self._on_log:
|
||||
self._on_log(message)
|
||||
|
||||
# ---- 模型扫描 ----
|
||||
def scan_models(self):
|
||||
"""异步扫描云端模型文件夹,完成后回调通知"""
|
||||
|
||||
def fetch_models():
|
||||
try:
|
||||
payload = {"type": "listModels", "folder": f"{the_folder}/model_config"}
|
||||
resp = requests.post(data_record_url, json=payload, timeout=10)
|
||||
result = resp.json()
|
||||
|
||||
if result.get("success"):
|
||||
files = result.get("files", [])
|
||||
file_list = result.get("fileList", [])
|
||||
|
||||
self.model_file_map = {
|
||||
item.get("fileName"): item.get("fileID")
|
||||
for item in file_list if item.get("fileName")
|
||||
}
|
||||
|
||||
self.log("模型列表刷新成功")
|
||||
|
||||
if self._on_models_loaded:
|
||||
self._on_models_loaded(files)
|
||||
else:
|
||||
err = result.get('errMsg', '未知错误')
|
||||
self.log(f"获取模型列表失败: {err}")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"扫描模型异常: {str(e)}")
|
||||
|
||||
threading.Thread(target=fetch_models, daemon=True).start()
|
||||
|
||||
# ---- 模型加载 ----
|
||||
def load_model(self, model_name: str):
|
||||
"""异步从云端加载指定的 RL 模型
|
||||
|
||||
Args:
|
||||
model_name: 模型文件名
|
||||
"""
|
||||
|
||||
if not model_name or model_name == "无模型文件":
|
||||
self.log("错误:请先选择一个有效的模型")
|
||||
return
|
||||
|
||||
def download_and_load():
|
||||
try:
|
||||
file_id = self.model_file_map.get(model_name)
|
||||
if not file_id:
|
||||
self.log("模型加载失败: 缺少 fileID,请先刷新模型列表")
|
||||
return
|
||||
|
||||
self.log(f"正在加载模型: {model_name}...")
|
||||
|
||||
# 获取临时下载 URL
|
||||
payload = {"type": "downloadModel", "fileID": file_id}
|
||||
resp = requests.post(data_record_url, json=payload, timeout=15)
|
||||
result = resp.json()
|
||||
|
||||
if not result.get("success"):
|
||||
err = result.get('errMsg', '未知错误')
|
||||
self.log(f"模型加载异常: {err}")
|
||||
return
|
||||
|
||||
url = result['url']
|
||||
|
||||
# 下载模型文件
|
||||
model_resp = requests.get(url, timeout=30)
|
||||
if model_resp.status_code != 200:
|
||||
self.log(f"模型加载异常: HTTP {model_resp.status_code}")
|
||||
return
|
||||
|
||||
model_bytes = model_resp.content
|
||||
|
||||
# 直接加载到内存
|
||||
model_stream = io.BytesIO(model_bytes)
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
self.rl_model = SAC.load(model_stream, device=device)
|
||||
|
||||
self.log(f"成功加载模型: {model_name}")
|
||||
|
||||
if self._on_load_complete:
|
||||
self._on_load_complete(True, f"成功加载模型: {model_name}")
|
||||
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
self.log(f"加载模型失败: {msg}")
|
||||
if self._on_load_complete:
|
||||
self._on_load_complete(False, f"加载失败: {msg}")
|
||||
|
||||
threading.Thread(target=download_and_load, daemon=True).start()
|
||||
|
||||
def is_model_loaded(self) -> bool:
|
||||
"""检查模型是否已加载"""
|
||||
return self.rl_model is not None
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Load, download, and validate the volume-measurement configuration."""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
REQUIRED_FIELDS = {
|
||||
"q_in_val", "dt", "p_max", "fit_low", "fit_high",
|
||||
"T_delta", "xa_full", "num_runs",
|
||||
}
|
||||
|
||||
|
||||
def default_config_path() -> Path:
|
||||
"""Return the config path without exposing a file picker in the GUI."""
|
||||
override = os.environ.get("REINLOOP_VOLUME_CONFIG")
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
base_dir = (Path(sys.executable).resolve().parent
|
||||
if getattr(sys, "frozen", False)
|
||||
else Path(__file__).resolve().parent.parent)
|
||||
return base_dir / "config" / "volume_measurement.json"
|
||||
|
||||
|
||||
def load_volume_config(path=None) -> dict:
|
||||
"""Read exactly eight validated parameters from a JSON object."""
|
||||
config_path = Path(path).resolve() if path else default_config_path()
|
||||
try:
|
||||
with config_path.open("r", encoding="utf-8") as file_obj:
|
||||
config = json.load(file_obj)
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(f"容积测试配置文件不存在: {config_path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"容积测试配置不是有效 JSON: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise ValueError(f"无法读取容积测试配置: {exc}") from exc
|
||||
|
||||
return validate_volume_config(config)
|
||||
|
||||
|
||||
def validate_volume_config(config) -> dict:
|
||||
"""Validate exactly eight parameters and normalize numeric values."""
|
||||
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError("容积测试配置必须是 JSON 对象")
|
||||
actual_fields = set(config)
|
||||
if actual_fields != REQUIRED_FIELDS:
|
||||
missing = sorted(REQUIRED_FIELDS - actual_fields)
|
||||
extra = sorted(actual_fields - REQUIRED_FIELDS)
|
||||
raise ValueError(f"配置字段错误,缺少={missing},多余={extra}")
|
||||
|
||||
for field in REQUIRED_FIELDS - {"num_runs"}:
|
||||
value = config[field]
|
||||
if (isinstance(value, bool) or not isinstance(value, (int, float))
|
||||
or not math.isfinite(float(value))):
|
||||
raise ValueError(f"参数 {field} 必须是有限数字")
|
||||
|
||||
runs = config["num_runs"]
|
||||
if isinstance(runs, bool) or not isinstance(runs, int) or runs <= 0:
|
||||
raise ValueError("参数 num_runs 必须是正整数")
|
||||
if config["q_in_val"] <= 0:
|
||||
raise ValueError("q_in_val 必须大于 0")
|
||||
if config["dt"] <= 0 or config["p_max"] <= 0:
|
||||
raise ValueError("dt 和 p_max 必须大于 0")
|
||||
if config["fit_low"] < 0 or config["fit_high"] <= config["fit_low"]:
|
||||
raise ValueError("必须满足 0 <= fit_low < fit_high")
|
||||
if config["fit_high"] > config["p_max"]:
|
||||
raise ValueError("fit_high 不能大于 p_max")
|
||||
if config["xa_full"] <= 0:
|
||||
raise ValueError("xa_full 必须大于 0")
|
||||
|
||||
return {
|
||||
"q_in_val": float(config["q_in_val"]),
|
||||
"dt": float(config["dt"]),
|
||||
"p_max": float(config["p_max"]),
|
||||
"fit_low": float(config["fit_low"]),
|
||||
"fit_high": float(config["fit_high"]),
|
||||
"T_delta": float(config["T_delta"]),
|
||||
"xa_full": float(config["xa_full"]),
|
||||
"num_runs": runs,
|
||||
}
|
||||
|
||||
|
||||
def _post_volume_request(payload, timeout=10):
|
||||
import requests
|
||||
from api import data_record_url
|
||||
|
||||
try:
|
||||
response = requests.post(data_record_url, json=payload, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except Exception as exc:
|
||||
raise ValueError(f"连接云端容积参数服务失败: {exc}") from exc
|
||||
|
||||
if not result.get("success"):
|
||||
raise ValueError(result.get("errMsg", "云端拒绝容积参数请求"))
|
||||
return result
|
||||
|
||||
|
||||
def create_volume_config_request(timeout=10) -> dict:
|
||||
"""Create exactly one cloud request after the customer clicks Test."""
|
||||
from api import the_folder
|
||||
|
||||
result = _post_volume_request({
|
||||
"type": "createVolumeConfigRequest",
|
||||
"deviceId": the_folder,
|
||||
}, timeout=timeout)
|
||||
if not result.get("requestId") or not result.get("expiresAtMs"):
|
||||
raise ValueError("云端未返回有效的容积参数请求编号")
|
||||
return {
|
||||
"request_id": result["requestId"],
|
||||
"expires_at_ms": int(result["expiresAtMs"]),
|
||||
}
|
||||
|
||||
|
||||
def poll_volume_config_request(request_id: str, timeout=10) -> dict:
|
||||
"""Poll one request; download and validate JSON only when it is ready."""
|
||||
import requests
|
||||
from api import the_folder
|
||||
|
||||
result = _post_volume_request({
|
||||
"type": "getVolumeConfigRequest",
|
||||
"deviceId": the_folder,
|
||||
"requestId": request_id,
|
||||
}, timeout=timeout)
|
||||
if result.get("expired"):
|
||||
return {"ready": False, "expired": True}
|
||||
if not result.get("ready"):
|
||||
return {"ready": False, "expired": False}
|
||||
|
||||
try:
|
||||
response = requests.get(result["url"], timeout=timeout)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
except Exception as exc:
|
||||
raise ValueError(f"下载或解析容积参数 JSON 失败: {exc}") from exc
|
||||
return {
|
||||
"ready": True,
|
||||
"expired": False,
|
||||
"config": validate_volume_config(config),
|
||||
}
|
||||
|
||||
|
||||
def acknowledge_volume_config_request(request_id: str, timeout=10) -> None:
|
||||
"""Delete the consumed/abandoned request and its temporary JSON file."""
|
||||
from api import the_folder
|
||||
|
||||
_post_volume_request({
|
||||
"type": "ackVolumeConfigRequest",
|
||||
"deviceId": the_folder,
|
||||
"requestId": request_id,
|
||||
}, timeout=timeout)
|
||||
@@ -0,0 +1,9 @@
|
||||
name: RL
|
||||
channels:
|
||||
- conda-forge
|
||||
- defaults
|
||||
dependencies:
|
||||
- python=3.10 # 建议固定版本
|
||||
- pip
|
||||
- pip:
|
||||
- -r requirements.txt # 自动引用刚才生成的文件
|
||||
@@ -0,0 +1,157 @@
|
||||
# ReinLoop 功能与接口
|
||||
|
||||
本文档描述 `ReinLoop/` Python 客户端当前提供的运行时功能与接口。
|
||||
|
||||
## 约定
|
||||
|
||||
- 业务服务端地址由 `REINLOOP_API_URL` 指定,未设置时使用
|
||||
`REINLOOP_SERVER_URL + /api`。
|
||||
- 新许可证的设备标识为 `<company_code>/<production_line_code>`,在代码中通过
|
||||
`api.the_folder` 使用。旧许可证才回退到 `REINLOOP_DEVICE_ID`。
|
||||
- 所有服务端业务请求均使用 `POST /api`,通过请求体的 `type` 字段分发。
|
||||
- 公司、产线、许可证签发/撤销、模型删除、审核反馈及配置提交均为
|
||||
ControlPanel 管理端能力,客户端不提供对应的管理接口。旧微信云函数和其管理脚本
|
||||
已移除。
|
||||
|
||||
## 应用与设备连接
|
||||
|
||||
| 功能 | 接口 | 返回或行为 |
|
||||
| --- | --- | --- |
|
||||
| 启动桌面程序 | `main.main()` | 初始化 PySide6 主窗口、全局日志及异常处理。 |
|
||||
| 连接 MT2-AM8 | `ConnectionManager.connect(tcp_ip, tcp_port, pressure_addr, motor_addr, flowmeter_addr, pressure_range=400, flow_range=100)` | 返回 `bool`。建立 Modbus TCP 连接并保存模拟量通道配置。 |
|
||||
| 断开设备 | `ConnectionManager.disconnect()` | 关闭连接并通知状态回调。 |
|
||||
| 查询连接状态 | `ConnectionManager.is_connected()` | 返回 `bool`。 |
|
||||
| 读取压力 | `ConnectionManager.read_pressure()` | 返回压力值 `kPa`,失败时为 `None`。 |
|
||||
| 读取流量 | `ConnectionManager.read_flow()` | 返回流量 `L/min`;未配置流量计或失败时为 `None`。 |
|
||||
| 设置电机位置 | `ConnectionManager.set_motor_position(xa)` | 将目标行程写入模拟量输出,返回 `bool`。 |
|
||||
| 日志和连接回调 | `set_log_callback(callback)`、`set_status_callback(callback)` | 回调签名分别为 `callback(message)`、`callback(connected, status_text)`。 |
|
||||
|
||||
主运行路径使用 `ConnectionManager`。底层调试或独立脚本还可使用
|
||||
`PcControl.py` 中的 `MT2AM8Client`、`Easy521ModbusClient`、
|
||||
`MotorModbusRTUClient` 和 `PressureModbusRTUClient`。
|
||||
|
||||
## 压力控制
|
||||
|
||||
| 功能 | 接口 | 返回或行为 |
|
||||
| --- | --- | --- |
|
||||
| 创建控制器 | `ControlEngine(pid)` | `pid` 为 `IncrementalPID` 实例。 |
|
||||
| 注入依赖 | `set_connection_manager(mgr)`、`set_model_manager(mgr)`、`set_data_collector(collector)` | 配置设备、RL 模型和数据采集服务。 |
|
||||
| 启动控制 | `ControlEngine.start()` | 要求设备已连接;RL 模式还要求模型已加载。 |
|
||||
| 执行一个周期 | `ControlEngine.control_tick()` | 读取压力、执行 PID/RL/手动控制、写入电机并更新显示。由 GUI 的 QTimer 调用。 |
|
||||
| 停止控制 | `ControlEngine.stop()` | 停止循环,并触发 `DataCollector.finalize_and_upload()`。 |
|
||||
| 查询运行状态 | `ControlEngine.is_running` | 只读属性,返回 `bool`。 |
|
||||
| 切换模式 | `engine.mode = "PID" / "RL" / "MANUAL"` | PID 闭环、RL 调参增强闭环或直接设置阀门开度。 |
|
||||
| 更新 PID 参数 | `IncrementalPID.update_parameters(kp, ki, kd)` | 重算增量 PID 系数。 |
|
||||
| 执行 PID 单步 | `IncrementalPID.update_pressure_values(current, target)`、`update(du_max=None)` | `update()` 返回受限后的阀门开度百分比。 |
|
||||
| 重置 PID 状态 | `IncrementalPID.reset()` | 清除误差历史与输出状态。 |
|
||||
| 设置单步限幅 | `IncrementalPID.set_du_max(value)` | 设置 PID 输出增量上限。 |
|
||||
|
||||
`ControlEngine` 的常用配置字段包括 `target_pressure`、`flow`、`volume`、
|
||||
`manual_valve`、`collect_data`、`dz`、`motor_max`、`xa_full` 和
|
||||
`pressure_alpha`。
|
||||
|
||||
## RL 模型管理
|
||||
|
||||
| 功能 | 接口 | 服务端请求 |
|
||||
| --- | --- | --- |
|
||||
| 刷新模型列表 | `ModelManager.scan_models()` | `listModels`,目录为 `<deviceId>/model_config`。异步执行。 |
|
||||
| 加载 SAC 模型 | `ModelManager.load_model(model_name)` | `downloadModel` 获取临时 URL,再下载并以 `SAC.load()` 加载。 |
|
||||
| 检查模型状态 | `ModelManager.is_model_loaded()` | 返回 `bool`。 |
|
||||
| 回调注册 | `set_models_loaded_callback(callback)`、`set_load_complete_callback(callback)` | 回调签名分别为 `callback(file_names)`、`callback(success, message)`。 |
|
||||
|
||||
RL 模式下,`ControlEngine` 使用模型根据流量、当前压力和压力误差预测 `Kp/Ki`,随后继续使用 PID 计算阀门开度。
|
||||
|
||||
## 控制过程数据采集与上传
|
||||
|
||||
| 功能 | 接口 | 返回或行为 |
|
||||
| --- | --- | --- |
|
||||
| 初始化一轮采集 | `DataCollector.reset()` | 清空 Episode 缓存。 |
|
||||
| 记录控制点 | `DataCollector.record_step(cycle_count, current_pressure, target_pressure, valve_opening, kp, ki, kd, q_in, v)` | 目标压力变化时自动切分 Episode。 |
|
||||
| 停止后异步上传 | `DataCollector.finalize_and_upload(flow, vol)` | 分片上传 Pickle 数据和 manifest 到 `<deviceId>/data_record/...`。 |
|
||||
| 上传凭证与直传 | `DataCollector._upload_to_cos(data_bytes, filename, folder)` | 内部接口;先请求上传凭证,再将对象直传。 |
|
||||
|
||||
上传地址通过服务端 `uploadDataFile` 获取。单个 Episode 数据文件超过约 5 MB 时会自动拆分。
|
||||
|
||||
## 系统辨识
|
||||
|
||||
| 功能 | 接口 | 返回或行为 |
|
||||
| --- | --- | --- |
|
||||
| 下载辨识配置 | `download_identification_config(timeout=20)` | 下载并返回已校验的九参数配置字典。 |
|
||||
| 校验配置对象 | `validate_identification_config(config)` | 规范化后返回字典,非法参数抛出 `ValueError`。 |
|
||||
| 解析配置 CSV | `parse_identification_config_csv(csv_text)` | 解析 `parameter,value` 两列 CSV 并完成校验。 |
|
||||
| 启动辨识 | `IdentificationManager.start_identification(conn_mgr=..., running_flag_check=..., q_in_val=..., dt=..., n_order=..., t_c=..., levels=..., dead_area=..., xa_full=..., V_val=..., repeat=2)` | 返回 `bool`;后台依次执行行程预扫描、PRBS 采集并上传 CSV。 |
|
||||
| 停止辨识 | `IdentificationManager.stop()` | 请求正在运行的辨识/容积任务停止。 |
|
||||
| 查询任务状态 | `IdentificationManager.is_running` | 返回 `bool`。 |
|
||||
| 生成 PRBS | `generate_prbs(n_order=7, low_val=40, high_val=60, samples_per_bit=20, levels=None)` | 返回 NumPy 激励序列。 |
|
||||
| 生成复合激励 | `generate_composite_sequence(dt, n_order, t_c, levels)` | 返回闭阀、全开和多电平 PRBS 组合序列。 |
|
||||
| 执行 PRBS 采集 | `collect_data_with_prbs(conn_mgr, q_in_val, dt=0.05, n_order=7, t_c=1.0, levels=None, dead_area=240, xa_full=1062.5, V_val=None, should_stop=None, log=print, on_sample=None, repeat=2)` | 返回含 `t`、`u`、`p`、`csv_data`、`filename`、`samples` 和 `success` 的字典。 |
|
||||
|
||||
辨识流程会先按 `1000` 到 `0` 的行程档位扫描稳定压力,将结果上传到
|
||||
`<deviceId>/ind_data`;随后上传 PRBS CSV 到同一目录。
|
||||
|
||||
### 辨识审核反馈
|
||||
|
||||
| 功能 | 接口 | 服务端请求 | 返回 |
|
||||
| --- | --- | --- | --- |
|
||||
| 登记结果 | `register_identification_result(run_id, timeout=10)` | `registerIdentificationResult` | 成功时返回 `None`。 |
|
||||
| 查询审核 | `get_identification_feedback(run_id, timeout=10)` | `getIdentificationFeedback` | 未就绪返回 `None`;就绪返回 `0` 或 `1`。 |
|
||||
| 确认清理 | `acknowledge_identification_feedback(run_id, timeout=10)` | `ackIdentificationFeedback` | 成功时返回 `None`。 |
|
||||
|
||||
## 容积测量
|
||||
|
||||
| 功能 | 接口 | 返回或行为 |
|
||||
| --- | --- | --- |
|
||||
| 读取本地配置 | `load_volume_config(path=None)` | 读取 JSON 并返回八参数配置。 |
|
||||
| 校验配置 | `validate_volume_config(config)` | 返回规范化配置;错误时抛出 `ValueError`。 |
|
||||
| 执行单次测量 | `measure_volume(conn_mgr, q_in_slm=50.0, dt=0.1, xa=1000, p_max=200.0, fit_low=50.0, fit_high=150.0, T_delta=30.0, should_stop=None, log=print, on_sample=None)` | 返回拟合斜率、截距、`c1`、`volume_L`、原始压力曲线和 `success`。 |
|
||||
| 启动多次测量 | `IdentificationManager.start_volume_measurement(conn_mgr=..., running_flag_check=..., q_in_val=..., dt=..., p_max=..., fit_low=..., fit_high=..., T_delta=..., xa_full=1000, num_runs=3)` | 返回 `bool`;后台多次测量、计算平均值并上传 JSON。 |
|
||||
| 创建配置请求 | `create_volume_config_request(timeout=10)` | 返回 `{"request_id", "expires_at_ms"}`。 |
|
||||
| 查询配置请求 | `poll_volume_config_request(request_id, timeout=10)` | 返回 `{"ready", "expired"}`;就绪时额外包含 `config`。 |
|
||||
| 确认配置请求 | `acknowledge_volume_config_request(request_id, timeout=10)` | 成功时返回 `None`。 |
|
||||
|
||||
容积测量汇总结果上传到 `<deviceId>/V_config`。容积参数请求与确认是客户端和 ControlPanel 的一次性协作流程。
|
||||
|
||||
## 许可证与设备标识
|
||||
|
||||
| 功能 | 接口 | 返回或行为 |
|
||||
| --- | --- | --- |
|
||||
| 本地验签 | `verify_license(lic_path=None)` | 验证 RSA-PSS/SHA-256 签名、载荷和有效期,返回许可证载荷。 |
|
||||
| 启动许可证检查 | `check_license(lic_path=None)` | 本地校验、在线校验并启动唯一的后台巡检线程;失败时退出程序。 |
|
||||
| 获取已验证载荷 | `get_verified_license()` | 返回缓存载荷副本,未验证时返回 `None`。 |
|
||||
| 在线校验 | `validate_license_online(payload)` | 调用 `validateLicense`;明确无效时抛出 `ExpiredError`。 |
|
||||
| 启动后台巡检 | `start_license_watchdog(interval_minutes=5)` | 幂等启动,最短巡检间隔为 5 分钟。 |
|
||||
| 注册生命周期回调 | `set_on_expired(callback)`、`set_on_grace(callback)`、`set_on_log(callback)` | 接收失效信息、宽限期小时数或许可证日志。 |
|
||||
|
||||
新许可证必须包含 `license_id`、`company_id`、`production_line_id`、
|
||||
`device_id`。`device_id` 必须是两个安全路径段组成的
|
||||
`<company>/<production-line>`。旧许可证仍可本地验签,但不支持在线撤销。
|
||||
|
||||
网络故障不会立即中断控制;离线时限由 `REINLOOP_LICENSE_OFFLINE_HOURS` 配置,默认 72 小时。
|
||||
|
||||
## 客户端服务端协议
|
||||
|
||||
所有业务请求都发送至 `POST /api`。业务成功响应应至少包含 `success: true`。
|
||||
|
||||
| `type` | 请求关键字段 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `validateLicense` | `licenseId`, `deviceId` | 校验许可证是否为 `active` 状态。 |
|
||||
| `listModels` | `folder` | 列出 `<deviceId>/model_config` 中的模型。 |
|
||||
| `downloadModel` | `fileID` | 获取模型临时下载 URL。 |
|
||||
| `uploadDataFile` | `fileName`, `folder` | 获取对象存储直传凭证。 |
|
||||
| `getIdentificationConfig` | `deviceId` | 获取九项辨识参数 CSV 的下载 URL。 |
|
||||
| `registerIdentificationResult` | `deviceId`, `runId`, `fileName` | 登记待审核的辨识 CSV。 |
|
||||
| `getIdentificationFeedback` | `deviceId`, `runId` | 查询辨识审核结果。 |
|
||||
| `ackIdentificationFeedback` | `deviceId`, `runId` | 确认并清理已消费的审核结果。 |
|
||||
| `createVolumeConfigRequest` | `deviceId` | 创建一次性容积配置请求。 |
|
||||
| `getVolumeConfigRequest` | `deviceId`, `requestId` | 查询请求状态;就绪时取得配置下载 URL。 |
|
||||
| `ackVolumeConfigRequest` | `deviceId`, `requestId` | 确认或清理容积配置请求。 |
|
||||
|
||||
## 配置环境变量
|
||||
|
||||
| 变量 | 用途 |
|
||||
| --- | --- |
|
||||
| `REINLOOP_SERVER_URL` | 服务端根地址。 |
|
||||
| `REINLOOP_API_URL` | 完整 API 地址,优先级高于根地址。 |
|
||||
| `REINLOOP_DEVICE_ID` | 旧许可证或开发测试设备标识;新许可证中必须与 `device_id` 一致。 |
|
||||
| `REINLOOP_LICENSE_OFFLINE_HOURS` | 许可证在线校验的最大离线时长,默认 `72`。 |
|
||||
| `REINLOOP_VOLUME_CONFIG` | 本地容积配置 JSON 的覆盖路径。 |
|
||||
@@ -0,0 +1,193 @@
|
||||
import numpy as np
|
||||
import time
|
||||
|
||||
|
||||
def measure_volume(conn_mgr,
|
||||
q_in_slm=50.0, dt=0.1,
|
||||
xa=1000, p_max=200.0,
|
||||
fit_low=50.0, fit_high=150.0,
|
||||
T_delta=30.0,
|
||||
should_stop=None, log=print, on_sample=None):
|
||||
"""充气升压测试,辨识系统等效体积 V(可直接被 GUI 导入调用)。
|
||||
|
||||
连接由调用方负责:传入已连接的 ConnectionManager。
|
||||
本函数不创建客户端、不调用 exit()、不画图、不阻塞,只跑测试并返回结果。
|
||||
|
||||
参数:
|
||||
conn_mgr : 已连接的 ConnectionManager(需有 read_pressure() / set_motor_position())
|
||||
q_in_slm : 进气流量设定 (SLM)
|
||||
dt : 控制/采样周期 (秒)
|
||||
xa : 阀门全开对应的电机位置指令
|
||||
p_max : 升压上限,超过即停止并关阀 (kPa)
|
||||
fit_low/high : 用于线性拟合的压力区间 (kPa)
|
||||
t_std : 流量计标况温度 (K)
|
||||
t_tank : 充气时估计气体温度 (K)
|
||||
should_stop : 可选回调,返回 True 时提前中止(供 GUI 停止按钮用)
|
||||
log : 日志回调,默认 print(GUI 可传入 self.log_message)
|
||||
on_sample : 可选回调 on_sample(t, pressure),每个采样点调用一次(供 GUI 刷新界面)
|
||||
|
||||
返回:
|
||||
dict: {
|
||||
'record_time': [...], 'p_actual': [...],
|
||||
'slope': float | None, 'intercept': float | None,
|
||||
'c1': float | None, 'volume_L': float | None,
|
||||
'valid_points': int, 'payload_data': dict | None,
|
||||
'success': bool
|
||||
}
|
||||
"""
|
||||
p_actual = []
|
||||
record_time = []
|
||||
|
||||
conn_mgr.set_motor_position(xa)
|
||||
# time.sleep(5) # 等待压力稳定
|
||||
|
||||
begin_time = time.perf_counter()
|
||||
current_pressure = conn_mgr.read_pressure()
|
||||
while True:
|
||||
if should_stop is not None and should_stop():
|
||||
log("测试被手动中止")
|
||||
conn_mgr.set_motor_position(0)
|
||||
break
|
||||
|
||||
start_time = time.perf_counter()
|
||||
if current_pressure is not None and current_pressure > p_max:
|
||||
conn_mgr.set_motor_position(0)
|
||||
break
|
||||
|
||||
current_pressure = conn_mgr.read_pressure()
|
||||
t = time.perf_counter() - begin_time
|
||||
if current_pressure is not None:
|
||||
p_actual.append(current_pressure)
|
||||
record_time.append(t)
|
||||
print(f"time:{t:.2f}, current_pressure:{current_pressure}")
|
||||
if on_sample is not None:
|
||||
on_sample(t, current_pressure)
|
||||
|
||||
i += 1
|
||||
|
||||
cycle_time = time.perf_counter() - start_time
|
||||
time.sleep(max(dt - cycle_time, 0.001))
|
||||
|
||||
# ==========================================
|
||||
# 自动计算 升压速率(dP/dt) 与 c1、等效体积 V
|
||||
# ==========================================
|
||||
valid_times = []
|
||||
valid_pressures = []
|
||||
for tt, p in zip(record_time, p_actual):
|
||||
if fit_low <= p <= fit_high:
|
||||
valid_times.append(tt)
|
||||
valid_pressures.append(p)
|
||||
|
||||
result = {
|
||||
'record_time': record_time,
|
||||
'p_actual': p_actual,
|
||||
'slope': None,
|
||||
'intercept': None,
|
||||
'c1': None,
|
||||
'volume_L': None,
|
||||
'valid_points': len(valid_times),
|
||||
'payload_data': None,
|
||||
'success': False,
|
||||
}
|
||||
|
||||
log("=" * 40)
|
||||
log("物理参数辨识结果")
|
||||
log("=" * 40)
|
||||
|
||||
t_std = 293.15
|
||||
t_tank = t_std + T_delta
|
||||
|
||||
if len(valid_times) > 1:
|
||||
slope, intercept = np.polyfit(valid_times, valid_pressures, 1)
|
||||
c1 = slope / q_in_slm
|
||||
P_atm = 101.325
|
||||
volume_L = P_atm / (60 * c1) * (t_tank / t_std)
|
||||
|
||||
# 打包json上传到云端
|
||||
payload_data = {
|
||||
"slope": slope,
|
||||
"intercept": intercept,
|
||||
"c1": c1,
|
||||
"volume_L": volume_L,
|
||||
"valid_points": len(valid_times),
|
||||
"q_in_slm": q_in_slm,
|
||||
}
|
||||
|
||||
result.update({
|
||||
'slope': float(slope),
|
||||
'intercept': float(intercept),
|
||||
'c1': float(c1),
|
||||
'volume_L': float(volume_L),
|
||||
'payload_data': payload_data,
|
||||
'success': True,
|
||||
})
|
||||
|
||||
print(f"有效数据点数量: {len(valid_times)}")
|
||||
print(f"实测升压速率 (dP/dt) : {slope:.4f} kPa/s")
|
||||
print(f"进气流量设定 (q_in) : {q_in_slm} SLM")
|
||||
print(f"最终进气增益 (c1) : {c1:.4f}")
|
||||
print(f"系统真实等效体积 (V) : {volume_L:.4f} L")
|
||||
|
||||
|
||||
|
||||
else:
|
||||
log(f"警告:{fit_low:.0f}~{fit_high:.0f}kPa 区间内的数据点太少,无法计算斜率!")
|
||||
log("=" * 40)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
"""独立运行入口:自建连接、跑测试、画图验证。"""
|
||||
import matplotlib.pyplot as plt
|
||||
from PcControl import Easy521ModbusClient, MotorModbusRTUClient
|
||||
|
||||
q_in_slm = 50.0
|
||||
|
||||
modbus_client = Easy521ModbusClient()
|
||||
if modbus_client.connect():
|
||||
print("成功连接到PLC")
|
||||
modbus_client.start_control()
|
||||
|
||||
motor = MotorModbusRTUClient()
|
||||
if not motor.connect():
|
||||
print("电机连接失败,退出。")
|
||||
return
|
||||
time.sleep(1) # 增加短暂延时,等待驱动器接口就绪
|
||||
if not motor.init():
|
||||
print("电机初始化失败,退出。")
|
||||
motor.disconnect()
|
||||
return
|
||||
|
||||
result = measure_volume(modbus_client, motor, q_in_slm=q_in_slm)
|
||||
|
||||
modbus_client.stop_control()
|
||||
motor.disconnect()
|
||||
|
||||
record_time = result['record_time']
|
||||
p_actual = result['p_actual']
|
||||
|
||||
# ==========================================
|
||||
# 画图验证
|
||||
# ==========================================
|
||||
plt.figure(figsize=(10, 6))
|
||||
plt.plot(record_time, p_actual, 'b.-', label='Actual P (kPa)')
|
||||
|
||||
if result['success']:
|
||||
slope = result['slope']
|
||||
intercept = result['intercept']
|
||||
valid_times = [t for t, p in zip(record_time, p_actual) if 50 <= p <= 150]
|
||||
ideal_p = [slope * t + intercept for t in valid_times]
|
||||
plt.plot(valid_times, ideal_p, 'r--', linewidth=2, label=f'Linear Fit (slope={slope:.1f})')
|
||||
|
||||
plt.title('Pressure Rise Test')
|
||||
plt.xlabel('Time (s)')
|
||||
plt.ylabel('Pressure (kPa)')
|
||||
plt.grid(True)
|
||||
plt.legend()
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,250 @@
|
||||
import numpy as np
|
||||
import time
|
||||
import pandas as pd
|
||||
import datetime
|
||||
import os
|
||||
import warnings
|
||||
# 忽略所有的 DeprecationWarning
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
import logging
|
||||
# 将 pymodbus 的日志级别提高到 ERROR,屏蔽 WARNING 及以下的信息
|
||||
logging.getLogger("pymodbus").setLevel(logging.ERROR)
|
||||
|
||||
|
||||
# ==================== 1.5 生成复合辨识序列 (三级火箭) ====================
|
||||
def generate_composite_sequence(dt, n_order, t_c, levels):
|
||||
"""
|
||||
生成包含 闭阀、全开、多电平M序列 的终极复合辨识序列
|
||||
"""
|
||||
# 1. 第一段:绝对闭阀段 (占位 8 秒)
|
||||
# 目的:憋气升压,暴露纯进气增益 c1
|
||||
part1_duration = 4.0
|
||||
part1_samples = int(part1_duration / dt)
|
||||
part1_signal = np.zeros(part1_samples)
|
||||
|
||||
# 2. 第二段:绝对全开段 (占位 6 秒)
|
||||
# 目的:极限泄压,暴露纯排气增益 c2 和机械延迟 tau
|
||||
part2_duration = 5.0
|
||||
part2_samples = int(part2_duration / dt)
|
||||
part2_signal = np.full(part2_samples, 100.0) # 假设 100 为全开
|
||||
|
||||
# 3. 第三段:多电平 M 序列段
|
||||
# 目的:中频动态跳变,暴露出 S 曲线非线性特征
|
||||
samples_per_bit = int(t_c / dt)
|
||||
part3_signal = generate_prbs(n_order=n_order, samples_per_bit=samples_per_bit, levels=levels)
|
||||
|
||||
# 拼接并返回完整序列
|
||||
composite_signal = np.concatenate([part1_signal, part2_signal, part3_signal])
|
||||
return composite_signal
|
||||
|
||||
|
||||
# ==================== 1. 生成 M 序列信号 ====================
|
||||
def generate_prbs(n_order=7, low_val=40, high_val=60, samples_per_bit=20, levels=None):
|
||||
"""
|
||||
生成线性反馈移位寄存器的 PRBS 信号。
|
||||
n_order: 阶数 (2^n-1 长度)
|
||||
low_val: 低位输出(两电平时使用)
|
||||
high_val: 高位输出(两电平时使用)
|
||||
samples_per_bit: 每个码元持续的控制周期数
|
||||
levels: 可选的多电平列表(长度必须为 2^k),若提供则忽略 low_val/high_val
|
||||
"""
|
||||
length = 2 ** n_order - 1
|
||||
reg = np.ones(n_order, dtype=int)
|
||||
bit_seq = []
|
||||
|
||||
# 反馈多项式:取最高位和次高位(可根据需要修改)
|
||||
for _ in range(length):
|
||||
feedback = reg[-1] ^ reg[-2] # 使用最后两位,适用于任意阶数
|
||||
bit_seq.append(reg[-1])
|
||||
reg = np.roll(reg, 1)
|
||||
reg[0] = feedback
|
||||
|
||||
# 若未指定 levels,则使用两电平映射
|
||||
if levels is None:
|
||||
raw = np.array(bit_seq)
|
||||
scaled = np.where(raw == 1, high_val, low_val)
|
||||
signal = np.repeat(scaled, samples_per_bit)
|
||||
return signal
|
||||
|
||||
# 多电平模式:将二进制序列按组转换为索引
|
||||
n_levels = len(levels)
|
||||
group_bits = int(np.log2(n_levels))
|
||||
if 2 ** group_bits != n_levels:
|
||||
raise ValueError("levels 长度必须是 2 的整数次幂")
|
||||
num_groups = len(bit_seq) // group_bits
|
||||
bit_seq = bit_seq[:num_groups * group_bits]
|
||||
indices = []
|
||||
for i in range(0, len(bit_seq), group_bits):
|
||||
idx = 0
|
||||
for j in range(group_bits):
|
||||
idx = (idx << 1) | bit_seq[i + j]
|
||||
indices.append(idx)
|
||||
scaled = [levels[idx] for idx in indices]
|
||||
signal = np.repeat(scaled, samples_per_bit)
|
||||
return signal
|
||||
|
||||
# ==================== 5. 实时数据采集(通过 PLC) ====================
|
||||
def collect_data_with_prbs(conn_mgr,
|
||||
q_in_val, dt=0.05, n_order=7, t_c=1.0, levels=None,
|
||||
dead_area=240, xa_full=1062.5,
|
||||
save_dir=None, V_val=None,
|
||||
should_stop=None, log=print, on_sample=None,
|
||||
repeat=2):
|
||||
"""使用复合 M 序列激励,通过 MT2-AM8 模块采集压力响应数据。
|
||||
|
||||
连接由调用方负责:传入已连接的 ConnectionManager。
|
||||
本函数不创建客户端、不调用 exit()/input()、不画图,只跑采集、存盘并返回结果。
|
||||
|
||||
参数:
|
||||
conn_mgr : 已连接的 ConnectionManager(需有 read_pressure() / set_motor_position())
|
||||
q_in_val : 实验流量 (SLM),写入数据列并用于文件名
|
||||
dt : 控制/采样周期 (秒)
|
||||
n_order : M 序列阶数
|
||||
t_c : 码元周期 (秒)
|
||||
levels : 多电平列表(长度需为 2 的整数次幂)
|
||||
dead_area : 电机死区补偿
|
||||
xa_full : 阀门全开对应的电机位置上限
|
||||
save_dir : CSV 保存目录,None 时存到当前目录的 ind_data/
|
||||
V_val : 可选容积 (L),提供时写入文件名
|
||||
should_stop : 可选回调,返回 True 时提前中止(供 GUI 停止按钮用)
|
||||
log : 日志回调,默认 print(GUI 可传入 self.log_message)
|
||||
on_sample : 可选回调 on_sample(t, u_cmd, pressure),每采样点调用(供 GUI 刷新界面)
|
||||
repeat : 整段复合序列重复次数,默认 2
|
||||
|
||||
返回:
|
||||
dict: {
|
||||
't': [...], 'u': [...], 'p': [...],
|
||||
'filename': str | None, 'samples': int, 'success': bool
|
||||
}
|
||||
"""
|
||||
# 生成复合序列(闭阀 + 全开 + 多电平 M 序列)
|
||||
signal = generate_composite_sequence(dt, n_order, t_c, levels)
|
||||
if repeat > 1:
|
||||
signal = np.tile(signal, repeat)
|
||||
log(f"序列已重复 {repeat} 次")
|
||||
total_samples = len(signal)
|
||||
duration = total_samples * dt
|
||||
log(f"复合序列总长度: {total_samples} 步, 预计耗时: {duration:.1f} 秒 ({duration/60:.1f} 分钟)")
|
||||
|
||||
# 数据记录
|
||||
t_record = []
|
||||
u_record = []
|
||||
p_record = []
|
||||
|
||||
# 初始化压力滤波
|
||||
p_filter = conn_mgr.read_pressure()
|
||||
|
||||
log("开始采集...")
|
||||
start_time = time.perf_counter()
|
||||
for step, u_cmd in enumerate(signal):
|
||||
if should_stop is not None and should_stop():
|
||||
log("辨识采集被手动中止")
|
||||
conn_mgr.set_motor_position(0)
|
||||
break
|
||||
|
||||
# 记录绝对时间
|
||||
current_t = time.perf_counter() - start_time
|
||||
# 写入阀门开度(含死区补偿)
|
||||
xa = dead_area + (100 - u_cmd) * (xa_full - dead_area) / 100
|
||||
conn_mgr.set_motor_position(xa)
|
||||
|
||||
# 读取压力
|
||||
p_raw = conn_mgr.read_pressure()
|
||||
alpha = 1
|
||||
if p_raw is not None:
|
||||
p_filter = alpha * p_raw + (1 - alpha) * p_filter
|
||||
|
||||
# 记录数据
|
||||
t_record.append(current_t)
|
||||
u_record.append(u_cmd)
|
||||
p_record.append(p_filter)
|
||||
if on_sample is not None:
|
||||
on_sample(current_t, u_cmd, p_filter)
|
||||
|
||||
# 控制周期延时
|
||||
elapsed = time.perf_counter() - start_time
|
||||
expected = step * dt
|
||||
if elapsed < expected:
|
||||
time.sleep(expected - elapsed)
|
||||
|
||||
# 保存为 CSV
|
||||
df = pd.DataFrame({'t': t_record, 'u': u_record, 'p': p_record})
|
||||
df['q_in'] = q_in_val
|
||||
if V_val is not None:
|
||||
df['V'] = V_val
|
||||
|
||||
# if save_dir is None:
|
||||
# save_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ind_data")
|
||||
# os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
# 生成 CSV 字节数据(不写入磁盘)
|
||||
csv_buffer = df.to_csv(index=False).encode('utf-8')
|
||||
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
if V_val is not None:
|
||||
filename = f'identification_data_{q_in_val}SLM_{V_val}L_{timestamp}.csv'
|
||||
else:
|
||||
filename = f'identification_data_{q_in_val}SLM_{timestamp}.csv'
|
||||
|
||||
# if V_val is not None:
|
||||
# filename = os.path.join(save_dir, f'identification_data_{q_in_val}SLM_{V_val}L_{timestamp}.csv')
|
||||
# else:
|
||||
# filename = os.path.join(save_dir, f'identification_data_{q_in_val}SLM_{timestamp}.csv')
|
||||
# df.to_csv(filename, index=False)
|
||||
# log(f"数据已保存至 {filename}")
|
||||
|
||||
return {
|
||||
't': t_record,
|
||||
'u': u_record,
|
||||
'p': p_record,
|
||||
'filename': filename,
|
||||
'csv_data': csv_buffer,
|
||||
'samples': len(t_record),
|
||||
'success': len(t_record) > 0,
|
||||
}
|
||||
|
||||
# ==================== 6. 主程序 ====================
|
||||
def main():
|
||||
"""独立运行入口:自建连接、采集、存盘。"""
|
||||
from PcControl import Easy521ModbusClient, MotorModbusRTUClient
|
||||
|
||||
dt = 0.1
|
||||
n_order = 7 # 码元数 127
|
||||
t_c = 5 # 码元周期 5 秒
|
||||
levels = [10, 20, 30, 40, 50, 60, 70, 80] # 8 个电平,对应 group_bits=3
|
||||
|
||||
# 连接 PLC
|
||||
modbus_client = Easy521ModbusClient()
|
||||
if not modbus_client.connect():
|
||||
print("无法连接 PLC,退出")
|
||||
return
|
||||
modbus_client.start_control()
|
||||
|
||||
motor = MotorModbusRTUClient()
|
||||
if not motor.connect():
|
||||
print("电机连接失败,退出。")
|
||||
return
|
||||
time.sleep(1) # 增加短暂延时,等待驱动器接口就绪
|
||||
if not motor.init():
|
||||
print("电机初始化失败,退出。")
|
||||
motor.disconnect()
|
||||
return
|
||||
|
||||
q_in_val = float(input("请输入实验时的流量 (SLM): "))
|
||||
try:
|
||||
collect_data_with_prbs(modbus_client, motor,
|
||||
q_in_val=q_in_val, dt=dt, n_order=n_order, t_c=t_c,
|
||||
levels=levels, save_dir="test_data", repeat=2)
|
||||
finally:
|
||||
modbus_client.stop_control()
|
||||
modbus_client.disconnect()
|
||||
motor.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
# license_utils.py
|
||||
"""RSA 验签许可证模块 —— 嵌入 exe,验签 + 每日巡检 + 防改系统时间。
|
||||
|
||||
用法(在 main.py 或主窗口 __init__ 中调用一次即可):
|
||||
from license_utils import check_license, start_license_watchdog
|
||||
|
||||
check_license() # 启动时验签(失败则抛异常退出)
|
||||
start_license_watchdog(interval_minutes=1440) # 后台每天巡检
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time as _time_module
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
# ============================================================
|
||||
# 公钥(编译进 exe,可公开)—— 与 ControlPanel 使用的签名私钥配对
|
||||
# 公钥更新由受控的发布流程完成,客户端不包含任何签发能力。
|
||||
# ============================================================
|
||||
# {{LICENSE_PUBLIC_KEY_START}}
|
||||
PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
|
||||
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEArQ4l2ePnl+p9yuUWcuOK
|
||||
CQ4bzixyJKh8tPSVMwqI7u02v9qdrIPu5j3T0tl+qCNTLMP6WHd6s09M7bWuVTnU
|
||||
220ljrdBb8opIzNGxTTri1k3JqMI95ljLwdEB6vp+ISyrdFKG4o+B+hDDvxkbyGH
|
||||
2vKBG71Wrws4ujOEI1H8MDWDRMyGrHFQZZk1Sz6WkgWT+yjoZ8L0K3o0afIYw8F9
|
||||
J50aaRQ9fvNW4EB+Pa5Yy5DQrNIs1smMVDpegdYt5uUMwEnfoS6Y6l98Gz7ljZ5n
|
||||
6/WVaFb55XquAwsF/zq6oDfKBrAOBqzT2YYZklr8swlKKIJ3ExA1sd/dxhfZhidi
|
||||
pqCvye6+cYa5GTRu9knzBsVPdzzhQC5AqKUuPglVJV8dQPfH7Nb7EP5wvNSgzpLT
|
||||
9wsIoZXm9GXD0hApHvobSiZnpqY5g9InV7fQZyold2zFhHWpDieNjX0844gQafnH
|
||||
Ue6JWRU4j3Wg37WDPbwkO3tQba2jbUQsLYomLGuohfkVAgMBAAE=
|
||||
-----END PUBLIC KEY-----"""
|
||||
# {{LICENSE_PUBLIC_KEY_END}}
|
||||
|
||||
# 许可证文件相对路径
|
||||
LICENSE_FILE = "license.lic"
|
||||
|
||||
# 巡检间隔(分钟)
|
||||
DEFAULT_CHECK_INTERVAL = 5
|
||||
ONLINE_CHECK_TIMEOUT_SECONDS = 5
|
||||
DEFAULT_OFFLINE_HOURS = 72
|
||||
|
||||
# 过期后宽限期(小时),给用户保存工作的时间
|
||||
GRACE_PERIOD_HOURS = 2
|
||||
|
||||
# ============================================================
|
||||
# 内部状态
|
||||
# ============================================================
|
||||
_startup_monotonic = _time_module.monotonic() # 软件启动时刻(不受系统时间影响)
|
||||
_last_check_result = None
|
||||
_verified_license = None
|
||||
_verified_license_lock = threading.RLock()
|
||||
_watchdog_started = False
|
||||
_watchdog_lock = threading.Lock()
|
||||
_last_online_success_monotonic = None
|
||||
_on_expired_callback = None # 过期回调,可由外部设置
|
||||
_on_grace_callback = None # 缓冲期回调
|
||||
_on_log_callback = None # 日志回调,供 UI 状态栏显示
|
||||
_warning_shown_states = {
|
||||
"expiring_today": False, # 到期当天警告是否已弹窗
|
||||
"expiring_soon": False, # 即将到期(30天内)警告是否已弹窗
|
||||
"expired_grace": False, # 过期缓冲期警告是否已弹窗
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# GUI 线程安全工具
|
||||
# ============================================================
|
||||
def _invoke_on_qt_thread(func):
|
||||
"""在 Qt 主线程中安全执行 func。
|
||||
|
||||
使用 QTimer.singleShot 将回调排队到主线程事件循环。
|
||||
关键:必须传入 QApplication 作为 context(receiver),否则 QTimer 会被调度到
|
||||
当前线程(watchdog 是 daemon 线程,没有 Qt event loop),导致静默永不触发。
|
||||
"""
|
||||
try:
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
QTimer.singleShot(0, app, func)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 兜底:没有 QApplication 时直接调用(GUI 还未初始化)
|
||||
try:
|
||||
func()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _show_message_box(icon, title, message):
|
||||
"""线程安全地显示 QMessageBox。
|
||||
|
||||
Args:
|
||||
icon: 'critical', 'warning', 'information'
|
||||
title: 弹窗标题
|
||||
message: 弹窗内容
|
||||
"""
|
||||
def _show():
|
||||
try:
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
if icon == 'critical':
|
||||
QMessageBox.critical(None, title, message)
|
||||
elif icon == 'warning':
|
||||
QMessageBox.warning(None, title, message)
|
||||
else:
|
||||
QMessageBox.information(None, title, message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_invoke_on_qt_thread(_show)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 验签核心
|
||||
# ============================================================
|
||||
def _load_public_key():
|
||||
"""从内嵌的 PEM 加载公钥"""
|
||||
return serialization.load_pem_public_key(PUBLIC_KEY_PEM.encode())
|
||||
|
||||
|
||||
def _validate_device_id(device_id):
|
||||
"""确保新许可证中的目录键只能是 ``company/line``。"""
|
||||
if not isinstance(device_id, str):
|
||||
raise ValueError("许可证 device_id 必须是字符串")
|
||||
segments = device_id.split("/")
|
||||
if len(segments) != 2 or any(
|
||||
not segment or segment in (".", "..") or "\\" in segment
|
||||
for segment in segments):
|
||||
raise ValueError("许可证 device_id 必须是 company/production-line 格式")
|
||||
|
||||
|
||||
def _validate_payload(payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("许可证内容必须是对象")
|
||||
|
||||
new_fields = ("license_id", "company_id", "production_line_id", "device_id")
|
||||
has_new_format = any(field in payload for field in new_fields)
|
||||
if has_new_format:
|
||||
missing = [field for field in new_fields
|
||||
if not isinstance(payload.get(field), str) or not payload[field].strip()]
|
||||
if missing:
|
||||
raise ValueError(f"新许可证缺少标识字段: {', '.join(missing)}")
|
||||
_validate_device_id(payload["device_id"])
|
||||
else:
|
||||
_log("旧许可证不支持在线撤销")
|
||||
|
||||
if not isinstance(payload.get("customer"), str) or not payload["customer"].strip():
|
||||
raise ValueError("许可证缺少客户信息")
|
||||
return has_new_format
|
||||
|
||||
|
||||
def verify_license(lic_path=None):
|
||||
"""验证许可证签名 + 有效期。
|
||||
|
||||
Args:
|
||||
lic_path: 许可证文件路径,默认 exe 同级目录下的 license.lic
|
||||
|
||||
Returns:
|
||||
dict: 许可证 payload(customer, expiry, issued 等)
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 许可证文件不存在
|
||||
InvalidSignature: 签名不匹配(伪造/篡改)
|
||||
RuntimeError: 许可证已过期
|
||||
ValueError: 许可证格式错误
|
||||
"""
|
||||
if lic_path is None:
|
||||
# PyInstaller 打包后 sys.executable 是 exe 路径
|
||||
exe_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path.cwd()
|
||||
lic_path = exe_dir / LICENSE_FILE
|
||||
|
||||
if not os.path.exists(lic_path):
|
||||
raise FileNotFoundError(f"许可证文件不存在: {lic_path}")
|
||||
|
||||
with open(lic_path, "r", encoding="utf-8") as f:
|
||||
raw = f.read().strip()
|
||||
|
||||
# 解析: payload_base64 | signature_base64
|
||||
if "|" not in raw:
|
||||
raise ValueError("许可证格式错误")
|
||||
|
||||
payload_b64, signature_b64 = raw.split("|", 1)
|
||||
signature = base64.b64decode(signature_b64)
|
||||
|
||||
# RSA-PSS SHA256 验签
|
||||
try:
|
||||
pub_key = _load_public_key()
|
||||
pub_key.verify(
|
||||
signature,
|
||||
payload_b64.encode(), # 签名的是 base64 字符串本身
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(hashes.SHA256()),
|
||||
salt_length=padding.PSS.MAX_LENGTH,
|
||||
),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
except InvalidSignature:
|
||||
raise InvalidSignature("许可证签名验证失败:文件可能被篡改")
|
||||
|
||||
# 解析载荷
|
||||
try:
|
||||
payload = json.loads(base64.b64decode(payload_b64))
|
||||
except Exception:
|
||||
raise ValueError("许可证内容解析失败")
|
||||
|
||||
_validate_payload(payload)
|
||||
|
||||
# 有效期检查(精确到小时)
|
||||
expiry_str = payload.get("expiry")
|
||||
if not expiry_str:
|
||||
raise ValueError("许可证缺少过期时间")
|
||||
|
||||
# 兼容旧格式 YYYY-MM-DD(视为当天 23:59)
|
||||
if len(expiry_str) == 10:
|
||||
expiry = datetime.datetime.strptime(expiry_str + " 23:59", "%Y-%m-%d %H:%M")
|
||||
else:
|
||||
expiry = datetime.datetime.strptime(expiry_str, "%Y-%m-%d %H:%M")
|
||||
|
||||
trusted_now = _get_trusted_now()
|
||||
|
||||
if trusted_now > expiry:
|
||||
gap_days = (trusted_now - expiry).days
|
||||
uptime_days = _get_uptime_days()
|
||||
|
||||
# 反时钟篡改:需同时满足两个条件才怀疑用户拨慢系统时间
|
||||
# ① 软件运行不到 1 天(刚启动)
|
||||
# ② 过期时间差超过 7 天(差距巨大 → 文件 mtime 暴露了真实时间)
|
||||
# 缺失任一条件 → 真过期,直接报错:
|
||||
# - gap 小(几分钟~几小时)→ 刚过期,正常报错
|
||||
# - 软件跑了很久 → 正常使用中过期,正常报错
|
||||
if uptime_days < 1 and gap_days > 7:
|
||||
issued_str = payload.get("issued", expiry_str)
|
||||
if len(issued_str) == 10:
|
||||
issued = datetime.datetime.strptime(issued_str + " 00:00", "%Y-%m-%d %H:%M")
|
||||
else:
|
||||
issued = datetime.datetime.strptime(issued_str, "%Y-%m-%d %H:%M")
|
||||
estimated_now = issued + datetime.timedelta(days=uptime_days)
|
||||
if estimated_now <= expiry:
|
||||
return payload
|
||||
|
||||
raise ExpiredError(
|
||||
f"许可证已过期 (到期: {expiry_str})",
|
||||
expiry=expiry,
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def get_verified_license():
|
||||
"""返回本进程已通过本地验证的许可证载荷,尚未验证时返回 ``None``。"""
|
||||
with _verified_license_lock:
|
||||
return dict(_verified_license) if _verified_license is not None else None
|
||||
|
||||
|
||||
def _is_new_license(payload):
|
||||
return all(payload.get(field) for field in (
|
||||
"license_id", "company_id", "production_line_id", "device_id"))
|
||||
|
||||
|
||||
def _api_url():
|
||||
base_url = os.environ.get(
|
||||
"REINLOOP_SERVER_URL", "http://ReinLoop.dominatedconvergence.com"
|
||||
).rstrip("/")
|
||||
return os.environ.get("REINLOOP_API_URL", f"{base_url}/api")
|
||||
|
||||
|
||||
def _offline_limit_seconds():
|
||||
try:
|
||||
hours = float(os.environ.get("REINLOOP_LICENSE_OFFLINE_HOURS", DEFAULT_OFFLINE_HOURS))
|
||||
except ValueError:
|
||||
hours = DEFAULT_OFFLINE_HOURS
|
||||
return max(0, hours) * 3600
|
||||
|
||||
|
||||
def validate_license_online(payload):
|
||||
"""验证可撤销许可证的在线状态。
|
||||
|
||||
网络故障只在超过离线时限后失效;服务端明确拒绝则立即返回 ExpiredError。
|
||||
"""
|
||||
global _last_online_success_monotonic
|
||||
if not _is_new_license(payload):
|
||||
return
|
||||
|
||||
try:
|
||||
response = requests.post(_api_url(), json={
|
||||
"type": "validateLicense",
|
||||
"licenseId": payload["license_id"],
|
||||
"deviceId": payload["device_id"],
|
||||
}, timeout=ONLINE_CHECK_TIMEOUT_SECONDS)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
now = _time_module.monotonic()
|
||||
if _last_online_success_monotonic is None:
|
||||
_last_online_success_monotonic = _startup_monotonic
|
||||
if now - _last_online_success_monotonic > _offline_limit_seconds():
|
||||
raise ExpiredError("许可证在线校验超过离线宽限期") from exc
|
||||
_log(f"许可证在线校验暂不可用: {exc}")
|
||||
return
|
||||
|
||||
status = result.get("status")
|
||||
if not result.get("success") or not result.get("valid") or status != "active":
|
||||
reason = status or result.get("errMsg") or "invalid"
|
||||
raise ExpiredError(f"许可证在线状态无效: {reason}")
|
||||
if result.get("licenseId") not in (None, payload["license_id"]):
|
||||
raise ExpiredError("许可证在线校验返回了不匹配的许可证")
|
||||
_last_online_success_monotonic = _time_module.monotonic()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 时间可信度
|
||||
# ============================================================
|
||||
def _get_uptime_days():
|
||||
"""软件已连续运行的天数(基于 monotonic,不受系统时间影响)"""
|
||||
return (_time_module.monotonic() - _startup_monotonic) / 86400
|
||||
|
||||
|
||||
def _get_trusted_now():
|
||||
"""多源交叉校验获取可信日期时间(精确到小时)。
|
||||
|
||||
取系统时间和文件时间的最大值,防止用户回拨系统时间。
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
# 1. 系统时间
|
||||
candidates.append(datetime.datetime.now())
|
||||
|
||||
# 2. 软件启动时记录的"最早可能时间"
|
||||
# monotonic 计时推导出的启动时间
|
||||
startup_guess = datetime.datetime.now() - datetime.timedelta(
|
||||
days=_get_uptime_days()
|
||||
)
|
||||
candidates.append(startup_guess)
|
||||
|
||||
# 3. 系统文件的修改时间(不易被用户修改)
|
||||
system_files = []
|
||||
if sys.platform == "darwin":
|
||||
system_files = [
|
||||
"/System/Library/CoreServices/SystemVersion.plist",
|
||||
"/usr/bin/python3",
|
||||
]
|
||||
elif sys.platform == "win32":
|
||||
system_files = [
|
||||
r"C:\Windows\System32\ntoskrnl.exe",
|
||||
r"C:\Windows\explorer.exe",
|
||||
]
|
||||
|
||||
for sf in system_files:
|
||||
if os.path.exists(sf):
|
||||
try:
|
||||
mtime = os.path.getmtime(sf)
|
||||
candidates.append(datetime.datetime.fromtimestamp(mtime))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 取最大值(真时间 ≥ 所有候选值。用户可能回拨,但不能让其他文件"变新")
|
||||
return max(candidates)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 后台巡检
|
||||
# ============================================================
|
||||
def start_license_watchdog(interval_minutes=DEFAULT_CHECK_INTERVAL):
|
||||
"""启动后台许可证巡检线程。
|
||||
|
||||
Args:
|
||||
interval_minutes: 检查间隔(分钟),默认 1440(24小时)
|
||||
"""
|
||||
global _watchdog_started
|
||||
with _watchdog_lock:
|
||||
if _watchdog_started:
|
||||
return
|
||||
_watchdog_started = True
|
||||
t = threading.Thread(
|
||||
target=_watchdog_loop,
|
||||
args=(max(5, interval_minutes),),
|
||||
daemon=True,
|
||||
name="license-watchdog",
|
||||
)
|
||||
t.start()
|
||||
|
||||
|
||||
def _watchdog_loop(interval_minutes):
|
||||
while True:
|
||||
_time_module.sleep(interval_minutes * 60)
|
||||
|
||||
try:
|
||||
payload = verify_license()
|
||||
with _verified_license_lock:
|
||||
global _verified_license
|
||||
_verified_license = dict(payload)
|
||||
validate_license_online(payload)
|
||||
expiry_str = payload["expiry"]
|
||||
# 解析到期时间(兼容旧格式)
|
||||
if len(expiry_str) == 10:
|
||||
expiry = datetime.datetime.strptime(expiry_str + " 23:59", "%Y-%m-%d %H:%M")
|
||||
else:
|
||||
expiry = datetime.datetime.strptime(expiry_str, "%Y-%m-%d %H:%M")
|
||||
|
||||
trusted_now = _get_trusted_now()
|
||||
hours_left = (expiry - trusted_now).total_seconds() / 3600
|
||||
days_left = int(hours_left / 24)
|
||||
|
||||
if hours_left < 0:
|
||||
# 已过期(不应走到这里,verify_license 会抛 ExpiredError)
|
||||
_log(f"⚠ 许可证已过期 ({expiry_str})")
|
||||
_show_message_box('critical', "许可证已过期",
|
||||
f"您的许可证已于 {expiry_str} 到期!\n请尽快联系厂商续期。")
|
||||
|
||||
elif hours_left <= 24:
|
||||
if not _warning_shown_states["expiring_today"]:
|
||||
_warning_shown_states["expiring_today"] = True
|
||||
_log(f"⚠ 许可证将在今天到期 ({expiry_str})")
|
||||
_show_message_box('warning', "许可证即将到期",
|
||||
f"您的许可证将于今天 {expiry_str} 到期!\n"
|
||||
f"剩余约 {int(hours_left)} 小时,请及时联系厂商续期。")
|
||||
|
||||
elif days_left <= 30:
|
||||
if not _warning_shown_states["expiring_soon"]:
|
||||
_warning_shown_states["expiring_soon"] = True
|
||||
_log(f"⚠ 许可证将在 {days_left} 天后到期 ({expiry_str})")
|
||||
_show_message_box('warning', "许可证即将到期",
|
||||
f"您的许可证将在 {days_left} 天后({expiry_str})到期\n请提前联系厂商续期。")
|
||||
|
||||
except ExpiredError as e:
|
||||
_handle_expired(e)
|
||||
|
||||
except Exception as e:
|
||||
_log(f"许可证巡检异常: {e}")
|
||||
|
||||
|
||||
def _handle_expired(error):
|
||||
"""处理许可证过期"""
|
||||
_log(f"❌ {error}")
|
||||
|
||||
# 弹窗:许可证已过期
|
||||
_show_message_box('critical', "许可证已过期",
|
||||
f"{error}\n\n软件将在 {GRACE_PERIOD_HOURS} 小时缓冲期后自动退出,\n请及时保存工作并联系厂商续期。")
|
||||
|
||||
if _on_expired_callback:
|
||||
_on_expired_callback(str(error))
|
||||
|
||||
# 宽限期:给用户时间保存工作
|
||||
grace_start = _time_module.monotonic()
|
||||
grace_seconds = GRACE_PERIOD_HOURS * 3600
|
||||
|
||||
if not _warning_shown_states["expired_grace"]:
|
||||
_warning_shown_states["expired_grace"] = True
|
||||
if _on_grace_callback:
|
||||
_on_grace_callback(GRACE_PERIOD_HOURS)
|
||||
|
||||
# 半小时后提醒一次
|
||||
warned_half = False
|
||||
while _time_module.monotonic() - grace_start < grace_seconds:
|
||||
elapsed = _time_module.monotonic() - grace_start
|
||||
if not warned_half and elapsed > grace_seconds / 2:
|
||||
warned_half = True
|
||||
_show_message_box('warning', "许可证已过期",
|
||||
f"缓冲期剩余约 {GRACE_PERIOD_HOURS // 2} 小时,\n请尽快保存工作!")
|
||||
_time_module.sleep(60) # 每分钟检查一次
|
||||
|
||||
# 宽限期过,强制退出
|
||||
_log("宽限期已过,软件即将退出")
|
||||
_show_message_box('critical', "许可证已过期",
|
||||
"缓冲期已结束,软件即将退出。\n请联系厂商续期后重新启动。")
|
||||
|
||||
# 给 5 秒做最后的清理
|
||||
_time_module.sleep(5)
|
||||
os._exit(1)
|
||||
|
||||
|
||||
def set_on_expired(callback):
|
||||
"""设置过期回调: callback(message: str)"""
|
||||
global _on_expired_callback
|
||||
_on_expired_callback = callback
|
||||
|
||||
|
||||
def set_on_grace(callback):
|
||||
"""设置宽限期回调: callback(hours: int)"""
|
||||
global _on_grace_callback
|
||||
_on_grace_callback = callback
|
||||
|
||||
|
||||
def set_on_log(callback):
|
||||
"""设置日志回调: callback(message: str)
|
||||
所有许可证关键日志会同时输出到此回调,供 UI 状态栏显示。
|
||||
"""
|
||||
global _on_log_callback
|
||||
_on_log_callback = callback
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 便捷入口
|
||||
# ============================================================
|
||||
class ExpiredError(RuntimeError):
|
||||
"""许可证过期异常"""
|
||||
def __init__(self, message, expiry=None):
|
||||
super().__init__(message)
|
||||
self.expiry = expiry
|
||||
|
||||
|
||||
def _log(msg):
|
||||
"""终端日志 + UI 回调(不依赖任何 UI 层)"""
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
formatted = f"[License {timestamp}] {msg}"
|
||||
print(formatted)
|
||||
# 同步推送到 UI 状态栏(如果已注册回调)
|
||||
if _on_log_callback:
|
||||
try:
|
||||
_on_log_callback(str(msg))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def check_license(lic_path=None):
|
||||
"""启动时调用:验证许可证,通过则返回 payload。
|
||||
|
||||
在 main.py 的 main() 函数开头调用一次即可。
|
||||
内部会自动启动后台巡检线程。
|
||||
|
||||
Returns:
|
||||
dict: 许可证载荷
|
||||
|
||||
Raises:
|
||||
SystemExit: 验签失败或已过期(启动阶段直接退出)
|
||||
"""
|
||||
try:
|
||||
payload = verify_license(lic_path)
|
||||
environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip()
|
||||
if (_is_new_license(payload) and environment_device_id
|
||||
and environment_device_id != payload["device_id"]):
|
||||
raise ValueError("REINLOOP_DEVICE_ID 与许可证 device_id 不一致")
|
||||
validate_license_online(payload)
|
||||
with _verified_license_lock:
|
||||
global _verified_license
|
||||
_verified_license = dict(payload)
|
||||
start_license_watchdog()
|
||||
expiry = payload.get("expiry", "未知")
|
||||
customer = payload.get("customer", "未知")
|
||||
_log(f"✅ 许可证有效 | 客户: {customer} | 到期: {expiry}")
|
||||
return payload
|
||||
|
||||
except FileNotFoundError as e:
|
||||
_log(f"❌ {e}")
|
||||
_show_error_and_exit(
|
||||
"未找到许可证文件",
|
||||
"请将 license.lic 放到软件根目录,然后重新启动程序。\n\n"
|
||||
"如有疑问,请联系厂商获取有效的许可证文件。"
|
||||
)
|
||||
|
||||
except InvalidSignature as e:
|
||||
_log(f"❌ {e}")
|
||||
_show_error_and_exit(
|
||||
"许可证验证失败",
|
||||
"许可证签名校验不通过,文件可能已被篡改。\n\n"
|
||||
"请使用原始签发的 license.lic 文件,\n"
|
||||
"或联系厂商重新签发。"
|
||||
)
|
||||
|
||||
except ExpiredError as e:
|
||||
_log(f"❌ {e}")
|
||||
_show_error_and_exit(
|
||||
"许可证已过期",
|
||||
f"您的许可证已于 {e.expiry} 到期。\n\n"
|
||||
"请联系厂商续期,获取新的许可证文件后重新启动。"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_log(f"❌ 许可证检查异常: {e}")
|
||||
_show_error_and_exit(f"许可证校验失败: {e}", str(e))
|
||||
|
||||
|
||||
def _show_error_and_exit(title, detail=""):
|
||||
"""显示错误弹窗并退出(兼容 GUI 和无 GUI 模式)。
|
||||
|
||||
Args:
|
||||
title: 弹窗标题(简短概要)
|
||||
detail: 弹窗正文(详细说明和操作建议)
|
||||
"""
|
||||
message = f"{title}\n\n{detail}" if detail else title
|
||||
|
||||
# 尝试 GUI 弹窗
|
||||
try:
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
QMessageBox.critical(None, title, detail or title)
|
||||
else:
|
||||
# 无 QApplication 实例时,尝试创建一个临时的
|
||||
try:
|
||||
app = QApplication(sys.argv[:1])
|
||||
QMessageBox.critical(None, title, detail or title)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(message)
|
||||
print(f"{'='*50}\n")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 辅助:获取许可证信息(供 UI 显示)
|
||||
# ============================================================
|
||||
def get_license_info(lic_path=None):
|
||||
"""读取许可证信息(不做过期检查),供 UI 显示。
|
||||
|
||||
Returns:
|
||||
dict | None: 许可证信息,文件不存在则返回 None
|
||||
"""
|
||||
try:
|
||||
if lic_path is None:
|
||||
exe_dir = (
|
||||
Path(sys.executable).parent
|
||||
if getattr(sys, 'frozen', False)
|
||||
else Path.cwd()
|
||||
)
|
||||
lic_path = exe_dir / LICENSE_FILE
|
||||
|
||||
if not os.path.exists(lic_path):
|
||||
return None
|
||||
|
||||
with open(lic_path, "r", encoding="utf-8") as f:
|
||||
raw = f.read().strip()
|
||||
|
||||
payload_b64 = raw.split("|")[0]
|
||||
payload = json.loads(base64.b64decode(payload_b64))
|
||||
|
||||
# 先验签保证内容可信
|
||||
verify_license(lic_path)
|
||||
|
||||
return {
|
||||
"customer": payload.get("customer", "未知"),
|
||||
"expiry": payload.get("expiry", "未知"),
|
||||
"issued": payload.get("issued", "未知"),
|
||||
"features": payload.get("features", "*"),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,99 @@
|
||||
# main.py
|
||||
"""主程序入口 — PySide6 版本"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import traceback
|
||||
import warnings
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
# 必须在导入任何 matplotlib 之前设置后端
|
||||
import matplotlib
|
||||
matplotlib.use('QtAgg')
|
||||
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from ui.main_window import MainWindow
|
||||
from styles import apply_app_style
|
||||
|
||||
# 屏蔽多余警告
|
||||
warnings.filterwarnings('ignore')
|
||||
logging.getLogger("pymodbus").setLevel(logging.ERROR)
|
||||
|
||||
# 优化 matplotlib 设置
|
||||
matplotlib.rcParams['figure.max_open_warning'] = 20
|
||||
matplotlib.rcParams['axes.linewidth'] = 0.5
|
||||
matplotlib.rcParams['lines.linewidth'] = 1.0
|
||||
|
||||
# 中文字体
|
||||
import matplotlib.pyplot as plt
|
||||
plt.rcParams['font.sans-serif'] = [
|
||||
'Microsoft YaHei', 'SimHei', 'PingFang SC', 'Heiti TC', 'sans-serif'
|
||||
]
|
||||
plt.rcParams['axes.unicode_minus'] = False
|
||||
|
||||
# ---- 全局异常日志(打包为 exe 后排查问题用) ----
|
||||
_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, f"reinloop_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log")
|
||||
|
||||
|
||||
def _write_log(msg: str):
|
||||
try:
|
||||
with open(_LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] {msg}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _global_excepthook(exc_type, exc_value, exc_tb):
|
||||
"""未捕获异常 → 写日志 + 弹窗"""
|
||||
err = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
||||
_write_log(f"未捕获异常:\n{err}")
|
||||
try:
|
||||
QMessageBox.critical(None, "程序错误",
|
||||
f"发生未捕获异常:\n{exc_value}\n\n详情见: {_LOG_FILE}")
|
||||
except Exception:
|
||||
pass
|
||||
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
||||
|
||||
|
||||
def main():
|
||||
"""主程序入口"""
|
||||
sys.excepthook = _global_excepthook
|
||||
_write_log(f"启动 | Python={sys.version} | exe={sys.executable}")
|
||||
print("正在启动控制系统...")
|
||||
|
||||
# 0. 防止 Windows 深色模式干扰 Qt 样式(打包后常见黑底问题)
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "windows:darkmode=0")
|
||||
|
||||
# 1. 初始化 QApplication
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("ReinLoop")
|
||||
|
||||
# 1.1 强制浅色模式(防止系统深色模式或 style 插件缺失导致黑底)
|
||||
app.setStyle("Fusion") # Fusion 内置于 QtCore,不依赖外部 style 插件
|
||||
try:
|
||||
app.styleHints().setColorScheme(Qt.ColorScheme.Light)
|
||||
except (AttributeError, TypeError):
|
||||
pass # Qt < 6.5 无此方法,忽略
|
||||
|
||||
# 2. 应用全局样式
|
||||
colors = apply_app_style(app)
|
||||
_write_log("样式加载完成")
|
||||
|
||||
# 3. 创建主窗口
|
||||
window = MainWindow(colors)
|
||||
_write_log("主窗口创建完成")
|
||||
window.show()
|
||||
|
||||
# 4. 进入事件循环
|
||||
_write_log("进入事件循环")
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"setting": {
|
||||
"es6": true,
|
||||
"postcss": true,
|
||||
"minified": true,
|
||||
"uglifyFileName": false,
|
||||
"enhance": true,
|
||||
"packNpmRelationList": [],
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
},
|
||||
"useCompilerPlugins": false,
|
||||
"minifyWXML": true
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"simulatorPluginLibVersion": {},
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"appid": "wx156896aa598edf68",
|
||||
"editorSetting": {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
cython==3.2.4
|
||||
cryptography>=42.0,<47
|
||||
matplotlib==3.11.0
|
||||
numpy==2.4.6
|
||||
pandas==3.0.3
|
||||
prompt_toolkit==3.0.52
|
||||
pyautogui==0.9.54
|
||||
pygetwindow==0.0.9
|
||||
pymodbus==3.6.9
|
||||
PySide6>=6.8,<7
|
||||
pyserial==3.5
|
||||
Requests==2.34.2
|
||||
setuptools==81.0.0
|
||||
stable_baselines3==2.8.0
|
||||
torch==2.11.0
|
||||
@@ -0,0 +1,69 @@
|
||||
# setup.py
|
||||
"""Cython 编译脚本 — 将核心业务 .py 文件编译为 .pyd/.so 防止反编译。
|
||||
|
||||
用法:
|
||||
python setup.py build_ext --inplace # 原地编译(开发测试)
|
||||
python setup.py build_ext # 输出到 build_libs/
|
||||
"""
|
||||
from setuptools import setup, find_packages
|
||||
from Cython.Build import cythonize
|
||||
import os
|
||||
|
||||
# ============================================================
|
||||
# 1. 明确指定要加密保护的核心业务文件(千万不要把 main.py 放进去)
|
||||
# ============================================================
|
||||
py_modules = [
|
||||
# 根目录业务文件
|
||||
"api.py",
|
||||
"controllers.py",
|
||||
"PcControl.py",
|
||||
"ind_collector.py",
|
||||
"get_V.py",
|
||||
"styles.py",
|
||||
# 许可证模块(含公钥 + 验签逻辑,编译后不可篡改)
|
||||
"license_utils.py",
|
||||
# core/ 业务逻辑层
|
||||
"core/__init__.py", # 模块级验签入口,import 时自动触发
|
||||
"core/connection_manager.py",
|
||||
"core/control_engine.py",
|
||||
"core/model_manager.py",
|
||||
"core/data_collector.py",
|
||||
"core/identification.py",
|
||||
"core/identification_config.py",
|
||||
"core/identification_feedback.py",
|
||||
"core/volume_config.py",
|
||||
]
|
||||
|
||||
# 过滤掉本地不存在的文件,防止报错
|
||||
py_modules = [f for f in py_modules if os.path.exists(f)]
|
||||
|
||||
# ============================================================
|
||||
# 2. 编译器优化指令
|
||||
# ============================================================
|
||||
compiler_directives = {
|
||||
'language_level': "3", # Python 3 语义
|
||||
'boundscheck': False, # 关闭数组越界检查(提升性能)
|
||||
'wraparound': False, # 关闭负索引检查
|
||||
'cdivision': True, # C 除法语义(更快)
|
||||
'always_allow_keywords': False, # 不生成 **kwargs(减小体积)
|
||||
}
|
||||
|
||||
setup(
|
||||
name="PressureControlCore",
|
||||
version="1.0.0",
|
||||
python_requires=">=3.8",
|
||||
packages=find_packages(include=["core", "core.*"]),
|
||||
ext_modules=cythonize(
|
||||
py_modules,
|
||||
compiler_directives=compiler_directives,
|
||||
annotate=False, # 不生成 html 报告,减少垃圾文件
|
||||
build_dir="build_libs/temp", # .c 文件的临时目录
|
||||
force=True, # 强制重新生成 .c 文件(防止用旧缓存)
|
||||
),
|
||||
options={
|
||||
"build_ext": {
|
||||
"build_lib": "build_libs", # 最终 .pyd/.so 输出目录
|
||||
"build_temp": "build_libs/temp" # 中间 .c 和 .o 输出目录
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"brandkit": {
|
||||
"source": "Leonxlnx/taste-skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/brandkit/SKILL.md",
|
||||
"computedHash": "b63012f3c3d21197e0185d3e9cc7ec40c589fb10e0b5a32a561739de31aa3f20"
|
||||
},
|
||||
"design-taste-frontend": {
|
||||
"source": "Leonxlnx/taste-skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/taste-skill/SKILL.md",
|
||||
"computedHash": "6d838b246d0e35d0b53f4f23f98ba7a1dd561937e64f7d0c7553b0928e376c3e"
|
||||
},
|
||||
"design-taste-frontend-v1": {
|
||||
"source": "Leonxlnx/taste-skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/taste-skill-v1/SKILL.md",
|
||||
"computedHash": "d704ab912c4d0ca954ffa858983da755ae4cd5cad9ba22554db5557382f5bd34"
|
||||
},
|
||||
"full-output-enforcement": {
|
||||
"source": "Leonxlnx/taste-skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/output-skill/SKILL.md",
|
||||
"computedHash": "26bd29ce4c5e02c7666b2d503609bf466bd32290822e91f0e984147048dbb924"
|
||||
},
|
||||
"high-end-visual-design": {
|
||||
"source": "Leonxlnx/taste-skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/soft-skill/SKILL.md",
|
||||
"computedHash": "7db385e4c5370e5a7fca9704a1361b056e4504ea6a03924bb86f33a4f00b5c73"
|
||||
},
|
||||
"image-to-code": {
|
||||
"source": "Leonxlnx/taste-skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/image-to-code-skill/SKILL.md",
|
||||
"computedHash": "58517b03b2a01f4c9ba65861559d03df931400871bbc200978c975b24bb92c73"
|
||||
},
|
||||
"industrial-brutalist-ui": {
|
||||
"source": "Leonxlnx/taste-skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/brutalist-skill/SKILL.md",
|
||||
"computedHash": "8fc355c4aadb7d29c53ca28bc41be3cd6eea765d121e3737c4dc2d0f90a8effa"
|
||||
},
|
||||
"redesign-existing-projects": {
|
||||
"source": "Leonxlnx/taste-skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/redesign-skill/SKILL.md",
|
||||
"computedHash": "b405eee0e0e80fc243f731d9aa368bca307e356db7e6157d27101d369dac6726"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 6.1401C6 6.0627 6.0627 6 6.1401 6H17.8599C17.9373 6 18 6.0627 18 6.1401V12C18 15.3137 15.3137 18 12 18C8.6863 18 6 15.3137 6 12V6.1401Z" stroke="#FEFEFE" stroke-width="2"/>
|
||||
<path d="M10 6V2" stroke="#FEFEFE" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 6V2" stroke="#FEFEFE" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11 13.5H13" stroke="#FEFEFE" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 18V20.5C12 21.3285 12.6715 22 13.5 22H19" stroke="#FEFEFE" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 740 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.75 5H17.75" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.75 3V7" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.75 5H2.75" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.75 12H2.75" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.75 10V14" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21.75 12H10.75" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M20.75 19H17.75" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.75 19H2.75" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 971 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.75 5H17.75" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.75 3V7" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.75 5H2.75" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.75 12H2.75" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.75 10V14" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21.75 12H10.75" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M20.75 19H17.75" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.75 19H2.75" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 971 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 6L15.7071 11.2929C15.3166 11.6834 14.6834 11.6834 14.2929 11.2929L12.7071 9.70711C12.3166 9.31658 11.6834 9.31658 11.2929 9.70711L7 14" stroke="#0A50A1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 3V17.8C3 18.9201 3 19.4802 3.21799 19.908C3.40973 20.2843 3.71569 20.5903 4.09202 20.782C4.51984 21 5.07989 21 6.2 21H21" stroke="#0A50A1" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 532 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 6L15.7071 11.2929C15.3166 11.6834 14.6834 11.6834 14.2929 11.2929L12.7071 9.70711C12.3166 9.31658 11.6834 9.31658 11.2929 9.70711L7 14" stroke="#64748B" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 3V17.8C3 18.9201 3 19.4802 3.21799 19.908C3.40973 20.2843 3.71569 20.5903 4.09202 20.782C4.51984 21 5.07989 21 6.2 21H21" stroke="#64748B" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 498 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.6667 13.3333L13.3333 18.6666" stroke="#0060AD" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21.3333 17.3333L24 14.6666C25.841 12.8257 25.841 9.84091 24 7.99996C22.1591 6.15901 19.1743 6.15901 17.3333 7.99996L14.6667 10.6666M10.6667 14.6666L8 17.3333C6.15905 19.1742 6.15905 22.159 8 24C9.84095 25.8409 12.8257 25.8409 14.6667 24L17.3333 21.3333" stroke="#0060AD" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 556 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.6667 13.3333L13.3333 18.6667" stroke="#64748B" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21.3333 17.3333L24 14.6667C25.841 12.8257 25.841 9.84095 24 8C22.1591 6.15905 19.1743 6.15905 17.3333 8L14.6667 10.6667M10.6667 14.6667L8 17.3333C6.15905 19.1743 6.15905 22.159 8 24C9.84095 25.8409 12.8257 25.8409 14.6667 24L17.3333 21.3333" stroke="#64748B" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 510 B |
@@ -0,0 +1,17 @@
|
||||
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_155_334)">
|
||||
<mask id="mask0_155_334" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="19" height="19">
|
||||
<path d="M19 0H0V19H19V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_155_334)">
|
||||
<path d="M2.375 9.50329V16.625H16.625V9.5" stroke="#0B64DD" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.0625 5.9375L9.5 2.375L5.9375 5.9375" stroke="#0B64DD" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.4967 12.6667V2.375" stroke="#0B64DD" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_155_334">
|
||||
<rect width="19" height="19" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 783 B |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.0771 6.02206L15.7622 11.337C15.3717 11.7275 14.7385 11.7275 14.348 11.337L12.7512 9.7402C12.3606 9.34968 11.7275 9.34968 11.337 9.7402L7.02568 14.0515" stroke="#0B64DD" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.01099 3.01102V17.8772C3.01099 18.9973 3.01099 19.5574 3.22897 19.9852C3.42072 20.3615 3.72668 20.6675 4.10301 20.8592C4.53083 21.0772 5.09088 21.0772 6.21099 21.0772H21.0772" stroke="#0B64DD" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 567 B |
@@ -0,0 +1,9 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M30.2972 18.7786C30.2972 18.7786 27.0679 27.8809 25.5334 29.47C23.9988 31.0591 21.4665 31.1033 19.8774 29.5687C18.2882 28.0341 18.244 25.5019 19.7786 23.9127C21.3132 22.3236 30.2972 18.7786 30.2972 18.7786Z" fill="#1C9B5D" stroke="#1C9B5D" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M38.8492 38.8492C42.6495 35.049 45 29.799 45 24C45 12.402 35.598 3 24 3C12.402 3 3 12.402 3 24C3 29.799 5.35051 35.049 9.15076 38.8492" stroke="#1C9B5D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24 4V8" stroke="#1C9B5D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M38.8454 11.1421L35.7368 13.6593" stroke="#1C9B5D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M42.5223 27.2328L38.6248 26.333" stroke="#1C9B5D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.47742 27.2328L9.3749 26.333" stroke="#1C9B5D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.15466 11.142L12.2632 13.6593" stroke="#1C9B5D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="23" height="23" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.125 3.83337V11.5" stroke="#0960D1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.875 11.5V19.1667" stroke="#0960D1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M20.125 11.5C20.125 6.73656 16.2634 2.875 11.5 2.875C9.0632 2.875 6.86243 3.88554 5.29388 5.51042M2.875 11.5C2.875 16.2634 6.73656 20.125 11.5 20.125C13.8266 20.125 15.9381 19.2038 17.4896 17.7061" stroke="#0960D1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 574 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3ZM10.7832 7.99023C9.98347 7.54594 9.00025 8.12429 9 9.03906V14.9609C9.00025 15.8757 9.98347 16.4541 10.7832 16.0098L16.4268 12.874C17.1122 12.493 17.1122 11.507 16.4268 11.126L10.7832 7.99023Z" fill="#FEFEFE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
@@ -0,0 +1,8 @@
|
||||
<svg width="53" height="53" viewBox="0 0 53 53" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="26.4999" cy="26.5" r="18.7909" stroke="#0B64DD" stroke-width="4"/>
|
||||
<circle cx="26.5" cy="26.5" r="7.22727" stroke="#0B64DD" stroke-width="4"/>
|
||||
<path d="M26.9819 7.7091V2.89091" stroke="#0B64DD" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M45.2909 26.9818L50.1091 26.9818" stroke="#0B64DD" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M26.9819 50.1091L26.9819 45.2909" stroke="#0B64DD" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M2.8908 26.9818H7.70898" stroke="#0B64DD" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 656 B |
@@ -0,0 +1,9 @@
|
||||
<svg width="49" height="39" viewBox="0 0 49 39" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M47.25 14.2084L47.25 11.6667C47.25 9.30305 47.25 8.12121 46.9902 7.15157C46.2851 4.52024 44.2298 2.46494 41.5985 1.75988C40.6289 1.50006 39.447 1.50006 37.0833 1.50006" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M1.5 14.2084L1.5 11.6667C1.5 9.30305 1.5 8.12121 1.75982 7.15157C2.46488 4.52024 4.52018 2.46494 7.1515 1.75988C8.12115 1.50006 9.30299 1.50006 11.6667 1.50006" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M47.25 24.3751L47.25 26.9167C47.25 29.2804 47.25 30.4622 46.9902 31.4319C46.2851 34.0632 44.2298 36.1185 41.5985 36.8236C40.6288 37.0834 39.447 37.0834 37.0833 37.0834" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M1.5 24.3751L1.5 26.9167C1.5 29.2804 1.5 30.4622 1.75982 31.4319C2.46488 34.0632 4.52018 36.1185 7.15151 36.8236C8.12115 37.0834 9.30299 37.0834 11.6667 37.0834" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24.375 26.9167L24.375 11.6667" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.2085 24.3751L14.2085 14.2084" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M34.5415 24.3751L34.5415 14.2084" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,569 @@
|
||||
# styles.py
|
||||
"""界面样式集中管理模块:PySide6 QSS 样式表。
|
||||
|
||||
颜色主题:品牌蓝 #0960D1,主背景 #F8FAFC,成功绿 #0F955D。
|
||||
通过 apply_app_style(app) 应用全局 QSS 样式,并拿到配色字典。
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtGui import QPalette, QColor
|
||||
|
||||
# ==========================================
|
||||
# 统一配色架构(与 Tkinter 版本保持一致)
|
||||
# ==========================================
|
||||
COLORS = {
|
||||
"BG_COLOR": "#F8FAFC", # 浅色大背景(微冷超浅灰蓝)
|
||||
"CARD_BG": "#FFFFFF", # 容器卡片背景
|
||||
"BORDER_COLOR": "#E2E8F0", # 扁平化细边框
|
||||
"TEXT_MAIN": "#1A1A1A", # 主文字颜色(高清晰度深灰)
|
||||
"TEXT_MUTED": "#757575", # 辅助文字颜色(中灰)
|
||||
"ACCENT_LIGHT": "#0960D1", # 主品牌色/高亮蓝
|
||||
"ACCENT_DARK": "#0960D1", # 品牌色(统一)
|
||||
"HOVER_BLUE": "#0856B8", # 悬停过渡色(品牌色加深)
|
||||
"SUCCESS_GREEN": "#0F955D", # 连接成功绿
|
||||
"SUCCESS_ACTIVE": "#0D8250", # 成功按钮按下态
|
||||
"DISABLED": "#CBD5E1", # 禁用态灰
|
||||
"ERROR_RED": "#FF2424", # 状态警示红
|
||||
"NAV_BG": "#FFFFFF", # 导航栏背景(白色)
|
||||
"NAV_BORDER": "#E2E8F0", # 导航栏底部边框
|
||||
"NAV_TAB_ACTIVE_TEXT": "#0960D1", # Tab激活态文字色
|
||||
"NAV_TAB_HOVER": "#F1F5F9", # Tab悬停背景
|
||||
"FORM_LABEL": "#333333", # 表单标签色
|
||||
"REFRESH_BORDER": "#CCDBF0", # 刷新按钮边框
|
||||
}
|
||||
# 科技蓝核心高亮统一为浅色主题色
|
||||
COLORS["ACCENT_BLUE"] = COLORS["ACCENT_LIGHT"]
|
||||
|
||||
# 字体配置
|
||||
BASE_FONT_FAMILY = "Microsoft YaHei, PingFang SC, SimHei, sans-serif"
|
||||
BASE_FONT_SIZE = "14px"
|
||||
BASE_FONT_SIZE_SM = "12px"
|
||||
BASE_FONT_SIZE_LG = "16px"
|
||||
BASE_FONT_SIZE_XL = "28px"
|
||||
BASE_FONT_SIZE_NAV_TITLE = "22px"
|
||||
|
||||
QSS_STYLESHEET = f"""
|
||||
/* ===== 全局默认 ===== */
|
||||
QMainWindow, QWidget {{
|
||||
background-color: {COLORS["BG_COLOR"]};
|
||||
color: {COLORS["TEXT_MAIN"]};
|
||||
font-family: "{BASE_FONT_FAMILY}";
|
||||
font-size: {BASE_FONT_SIZE};
|
||||
}}
|
||||
|
||||
/* ===== 标题行(Layer 1:纯白背景) ===== */
|
||||
QWidget[cssClass="titleRow"] {{
|
||||
background-color: #FFFFFF;
|
||||
}}
|
||||
|
||||
/* ===== Tab 栏容器(Layer 2:一体化浅灰背景 #F8FAFC) ===== */
|
||||
QWidget[cssClass="tabRow"] {{
|
||||
background-color: #F8FAFC;
|
||||
}}
|
||||
|
||||
/* ===== QTabBar 导航标签 ===== */
|
||||
QTabBar[cssClass="mainTab"]::tab {{
|
||||
background: transparent;
|
||||
padding: 10px 24px 10px 16px;
|
||||
font-size: 15px;
|
||||
color: #64748B;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
font-weight: normal;
|
||||
}}
|
||||
|
||||
QTabBar[cssClass="mainTab"]::tab:selected {{
|
||||
color: #0960D1;
|
||||
font-weight: bold;
|
||||
border-bottom: 3px solid #0960D1;
|
||||
}}
|
||||
|
||||
QTabBar[cssClass="mainTab"]::tab:hover:!selected {{
|
||||
color: #0960D1;
|
||||
}}
|
||||
|
||||
/* ===== QGroupBox ===== */
|
||||
QGroupBox {{
|
||||
background-color: {COLORS["CARD_BG"]};
|
||||
border: 1px solid {COLORS["BORDER_COLOR"]};
|
||||
border-radius: 12px;
|
||||
margin-top: 14px;
|
||||
padding: 16px 12px 12px 12px;
|
||||
font-weight: bold;
|
||||
color: {COLORS["TEXT_MAIN"]};
|
||||
font-size: {BASE_FONT_SIZE_LG};
|
||||
}}
|
||||
|
||||
QGroupBox::title {{
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
padding: 0 10px;
|
||||
color: {COLORS["ACCENT_DARK"]};
|
||||
font-weight: bold;
|
||||
font-size: {BASE_FONT_SIZE_LG};
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
/* ===== Section 卡片(替代 QGroupBox 的轻量方案) ===== */
|
||||
QFrame[cssClass="sectionCard"] {{
|
||||
background-color: {COLORS["CARD_BG"]};
|
||||
border: 1px solid {COLORS["BORDER_COLOR"]};
|
||||
border-radius: 12px;
|
||||
}}
|
||||
|
||||
/* ===== Section 标题 ===== */
|
||||
QLabel[cssClass="sectionTitle"] {{
|
||||
color: {COLORS["TEXT_MAIN"]};
|
||||
font-weight: bold;
|
||||
font-size: {BASE_FONT_SIZE_LG};
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
/* Section 标题左侧蓝色竖线(4px x 16px) */
|
||||
QWidget[cssClass="sectionAccent"] {{
|
||||
background-color: {COLORS["ACCENT_LIGHT"]};
|
||||
border-radius: 2px;
|
||||
}}
|
||||
|
||||
/* ===== 表单标签 ===== */
|
||||
QLabel[cssClass="formLabel"] {{
|
||||
color: {COLORS["FORM_LABEL"]};
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
background-color: transparent;
|
||||
min-width: 140px;
|
||||
}}
|
||||
|
||||
/* ===== QPushButton 基础 ===== */
|
||||
QPushButton {{
|
||||
background-color: {COLORS["ACCENT_BLUE"]};
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 9px 20px;
|
||||
font-weight: bold;
|
||||
font-size: {BASE_FONT_SIZE};
|
||||
}}
|
||||
|
||||
QPushButton:hover {{
|
||||
background-color: {COLORS["HOVER_BLUE"]};
|
||||
}}
|
||||
|
||||
QPushButton:pressed {{
|
||||
background-color: {COLORS["ACCENT_DARK"]};
|
||||
}}
|
||||
|
||||
QPushButton:disabled {{
|
||||
background-color: {COLORS["DISABLED"]};
|
||||
color: #94A3B8;
|
||||
}}
|
||||
|
||||
/* 主操作按钮(绿色 - 连接设备) */
|
||||
QPushButton[cssClass="action"] {{
|
||||
background-color: {COLORS["SUCCESS_GREEN"]};
|
||||
border: none;
|
||||
color: #FFFFFF;
|
||||
padding: 8px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
|
||||
QPushButton[cssClass="action"]:hover {{
|
||||
background-color: #0D8250;
|
||||
}}
|
||||
|
||||
QPushButton[cssClass="action"]:pressed {{
|
||||
background-color: {COLORS["SUCCESS_ACTIVE"]};
|
||||
}}
|
||||
|
||||
/* 次要按钮 / 刷新按钮 */
|
||||
QPushButton[cssClass="refresh"] {{
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid {COLORS["REFRESH_BORDER"]};
|
||||
color: {COLORS["ACCENT_LIGHT"]};
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
|
||||
QPushButton[cssClass="refresh"]:hover {{
|
||||
background-color: #F1F5F9;
|
||||
border-color: {COLORS["ACCENT_LIGHT"]};
|
||||
}}
|
||||
|
||||
QPushButton[cssClass="refresh"]:pressed {{
|
||||
background-color: #E2E8F0;
|
||||
}}
|
||||
|
||||
/* ===== 底部操作按钮(ID 选择器,精确控制高度与内边距,修复文字截断) ===== */
|
||||
QPushButton#refresh_btn {{
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #CCDBF0;
|
||||
color: #0960D1;
|
||||
border-radius: 6px;
|
||||
min-height: 34px;
|
||||
max-height: 34px;
|
||||
padding: 0px 20px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
QPushButton#refresh_btn:hover {{
|
||||
background-color: #F0F4FA;
|
||||
}}
|
||||
|
||||
QPushButton#refresh_btn:focus {{
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
QPushButton#connect_btn {{
|
||||
background-color: #0F955D;
|
||||
border: none;
|
||||
color: #FFFFFF;
|
||||
border-radius: 6px;
|
||||
min-height: 34px;
|
||||
max-height: 34px;
|
||||
padding: 0px 24px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
QPushButton#connect_btn:hover {{
|
||||
background-color: #0D8250;
|
||||
}}
|
||||
|
||||
QPushButton#connect_btn:focus {{
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
/* 危险按钮(红色,用于停止) */
|
||||
QPushButton[cssClass="danger"] {{
|
||||
background-color: #EF4444;
|
||||
border: none;
|
||||
color: #FFFFFF;
|
||||
padding: 8px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
|
||||
QPushButton[cssClass="danger"]:hover {{
|
||||
background-color: #DC2626;
|
||||
}}
|
||||
|
||||
QPushButton[cssClass="danger"]:pressed {{
|
||||
background-color: #B91C1C;
|
||||
}}
|
||||
|
||||
/* ===== QLineEdit 输入框 ===== */
|
||||
QLineEdit {{
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid {COLORS["BORDER_COLOR"]};
|
||||
border-radius: 6px;
|
||||
padding-left: 12px;
|
||||
color: #333333;
|
||||
font-size: 14px;
|
||||
min-height: 36px;
|
||||
max-height: 36px;
|
||||
}}
|
||||
|
||||
QLineEdit:focus {{
|
||||
border: 1px solid {COLORS["ACCENT_LIGHT"]};
|
||||
}}
|
||||
|
||||
QLineEdit:hover:!focus {{
|
||||
border-color: #94A3B8;
|
||||
}}
|
||||
|
||||
QLineEdit:disabled {{
|
||||
background-color: #F1F5F9;
|
||||
color: {COLORS["TEXT_MUTED"]};
|
||||
border-color: #E2E8F0;
|
||||
}}
|
||||
|
||||
/* ===== QComboBox 下拉框(增强鲁棒性,防止打包后黑底) ===== */
|
||||
QComboBox {{
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid {COLORS["BORDER_COLOR"]};
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
color: {COLORS["TEXT_MAIN"]};
|
||||
font-size: 14px;
|
||||
min-width: 100px;
|
||||
min-height: 36px;
|
||||
max-height: 36px;
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
QComboBox:hover {{
|
||||
border-color: #94A3B8;
|
||||
}}
|
||||
|
||||
QComboBox:focus {{
|
||||
border-color: {COLORS["ACCENT_LIGHT"]};
|
||||
border-width: 1px;
|
||||
}}
|
||||
|
||||
QComboBox:disabled {{
|
||||
background-color: #F1F5F9;
|
||||
color: {COLORS["TEXT_MUTED"]};
|
||||
border-color: #E2E8F0;
|
||||
}}
|
||||
|
||||
QComboBox::drop-down {{
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: top right;
|
||||
width: 28px;
|
||||
border: none;
|
||||
border-left: 1px solid {COLORS["BORDER_COLOR"]};
|
||||
border-top-right-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
background-color: #F8FAFC;
|
||||
}}
|
||||
|
||||
QComboBox::down-arrow {{
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}}
|
||||
|
||||
/* 下拉弹出视图(最关键的修复点——保证白色背景) */
|
||||
QComboBox QAbstractItemView {{
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid {COLORS["BORDER_COLOR"]};
|
||||
border-radius: 4px;
|
||||
selection-background-color: {COLORS["ACCENT_LIGHT"]};
|
||||
selection-color: #FFFFFF;
|
||||
color: {COLORS["TEXT_MAIN"]};
|
||||
font-size: {BASE_FONT_SIZE};
|
||||
padding: 4px;
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
QComboBox QAbstractItemView::item {{
|
||||
padding: 6px 10px;
|
||||
border-radius: 3px;
|
||||
color: {COLORS["TEXT_MAIN"]};
|
||||
background-color: #FFFFFF;
|
||||
}}
|
||||
|
||||
QComboBox QAbstractItemView::item:selected {{
|
||||
background-color: {COLORS["ACCENT_LIGHT"]};
|
||||
color: #FFFFFF;
|
||||
}}
|
||||
|
||||
QComboBox QAbstractItemView::item:hover {{
|
||||
background-color: #EFF6FF;
|
||||
}}
|
||||
|
||||
/* 防止下拉滚动条区域也变黑 */
|
||||
QComboBox QAbstractScrollArea {{
|
||||
background-color: #FFFFFF;
|
||||
color: {COLORS["TEXT_MAIN"]};
|
||||
}}
|
||||
|
||||
/* ===== QRadioButton / QCheckBox ===== */
|
||||
QRadioButton, QCheckBox {{
|
||||
background-color: transparent;
|
||||
color: {COLORS["TEXT_MAIN"]};
|
||||
font-size: {BASE_FONT_SIZE};
|
||||
spacing: 8px;
|
||||
}}
|
||||
|
||||
QRadioButton::indicator {{
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
border: 2px solid {COLORS["BORDER_COLOR"]};
|
||||
background-color: #FFFFFF;
|
||||
}}
|
||||
|
||||
QRadioButton::indicator:checked {{
|
||||
background-color: {COLORS["ACCENT_BLUE"]};
|
||||
border-color: {COLORS["ACCENT_BLUE"]};
|
||||
}}
|
||||
|
||||
QRadioButton::indicator:hover {{
|
||||
border-color: {COLORS["ACCENT_BLUE"]};
|
||||
}}
|
||||
|
||||
QRadioButton::indicator:checked:hover {{
|
||||
background-color: {COLORS["ACCENT_DARK"]};
|
||||
border-color: {COLORS["ACCENT_DARK"]};
|
||||
}}
|
||||
|
||||
QRadioButton:disabled {{
|
||||
color: {COLORS["DISABLED"]};
|
||||
}}
|
||||
|
||||
QRadioButton::indicator:disabled {{
|
||||
background-color: #F1F5F9;
|
||||
border-color: {COLORS["DISABLED"]};
|
||||
}}
|
||||
|
||||
QCheckBox::indicator {{
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid {COLORS["BORDER_COLOR"]};
|
||||
background-color: #FFFFFF;
|
||||
}}
|
||||
|
||||
QCheckBox::indicator:checked {{
|
||||
background-color: {COLORS["ACCENT_BLUE"]};
|
||||
border-color: {COLORS["ACCENT_BLUE"]};
|
||||
}}
|
||||
|
||||
QCheckBox::indicator:hover {{
|
||||
border-color: {COLORS["ACCENT_BLUE"]};
|
||||
}}
|
||||
|
||||
QCheckBox::indicator:checked:hover {{
|
||||
background-color: {COLORS["ACCENT_DARK"]};
|
||||
border-color: {COLORS["ACCENT_DARK"]};
|
||||
}}
|
||||
|
||||
QCheckBox:disabled {{
|
||||
color: {COLORS["DISABLED"]};
|
||||
}}
|
||||
|
||||
QCheckBox::indicator:disabled {{
|
||||
background-color: #F1F5F9;
|
||||
border-color: {COLORS["DISABLED"]};
|
||||
}}
|
||||
|
||||
/* ===== 仪表板卡片 ===== */
|
||||
QFrame[cssClass="dashboardCard"] {{
|
||||
background-color: {COLORS["CARD_BG"]};
|
||||
border: 1px solid {COLORS["BORDER_COLOR"]};
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}}
|
||||
|
||||
QFrame[cssClass="dashboardCard"] QLabel {{
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
QLabel[cssClass="dashboardLabel"] {{
|
||||
color: {COLORS["TEXT_MUTED"]};
|
||||
font-size: {BASE_FONT_SIZE_SM};
|
||||
font-weight: normal;
|
||||
}}
|
||||
|
||||
QLabel[cssClass="dashboardValue"] {{
|
||||
font-family: "SF Mono, Menlo, Consolas, monospace";
|
||||
font-size: {BASE_FONT_SIZE_XL};
|
||||
font-weight: bold;
|
||||
}}
|
||||
|
||||
/* ===== 底部状态栏 ===== */
|
||||
QWidget[cssClass="bottomBar"] {{
|
||||
background-color: #FFFFFF;
|
||||
border-top: 1px solid {COLORS["BORDER_COLOR"]};
|
||||
}}
|
||||
|
||||
QLabel[cssClass="logLabel"] {{
|
||||
color: #757575;
|
||||
font-family: "SF Mono, Consolas, Menlo, monospace";
|
||||
font-size: 12px;
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
QLabel[cssClass="statusLabel"] {{
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
/* ===== 透明背景容器(避免 inline stylesheet 覆盖子控件 QSS) ===== */
|
||||
QWidget[cssClass="transparentBg"] {{
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
/* ===== QStackedWidget 页面 ===== */
|
||||
QWidget[cssClass="tabPage"] {{
|
||||
background-color: #FFFFFF;
|
||||
}}
|
||||
|
||||
/* ===== 分组辅助标签 ===== */
|
||||
QLabel[cssClass="section"] {{
|
||||
color: {COLORS["ACCENT_DARK"]};
|
||||
font-weight: bold;
|
||||
font-size: {BASE_FONT_SIZE_LG};
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
/* ===== 导航栏标题 ===== */
|
||||
QLabel[cssClass="navTitle"] {{
|
||||
color: {COLORS["TEXT_MAIN"]};
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
background-color: transparent;
|
||||
letter-spacing: 0px;
|
||||
}}
|
||||
|
||||
QLabel[cssClass="navSubtitle"] {{
|
||||
color: {COLORS["ACCENT_DARK"]};
|
||||
font-size: 11px;
|
||||
font-weight: normal;
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
QLabel[cssClass="navDivider"] {{
|
||||
color: {COLORS["BORDER_COLOR"]};
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
/* 设置按钮(圆形,右上角) */
|
||||
QPushButton[cssClass="navSettings"] {{
|
||||
background-color: transparent;
|
||||
border: 1.5px solid {COLORS["BORDER_COLOR"]};
|
||||
border-radius: 18px;
|
||||
padding: 4px;
|
||||
min-width: 36px;
|
||||
max-width: 36px;
|
||||
min-height: 36px;
|
||||
max-height: 36px;
|
||||
}}
|
||||
|
||||
QPushButton[cssClass="navSettings"]:hover {{
|
||||
background-color: {COLORS["BG_COLOR"]};
|
||||
border-color: #CBD5E1;
|
||||
}}
|
||||
|
||||
QPushButton[cssClass="navSettings"]:pressed {{
|
||||
background-color: #E2E8F0;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def apply_app_style(app: QApplication):
|
||||
"""配置全局 QSS 样式表与窗口默认调色板,返回配色字典供布局复用。"""
|
||||
# ---- 强制使用 Fusion 风格(跨平台一致,避免 Windows 原生风格/深色模式干扰 QSS) ----
|
||||
app.setStyle("Fusion")
|
||||
|
||||
# 应用 QSS 样式表
|
||||
app.setStyleSheet(QSS_STYLESHEET)
|
||||
|
||||
# 设置默认字体
|
||||
font = app.font()
|
||||
font.setFamily(BASE_FONT_FAMILY.split(",")[0].strip().strip('"'))
|
||||
font.setPointSize(10)
|
||||
app.setFont(font)
|
||||
|
||||
# 配置默认调色板(仅设置 Window/Base 等基础角色,不污染 Button/ComboBox)
|
||||
palette = QPalette()
|
||||
palette.setColor(QPalette.Window, QColor(COLORS["BG_COLOR"]))
|
||||
palette.setColor(QPalette.WindowText, QColor(COLORS["TEXT_MAIN"]))
|
||||
palette.setColor(QPalette.Base, QColor("#FFFFFF"))
|
||||
palette.setColor(QPalette.Text, QColor(COLORS["TEXT_MAIN"]))
|
||||
palette.setColor(QPalette.Button, QColor("#FFFFFF")) # 白色底,避免黑色
|
||||
palette.setColor(QPalette.ButtonText, QColor(COLORS["TEXT_MAIN"]))
|
||||
palette.setColor(QPalette.Highlight, QColor(COLORS["ACCENT_LIGHT"]))
|
||||
palette.setColor(QPalette.HighlightedText, QColor("#FFFFFF"))
|
||||
app.setPalette(palette)
|
||||
|
||||
return COLORS
|
||||
@@ -0,0 +1,41 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "device_heartbeat.py"
|
||||
SPEC = importlib.util.spec_from_file_location("device_heartbeat_under_test", MODULE_PATH)
|
||||
HEARTBEAT = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(HEARTBEAT)
|
||||
|
||||
|
||||
class DeviceHeartbeatTests(unittest.TestCase):
|
||||
def test_sends_current_device_id_to_server(self):
|
||||
calls = []
|
||||
requests_module = types.ModuleType("requests")
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"success": True, "lastSeenAt": "2026-07-28T00:00:00.000Z"}
|
||||
|
||||
def post(url, json, timeout):
|
||||
calls.append((url, json, timeout))
|
||||
return Response()
|
||||
|
||||
requests_module.post = post
|
||||
api_module = types.ModuleType("api")
|
||||
api_module.data_record_url = "https://server.example/api"
|
||||
api_module.the_folder = "company/line"
|
||||
with patch.dict(sys.modules, {"requests": requests_module, "api": api_module}):
|
||||
timestamp = HEARTBEAT.heartbeat_device(timeout=7)
|
||||
|
||||
self.assertEqual(timestamp, "2026-07-28T00:00:00.000Z")
|
||||
self.assertEqual(calls, [("https://server.example/api", {
|
||||
"type": "deviceHeartbeat", "deviceId": "company/line"
|
||||
}, 7)])
|
||||
@@ -0,0 +1,157 @@
|
||||
import importlib.util
|
||||
import csv
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "identification_config.py"
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"identification_config_under_test", MODULE_PATH
|
||||
)
|
||||
IDENTIFICATION_CONFIG = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(IDENTIFICATION_CONFIG)
|
||||
validate_identification_config = IDENTIFICATION_CONFIG.validate_identification_config
|
||||
parse_identification_config_csv = IDENTIFICATION_CONFIG.parse_identification_config_csv
|
||||
download_identification_config = IDENTIFICATION_CONFIG.download_identification_config
|
||||
|
||||
|
||||
VALID_CONFIG = {
|
||||
"q_in_val": 50.0,
|
||||
"dt": 0.1,
|
||||
"n_order": 6,
|
||||
"t_c": 2.5,
|
||||
"levels": [10, 20, 30, 40, 50, 60, 70, 80],
|
||||
"dead_area": 240.0,
|
||||
"xa_full": 1000.0,
|
||||
"V_val": 5.0,
|
||||
"repeat": 2,
|
||||
}
|
||||
|
||||
|
||||
def config_csv(config):
|
||||
output = io.StringIO(newline="")
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(("parameter", "value"))
|
||||
for key in (
|
||||
"q_in_val", "dt", "n_order", "t_c", "levels", "dead_area",
|
||||
"xa_full", "V_val", "repeat"):
|
||||
value = config[key]
|
||||
if key == "levels":
|
||||
value = ",".join(str(item) for item in value)
|
||||
writer.writerow((key, value))
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class IdentificationConfigTests(unittest.TestCase):
|
||||
def test_accepts_and_normalizes_valid_config(self):
|
||||
result = validate_identification_config(VALID_CONFIG)
|
||||
self.assertEqual(result["repeat"], 2)
|
||||
self.assertEqual(result["levels"], [
|
||||
10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0
|
||||
])
|
||||
|
||||
def test_rejects_missing_field(self):
|
||||
config = dict(VALID_CONFIG)
|
||||
config.pop("repeat")
|
||||
with self.assertRaisesRegex(ValueError, "缺少"):
|
||||
validate_identification_config(config)
|
||||
|
||||
def test_parses_parameter_value_csv(self):
|
||||
result = parse_identification_config_csv(config_csv(VALID_CONFIG))
|
||||
self.assertEqual(result, VALID_CONFIG)
|
||||
|
||||
def test_rejects_non_power_of_two_levels(self):
|
||||
config = dict(VALID_CONFIG, levels=[10, 20, 30])
|
||||
with self.assertRaisesRegex(ValueError, "2 的整数次幂"):
|
||||
validate_identification_config(config)
|
||||
|
||||
def test_rejects_symbol_period_shorter_than_sample_period(self):
|
||||
config = dict(VALID_CONFIG, dt=0.1, t_c=0.05)
|
||||
with self.assertRaisesRegex(ValueError, "t_c 必须大于等于 dt"):
|
||||
validate_identification_config(config)
|
||||
|
||||
def test_rejects_travel_scan_above_xa_full(self):
|
||||
config = dict(VALID_CONFIG, xa_full=999.0)
|
||||
with self.assertRaisesRegex(ValueError, "1000"):
|
||||
validate_identification_config(config)
|
||||
|
||||
def test_rejects_dead_area_at_or_above_xa_full(self):
|
||||
config = dict(VALID_CONFIG, dead_area=1000.0)
|
||||
with self.assertRaisesRegex(ValueError, "dead_area"):
|
||||
validate_identification_config(config)
|
||||
|
||||
def test_download_requests_customer_config_and_validates_it(self):
|
||||
calls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, body=None, text=None):
|
||||
self.body = body
|
||||
self.text = text
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.body
|
||||
|
||||
requests_module = types.ModuleType("requests")
|
||||
requests_module.RequestException = Exception
|
||||
|
||||
def post(url, json, timeout):
|
||||
calls.append(("post", url, json, timeout))
|
||||
return FakeResponse({"success": True, "url": "https://temp/config"})
|
||||
|
||||
def get(url, timeout):
|
||||
calls.append(("get", url, timeout))
|
||||
return FakeResponse(text=config_csv(VALID_CONFIG))
|
||||
|
||||
requests_module.post = post
|
||||
requests_module.get = get
|
||||
api_module = types.ModuleType("api")
|
||||
api_module.data_record_url = "https://cloud/data_record"
|
||||
api_module.the_folder = "客户A"
|
||||
|
||||
with patch.dict(sys.modules, {
|
||||
"requests": requests_module,
|
||||
"api": api_module,
|
||||
}):
|
||||
result = download_identification_config(timeout=7)
|
||||
|
||||
self.assertEqual(result["repeat"], 2)
|
||||
self.assertEqual(calls[0], (
|
||||
"post",
|
||||
"https://cloud/data_record",
|
||||
{"type": "getIdentificationConfig", "deviceId": "客户A"},
|
||||
7,
|
||||
))
|
||||
self.assertEqual(calls[1], ("get", "https://temp/config", 7))
|
||||
|
||||
def test_download_reports_cloud_rejection(self):
|
||||
class FakeResponse:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"success": False, "errMsg": "配置不存在"}
|
||||
|
||||
requests_module = types.ModuleType("requests")
|
||||
requests_module.RequestException = Exception
|
||||
requests_module.post = lambda *args, **kwargs: FakeResponse()
|
||||
api_module = types.ModuleType("api")
|
||||
api_module.data_record_url = "https://cloud/data_record"
|
||||
api_module.the_folder = "客户A"
|
||||
|
||||
with patch.dict(sys.modules, {
|
||||
"requests": requests_module,
|
||||
"api": api_module,
|
||||
}):
|
||||
with self.assertRaisesRegex(ValueError, "配置不存在"):
|
||||
download_identification_config()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,85 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "core" / "identification_feedback.py"
|
||||
)
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"identification_feedback_under_test", MODULE_PATH
|
||||
)
|
||||
FEEDBACK = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(FEEDBACK)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.body
|
||||
|
||||
|
||||
class IdentificationFeedbackTests(unittest.TestCase):
|
||||
def call_with_response(self, response_body, callback):
|
||||
calls = []
|
||||
requests_module = types.ModuleType("requests")
|
||||
|
||||
def post(url, json, timeout):
|
||||
calls.append((url, json, timeout))
|
||||
return FakeResponse(response_body)
|
||||
|
||||
requests_module.post = post
|
||||
api_module = types.ModuleType("api")
|
||||
api_module.data_record_url = "https://cloud/data_record"
|
||||
api_module.the_folder = "客户A"
|
||||
with patch.dict(sys.modules, {
|
||||
"requests": requests_module,
|
||||
"api": api_module,
|
||||
}):
|
||||
value = callback()
|
||||
return value, calls
|
||||
|
||||
def test_registers_uploaded_csv(self):
|
||||
_, calls = self.call_with_response(
|
||||
{"success": True},
|
||||
lambda: FEEDBACK.register_identification_result("result.csv", 7),
|
||||
)
|
||||
self.assertEqual(calls[0][1], {
|
||||
"type": "registerIdentificationResult",
|
||||
"deviceId": "客户A",
|
||||
"runId": "result.csv",
|
||||
"fileName": "result.csv",
|
||||
})
|
||||
|
||||
def test_pending_feedback_returns_none(self):
|
||||
value, _ = self.call_with_response(
|
||||
{"success": True, "ready": False},
|
||||
lambda: FEEDBACK.get_identification_feedback("result.csv"),
|
||||
)
|
||||
self.assertIsNone(value)
|
||||
|
||||
def test_feedback_returns_only_zero_or_one(self):
|
||||
for result in (0, 1):
|
||||
value, _ = self.call_with_response(
|
||||
{"success": True, "ready": True, "result": result},
|
||||
lambda: FEEDBACK.get_identification_feedback("result.csv"),
|
||||
)
|
||||
self.assertEqual(value, result)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "0 或 1"):
|
||||
self.call_with_response(
|
||||
{"success": True, "ready": True, "result": 2},
|
||||
lambda: FEEDBACK.get_identification_feedback("result.csv"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,153 @@
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "identification.py"
|
||||
|
||||
|
||||
def load_identification_module():
|
||||
get_v = types.ModuleType("get_V")
|
||||
get_v.measure_volume = lambda *args, **kwargs: None
|
||||
ind_collector = types.ModuleType("ind_collector")
|
||||
ind_collector.collect_data_with_prbs = lambda *args, **kwargs: {}
|
||||
api = types.ModuleType("api")
|
||||
api.base_url = "https://cloud.example"
|
||||
api.data_record_url = "https://cloud.example/data_record"
|
||||
api.the_folder = "customer-a"
|
||||
requests = types.ModuleType("requests")
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"identification_under_test", MODULE_PATH
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {
|
||||
"get_V": get_v,
|
||||
"ind_collector": ind_collector,
|
||||
"api": api,
|
||||
"requests": requests,
|
||||
}):
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
IDENTIFICATION = load_identification_module()
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self):
|
||||
self.now = 0.0
|
||||
|
||||
def monotonic(self):
|
||||
self.now += 0.001
|
||||
return self.now
|
||||
|
||||
def sleep(self, duration):
|
||||
self.now += max(0.0, duration)
|
||||
|
||||
|
||||
class FakeConnectionManager:
|
||||
def __init__(self):
|
||||
self.distance = 0
|
||||
|
||||
def set_motor_position(self, distance):
|
||||
self.distance = int(distance)
|
||||
return True
|
||||
|
||||
def read_pressure(self):
|
||||
return self.distance / 100.0
|
||||
|
||||
|
||||
class InitialTravelScanTests(unittest.TestCase):
|
||||
def test_uploads_distance_and_pressure_json_without_time_fields(self):
|
||||
manager = IDENTIFICATION.IdentificationManager()
|
||||
manager._identifying = True
|
||||
captured = {}
|
||||
|
||||
def capture_upload(body, filename, folder):
|
||||
captured.update(body=body, filename=filename, folder=folder)
|
||||
return True
|
||||
|
||||
manager._upload_to_cos = capture_upload
|
||||
clock = FakeClock()
|
||||
with patch.object(IDENTIFICATION.time, "monotonic", clock.monotonic), \
|
||||
patch.object(IDENTIFICATION.time, "sleep", clock.sleep):
|
||||
payload = manager._run_initial_travel_scan(
|
||||
FakeConnectionManager()
|
||||
)
|
||||
|
||||
records = payload["stable_pressures"]
|
||||
expected_distances = list(range(1000, -1, -100))
|
||||
self.assertEqual(
|
||||
[record["distance"] for record in records], expected_distances
|
||||
)
|
||||
self.assertEqual(
|
||||
[record["pressure"] for record in records],
|
||||
[distance / 100.0 for distance in expected_distances],
|
||||
)
|
||||
self.assertTrue(captured["filename"].endswith(".json"))
|
||||
self.assertEqual(captured["folder"], "customer-a/ind_data")
|
||||
self.assertEqual(json.loads(captured["body"]), payload)
|
||||
self.assertTrue(all(
|
||||
set(record) == {"distance", "pressure"} for record in records
|
||||
))
|
||||
|
||||
def test_identification_uploads_collector_csv_and_notifies_filename(self):
|
||||
manager = IDENTIFICATION.IdentificationManager()
|
||||
manager._run_initial_travel_scan = lambda conn_mgr: {}
|
||||
uploaded = {}
|
||||
callbacks = []
|
||||
csv_data = b"t,u,p,q_in,V\n0.0,10.0,20.0,50.0,5.0\n"
|
||||
csv_filename = "identification_data_test.csv"
|
||||
|
||||
manager._upload_to_cos = lambda content, filename, folder: (
|
||||
uploaded.update(
|
||||
content=content, filename=filename, folder=folder
|
||||
) or True
|
||||
)
|
||||
manager.set_identification_upload_callback(
|
||||
lambda success, filename, error:
|
||||
callbacks.append((success, filename, error))
|
||||
)
|
||||
|
||||
class ConnectedManager:
|
||||
def is_connected(self):
|
||||
return True
|
||||
|
||||
collector_result = {
|
||||
"success": True,
|
||||
"csv_data": csv_data,
|
||||
"filename": csv_filename,
|
||||
}
|
||||
with patch.object(
|
||||
IDENTIFICATION, "collect_data_with_prbs",
|
||||
return_value=collector_result):
|
||||
started = manager.start_identification(
|
||||
conn_mgr=ConnectedManager(),
|
||||
running_flag_check=lambda: False,
|
||||
q_in_val=50.0,
|
||||
dt=0.1,
|
||||
n_order=6,
|
||||
t_c=2.5,
|
||||
levels=[10, 20, 30, 40, 50, 60, 70, 80],
|
||||
dead_area=240.0,
|
||||
xa_full=1000.0,
|
||||
V_val=5.0,
|
||||
repeat=2,
|
||||
)
|
||||
manager._task_thread.join(timeout=2)
|
||||
|
||||
self.assertTrue(started)
|
||||
self.assertFalse(manager._task_thread.is_alive())
|
||||
self.assertEqual(uploaded["content"], csv_data)
|
||||
self.assertEqual(uploaded["filename"], csv_filename)
|
||||
self.assertEqual(uploaded["folder"], "customer-a/ind_data")
|
||||
self.assertEqual(callbacks, [(True, csv_filename, None)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Tests for the company/production-line license protocol."""
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LICENSE_SPEC = importlib.util.spec_from_file_location(
|
||||
"license_utils_under_test", ROOT / "license_utils.py"
|
||||
)
|
||||
LICENSE = importlib.util.module_from_spec(LICENSE_SPEC)
|
||||
LICENSE_SPEC.loader.exec_module(LICENSE)
|
||||
|
||||
|
||||
class FakePublicKey:
|
||||
def verify(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def license_file(payload):
|
||||
payload_b64 = base64.b64encode(json.dumps(payload).encode()).decode()
|
||||
handle = tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", delete=False)
|
||||
handle.write(f"{payload_b64}|{base64.b64encode(b'signature').decode()}")
|
||||
handle.close()
|
||||
return handle.name
|
||||
|
||||
|
||||
NEW_LICENSE = {
|
||||
"license_id": "license-123",
|
||||
"customer": "Sample Co",
|
||||
"company_id": "company-123",
|
||||
"production_line_id": "line-123",
|
||||
"device_id": "sample-co/line-1",
|
||||
"issued": "2026-01-01 00:00",
|
||||
"expiry": "2099-01-01 00:00",
|
||||
"features": "*",
|
||||
}
|
||||
|
||||
|
||||
class LicenseProtocolTests(unittest.TestCase):
|
||||
def verify_payload(self, payload):
|
||||
path = license_file(payload)
|
||||
self.addCleanup(os.unlink, path)
|
||||
with patch.object(LICENSE, "_load_public_key", return_value=FakePublicKey()):
|
||||
return LICENSE.verify_license(path)
|
||||
|
||||
def test_new_license_returns_company_and_line_identifiers(self):
|
||||
self.assertEqual(self.verify_payload(NEW_LICENSE), NEW_LICENSE)
|
||||
|
||||
def test_old_license_remains_valid(self):
|
||||
legacy = {
|
||||
"customer": "Legacy Customer",
|
||||
"issued": "2026-01-01",
|
||||
"expiry": "2099-01-01",
|
||||
"features": "*",
|
||||
}
|
||||
self.assertEqual(self.verify_payload(legacy), legacy)
|
||||
|
||||
def test_new_license_rejects_missing_organization_identifier(self):
|
||||
invalid = dict(NEW_LICENSE)
|
||||
invalid.pop("production_line_id")
|
||||
with self.assertRaisesRegex(ValueError, "production_line_id"):
|
||||
self.verify_payload(invalid)
|
||||
|
||||
def test_new_license_rejects_unsafe_device_id(self):
|
||||
invalid = dict(NEW_LICENSE, device_id="sample-co/../line-1")
|
||||
with self.assertRaisesRegex(ValueError, "device_id"):
|
||||
self.verify_payload(invalid)
|
||||
|
||||
def test_online_active_status_is_accepted(self):
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"success": True, "valid": True, "status": "active",
|
||||
"licenseId": NEW_LICENSE["license_id"]}
|
||||
|
||||
with patch.object(LICENSE.requests, "post", return_value=Response()):
|
||||
LICENSE.validate_license_online(NEW_LICENSE)
|
||||
|
||||
def test_online_invalid_statuses_are_rejected(self):
|
||||
for status in ("revoked", "expired", "device_mismatch"):
|
||||
with self.subTest(status=status):
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"success": True, "valid": False, "status": status}
|
||||
|
||||
with patch.object(LICENSE.requests, "post", return_value=Response()):
|
||||
with self.assertRaisesRegex(LICENSE.ExpiredError, status):
|
||||
LICENSE.validate_license_online(NEW_LICENSE)
|
||||
|
||||
def test_online_network_failure_is_allowed_within_offline_grace(self):
|
||||
LICENSE._last_online_success_monotonic = LICENSE._time_module.monotonic()
|
||||
with patch.object(LICENSE.requests, "post",
|
||||
side_effect=LICENSE.requests.ConnectionError("offline")):
|
||||
LICENSE.validate_license_online(NEW_LICENSE)
|
||||
|
||||
def test_api_rejects_environment_device_id_mismatch(self):
|
||||
fake_license_utils = types.ModuleType("license_utils")
|
||||
fake_license_utils.get_verified_license = lambda: dict(NEW_LICENSE)
|
||||
api_spec = importlib.util.spec_from_file_location("api_under_test", ROOT / "api.py")
|
||||
api_module = importlib.util.module_from_spec(api_spec)
|
||||
with patch.dict(os.environ, {"REINLOOP_DEVICE_ID": "other/line"}, clear=False), \
|
||||
patch.dict(sys.modules, {"license_utils": fake_license_utils}):
|
||||
with self.assertRaisesRegex(RuntimeError, "不一致"):
|
||||
api_spec.loader.exec_module(api_module)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,218 @@
|
||||
import json
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "volume_config.py"
|
||||
SPEC = importlib.util.spec_from_file_location("volume_config_under_test", MODULE_PATH)
|
||||
VOLUME_CONFIG = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(VOLUME_CONFIG)
|
||||
load_volume_config = VOLUME_CONFIG.load_volume_config
|
||||
validate_volume_config = VOLUME_CONFIG.validate_volume_config
|
||||
create_volume_config_request = VOLUME_CONFIG.create_volume_config_request
|
||||
poll_volume_config_request = VOLUME_CONFIG.poll_volume_config_request
|
||||
acknowledge_volume_config_request = VOLUME_CONFIG.acknowledge_volume_config_request
|
||||
|
||||
|
||||
VALID_CONFIG = {
|
||||
"q_in_val": 50.0, "dt": 0.05, "p_max": 200.0,
|
||||
"fit_low": 50.0, "fit_high": 150.0, "T_delta": 30.0,
|
||||
"xa_full": 1000.0, "num_runs": 3,
|
||||
}
|
||||
|
||||
|
||||
class VolumeConfigTests(unittest.TestCase):
|
||||
def write_config(self, directory, config):
|
||||
path = Path(directory) / "volume.json"
|
||||
path.write_text(json.dumps(config), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def test_load_valid_config(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
result = load_volume_config(self.write_config(directory, VALID_CONFIG))
|
||||
self.assertEqual(result["num_runs"], 3)
|
||||
self.assertEqual(result["xa_full"], 1000.0)
|
||||
|
||||
def test_rejects_missing_field(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = dict(VALID_CONFIG)
|
||||
config.pop("dt")
|
||||
with self.assertRaisesRegex(ValueError, "缺少"):
|
||||
load_volume_config(self.write_config(directory, config))
|
||||
|
||||
def test_rejects_invalid_range(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = dict(VALID_CONFIG, fit_high=40.0)
|
||||
with self.assertRaisesRegex(ValueError, "fit_low"):
|
||||
load_volume_config(self.write_config(directory, config))
|
||||
|
||||
def test_rejects_zero_flow(self):
|
||||
with self.assertRaisesRegex(ValueError, "q_in_val 必须大于 0"):
|
||||
validate_volume_config(dict(VALID_CONFIG, q_in_val=0))
|
||||
|
||||
def test_customer_creates_exactly_one_request_instruction(self):
|
||||
calls = []
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"success": True,
|
||||
"requestId": "request-1",
|
||||
"expiresAtMs": 123456,
|
||||
}
|
||||
|
||||
requests_module = types.ModuleType("requests")
|
||||
|
||||
def post(url, json, timeout):
|
||||
calls.append((url, json, timeout))
|
||||
return FakeResponse()
|
||||
|
||||
requests_module.post = post
|
||||
api_module = types.ModuleType("api")
|
||||
api_module.data_record_url = "https://cloud/data_record"
|
||||
api_module.the_folder = "客户A"
|
||||
|
||||
with patch.dict(sys.modules, {
|
||||
"requests": requests_module,
|
||||
"api": api_module,
|
||||
}):
|
||||
result = create_volume_config_request(timeout=7)
|
||||
|
||||
self.assertEqual(result, {
|
||||
"request_id": "request-1",
|
||||
"expires_at_ms": 123456,
|
||||
})
|
||||
self.assertEqual(calls, [(
|
||||
"https://cloud/data_record",
|
||||
{"type": "createVolumeConfigRequest", "deviceId": "客户A"},
|
||||
7,
|
||||
)])
|
||||
|
||||
def test_pending_request_does_not_download_a_file(self):
|
||||
calls = []
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"success": True, "ready": False, "expired": False}
|
||||
|
||||
requests_module = types.ModuleType("requests")
|
||||
requests_module.post = lambda *args, **kwargs: (
|
||||
calls.append(("post", kwargs["json"])) or FakeResponse()
|
||||
)
|
||||
requests_module.get = lambda *args, **kwargs: calls.append(("get", args[0]))
|
||||
api_module = types.ModuleType("api")
|
||||
api_module.data_record_url = "https://cloud/data_record"
|
||||
api_module.the_folder = "客户A"
|
||||
|
||||
with patch.dict(sys.modules, {
|
||||
"requests": requests_module,
|
||||
"api": api_module,
|
||||
}):
|
||||
result = poll_volume_config_request("request-1")
|
||||
|
||||
self.assertEqual(result, {"ready": False, "expired": False})
|
||||
self.assertEqual(calls, [("post", {
|
||||
"type": "getVolumeConfigRequest",
|
||||
"deviceId": "客户A",
|
||||
"requestId": "request-1",
|
||||
})])
|
||||
|
||||
def test_ready_request_downloads_and_validates_json(self):
|
||||
class FakeResponse:
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.body
|
||||
|
||||
requests_module = types.ModuleType("requests")
|
||||
requests_module.post = lambda *args, **kwargs: FakeResponse({
|
||||
"success": True,
|
||||
"ready": True,
|
||||
"expired": False,
|
||||
"url": "https://temp/volume.json",
|
||||
})
|
||||
requests_module.get = lambda *args, **kwargs: FakeResponse(
|
||||
dict(VALID_CONFIG)
|
||||
)
|
||||
api_module = types.ModuleType("api")
|
||||
api_module.data_record_url = "https://cloud/data_record"
|
||||
api_module.the_folder = "客户A"
|
||||
|
||||
with patch.dict(sys.modules, {
|
||||
"requests": requests_module,
|
||||
"api": api_module,
|
||||
}):
|
||||
result = poll_volume_config_request("request-1")
|
||||
|
||||
self.assertTrue(result["ready"])
|
||||
self.assertEqual(result["config"], VALID_CONFIG)
|
||||
|
||||
def test_create_request_reports_server_rejection(self):
|
||||
class FakeResponse:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"success": False, "errMsg": "尚未配置"}
|
||||
|
||||
requests_module = types.ModuleType("requests")
|
||||
requests_module.post = lambda *args, **kwargs: FakeResponse()
|
||||
api_module = types.ModuleType("api")
|
||||
api_module.data_record_url = "https://cloud/data_record"
|
||||
api_module.the_folder = "客户A"
|
||||
|
||||
with patch.dict(sys.modules, {
|
||||
"requests": requests_module,
|
||||
"api": api_module,
|
||||
}):
|
||||
with self.assertRaisesRegex(ValueError, "尚未配置"):
|
||||
create_volume_config_request()
|
||||
|
||||
def test_acknowledges_the_same_request_for_cleanup(self):
|
||||
calls = []
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"success": True, "deleted": 1}
|
||||
|
||||
requests_module = types.ModuleType("requests")
|
||||
requests_module.post = lambda *args, **kwargs: (
|
||||
calls.append(kwargs["json"]) or FakeResponse()
|
||||
)
|
||||
api_module = types.ModuleType("api")
|
||||
api_module.data_record_url = "https://cloud/data_record"
|
||||
api_module.the_folder = "客户A"
|
||||
|
||||
with patch.dict(sys.modules, {
|
||||
"requests": requests_module,
|
||||
"api": api_module,
|
||||
}):
|
||||
acknowledge_volume_config_request("request-1")
|
||||
|
||||
self.assertEqual(calls, [{
|
||||
"type": "ackVolumeConfigRequest",
|
||||
"deviceId": "客户A",
|
||||
"requestId": "request-1",
|
||||
}])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
自动控制 GUI 界面脚本 - 通过模拟用户操作设置目标压力
|
||||
|
||||
升级功能:
|
||||
1. 加入坐标校准功能,摆脱写死的硬编码坐标
|
||||
2. 自动寻找并置顶 GUI 窗口
|
||||
3. 加入 PyAutoGUI 故障保护 (防失控)
|
||||
|
||||
使用方法:
|
||||
1. 首次使用建议进行校准: python auto_test.py --calibrate --targets 50 80 100
|
||||
2. 后续固定窗口位置后直接运行: python auto_test.py --targets 50 80 100
|
||||
python tool/auto_test.py --calibrate --targets 50 80 100 180 170 130 200 210 270 290 280 250 175 165 100 45
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import time
|
||||
import platform
|
||||
import pyautogui
|
||||
|
||||
try:
|
||||
import pygetwindow as gw
|
||||
except ImportError:
|
||||
gw = None
|
||||
|
||||
# 配置 PyAutoGUI
|
||||
pyautogui.FAILSAFE = True # 将鼠标移动到屏幕四个角落可紧急停止脚本
|
||||
pyautogui.PAUSE = 0.3 # 每个动作后默认停顿 0.3 秒,让 UI 有时间反应
|
||||
|
||||
# 平台相关的全选快捷键:macOS 用 command,Windows/Linux 用 ctrl
|
||||
_MODIFIER_KEY = 'command' if platform.system() == 'Darwin' else 'ctrl'
|
||||
|
||||
|
||||
class GUIController:
|
||||
def __init__(self):
|
||||
# 默认坐标 (如果不使用 calibrate 模式,将使用这些备用坐标)
|
||||
# 注意:这些默认值是错误的,请务必使用 --calibrate 参数校准
|
||||
self.input_x, self.input_y = 200, 150
|
||||
self.btn_x, self.btn_y = 320, 150
|
||||
|
||||
def activate_window(self, title_keyword="ReinLoop"):
|
||||
"""尝试寻找并激活目标窗口(支持部分标题匹配)"""
|
||||
if gw is None:
|
||||
print("⚠️ 未安装 pygetwindow,请手动确保 GUI 窗口在前台。")
|
||||
print(" 安装命令: pip install pygetwindow")
|
||||
return
|
||||
|
||||
print(f"正在寻找包含 '{title_keyword}' 的窗口...")
|
||||
try:
|
||||
windows = gw.getWindowsWithTitle(title_keyword)
|
||||
if windows:
|
||||
win = windows[0]
|
||||
if win.isMinimized:
|
||||
win.restore()
|
||||
win.activate()
|
||||
print(f"✅ 成功激活窗口: {win.title}")
|
||||
time.sleep(1) # 等待窗口彻底弹出
|
||||
else:
|
||||
print(f"⚠️ 未找到包含 '{title_keyword}' 的窗口。")
|
||||
print(f" 当前所有窗口列表:")
|
||||
all_wins = gw.getAllWindows()
|
||||
for w in all_wins:
|
||||
if w.title.strip():
|
||||
print(f" - {w.title}")
|
||||
print(" 请确保 ReinLoop GUI 已打开,或使用 --calibrate 后手动置顶窗口。")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 窗口激活失败: {e},请手动将窗口切换到前台。")
|
||||
|
||||
def calibrate(self):
|
||||
"""交互式坐标校准,动态获取按钮位置"""
|
||||
print("\n" + "=" * 40)
|
||||
print("🔧 进入坐标校准模式 (请不要切走窗口)")
|
||||
print("=" * 40)
|
||||
|
||||
print("\n👉 请在 5 秒内将鼠标光标移动到【目标压力输入框】中心...")
|
||||
for i in range(5, 0, -1):
|
||||
print(f"\r倒计时: {i} 秒", end='')
|
||||
time.sleep(1)
|
||||
self.input_x, self.input_y = pyautogui.position()
|
||||
print(f"\n✅ 输入框坐标已记录: ({self.input_x}, {self.input_y})")
|
||||
|
||||
print("\n👉 请在 5 秒内将鼠标光标移动到【设置目标】按钮中心...")
|
||||
for i in range(5, 0, -1):
|
||||
print(f"\r倒计时: {i} 秒", end='')
|
||||
time.sleep(1)
|
||||
self.btn_x, self.btn_y = pyautogui.position()
|
||||
print(f"\n✅ 按钮坐标已记录: ({self.btn_x}, {self.btn_y})")
|
||||
print("=" * 40 + "\n")
|
||||
|
||||
def set_target_pressure(self, target):
|
||||
"""模拟用户操作设置目标压力"""
|
||||
print(f"▶ 正在设置目标压力: {target} kPa")
|
||||
try:
|
||||
# 点击输入框
|
||||
pyautogui.click(x=self.input_x, y=self.input_y)
|
||||
|
||||
# 全选并删除现有内容(macOS: command+a, Windows/Linux: ctrl+a)
|
||||
pyautogui.hotkey(_MODIFIER_KEY, 'a')
|
||||
pyautogui.press('backspace')
|
||||
|
||||
# 输入新的目标压力值
|
||||
pyautogui.typewrite(str(target))
|
||||
|
||||
# 点击"设置目标"按钮
|
||||
pyautogui.click(x=self.btn_x, y=self.btn_y)
|
||||
|
||||
print(f"✅ 成功设置目标压力: {target} kPa")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 设置目标压力失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def auto_control(targets, interval, do_calibrate):
|
||||
print("=" * 60)
|
||||
print("🤖 GUI 自动控制脚本启动")
|
||||
print("提示: 运行过程中将鼠标移动到屏幕四个角落即可紧急停止")
|
||||
print("=" * 60)
|
||||
|
||||
controller = GUIController()
|
||||
controller.activate_window()
|
||||
|
||||
if do_calibrate:
|
||||
controller.calibrate()
|
||||
else:
|
||||
print(
|
||||
f"ℹ️ 使用默认坐标 (输入框: {controller.input_x},{controller.input_y} | "
|
||||
f"按钮: {controller.btn_x},{controller.btn_y})")
|
||||
print("⚠️ 如果点击位置不准确,请使用 --calibrate 参数运行脚本。")
|
||||
|
||||
print("\n3秒后开始自动控制序列...")
|
||||
time.sleep(3)
|
||||
|
||||
for i, target in enumerate(targets):
|
||||
print(f"\n--- 步骤 {i + 1}/{len(targets)} ---")
|
||||
|
||||
if not controller.set_target_pressure(target):
|
||||
print(f"❌ 步骤 {i + 1} 出现异常,提前终止自动控制")
|
||||
break
|
||||
|
||||
if i < len(targets) - 1:
|
||||
print(f"等待 {interval} 秒...")
|
||||
for j in range(interval, 0, -1):
|
||||
print(f"\r剩余时间: {j} 秒 ", end='')
|
||||
time.sleep(1)
|
||||
print()
|
||||
|
||||
print("\n🎉 自动控制序列全部完成!")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='GUI 自动控制脚本')
|
||||
parser.add_argument('--targets', type=float, nargs='+', default=[50, 80, 100, 120],
|
||||
help='目标压力值列表,用空格隔开,单位 kPa')
|
||||
parser.add_argument('--interval', type=int, default=10,
|
||||
help='每个目标压力持续时间,单位秒')
|
||||
parser.add_argument('--calibrate', action='store_true',
|
||||
help='启动坐标校准模式,动态获取输入框和按钮的屏幕坐标')
|
||||
|
||||
args = parser.parse_args()
|
||||
auto_control(args.targets, args.interval, args.calibrate)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,285 @@
|
||||
import os
|
||||
import pickle
|
||||
import glob
|
||||
|
||||
def load_and_merge_pickle_chunks(folder_path, file_pattern="*.pkl"):
|
||||
"""
|
||||
从指定文件夹中读取所有匹配的分片文件,解包并合并成一个总的数据列表。
|
||||
|
||||
Args:
|
||||
folder_path: 存放 .pkl 分片文件的文件夹路径
|
||||
file_pattern: 文件匹配模式,默认匹配所有 .pkl 文件
|
||||
"""
|
||||
all_episodes = []
|
||||
|
||||
# 获取所有匹配的 pkl 文件路径,并按名称排序(确保 part1, part2 顺序或逻辑清晰)
|
||||
search_path = os.path.join(folder_path, file_pattern)
|
||||
file_list = sorted(glob.glob(search_path))
|
||||
|
||||
if not file_list:
|
||||
print(f"❌ 未在路径 【{folder_path}】 下找到任何匹配 【{file_pattern}】 的文件!")
|
||||
return []
|
||||
|
||||
print(f"📂 找到 {len(file_list)} 个数据分片文件,开始加载...")
|
||||
|
||||
for file_path in file_list:
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
# 每个分片解包出来都是一个 list [ep1, ep2, ...]
|
||||
chunk_data = pickle.load(f)
|
||||
|
||||
if isinstance(chunk_data, list):
|
||||
all_episodes.extend(chunk_data)
|
||||
print(f" ✅ 成功加载: {os.path.basename(file_path)} (包含 {len(chunk_data)} 个 Episode)")
|
||||
else:
|
||||
print(f" ⚠️ 警告: {os.path.basename(file_path)} 解析出的数据格式不是列表,跳过。")
|
||||
except Exception as e:
|
||||
print(f" ❌ 读取文件 {os.path.basename(file_path)} 失败: {e}")
|
||||
|
||||
print(f"整个序列加载完成,共合并了 {len(all_episodes)} 个 Episode。")
|
||||
return all_episodes
|
||||
|
||||
|
||||
def analyze_episodes_data(episode_data_raw):
|
||||
"""
|
||||
分析 Episode 数据,统计超调情况。
|
||||
"""
|
||||
total_episodes = len(episode_data_raw)
|
||||
if total_episodes == 0:
|
||||
print("没有数据可供分析。")
|
||||
return
|
||||
|
||||
invalid_count = 0 # 最后一步误差绝对值 > 2 kPa 的无效 episode
|
||||
invalid_high_flow = 0 # 无效 episode 中流量 > 200
|
||||
invalid_low_flow = 0 # 无效 episode 中流量 < 100
|
||||
all_steady_abs_errors = [] # 所有有效 episode 的稳态误差(绝对值)
|
||||
no_overshoot_count = 0
|
||||
no_overshoot_abs_errors = [] # 绝对值稳态误差
|
||||
no_overshoot_raw_errors = [] # 带符号稳态误差(+ = 高于目标, - = 低于目标)
|
||||
overshoot_lt_1_count = 0
|
||||
overshoot_1_to_2_count = 0
|
||||
overshoot_2_to_3_count = 0
|
||||
overshoot_3_to_4_count = 0
|
||||
overshoot_4_to_5_count = 0
|
||||
overshoot_5_to_10_count = 0
|
||||
overshoot_gt_10_count = 0
|
||||
overshoots_5_to_10 = []
|
||||
overshoots_gt_10 = []
|
||||
|
||||
for idx, ep in enumerate(episode_data_raw):
|
||||
pressures = ep.get('pressures', [])
|
||||
target_p = ep.get('target_pressure', 0.0)
|
||||
|
||||
if not pressures:
|
||||
continue
|
||||
|
||||
# 最后一步误差绝对值 > 2 kPa → 无效 episode,跳过
|
||||
errors = ep.get('errors', [])
|
||||
if errors and abs(errors[-1]) > 2:
|
||||
invalid_count += 1
|
||||
q = ep.get('Q_in', 0)
|
||||
if q > 200:
|
||||
invalid_high_flow += 1
|
||||
elif q < 100:
|
||||
invalid_low_flow += 1
|
||||
continue
|
||||
|
||||
initial_p = pressures[0]
|
||||
|
||||
# 所有有效 episode 的稳态误差(最后 30 步绝对值均值)
|
||||
if errors:
|
||||
last_n = errors[-30:] if len(errors) >= 30 else errors
|
||||
all_steady_abs_errors.append(sum(abs(e) for e in last_n) / len(last_n))
|
||||
|
||||
is_step_up = target_p >= initial_p # 升压为 True,降压为 False
|
||||
overshoot = 0.0
|
||||
|
||||
if is_step_up:
|
||||
# 升压:最大值大于目标压力为超调
|
||||
max_p = max(pressures)
|
||||
if max_p > target_p:
|
||||
overshoot = max_p - target_p
|
||||
else:
|
||||
# 降压:最小值小于目标压力为超调
|
||||
min_p = min(pressures)
|
||||
if min_p < target_p:
|
||||
overshoot = target_p - min_p
|
||||
|
||||
# 统计区间
|
||||
if overshoot == 0:
|
||||
no_overshoot_count += 1
|
||||
elif overshoot < 1.0:
|
||||
overshoot_lt_1_count += 1
|
||||
# 最后 30 步的平均误差作为稳态误差(分别记录绝对值和带符号值)
|
||||
if len(errors) >= 30:
|
||||
last_30 = errors[-30:]
|
||||
elif errors:
|
||||
last_30 = errors
|
||||
else:
|
||||
last_30 = []
|
||||
if last_30:
|
||||
no_overshoot_abs_errors.append(sum(abs(e) for e in last_30) / len(last_30))
|
||||
no_overshoot_raw_errors.append(sum(last_30) / len(last_30))
|
||||
elif 1.0 <= overshoot < 2.0:
|
||||
overshoot_1_to_2_count += 1
|
||||
elif 2.0 <= overshoot < 3.0:
|
||||
overshoot_2_to_3_count += 1
|
||||
elif 3.0 <= overshoot < 4.0:
|
||||
overshoot_3_to_4_count += 1
|
||||
elif 4.0 <= overshoot <= 5.0:
|
||||
overshoot_4_to_5_count += 1
|
||||
else:
|
||||
item = {
|
||||
"index": idx,
|
||||
"direction": "升压" if is_step_up else "降压",
|
||||
"initial_p": initial_p,
|
||||
"target_p": target_p,
|
||||
"overshoot_value": round(overshoot, 3),
|
||||
"Q_in": ep.get("Q_in", 0),
|
||||
}
|
||||
if overshoot <= 10.0:
|
||||
overshoot_5_to_10_count += 1
|
||||
overshoots_5_to_10.append(item)
|
||||
else:
|
||||
overshoot_gt_10_count += 1
|
||||
overshoots_gt_10.append(item)
|
||||
|
||||
# 打印报告
|
||||
def _pct(n): return f"{n / total_episodes * 100:.1f}%"
|
||||
|
||||
print("\n" + "="*25 + " 离线数据分析 " + "="*25)
|
||||
valid_episodes = total_episodes - invalid_count
|
||||
print(f"合并后的总 Episode 数 : {total_episodes}")
|
||||
print(f" - 无效 Episode(末步误差>2): {invalid_count} ({_pct(invalid_count)})")
|
||||
if invalid_count > 0:
|
||||
print(f" ├ 流量 > 200 L/min : {invalid_high_flow}")
|
||||
print(f" └ 流量 < 100 L/min : {invalid_low_flow}")
|
||||
print(f" - 有效 Episode 数 : {valid_episodes}")
|
||||
print(f" - 未超调的 Episode 数 : {no_overshoot_count} ({_pct(no_overshoot_count)})")
|
||||
print(f" - 超调 < 1 kPa : {overshoot_lt_1_count} ({_pct(overshoot_lt_1_count)})")
|
||||
print(f" - 超调在 1 ~ 2 kPa 之间 : {overshoot_1_to_2_count} ({_pct(overshoot_1_to_2_count)})")
|
||||
print(f" - 超调在 2 ~ 3 kPa 之间 : {overshoot_2_to_3_count} ({_pct(overshoot_2_to_3_count)})")
|
||||
print(f" - 超调在 3 ~ 4 kPa 之间 : {overshoot_3_to_4_count} ({_pct(overshoot_3_to_4_count)})")
|
||||
print(f" - 超调在 4 ~ 5 kPa 之间 : {overshoot_4_to_5_count} ({_pct(overshoot_4_to_5_count)})")
|
||||
print(f" - 超调在 5 ~ 10 kPa 之间 : {overshoot_5_to_10_count} ({_pct(overshoot_5_to_10_count)})")
|
||||
print(f" - 超调 > 10 kPa : {overshoot_gt_10_count} ({_pct(overshoot_gt_10_count)})")
|
||||
print("=" * 68)
|
||||
|
||||
def _print_detail(title, items):
|
||||
if items:
|
||||
print(f"\n[⚠️ {title}]:")
|
||||
for item in items:
|
||||
print(f" * Episode [{item['index']}] ({item['direction']}): "
|
||||
f"初始 {item['initial_p']:.2f} -> 目标 {item['target_p']:.2f} | "
|
||||
f"超调量: {item['overshoot_value']:.2f} kPa | "
|
||||
f"流量: {item['Q_in']:.1f} L/min")
|
||||
|
||||
_print_detail("超调在 5 ~ 10 kPa", overshoots_5_to_10)
|
||||
_print_detail("超调大于 10 kPa", overshoots_gt_10)
|
||||
|
||||
if not overshoots_5_to_10 and not overshoots_gt_10:
|
||||
print("\n🎉 极好!没有发现超调大于 5 kPa 的数据。")
|
||||
|
||||
# ---- 流量分布统计 ----
|
||||
flow_bins = [
|
||||
(0, 10), (10, 50), (50, 100), (100, 150),
|
||||
(150, 200), (200, 250), (250, 300),
|
||||
]
|
||||
flow_counts = {f"{lo}~{hi}": 0 for lo, hi in flow_bins}
|
||||
flow_counts["300+"] = 0
|
||||
|
||||
for ep in episode_data_raw:
|
||||
q = ep.get('Q_in', 0)
|
||||
placed = False
|
||||
for lo, hi in flow_bins:
|
||||
if lo <= q < hi:
|
||||
flow_counts[f"{lo}~{hi}"] += 1
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
flow_counts["300+"] += 1
|
||||
|
||||
print(f"\n📊 流量分布统计 (共 {total_episodes} 个 Episode):")
|
||||
for lo, hi in flow_bins:
|
||||
label = f"{lo}~{hi}"
|
||||
print(f" {label:>10} L/min : {flow_counts[label]:>5} ({flow_counts[label]/total_episodes*100:5.1f}%)")
|
||||
print(f" {'300+':>10} L/min : {flow_counts['300+']:>5} ({flow_counts['300+']/total_episodes*100:5.1f}%)")
|
||||
|
||||
if all_steady_abs_errors:
|
||||
avg_all = sum(all_steady_abs_errors) / len(all_steady_abs_errors)
|
||||
print(f"\n📊 所有有效 Episode 平均稳态误差(最后 30 步绝对值均值): {avg_all:.3f} kPa"
|
||||
f" ({len(all_steady_abs_errors)} 个 Episode)")
|
||||
|
||||
if no_overshoot_abs_errors:
|
||||
avg_abs = sum(no_overshoot_abs_errors) / len(no_overshoot_abs_errors)
|
||||
avg_raw = sum(no_overshoot_raw_errors) / len(no_overshoot_raw_errors)
|
||||
print(f"\n📊 超调0~1kpa Episode 平均稳态误差(最后 30 步):")
|
||||
print(f" 绝对值均值 : {avg_abs:.3f} kPa")
|
||||
print(f" 带符号均值 : {avg_raw:.3f} kPa ({'偏高于目标' if avg_raw > 0 else '偏低' if avg_raw < 0 else '无偏'})"
|
||||
f" ({no_overshoot_count} 个 Episode)")
|
||||
|
||||
|
||||
def print_episode_detail(episode_data_raw, index):
|
||||
"""打印指定 episode 的完整数据"""
|
||||
if index < 0 or index >= len(episode_data_raw):
|
||||
print(f"❌ Episode 索引 {index} 超出范围 (0~{len(episode_data_raw)-1})")
|
||||
return
|
||||
|
||||
ep = episode_data_raw[index]
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Episode [{index}] 完整数据")
|
||||
print(f"{'='*60}")
|
||||
|
||||
for key in ['Q_in', 'volume', 'target_pressure', 'mode']:
|
||||
if key in ep:
|
||||
print(f" {key}: {ep[key]}")
|
||||
|
||||
pressures = ep.get('pressures', [])
|
||||
errors = ep.get('errors', [])
|
||||
valve_openings = ep.get('valves', [])
|
||||
|
||||
print(f"\n 步数: {len(pressures)}")
|
||||
if pressures:
|
||||
print(f" 初始压力: {pressures[0]:.2f} kPa")
|
||||
print(f" 最终压力: {pressures[-1]:.2f} kPa")
|
||||
print(f" 目标压力: {ep.get('target_pressure', 'N/A')} kPa")
|
||||
if errors:
|
||||
print(f" 最终误差: {errors[-1]:.3f} kPa")
|
||||
|
||||
print(f"\n {'步':>4s} {'压力(kPa)':>10s} {'误差(kPa)':>10s} {'开度(%)':>8s}")
|
||||
print(f" {'-'*36}")
|
||||
n = len(pressures)
|
||||
for i in range(n):
|
||||
p = pressures[i]
|
||||
e = errors[i] if i < len(errors) else float('nan')
|
||||
vo = valve_openings[i] if i < len(valve_openings) else float('nan')
|
||||
print(f" {i:4d} {p:10.2f} {e:10.3f} {vo:8.2f}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
|
||||
# --- 执行离线分析 ---
|
||||
if __name__ == "__main__":
|
||||
# 💡 数据存放文件夹路径
|
||||
DATA_FOLDER = "/Users/menglingrui/Documents/DominatedConvergence/cloud_down_file/永久/data_8L"
|
||||
|
||||
# 1. 读取并合并分片
|
||||
merged_data = load_and_merge_pickle_chunks(DATA_FOLDER, file_pattern="*part*.pkl")
|
||||
|
||||
# 2. 执行分析
|
||||
if merged_data:
|
||||
analyze_episodes_data(merged_data)
|
||||
# 3. 找出无效 episode(末步误差绝对值 > 2 kPa),打印前 3 个的完整数据
|
||||
# invalid_indices = []
|
||||
# for idx, ep in enumerate(merged_data):
|
||||
# errors = ep.get('errors', [])
|
||||
# if errors and abs(errors[-1]) > 2:
|
||||
# invalid_indices.append(idx)
|
||||
# if len(invalid_indices) >= 3:
|
||||
# break
|
||||
# if invalid_indices:
|
||||
# print(f"\n找到 {len(invalid_indices)} 个无效 Episode,索引: {invalid_indices}")
|
||||
# for idx in invalid_indices:
|
||||
# print_episode_detail(merged_data, idx)
|
||||
# else:
|
||||
# print("\n未找到无效 Episode")
|
||||
print_episode_detail(merged_data, 2500)
|
||||
@@ -0,0 +1,10 @@
|
||||
parameter,value
|
||||
q_in_val,50.0
|
||||
dt,0.1
|
||||
n_order,6
|
||||
t_c,2.5
|
||||
levels,"10,20,30,40,50,60,70,80"
|
||||
dead_area,240.0
|
||||
xa_full,1000.0
|
||||
V_val,5.0
|
||||
repeat,2
|
||||
|
@@ -0,0 +1,61 @@
|
||||
import matplotlib
|
||||
import shutil
|
||||
import os
|
||||
|
||||
# 获取 Matplotlib 缓存目录
|
||||
cache_dir = matplotlib.get_cachedir()
|
||||
print(f"正在清理缓存目录: {cache_dir}")
|
||||
|
||||
# 删除缓存
|
||||
if os.path.exists(cache_dir):
|
||||
shutil.rmtree(cache_dir)
|
||||
print("字体缓存已清除!请重新运行你的主程序。")
|
||||
else:
|
||||
print("未找到缓存目录。")
|
||||
|
||||
import os
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use('TkAgg')
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.font_manager as fm
|
||||
|
||||
|
||||
# ----------------- 强制解决中文乱码 (Mac版) -----------------
|
||||
def force_chinese_font_mac():
|
||||
"""强制加载 macOS 系统自带的苹方或黑体"""
|
||||
# macOS 常见中文字体路径
|
||||
font_paths = [
|
||||
"/System/Library/Fonts/PingFang.ttc", # 苹方 (现代 macOS 默认中文字体)
|
||||
"/System/Library/Fonts/STHeiti Light.ttc", # 华文黑体
|
||||
"/System/Library/Fonts/STHeiti Medium.ttc", # 华文黑体 (中等粗细)
|
||||
"/System/Library/Fonts/Supplemental/Songti.ttc", # 宋体 (部分较新 macOS 系统的路径)
|
||||
"/Library/Fonts/Arial Unicode.ttf" # 包含中文的通用字体
|
||||
]
|
||||
|
||||
font_loaded = False
|
||||
for path in font_paths:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
# 强制将字体加入 Matplotlib 的内存库
|
||||
fm.fontManager.addfont(path)
|
||||
# 获取该字体在 matplotlib 内部的真实名称
|
||||
prop = fm.FontProperties(fname=path)
|
||||
plt.rcParams['font.family'] = prop.get_name()
|
||||
font_loaded = True
|
||||
print(f"已成功加载 Mac 系统字体: {path}")
|
||||
break # 加载成功一个就跳出
|
||||
except Exception as e:
|
||||
print(f"尝试加载字体 {path} 失败: {e}")
|
||||
continue
|
||||
|
||||
if not font_loaded:
|
||||
print("警告: 未在 macOS 默认路径找到中文字体文件。")
|
||||
|
||||
# 解决负号 '-' 显示为方块的问题
|
||||
plt.rcParams['axes.unicode_minus'] = False
|
||||
|
||||
|
||||
# 立即执行字体加载
|
||||
force_chinese_font_mac()
|
||||
# ----------------------------------------------------
|
||||
@@ -0,0 +1,26 @@
|
||||
"""已迁移至 ControlPanel 的辨识反馈管理能力。"""
|
||||
|
||||
import argparse
|
||||
def submit_feedback(customer: str, result: int, run_id=None, timeout=20):
|
||||
raise RuntimeError("辨识反馈已迁移至 ControlPanel,客户端不提供管理接口")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="提交辨识结果 0/1")
|
||||
parser.add_argument("customer", help="许可证中的客户名称")
|
||||
parser.add_argument("result", type=int, choices=(0, 1), help="1=通过,0=未通过")
|
||||
parser.add_argument("--run-id", help="可选:限定当前辨识 CSV 文件名")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
data = submit_feedback(args.customer, args.result, args.run_id)
|
||||
except Exception as exc:
|
||||
print(f"提交失败: {exc}")
|
||||
return 1
|
||||
state = "已通过" if data["result"] == 1 else "未通过"
|
||||
print(f"提交成功:{state},runId={data.get('runId', '')}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"q_in_val": 50.0,
|
||||
"dt": 0.05,
|
||||
"p_max": 200.0,
|
||||
"fit_low": 50.0,
|
||||
"fit_high": 150.0,
|
||||
"T_delta": 30.0,
|
||||
"xa_full": 1000.0,
|
||||
"num_runs": 3
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# ui package - Pure PySide6 UI layer
|
||||
@@ -0,0 +1,177 @@
|
||||
# connection_tab.py
|
||||
"""页面1:Modbus TCP 连接参数设置"""
|
||||
|
||||
import os
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
|
||||
QLabel, QLineEdit, QPushButton, QFrame,
|
||||
QSizePolicy, QGraphicsDropShadowEffect
|
||||
)
|
||||
from PySide6.QtCore import Qt, QSize
|
||||
from PySide6.QtGui import QColor, QIcon
|
||||
|
||||
|
||||
_SRC_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 工具函数:创建带左侧蓝色竖线的 Section 卡片
|
||||
# ==========================================
|
||||
def _make_section_card(parent, title_text: str, colors: dict):
|
||||
card = QFrame(parent)
|
||||
card.setProperty("cssClass", "sectionCard")
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
|
||||
# 添加微弱的模糊阴影效果
|
||||
shadow = QGraphicsDropShadowEffect(card)
|
||||
shadow.setColor(QColor(0, 0, 0, 12))
|
||||
shadow.setBlurRadius(16)
|
||||
shadow.setOffset(0, 4)
|
||||
card.setGraphicsEffect(shadow)
|
||||
|
||||
outer = QVBoxLayout(card)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
|
||||
# ---- 标题行(蓝色左竖线 + 标题文字) ----
|
||||
title_row = QHBoxLayout()
|
||||
title_row.setContentsMargins(20, 16, 20, 0)
|
||||
title_row.setSpacing(10)
|
||||
|
||||
accent = QWidget()
|
||||
accent.setProperty("cssClass", "sectionAccent")
|
||||
accent.setFixedSize(4, 16)
|
||||
title_row.addWidget(accent)
|
||||
|
||||
title_lbl = QLabel(title_text)
|
||||
title_lbl.setProperty("cssClass", "sectionTitle")
|
||||
title_row.addWidget(title_lbl)
|
||||
title_row.addStretch()
|
||||
outer.addLayout(title_row)
|
||||
|
||||
# ---- 内容区 ----
|
||||
content_widget = QWidget()
|
||||
content_widget.setStyleSheet("background-color: transparent;")
|
||||
content_layout = QGridLayout(content_widget)
|
||||
content_layout.setContentsMargins(20, 14, 20, 18)
|
||||
content_layout.setHorizontalSpacing(0)
|
||||
content_layout.setVerticalSpacing(10)
|
||||
# 列0(标签)固定宽度,列1(输入框)拉伸
|
||||
content_layout.setColumnMinimumWidth(0, 148)
|
||||
content_layout.setColumnStretch(1, 1)
|
||||
outer.addWidget(content_widget)
|
||||
|
||||
return card, content_layout
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 工具函数:创建表单标签(左对齐)
|
||||
# ==========================================
|
||||
def _form_label(text: str, parent=None):
|
||||
lbl = QLabel(text, parent)
|
||||
lbl.setProperty("cssClass", "formLabel")
|
||||
lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
return lbl
|
||||
|
||||
|
||||
class ConnectionTab(QWidget):
|
||||
"""连接设置页面"""
|
||||
|
||||
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)
|
||||
|
||||
# ==========================================
|
||||
# Section: Modbus TCP
|
||||
# ==========================================
|
||||
tcp_card, tcp_layout = _make_section_card(self, "Modbus TCP", colors)
|
||||
self._build_tcp_section(tcp_layout)
|
||||
main_layout.addWidget(tcp_card)
|
||||
|
||||
# ==========================================
|
||||
# 按钮组
|
||||
# ==========================================
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.setContentsMargins(0, 4, 0, 0)
|
||||
btn_row.setSpacing(12)
|
||||
|
||||
# 连接设备按钮
|
||||
self.connect_btn = QPushButton(" 连接设备")
|
||||
self.connect_btn.setObjectName("connect_btn")
|
||||
self.connect_btn.setIcon(QIcon(os.path.join(_SRC_DIR, "connect_device.svg")))
|
||||
self.connect_btn.setIconSize(QSize(18, 18))
|
||||
self.connect_btn.setCursor(Qt.PointingHandCursor)
|
||||
|
||||
# 保存按钮引用(connect/disconnect 切换文字时使用)
|
||||
self._connect_text_lbl = self.connect_btn
|
||||
|
||||
btn_row.addWidget(self.connect_btn)
|
||||
btn_row.addStretch()
|
||||
|
||||
main_layout.addLayout(btn_row)
|
||||
main_layout.addStretch()
|
||||
|
||||
# ==========================================
|
||||
# Modbus TCP 表单
|
||||
# ==========================================
|
||||
def _build_tcp_section(self, grid: QGridLayout):
|
||||
row = 0
|
||||
|
||||
grid.addWidget(_form_label("模块地址:", self), row, 0)
|
||||
self.tcp_ip_entry = QLineEdit("192.168.1.12")
|
||||
self.tcp_ip_entry.setPlaceholderText("输入模块 IP地址")
|
||||
grid.addWidget(self.tcp_ip_entry, row, 1)
|
||||
row += 1
|
||||
|
||||
grid.addWidget(_form_label("端口:", self), row, 0)
|
||||
self.tcp_port_entry = QLineEdit("502")
|
||||
self.tcp_port_entry.setPlaceholderText("默认502")
|
||||
grid.addWidget(self.tcp_port_entry, row, 1)
|
||||
row += 1
|
||||
|
||||
grid.addWidget(_form_label("读取压力寄存器地址:", self), row, 0)
|
||||
self.pressure_addr_entry = QLineEdit("0")
|
||||
self.pressure_addr_entry.setPlaceholderText("寄存器地址")
|
||||
grid.addWidget(self.pressure_addr_entry, row, 1)
|
||||
row += 1
|
||||
|
||||
grid.addWidget(_form_label("电机地址:", self), row, 0)
|
||||
self.motor_addr_entry = QLineEdit("0")
|
||||
self.motor_addr_entry.setPlaceholderText("电机模拟量通道地址")
|
||||
grid.addWidget(self.motor_addr_entry, row, 1)
|
||||
row += 1
|
||||
|
||||
grid.addWidget(_form_label("流量计地址:", self), row, 0)
|
||||
self.flowmeter_addr_entry = QLineEdit("1")
|
||||
self.flowmeter_addr_entry.setPlaceholderText("留空则使用手动输入流量")
|
||||
grid.addWidget(self.flowmeter_addr_entry, row, 1)
|
||||
row += 1
|
||||
|
||||
grid.addWidget(_form_label("压力表量程:", self), row, 0)
|
||||
self.pressure_range_entry = QLineEdit("400")
|
||||
self.pressure_range_entry.setPlaceholderText("压力传感器量程上限")
|
||||
grid.addWidget(self.pressure_range_entry, row, 1)
|
||||
row += 1
|
||||
|
||||
grid.addWidget(_form_label("流量计量程:", self), row, 0)
|
||||
self.flow_range_entry = QLineEdit("300")
|
||||
self.flow_range_entry.setPlaceholderText("流量计量程上限")
|
||||
grid.addWidget(self.flow_range_entry, row, 1)
|
||||
|
||||
# ---- 公开方法 ----
|
||||
def get_connection_params(self) -> dict:
|
||||
flow_str = self.flowmeter_addr_entry.text().strip()
|
||||
return {
|
||||
"tcp_ip": self.tcp_ip_entry.text().strip(),
|
||||
"tcp_port": int(self.tcp_port_entry.text() or "502"),
|
||||
"pressure_addr": int(self.pressure_addr_entry.text() or "504"),
|
||||
"motor_addr": int(self.motor_addr_entry.text() or "0"),
|
||||
"flowmeter_addr": int(flow_str) if flow_str else None,
|
||||
"pressure_range": float(self.pressure_range_entry.text() or "400"),
|
||||
"flow_range": float(self.flow_range_entry.text() or "300"),
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
# 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)
|
||||
|
||||
# ============================================================
|
||||
# 以下方法完全兼容旧版 API,main_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))
|
||||
@@ -0,0 +1,427 @@
|
||||
# debug_tab.py
|
||||
"""页面3:系统辨识 + 高级设置(Section 卡片 + 蓝竖线装饰)
|
||||
|
||||
参考 connection_tab 的页面设计,使用 _make_section_card 创建带蓝色左侧竖线的
|
||||
纯白卡片,内部以 QGridLayout 双列排列表单项。
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
|
||||
QLabel, QLineEdit, QPushButton,
|
||||
)
|
||||
from PySide6.QtCore import Qt, QSettings, Signal
|
||||
|
||||
from ui.connection_tab import _make_section_card
|
||||
|
||||
# ---- 按钮默认样式(品牌蓝底白字,保证不被父级 inline stylesheet 覆盖) ----
|
||||
_BTN_STYLE = """
|
||||
QPushButton {
|
||||
background-color: #0960D1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 9px 20px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #0856B8;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #0960D1;
|
||||
}
|
||||
"""
|
||||
|
||||
_BTN_STYLE_DANGER = """
|
||||
QPushButton {
|
||||
background-color: #EF4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 9px 20px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #DC2626;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #B91C1C;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _compact_label(text: str, parent=None) -> QLabel:
|
||||
"""紧凑表单标签(无 140px min-width,自然适应文字宽度)。"""
|
||||
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 _wrap_widget(child: QWidget) -> QWidget:
|
||||
"""将子控件放入透明容器(使用 cssClass 而非 inline stylesheet,
|
||||
避免覆盖子控件的 QSS 样式)。"""
|
||||
w = QWidget()
|
||||
w.setProperty("cssClass", "transparentBg")
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
lay = QHBoxLayout(w)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(0)
|
||||
lay.addWidget(child, 1)
|
||||
return w
|
||||
|
||||
|
||||
def _transparent_widget() -> QWidget:
|
||||
"""创建一个透明的空容器(用于包裹多个控件)。"""
|
||||
w = QWidget()
|
||||
w.setProperty("cssClass", "transparentBg")
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
return w
|
||||
|
||||
|
||||
class DebugTab(QWidget):
|
||||
"""模型调试页面"""
|
||||
|
||||
# ---- 信号 ----
|
||||
identify_start_requested = Signal()
|
||||
identify_stop_requested = Signal()
|
||||
volume_measure_requested = Signal()
|
||||
volume_stop_requested = Signal()
|
||||
|
||||
def __init__(self, colors: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setProperty("cssClass", "tabPage")
|
||||
self.colors = colors
|
||||
|
||||
# 记录按钮当前是否处于"运行中"状态
|
||||
self._identifying_running = False
|
||||
self._volume_running = False
|
||||
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(20, 16, 20, 16)
|
||||
main_layout.setSpacing(14)
|
||||
|
||||
# ==========================================
|
||||
# Card 1: 系统辨识
|
||||
# ==========================================
|
||||
ident_card, ident_grid = _make_section_card(self, "系统辨识", colors)
|
||||
self._build_ident_section(ident_grid)
|
||||
main_layout.addWidget(ident_card)
|
||||
|
||||
# ==========================================
|
||||
# Card 2: 高级设置
|
||||
# ==========================================
|
||||
adv_card, adv_grid = _make_section_card(self, "高级设置", colors)
|
||||
self._build_advanced_section(adv_grid)
|
||||
main_layout.addWidget(adv_card)
|
||||
adv_card.hide()
|
||||
|
||||
main_layout.addStretch()
|
||||
|
||||
# 恢复上次保存的设置
|
||||
self._load_settings()
|
||||
|
||||
# ==========================================
|
||||
# Card 1: 系统辨识 — 4 行双列 + 1 行通栏
|
||||
# ==========================================
|
||||
def _build_ident_section(self, grid: QGridLayout):
|
||||
# 重置 _make_section_card 预设的单列表单列宽配置
|
||||
for c in range(10):
|
||||
grid.setColumnMinimumWidth(c, 0)
|
||||
grid.setColumnStretch(c, 0)
|
||||
|
||||
# 紧凑双列布局: 左标签 | 左输入区 | 间距 | 右标签 | 右输入区
|
||||
grid.setColumnMinimumWidth(0, 60)
|
||||
grid.setColumnStretch(1, 1)
|
||||
grid.setColumnMinimumWidth(2, 100)
|
||||
grid.setColumnMinimumWidth(3, 60)
|
||||
grid.setColumnStretch(4, 1)
|
||||
grid.setVerticalSpacing(8)
|
||||
|
||||
# ---- 第 1 行:压力上限(kPa) | 过程升温(°C) ----
|
||||
row = 0
|
||||
grid.addWidget(_compact_label("压力上限:", self), row, 0)
|
||||
self.p_max_entry = QLineEdit("200")
|
||||
grid.addWidget(self._with_unit(self.p_max_entry, "kPa"), row, 1)
|
||||
|
||||
grid.addWidget(_compact_label("过程升温:", self), row, 3)
|
||||
self.T_delta_entry = QLineEdit("30")
|
||||
grid.addWidget(self._with_unit(self.T_delta_entry, "°C"), row, 4)
|
||||
|
||||
# ---- 第 2 行:约束上界 | 下界 ----
|
||||
row = 1
|
||||
grid.addWidget(_compact_label("约束上界:", self), row, 0)
|
||||
self.fit_high_entry = QLineEdit("200")
|
||||
grid.addWidget(_wrap_widget(self.fit_high_entry), row, 1)
|
||||
|
||||
grid.addWidget(_compact_label("下界:", self), row, 3)
|
||||
self.fit_low_entry = QLineEdit("50")
|
||||
grid.addWidget(_wrap_widget(self.fit_low_entry), row, 4)
|
||||
|
||||
# ---- 第 3 行:容积(L) | 测试按钮 ----
|
||||
row = 2
|
||||
grid.addWidget(_compact_label("容积:", self), row, 0)
|
||||
self.volume_entry = QLineEdit()
|
||||
grid.addWidget(self._with_unit(self.volume_entry, "L"), row, 1)
|
||||
|
||||
self.test_btn = QPushButton("测试")
|
||||
self.test_btn.setStyleSheet(_BTN_STYLE)
|
||||
self.test_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.test_btn.clicked.connect(self._on_measure_volume)
|
||||
|
||||
btn_wrap = _transparent_widget()
|
||||
btn_h = QHBoxLayout(btn_wrap)
|
||||
btn_h.setContentsMargins(0, 0, 0, 0)
|
||||
btn_h.addWidget(self.test_btn)
|
||||
btn_h.addStretch()
|
||||
grid.addWidget(btn_wrap, row, 4)
|
||||
|
||||
# ---- 第 4 行:周期(s) | 阶数 ----
|
||||
row = 3
|
||||
grid.addWidget(_compact_label("周期:", self), row, 0)
|
||||
self.period_entry = QLineEdit("2.5")
|
||||
grid.addWidget(self._with_unit(self.period_entry, "s"), row, 1)
|
||||
|
||||
grid.addWidget(_compact_label("阶数:", self), row, 3)
|
||||
self.order_entry = QLineEdit("6")
|
||||
grid.addWidget(_wrap_widget(self.order_entry), row, 4)
|
||||
|
||||
# ---- 第 5 行(通栏):序列 + 开始辨识按钮 ----
|
||||
row = 4
|
||||
grid.addWidget(_compact_label("序列:", self), row, 0)
|
||||
|
||||
seq_wrap = _transparent_widget()
|
||||
seq_h = QHBoxLayout(seq_wrap)
|
||||
seq_h.setContentsMargins(0, 0, 0, 0)
|
||||
seq_h.setSpacing(8)
|
||||
|
||||
self.levels_entry = QLineEdit()
|
||||
seq_h.addWidget(self.levels_entry, 1)
|
||||
|
||||
self.ident_result_label = QLabel("等待开始")
|
||||
self.ident_result_label.setStyleSheet(
|
||||
"color: #64748B; font-size: 13px; font-weight: 600;"
|
||||
)
|
||||
seq_h.addWidget(self.ident_result_label)
|
||||
|
||||
self.identify_btn = QPushButton(" ▶ 开始辨识")
|
||||
self.identify_btn.setStyleSheet(_BTN_STYLE)
|
||||
self.identify_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.identify_btn.clicked.connect(self._on_start_identify)
|
||||
seq_h.addWidget(self.identify_btn)
|
||||
|
||||
grid.addWidget(seq_wrap, row, 1, 1, 4) # 跨越列 1-4
|
||||
|
||||
# Keep the legacy widgets for internal compatibility, but do not
|
||||
# expose confidential measurement parameters in the customer UI.
|
||||
# Volume-test values come only from volume_measurement.json.
|
||||
for index in range(grid.count()):
|
||||
widget = grid.itemAt(index).widget()
|
||||
if widget is not None and widget not in (btn_wrap, seq_wrap):
|
||||
widget.hide()
|
||||
self.levels_entry.hide()
|
||||
|
||||
# ==========================================
|
||||
# Card 2: 高级设置 — 2 行双列
|
||||
# ==========================================
|
||||
def _build_advanced_section(self, grid: QGridLayout):
|
||||
# 重置 _make_section_card 预设的单列表单列宽配置
|
||||
for c in range(10):
|
||||
grid.setColumnMinimumWidth(c, 0)
|
||||
grid.setColumnStretch(c, 0)
|
||||
|
||||
# 同样采用紧凑双列布局
|
||||
grid.setColumnMinimumWidth(0, 60)
|
||||
grid.setColumnStretch(1, 1)
|
||||
grid.setColumnMinimumWidth(2, 100)
|
||||
grid.setColumnMinimumWidth(3, 60)
|
||||
grid.setColumnStretch(4, 1)
|
||||
grid.setVerticalSpacing(8)
|
||||
|
||||
# ---- 第 1 行:死区 ----
|
||||
row = 0
|
||||
grid.addWidget(_compact_label("死区:", self), row, 0)
|
||||
self.dz_entry = QLineEdit()
|
||||
self.dz_entry.setPlaceholderText("默认2...")
|
||||
grid.addWidget(_wrap_widget(self.dz_entry), row, 1)
|
||||
|
||||
# ---- 第 2 行:单步限幅 | 总限幅 ----
|
||||
row = 1
|
||||
grid.addWidget(_compact_label("单步限幅:", self), row, 0)
|
||||
self.motor_max_entry = QLineEdit()
|
||||
grid.addWidget(_wrap_widget(self.motor_max_entry), row, 1)
|
||||
|
||||
grid.addWidget(_compact_label("总限幅:", self), row, 3)
|
||||
self.xa_full_entry = QLineEdit()
|
||||
grid.addWidget(_wrap_widget(self.xa_full_entry), row, 4)
|
||||
|
||||
# ---- 第 3 行:模拟量映射最小值 | 最大值 ----
|
||||
row = 2
|
||||
grid.addWidget(_compact_label("模拟量映射最小值:", self), row, 0)
|
||||
self.volthege_min_entry = QLineEdit("819")
|
||||
grid.addWidget(_wrap_widget(self.volthege_min_entry), row, 1)
|
||||
|
||||
grid.addWidget(_compact_label("最大值:", self), row, 3)
|
||||
self.volthege_max_entry = QLineEdit("4095")
|
||||
grid.addWidget(_wrap_widget(self.volthege_max_entry), row, 4)
|
||||
|
||||
# ---- 第 4 行:确认设置按钮 ----
|
||||
row = 3
|
||||
self.confirm_settings_btn = QPushButton("确认设置")
|
||||
self.confirm_settings_btn.setStyleSheet(_BTN_STYLE)
|
||||
self.confirm_settings_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.confirm_settings_btn.clicked.connect(self._save_settings)
|
||||
|
||||
btn_wrap = _transparent_widget()
|
||||
btn_h = QHBoxLayout(btn_wrap)
|
||||
btn_h.setContentsMargins(0, 0, 0, 0)
|
||||
btn_h.addWidget(self.confirm_settings_btn)
|
||||
btn_h.addStretch()
|
||||
grid.addWidget(btn_wrap, row, 0, 1, 5)
|
||||
|
||||
# ==========================================
|
||||
# 辅助方法
|
||||
# ==========================================
|
||||
def _with_unit(self, line_edit: QLineEdit, unit: str) -> QWidget:
|
||||
"""将输入框与单位标签组合为一个 widget,单位以灰色显示在输入框右侧。"""
|
||||
w = _transparent_widget()
|
||||
h = QHBoxLayout(w)
|
||||
h.setContentsMargins(0, 0, 0, 0)
|
||||
h.setSpacing(0)
|
||||
h.addWidget(line_edit, 1)
|
||||
|
||||
unit_lbl = QLabel(unit)
|
||||
unit_lbl.setStyleSheet(
|
||||
"color: #94A3B8; font-size: 12px; background: transparent;"
|
||||
"padding: 0 10px 0 6px;"
|
||||
)
|
||||
h.addWidget(unit_lbl)
|
||||
return w
|
||||
|
||||
# ---- 信号处理 ----
|
||||
def _on_start_identify(self):
|
||||
if self._identifying_running:
|
||||
self.identify_stop_requested.emit()
|
||||
else:
|
||||
self._identifying_running = True
|
||||
self.identify_btn.setText(" ■ 结束辨识")
|
||||
self.identify_btn.setStyleSheet(_BTN_STYLE_DANGER)
|
||||
self.identify_start_requested.emit()
|
||||
|
||||
def _on_measure_volume(self):
|
||||
if self._volume_running:
|
||||
self.volume_stop_requested.emit()
|
||||
else:
|
||||
self._volume_running = True
|
||||
self.test_btn.setText("停止")
|
||||
self.test_btn.setStyleSheet(_BTN_STYLE_DANGER)
|
||||
self.volume_measure_requested.emit()
|
||||
|
||||
# ---- 公开方法:任务完成后由 main_window 调用恢复按钮 ----
|
||||
def set_identify_finished(self):
|
||||
self._identifying_running = False
|
||||
self.identify_btn.setText(" ▶ 开始辨识")
|
||||
self.identify_btn.setStyleSheet(_BTN_STYLE)
|
||||
|
||||
def set_identification_feedback(self, text: str, state="neutral"):
|
||||
"""显示当前辨识审核状态。"""
|
||||
colors = {
|
||||
"neutral": "#64748B",
|
||||
"pending": "#2563EB",
|
||||
"passed": "#15803D",
|
||||
"failed": "#B91C1C",
|
||||
}
|
||||
color = colors.get(state, colors["neutral"])
|
||||
self.ident_result_label.setText(text)
|
||||
self.ident_result_label.setStyleSheet(
|
||||
f"color: {color}; font-size: 13px; font-weight: 600;"
|
||||
)
|
||||
|
||||
def set_volume_finished(self):
|
||||
self._volume_running = False
|
||||
self.test_btn.setText("测试")
|
||||
self.test_btn.setStyleSheet(_BTN_STYLE)
|
||||
|
||||
# ---- 公开数据获取方法(接口与旧版完全兼容) ----
|
||||
def get_identify_params(self) -> dict:
|
||||
"""获取辨识参数"""
|
||||
return {
|
||||
"p_max": float(self.p_max_entry.text() or "200"),
|
||||
"T_delta": float(self.T_delta_entry.text() or "30"),
|
||||
"fit_high": float(self.fit_high_entry.text() or "200"),
|
||||
"fit_low": float(self.fit_low_entry.text() or "50"),
|
||||
"volume": float(self.volume_entry.text() or "0"),
|
||||
"period": float(self.period_entry.text() or "2.5"),
|
||||
"order": int(self.order_entry.text() or "6"),
|
||||
"levels": self._parse_levels(),
|
||||
}
|
||||
|
||||
def get_advanced_params(self) -> dict:
|
||||
"""获取高级设置参数"""
|
||||
dz = self.dz_entry.text().strip()
|
||||
mm = self.motor_max_entry.text().strip()
|
||||
xa = self.xa_full_entry.text().strip()
|
||||
return {
|
||||
"dz": float(dz) if dz else None,
|
||||
"motor_max": float(mm) if mm else None,
|
||||
"xa_full": float(xa) if xa else None,
|
||||
"volthege_min": int(self.volthege_min_entry.text() or "0"),
|
||||
"volthege_max": int(self.volthege_max_entry.text() or "4095"),
|
||||
}
|
||||
|
||||
def _parse_levels(self) -> list:
|
||||
"""解析序列输入"""
|
||||
levels_str = self.levels_entry.text().strip()
|
||||
if not levels_str:
|
||||
print("未输入序列,使用默认值: 10,20,30,40,50,60,70,80")
|
||||
return [10, 20, 30, 40, 50, 60, 70, 80]
|
||||
try:
|
||||
levels = [int(x.strip()) for x in levels_str.split(',')]
|
||||
if len(levels) < 2:
|
||||
print("序列至少需要两个值,使用默认值: 10,20,30,40,50,60,70,80")
|
||||
return [10, 20, 30, 40, 50, 60, 70, 80]
|
||||
print(f"使用自定义序列: {levels}")
|
||||
return levels
|
||||
except ValueError:
|
||||
print("序列格式错误,使用默认值: 10,20,30,40,50,60,70,80")
|
||||
return [10, 20, 30, 40, 50, 60, 70, 80]
|
||||
|
||||
def set_volume_text(self, vol: float):
|
||||
"""设置容积输入框(测量完成后回填)"""
|
||||
self.volume_entry.setText(f"{vol:.2f}")
|
||||
|
||||
# ---- 设置持久化 ----
|
||||
def _save_settings(self):
|
||||
"""将高级设置和容积保存到 QSettings,下次启动自动恢复"""
|
||||
settings = QSettings("ReinLoop", "ReinLoop")
|
||||
settings.setValue("advanced/dz", self.dz_entry.text())
|
||||
settings.setValue("advanced/xa_full", self.xa_full_entry.text())
|
||||
settings.setValue("advanced/volthege_min", self.volthege_min_entry.text())
|
||||
settings.setValue("advanced/volthege_max", self.volthege_max_entry.text())
|
||||
settings.setValue("identify/volume", self.volume_entry.text())
|
||||
print("设置已保存")
|
||||
|
||||
def _load_settings(self):
|
||||
"""从 QSettings 恢复上次保存的设置(contains 确保空值也能覆盖默认值)"""
|
||||
settings = QSettings("ReinLoop", "ReinLoop")
|
||||
|
||||
if settings.contains("advanced/dz"):
|
||||
self.dz_entry.setText(settings.value("advanced/dz"))
|
||||
|
||||
if settings.contains("advanced/xa_full"):
|
||||
self.xa_full_entry.setText(settings.value("advanced/xa_full"))
|
||||
|
||||
if settings.contains("advanced/volthege_min"):
|
||||
self.volthege_min_entry.setText(settings.value("advanced/volthege_min"))
|
||||
|
||||
if settings.contains("advanced/volthege_max"):
|
||||
self.volthege_max_entry.setText(settings.value("advanced/volthege_max"))
|
||||
|
||||
if settings.contains("identify/volume"):
|
||||
self.volume_entry.setText(settings.value("identify/volume"))
|
||||
@@ -0,0 +1,189 @@
|
||||
# plot_window.py
|
||||
"""独立绘图窗口:嵌入 matplotlib (QtAgg 后端) 显示控制数据曲线。
|
||||
|
||||
注意:matplotlib backend 由 main.py 在最早期统一设置,此处不再重复调用。
|
||||
使用 Figure() 直接创建图形,避免 plt.subplots() 污染 pyplot 全局状态导致闪退。
|
||||
"""
|
||||
|
||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
|
||||
from matplotlib.figure import Figure
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QLineEdit, QPushButton, QWidget
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
|
||||
class PlotWindow(QDialog):
|
||||
"""压力控制数据曲线窗口"""
|
||||
|
||||
def __init__(self, time_data, pressure_data, target_data, valve_data, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("控制数据曲线图")
|
||||
self.resize(1100, 800)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose)
|
||||
|
||||
self.time_data = list(time_data)
|
||||
self.pressure_data = list(pressure_data)
|
||||
self.target_data = list(target_data)
|
||||
self.valve_data = list(valve_data)
|
||||
|
||||
self._fig = None
|
||||
self._ax1 = None
|
||||
self._ax2 = None
|
||||
self._canvas = None
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# ---- 控制面板 ----
|
||||
ctrl_widget = QWidget()
|
||||
ctrl_layout = QHBoxLayout(ctrl_widget)
|
||||
ctrl_layout.setContentsMargins(0, 0, 0, 0)
|
||||
ctrl_layout.setSpacing(8)
|
||||
|
||||
ctrl_layout.addWidget(QLabel("时间轴范围 (秒):"))
|
||||
|
||||
self.x_min_entry = QLineEdit("0")
|
||||
self.x_min_entry.setMaximumWidth(80)
|
||||
ctrl_layout.addWidget(self.x_min_entry)
|
||||
|
||||
ctrl_layout.addWidget(QLabel("到"))
|
||||
|
||||
x_max_default = f"{max(self.time_data):.1f}" if self.time_data else "10"
|
||||
self.x_max_entry = QLineEdit(x_max_default)
|
||||
self.x_max_entry.setMaximumWidth(80)
|
||||
ctrl_layout.addWidget(self.x_max_entry)
|
||||
|
||||
apply_btn = QPushButton("应用")
|
||||
apply_btn.clicked.connect(self._apply_x_limits)
|
||||
ctrl_layout.addWidget(apply_btn)
|
||||
|
||||
reset_btn = QPushButton("重置")
|
||||
reset_btn.clicked.connect(self._reset_view)
|
||||
ctrl_layout.addWidget(reset_btn)
|
||||
|
||||
all_btn = QPushButton("全部")
|
||||
all_btn.clicked.connect(self._show_all)
|
||||
ctrl_layout.addWidget(all_btn)
|
||||
|
||||
last30_btn = QPushButton("最后30秒")
|
||||
last30_btn.clicked.connect(lambda: self._zoom_last_n(30))
|
||||
ctrl_layout.addWidget(last30_btn)
|
||||
|
||||
ctrl_layout.addStretch()
|
||||
layout.addWidget(ctrl_widget)
|
||||
|
||||
# ---- matplotlib 画布 ----
|
||||
if not self.time_data or len(self.time_data) < 2:
|
||||
layout.addWidget(QLabel("数据不足,无法绘制图表"))
|
||||
return
|
||||
|
||||
try:
|
||||
# 使用 Figure() 直接创建,避免 plt.subplots() 将图形注册到 pyplot 全局状态
|
||||
self._fig = Figure(figsize=(10, 7), dpi=100)
|
||||
self._ax1 = self._fig.add_subplot(2, 1, 1)
|
||||
self._ax2 = self._fig.add_subplot(2, 1, 2)
|
||||
|
||||
# 压力曲线
|
||||
self._ax1.plot(self.time_data, self.pressure_data, 'b-o',
|
||||
linewidth=1, markersize=1, alpha=0.8, label='实际压力')
|
||||
self._ax1.plot(self.time_data, self.target_data, 'r--',
|
||||
linewidth=1.5, alpha=0.8, label='目标压力')
|
||||
self._ax1.set_ylabel('压力 (kPa)', fontsize=12)
|
||||
self._ax1.set_title('压力控制性能', fontsize=14, fontweight='bold')
|
||||
self._ax1.legend(loc='upper right', fontsize=10)
|
||||
self._ax1.grid(True, alpha=0.3)
|
||||
|
||||
# 阀门开度曲线
|
||||
self._ax2.plot(self.time_data, self.valve_data, 'm-o',
|
||||
linewidth=1, markersize=1, alpha=0.8, label='实际阀门指令')
|
||||
self._ax2.set_xlabel('时间 (秒)', fontsize=12)
|
||||
self._ax2.set_ylabel('阀门开度 (%)', fontsize=12)
|
||||
self._ax2.legend(loc='upper right', fontsize=10)
|
||||
self._ax2.set_ylim([0, 105])
|
||||
self._ax2.grid(True, alpha=0.3)
|
||||
|
||||
self._fig.tight_layout()
|
||||
|
||||
# 创建 canvas
|
||||
self._canvas = FigureCanvasQTAgg(self._fig)
|
||||
layout.addWidget(self._canvas, stretch=1)
|
||||
|
||||
# 导航工具栏(macOS 上某些版本可能崩溃,加容错)
|
||||
try:
|
||||
toolbar = NavigationToolbar2QT(self._canvas, self)
|
||||
layout.addWidget(toolbar)
|
||||
except Exception as e:
|
||||
print(f"[PlotWindow] 工具栏创建失败: {e}")
|
||||
|
||||
# 提示标签
|
||||
hint = QLabel("提示: 使用工具栏缩放/平移 | 拖动矩形区域可局部放大")
|
||||
hint.setStyleSheet("color: gray; font-size: 12px;")
|
||||
layout.addWidget(hint)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
layout.addWidget(QLabel(f"绘图创建失败: {e}"))
|
||||
|
||||
def _apply_x_limits(self):
|
||||
try:
|
||||
x_min = float(self.x_min_entry.text())
|
||||
x_max = float(self.x_max_entry.text())
|
||||
if x_min >= x_max or self._ax1 is None:
|
||||
return
|
||||
self._ax1.set_xlim([x_min, x_max])
|
||||
self._ax2.set_xlim([x_min, x_max])
|
||||
self._canvas.draw()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _reset_view(self):
|
||||
if not self._ax1 or not self.time_data:
|
||||
return
|
||||
x_min = min(self.time_data)
|
||||
x_max = max(self.time_data)
|
||||
self._ax1.set_xlim([x_min, x_max])
|
||||
self._ax2.set_xlim([x_min, x_max])
|
||||
self.x_min_entry.setText(f"{x_min:.1f}")
|
||||
self.x_max_entry.setText(f"{x_max:.1f}")
|
||||
self._canvas.draw()
|
||||
|
||||
def _show_all(self):
|
||||
if not self._ax1 or not self.time_data:
|
||||
return
|
||||
x_min = min(self.time_data)
|
||||
x_max = max(self.time_data)
|
||||
self._ax1.set_xlim([x_min, x_max])
|
||||
self._ax2.set_xlim([x_min, x_max])
|
||||
self.x_min_entry.setText(f"{x_min:.1f}")
|
||||
self.x_max_entry.setText(f"{x_max:.1f}")
|
||||
self._canvas.draw()
|
||||
|
||||
def _zoom_last_n(self, n_seconds):
|
||||
if not self._ax1 or not self.time_data:
|
||||
return
|
||||
x_max = max(self.time_data)
|
||||
x_min = max(0, x_max - n_seconds)
|
||||
self._ax1.set_xlim([x_min, x_max])
|
||||
self._ax2.set_xlim([x_min, x_max])
|
||||
self.x_min_entry.setText(f"{x_min:.1f}")
|
||||
self.x_max_entry.setText(f"{x_max:.1f}")
|
||||
self._canvas.draw()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Qt 会按控件树父子关系自动销毁所有子控件(canvas + toolbar)。
|
||||
此处只需清空 Python 侧引用,让 Figure 能被 GC 正常回收。
|
||||
|
||||
严禁 plt.close(self._fig)!plt.close() 内部绕过 Qt 直接销毁 canvas
|
||||
widget,与 WA_DeleteOnClose 冲突导致 double-free → SIGSEGV 闪退。
|
||||
"""
|
||||
self._canvas = None
|
||||
self._ax1 = None
|
||||
self._ax2 = None
|
||||
self._fig = None
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,47 @@
|
||||
# status_bar.py
|
||||
"""底部状态栏组件:日志 + 连接状态"""
|
||||
|
||||
import time
|
||||
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
|
||||
class StatusBar(QWidget):
|
||||
"""底部状态栏 —— 左侧日志,右侧连接状态"""
|
||||
|
||||
def __init__(self, colors: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self.colors = colors
|
||||
self.setProperty("cssClass", "bottomBar")
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(12, 6, 12, 6)
|
||||
|
||||
# ---- 左侧:日志 ----
|
||||
self.log_label = QLabel("就绪")
|
||||
self.log_label.setProperty("cssClass", "logLabel")
|
||||
self.log_label.setMinimumHeight(24)
|
||||
layout.addWidget(self.log_label, stretch=3)
|
||||
|
||||
# ---- 右侧:连接状态(● 未连接 / ● 已连接) ----
|
||||
self.status_label = QLabel("● 未连接")
|
||||
self.status_label.setProperty("cssClass", "statusLabel")
|
||||
self.status_label.setStyleSheet(
|
||||
f"color: {colors.get('ERROR_RED', '#FF2424')}; font-weight: bold; font-size: 14px;"
|
||||
)
|
||||
layout.addWidget(self.status_label, stretch=1, alignment=Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
# ---- 公开接口 ----
|
||||
def set_log(self, message: str):
|
||||
"""设置日志消息(仅显示最新一条)"""
|
||||
self.log_label.setText(f"{time.strftime('%H:%M:%S')} - {message}")
|
||||
|
||||
def set_connection_status(self, connected: bool, status_text: str = None):
|
||||
"""设置连接状态显示"""
|
||||
if status_text is None:
|
||||
status_text = "● 已连接" if connected else "● 未连接"
|
||||
color = self.colors.get("SUCCESS_GREEN", "#0F955D") if connected else self.colors.get("ERROR_RED", "#FF2424")
|
||||
self.status_label.setText(status_text)
|
||||
self.status_label.setStyleSheet(
|
||||
f"color: {color}; font-weight: bold; font-size: 14px;"
|
||||
)
|
||||
@@ -0,0 +1,716 @@
|
||||
# 修改记录
|
||||
|
||||
> 当前状态说明:本节以 Git 基线提交 `5841f6d` 为参照,记录 2026-07-23 工作区中的最终代码差异。后面的“历史过程记录”仅用于追溯,若与本节冲突,以本节和当前代码为准。
|
||||
|
||||
## 当前修改总览
|
||||
|
||||
| 项目 | 当前值 |
|
||||
| --- | --- |
|
||||
| 仓库 | `https://github.com/azuki-m/pressure_control_gui.git` |
|
||||
| 本地目录 | `C:\Users\31765\.codex\pressure_control_gui_source` |
|
||||
| 分支 | `MT2-AM8` |
|
||||
| 基线提交 | `5841f6d 修改默认值,增加压力滤波(暂未启用)` |
|
||||
| 工作区状态 | 本文所列修改均尚未提交 |
|
||||
|
||||
相对基线,当前增加了三条主要业务链路:
|
||||
|
||||
1. 辨识前执行 `1000 -> 0` 的绝对行程稳态压力预扫描,上传不含时间字段的 JSON。
|
||||
2. 辨识 9 参数改为从云端 CSV 获取;PRBS 结果保持 CSV 上传,并根据云端数字 `0/1` 显示审核结果。未通过时等待公司更新参数,再重新执行完整辨识。
|
||||
3. 容积测试 8 参数改为按请求传递:客户点击“测试”只创建一次请求指令,公司端检测到后上传本次 JSON,客户端持续查询同一个请求,加载参数后删除临时文件和请求记录。
|
||||
|
||||
客户调试界面不再读取或显示这些参数输入框。旧控件对象仍保留以兼容现有代码,但不是新流程的数据来源。
|
||||
|
||||
## 当前文件差异
|
||||
|
||||
### 修改的原文件
|
||||
|
||||
| 文件 | 当前修改 |
|
||||
| --- | --- |
|
||||
| `core/identification.py` | 增加行程稳态预扫描;上传函数支持文本和字节;PRBS 原始结果改为 CSV 直传;增加上传回调;加强任务线程存活判断和启动返回值。 |
|
||||
| `ui/main_window.py` | 增加辨识参数下载、反馈轮询、未通过后等待新参数、容积请求握手、超时/停止清理及 Qt 线程信号桥。 |
|
||||
| `ui/debug_tab.py` | 隐藏客户不应输入的辨识/容积参数和高级设置;增加辨识审核状态显示。 |
|
||||
| `setup.py` | 将 3 个新增核心模块加入 Cython 编译列表。 |
|
||||
|
||||
### 新增业务文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| `core/identification_config.py` | 下载、解析、校验 9 参数 CSV。 |
|
||||
| `core/identification_feedback.py` | 登记辨识 CSV、查询数字 `0/1`、确认并清理反馈。 |
|
||||
| `core/volume_config.py` | 校验 8 参数 JSON,创建、查询、清理一次容积参数请求。 |
|
||||
| `index.js` | 云函数入口,增加辨识参数、辨识反馈、容积请求接口。 |
|
||||
| `config/identification_config.json` | 旧本地格式迁移提示;客户端不读取。 |
|
||||
| `config/volume_measurement.json` | 旧本地格式迁移提示;客户端不读取。 |
|
||||
|
||||
### 新增公司端工具和示例
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| `tool/identification_config.example.csv` | 9 参数 CSV 示例。 |
|
||||
| `tool/upload_identification_config.py` | 校验并上传客户的固定辨识参数 CSV。 |
|
||||
| `tool/submit_identification_feedback.py` | 提交辨识审核数字 `1` 或 `0`。 |
|
||||
| `tool/volume_measurement.example.json` | 8 参数 JSON 示例。 |
|
||||
| `tool/upload_volume_config.py` | 等待客户请求,检测到后校验、上传并关联本次 JSON。 |
|
||||
|
||||
### 新增测试
|
||||
|
||||
- `tests/test_initial_travel_scan.py`
|
||||
- `tests/test_identification_config.py`
|
||||
- `tests/test_identification_feedback.py`
|
||||
- `tests/test_volume_config.py`
|
||||
|
||||
## 当前辨识流程
|
||||
|
||||
### 云端 9 参数 CSV
|
||||
|
||||
客户点击“开始辨识”后,客户端按许可证中的客户名称读取:
|
||||
|
||||
```text
|
||||
ReinLoop_GUI/{客户名称}/identification_config/identification_config.csv
|
||||
```
|
||||
|
||||
CSV 固定使用 `parameter,value` 两列:
|
||||
|
||||
```csv
|
||||
parameter,value
|
||||
q_in_val,50.0
|
||||
dt,0.1
|
||||
n_order,6
|
||||
t_c,2.5
|
||||
levels,"10,20,30,40,50,60,70,80"
|
||||
dead_area,240.0
|
||||
xa_full,1000.0
|
||||
V_val,5.0
|
||||
repeat,2
|
||||
```
|
||||
|
||||
客户端要求且只允许这 9 个字段。主要约束:
|
||||
|
||||
| 参数 | 约束 |
|
||||
| --- | --- |
|
||||
| `q_in_val` | 有限数字且 `>= 0` |
|
||||
| `dt` | 有限数字且 `> 0` |
|
||||
| `n_order` | 整数且 `>= 2` |
|
||||
| `t_c` | 有限数字且 `>= dt` |
|
||||
| `levels` | 至少 2 项,长度为 2 的整数次幂,每项在 `0..100` |
|
||||
| `dead_area` | `0 <= dead_area < xa_full` |
|
||||
| `xa_full` | `>= 1000` |
|
||||
| `V_val` | 有限数字且 `> 0` |
|
||||
| `repeat` | 正整数 |
|
||||
|
||||
校验成功后,9 个参数通过 `**config` 传给 `start_identification()`;`conn_mgr` 和 `running_flag_check` 仍由客户端本地创建,不属于 CSV。
|
||||
|
||||
公司端上传命令:
|
||||
|
||||
```powershell
|
||||
python tool/upload_identification_config.py "客户名称" "公司内部路径\identification_config.csv"
|
||||
```
|
||||
|
||||
同一路径再次上传会覆盖固定 CSV。客户端只在开始一轮辨识或收到未通过结果后重新读取,不会在本轮运行中途替换参数。
|
||||
|
||||
### `1000 -> 0` 行程稳态预扫描
|
||||
|
||||
`start_identification()` 先扫描以下绝对行程,再调用原有 `collect_data_with_prbs()`:
|
||||
|
||||
```text
|
||||
1000, 900, 800, 700, 600, 500, 400, 300, 200, 100, 0
|
||||
```
|
||||
|
||||
每个行程至少等待 5 秒,以 0.1 秒周期采样;使用最近 5 秒窗口,在压力极差 `<= 0.5 kPa`、压力斜率绝对值 `<= 0.05 kPa/s` 且连续稳定 3 秒后记录平均压力。每个行程最长等待 60 秒,超时跳过。停止、异常或结束时尝试把行程写回 `0`。
|
||||
|
||||
结果上传到 `ReinLoop_GUI/{客户名称}/ind_data/`,文件名为 `travel_stability_pressures_时间戳.json`。内容只包含行程和稳定压力,不包含相对时间,也不保存压力变化过程数组:
|
||||
|
||||
```json
|
||||
{
|
||||
"stable_pressures": [
|
||||
{"distance": 1000, "pressure": 12.3},
|
||||
{"distance": 900, "pressure": 15.6}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
预扫描 JSON 上传失败只记录日志,不阻止后续 PRBS。
|
||||
|
||||
### PRBS CSV 和 `0/1` 反馈
|
||||
|
||||
基线会把 PRBS 结果重新包装为 JSON;当前直接上传 `collect_data_with_prbs()` 返回的 `csv_data` 和 `.csv` 文件名,不改变采集器的原始 CSV 格式。
|
||||
|
||||
```text
|
||||
上传 PRBS CSV
|
||||
-> registerIdentificationResult 登记本轮 CSV 文件名为 runId
|
||||
-> 客户端每 2 秒查询 getIdentificationFeedback
|
||||
-> 数字 1:显示“已通过”,清理反馈记录,结束
|
||||
-> 数字 0:显示“未通过”,清理反馈记录,等待云端 CSV 更新
|
||||
-> 每 2 秒重新获取 identification_config.csv
|
||||
-> 9 参数内容与本轮不同后,才重新执行完整辨识
|
||||
```
|
||||
|
||||
反馈只接受数字 `0` 或 `1`,布尔值和其他数字均拒绝。公司端命令:
|
||||
|
||||
```powershell
|
||||
python tool/submit_identification_feedback.py "客户名称" 1
|
||||
python tool/submit_identification_feedback.py "客户名称" 0
|
||||
```
|
||||
|
||||
云端集合 `identification_reviews` 对每个客户只保留当前待审核记录,客户端消费后调用 `ackIdentificationFeedback` 删除,防止下一轮误用旧结果。
|
||||
|
||||
## 当前容积测试流程
|
||||
|
||||
### 8 参数 JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"q_in_val": 50.0,
|
||||
"dt": 0.05,
|
||||
"p_max": 200.0,
|
||||
"fit_low": 50.0,
|
||||
"fit_high": 150.0,
|
||||
"T_delta": 30.0,
|
||||
"xa_full": 1000.0,
|
||||
"num_runs": 3
|
||||
}
|
||||
```
|
||||
|
||||
客户端要求且只允许这 8 个字段。主要约束:`q_in_val > 0`、`dt > 0`、`p_max > 0`、`0 <= fit_low < fit_high <= p_max`、`xa_full > 0`、`num_runs` 为正整数,其他数值必须有限。
|
||||
|
||||
### 最终请求握手
|
||||
|
||||
服务器不能主动向客户端或公司端推送,因此采用“一次创建请求 + 两端查询同一请求状态”:
|
||||
|
||||
```text
|
||||
客户点击“测试”
|
||||
-> 客户端只调用一次 createVolumeConfigRequest
|
||||
-> 云端生成 requestId,写入 volume_config_requests,有效期 5 分钟
|
||||
|
||||
公司端工具
|
||||
-> 每 2 秒查询 getPendingVolumeConfigRequest
|
||||
-> 检测到 requestId 后才上传 8 参数 JSON
|
||||
-> submitVolumeConfigFile 把文件与 requestId 关联
|
||||
|
||||
客户端等待期间
|
||||
-> 每 2 秒查询 getVolumeConfigRequest,始终使用同一个 requestId
|
||||
-> 状态查询不会重复创建请求,也不会重复要求公司端上传
|
||||
-> 检测到本次新文件后下载并校验 JSON
|
||||
-> ackVolumeConfigRequest 删除临时文件和请求记录
|
||||
-> 执行一次 start_volume_measurement(..., **config)
|
||||
```
|
||||
|
||||
公司端命令:
|
||||
|
||||
```powershell
|
||||
python tool/upload_volume_config.py "客户名称" "公司内部路径\volume.json" --wait-seconds 300
|
||||
```
|
||||
|
||||
云端文件固定为:
|
||||
|
||||
```text
|
||||
ReinLoop_GUI/{客户名称}/volume_config_requests/{requestId}/volume_measurement.json
|
||||
```
|
||||
|
||||
`submitVolumeConfigFile` 会核对 `file_records` 中的客户目录、`requestId`、文件名和上传时间。只有请求创建后、5 分钟内上传且属于该请求的 JSON 才能加载;旧目录或其他请求的文件不能关联。
|
||||
|
||||
### 临时文件处理
|
||||
|
||||
- 创建新请求时,云端清理该客户遗留的旧容积请求及临时 JSON。
|
||||
- 客户端加载成功、用户停止或请求超时后,删除当前请求、云存储 JSON 和对应 `file_records` 记录。
|
||||
- 公司端上传或关联失败时,工具尝试删除刚上传的文件。
|
||||
- 测量开始后不再监听参数变化,也不会因云端更新而自动重测;下一次必须由客户再次点击“测试”。
|
||||
- 辨识 CSV 是公司维护的固定文件,后续上传会覆盖;容积 JSON 是一次请求的临时文件,消费后删除。
|
||||
|
||||
上一版“公司预先写入最新 8 参数、客户端直接获取”的方案已移除。当前代码不存在 `pushVolumeConfig`、`getVolumeConfig` 或 `volume_measurement_configs` 的有效调用路径。
|
||||
|
||||
## 当前客户端和构建修改
|
||||
|
||||
- 调试页隐藏辨识、容积参数和高级设置,保留开始/停止按钮及辨识审核状态。
|
||||
- 网络请求在后台线程中执行,通过 Qt `Signal` 回到主线程更新界面。
|
||||
- 请求代数编号和 `inflight` 标志用于忽略停止后迟到的结果,并阻止同类请求并发。
|
||||
- 辨识与容积测试互斥;停止或关闭窗口时停止定时器、使旧请求失效并尝试清理云端状态。
|
||||
- `IdentificationManager.is_running` 同时检查运行标志和任务线程是否存活。
|
||||
- `start_identification()` 与 `start_volume_measurement()` 返回布尔值,调用方可判断任务是否启动。
|
||||
- `setup.py` 新增 `core/identification_config.py`、`core/identification_feedback.py`、`core/volume_config.py` 三个 Cython 编译目标。
|
||||
|
||||
## 当前新增云函数接口
|
||||
|
||||
| 接口 | 调用方 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `getIdentificationConfig` | 客户端 | 获取当前客户固定辨识 CSV 的临时地址。 |
|
||||
| `registerIdentificationResult` | 客户端 | 登记刚上传的 PRBS CSV。 |
|
||||
| `getIdentificationFeedback` | 客户端 | 查询本轮数字 `0/1`。 |
|
||||
| `setIdentificationFeedback` | 公司端 | 提交本轮数字 `0/1`。 |
|
||||
| `ackIdentificationFeedback` | 客户端 | 删除已消费反馈。 |
|
||||
| `createVolumeConfigRequest` | 客户端 | 点击“测试”时创建一次 5 分钟请求。 |
|
||||
| `getPendingVolumeConfigRequest` | 公司端 | 查询客户的待上传请求。 |
|
||||
| `submitVolumeConfigFile` | 公司端 | 把 JSON 与本次请求关联。 |
|
||||
| `getVolumeConfigRequest` | 客户端 | 查询同一请求是否已有有效 JSON。 |
|
||||
| `ackVolumeConfigRequest` | 客户端 | 删除已消费、取消或超时的请求和文件。 |
|
||||
|
||||
## 当前验证结果
|
||||
|
||||
已执行:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s tests -p 'test_*.py' -v
|
||||
```
|
||||
|
||||
- 23 项单元测试全部通过。
|
||||
- 38 个 Python 文件通过 AST 语法解析。
|
||||
- `git diff --check` 通过,仅有 Windows 的 LF/CRLF 转换提示。
|
||||
- 测试覆盖 9 参数 CSV、预扫描 JSON 无时间字段、PRBS CSV 直传、数字 `0/1`、8 参数 JSON、一次请求创建、等待/就绪状态和请求清理。
|
||||
|
||||
## 尚未完成和发布风险
|
||||
|
||||
1. 尚未连接真实 MT2-AM8、真实云环境和公司端工具完成端到端联调。
|
||||
2. 新 `index.js` 尚未部署;部署前客户端无法使用新增接口。
|
||||
3. 本机没有独立 Node.js,`index.js` 尚未完成语法检查;上一次借用 VS Code 运行时的检查被中止,不计为通过。
|
||||
4. 云数据库需要允许云函数读写 `identification_reviews`、`volume_config_requests` 和现有 `file_records`。
|
||||
5. 当前 HTTP 接口主要依赖 `deviceId` 区分客户,没有请求签名或设备令牌;正式发布前需要服务端身份认证。
|
||||
6. 当前 `index.js` 含明文小程序 `SECRET`。不得直接提交或分发,应立即轮换,并改为从云函数环境变量或密钥服务读取。
|
||||
7. `requirements.txt` 未声明程序实际使用的 `PySide6`,新机器仅按该文件安装仍不能启动。
|
||||
8. 所有改动仍在工作区,尚未形成 Git 提交。
|
||||
|
||||
## 发布顺序建议
|
||||
|
||||
1. 轮换并移除 `index.js` 中的明文 `SECRET`。
|
||||
2. 在测试云环境部署 `index.js`,建立并授权新增集合。
|
||||
3. 公司端先上传一份辨识参数 CSV。
|
||||
4. 联调一次容积请求的创建、发现、上传、下载和删除。
|
||||
5. 联调预扫描、PRBS CSV 上传和 `0/1` 反馈重测。
|
||||
6. 补齐运行依赖和打包配置,再生成客户安装包。
|
||||
|
||||
<details>
|
||||
<summary>历史过程记录(仅供追溯,当前行为以上述整理为准)</summary>
|
||||
|
||||
## 项目基线
|
||||
|
||||
- 仓库:`https://github.com/azuki-m/pressure_control_gui.git`
|
||||
- 分支:`MT2-AM8`
|
||||
- 基线提交:`5841f6d 修改默认值,增加压力滤波(暂未启用)`
|
||||
- 本地目录:`C:\Users\31765\.codex\pressure_control_gui_source`
|
||||
- 开始日期:2026-07-22
|
||||
|
||||
## 记录规则
|
||||
|
||||
每次修改应记录以下内容:
|
||||
|
||||
1. 修改目标和需求来源。
|
||||
2. 涉及的文件、类和函数。
|
||||
3. 修改前后的行为差异。
|
||||
4. 参数、接口或数据格式变化。
|
||||
5. 验证方法和验证结果。
|
||||
6. 尚未完成的事项与风险。
|
||||
|
||||
## 修改历史
|
||||
|
||||
### 0. 基线建立
|
||||
|
||||
- 从 GitHub 重新克隆 `MT2-AM8` 分支。
|
||||
- 保留原始代码,不继承此前测试版 1.0 的工作区修改。
|
||||
- 对 28 个 Python 文件执行 AST 语法解析,全部通过。
|
||||
|
||||
### 1. 云函数恢复
|
||||
|
||||
- 将此前测试版云函数备份到 `pressure_control_gui_test_v1.0/cloud_index.latest-test.js`。
|
||||
- 恢复 `index.js` 的原始接口分发,仅保留:
|
||||
`uploadDataFile`、`listModels`、`downloadModel`、`deleteFile`、`uploadUserInfo`。
|
||||
- 测试版新增的参数传输和多轮调试接口不再从云函数入口暴露。
|
||||
- 已从恢复版 `index.js` 中完整移除测试版新增的参数传输和多轮会话函数。
|
||||
- 恢复后的 `index.js` 已同步至 `C:\Users\31765\Desktop\index.js`。
|
||||
|
||||
### 2. 容积测试的 8 个参数改为 JSON 输入(历史阶段,已由第 4 节替代)
|
||||
|
||||
#### 2.1 修改目标
|
||||
|
||||
- 客户端不再通过 UI 输入容积测试参数。
|
||||
- 参数从固定 JSON 文件读取并校验后,传给 `start_volume_measurement()`。
|
||||
- 参数无效或文件读取失败时禁止启动设备,并在状态栏显示错误。
|
||||
- 旧 UI 控件对象继续保留,避免影响仍依赖这些属性的历史代码。
|
||||
|
||||
#### 2.2 JSON 文件和字段
|
||||
|
||||
- 默认文件:`config/volume_measurement.json`
|
||||
- 打包后默认位置:可执行文件同级的 `config/volume_measurement.json`
|
||||
- 可使用环境变量 `REINLOOP_VOLUME_CONFIG` 覆盖默认路径。
|
||||
- JSON 必须且只能包含下面 8 个字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"q_in_val": 50.0,
|
||||
"dt": 0.05,
|
||||
"p_max": 200.0,
|
||||
"fit_low": 50.0,
|
||||
"fit_high": 150.0,
|
||||
"T_delta": 30.0,
|
||||
"xa_full": 1000.0,
|
||||
"num_runs": 3
|
||||
}
|
||||
```
|
||||
|
||||
字段与 `start_volume_measurement()` 参数的对应关系:
|
||||
|
||||
| JSON 字段 | 类型 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `q_in_val` | float | 进气流量 |
|
||||
| `dt` | float | 控制与采样周期 |
|
||||
| `p_max` | float | 测量压力上限 |
|
||||
| `fit_low` | float | 压力拟合区间下限 |
|
||||
| `fit_high` | float | 压力拟合区间上限 |
|
||||
| `T_delta` | float | 测量过程温升参数 |
|
||||
| `xa_full` | float | 电机总行程/全开行程参数 |
|
||||
| `num_runs` | int | 重复测量次数 |
|
||||
|
||||
#### 2.3 代码位置和改动
|
||||
|
||||
1. `core/volume_config.py`
|
||||
|
||||
- `REQUIRED_FIELDS`(约第 10 行):定义必须存在的 8 个字段。
|
||||
- `default_config_path()`(约第 16 行):确定默认路径,并支持环境变量覆盖。
|
||||
- `load_volume_config()`(约第 27 行):读取 JSON、拒绝缺失或多余字段、
|
||||
校验数据类型及范围,最后返回可直接展开传参的字典。
|
||||
- 范围约束包括:`dt > 0`、`p_max > 0`、
|
||||
`0 <= fit_low < fit_high <= p_max`、`xa_full > 0`、
|
||||
`num_runs` 为正整数。
|
||||
|
||||
2. `config/volume_measurement.json`
|
||||
|
||||
- 新增默认配置模板。
|
||||
- 该文件中的值是当前测试默认值,部署前应由项目负责人确认。
|
||||
|
||||
3. `ui/main_window.py`
|
||||
|
||||
- 第 27 行附近:导入 `load_volume_config`。
|
||||
- `_on_volume_measure()`(约第 589 行):删除以下 UI 参数读取逻辑:
|
||||
`get_identify_params()`、`get_advanced_params()`、控制页流量输入和 PID 周期。
|
||||
- 新流程为:
|
||||
|
||||
```text
|
||||
点击测试
|
||||
-> load_volume_config()
|
||||
-> 校验成功
|
||||
-> start_volume_measurement(conn_mgr, running_flag_check, **config)
|
||||
```
|
||||
|
||||
- 配置失败时调用 `set_volume_finished()`,恢复测试按钮状态,不启动测量线程。
|
||||
|
||||
4. `ui/debug_tab.py`
|
||||
|
||||
- 高级设置卡片创建完成后调用 `adv_card.hide()`(约第 123 行)。
|
||||
- `_build_ident_section()` 末尾(约第 215 行)遍历布局并隐藏参数控件。
|
||||
- 保留 `btn_wrap` 和 `seq_wrap`,因此测试和辨识操作按钮仍可见。
|
||||
- `levels_entry` 单独隐藏。
|
||||
- `get_identify_params()`、`get_advanced_params()` 和 QSettings 逻辑没有删除,
|
||||
仅不再作为容积测试的数据来源。
|
||||
|
||||
5. `core/identification.py`
|
||||
|
||||
- `start_volume_measurement()`(约第 196 行)接口本身未改名。
|
||||
- 仍接收上述 8 个业务参数,内部继续调用 `measure_volume()` 并上传测量结果。
|
||||
|
||||
6. `tests/test_volume_config.py`
|
||||
|
||||
- `test_load_valid_config()`:验证合法配置可以读取。
|
||||
- `test_rejects_missing_field()`:验证缺少字段时拒绝启动。
|
||||
- `test_rejects_invalid_range()`:验证非法拟合区间被拒绝。
|
||||
|
||||
#### 2.4 修改前后行为
|
||||
|
||||
修改前:
|
||||
|
||||
```text
|
||||
UI 流量输入 + PID 周期 + 调试页参数 + 高级设置
|
||||
-> main_window.py 组合参数
|
||||
-> start_volume_measurement()
|
||||
```
|
||||
|
||||
修改后:
|
||||
|
||||
```text
|
||||
config/volume_measurement.json
|
||||
-> load_volume_config() 严格校验
|
||||
-> main_window.py 使用 **config
|
||||
-> start_volume_measurement()
|
||||
```
|
||||
|
||||
#### 2.5 验证结果
|
||||
|
||||
- `tests/test_volume_config.py`:3 个测试全部通过。
|
||||
- 30 个 Python 文件通过 AST 语法解析。
|
||||
- `git diff --check` 通过,仅提示 Windows 的 LF/CRLF 转换警告。
|
||||
- 未连接真实 MT2-AM8,因此尚未执行设备端容积测量联调。
|
||||
|
||||
#### 2.6 当前限制和安全说明
|
||||
|
||||
- 该阶段读取本地 JSON;当前实现已由第 4 节的云端单次请求替代。
|
||||
- “隐藏”仅指参数不在客户 UI 中显示;如果 JSON 明文部署在客户电脑上,
|
||||
有文件系统访问权限的用户仍可读取它。
|
||||
- 若参数属于公司机密,后续应增加云端临时下载、身份校验、加密或用后销毁流程。
|
||||
- 控制页面原有流量输入仍服务于其他控制功能,但容积测试不会读取该输入。
|
||||
|
||||
## 待修改事项
|
||||
|
||||
### 已完成:PRBS 前增加绝对行程稳态预扫描
|
||||
|
||||
- 修改文件:`core/identification.py`。
|
||||
- 新增函数:`IdentificationManager._run_initial_travel_scan()`。
|
||||
- `start_identification()` 的后台线程先调用新函数,完成后继续执行原有
|
||||
`collect_data_with_prbs()`;PRBS 的生成、参数和上传逻辑未替换。
|
||||
- 固定行程序列:`1000, 900, 800, 700, 600, 500, 400, 300, 200, 100, 0`。
|
||||
- 稳态判据:最短等待 5 s、采样周期 0.1 s、滑动窗口 5 s、压力极差
|
||||
`<= 0.5 kPa`、斜率绝对值 `<= 0.05 kPa/s`、连续稳定 3 s、单行程
|
||||
最大等待 60 s。
|
||||
- 每个达到稳态的行程只记录行程和平均稳定压力,不保存相对时间或响应过程。
|
||||
- 输出为 JSON 对象,格式如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"stable_pressures": [
|
||||
{"distance": 1000, "pressure": 12.3},
|
||||
{"distance": 900, "pressure": 15.6}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 压力单位为 kPa;未达到稳态或写入失败的行程不会写入结果,但其余记录仍能
|
||||
通过 `distance` 明确对应行程。
|
||||
- 预扫描结果单独上传至
|
||||
`{客户名称}/ind_data/travel_stability_pressures_时间戳.json`。
|
||||
- 行程指令不通过实时 UI 回调显示;UI 只接收压力。
|
||||
- 用户停止或发生异常时尝试将行程写回 `0`。
|
||||
- 预扫描上传失败不会改变后续 PRBS 行为,程序记录日志后继续 PRBS。
|
||||
- 新增 `tests/test_initial_travel_scan.py`,模拟全部 11 个行程达到稳态,验证
|
||||
上传文件为 JSON,且每条记录只包含 `distance` 和 `pressure`。
|
||||
- 验证结果:34 个 Python 文件通过 AST 语法解析;原
|
||||
`collect_data_with_prbs()` 调用及其 9 个传参保持不变;已有 3 个 JSON
|
||||
配置单元测试继续通过;`git diff --check` 通过。
|
||||
- 尚未连接真实 MT2-AM8,稳态等待、行程方向和 `0` 是否为安全位置需要硬件联调。
|
||||
|
||||
### 3. 辨识的 9 个参数改为从云端 CSV 获取
|
||||
|
||||
#### 3.1 修改目标
|
||||
|
||||
- 客户端点击“开始辨识”后,不再读取调试页、控制页或高级设置中的本地参数。
|
||||
- 客户端根据许可证中的客户名称,从云端读取固定 CSV 文件。
|
||||
- CSV 解析和严格校验成功后,将 9 个业务参数一次性传给
|
||||
`IdentificationManager.start_identification()`。
|
||||
- `conn_mgr` 和 `running_flag_check` 是客户端运行时对象,仍由本地创建,
|
||||
不属于云端 CSV。
|
||||
- 参数不在客户界面显示;之前隐藏的参数控件继续保留用于兼容旧代码,
|
||||
但辨识流程不会读取这些控件。
|
||||
- 既有的绝对行程稳态预扫描和原始 PRBS 采集顺序不变。
|
||||
|
||||
#### 3.2 云端文件和接口
|
||||
|
||||
- 固定云端目录:`{客户名称}/identification_config`
|
||||
- 固定文件名:`identification_config.csv`
|
||||
- 完整对象存储路径:
|
||||
`ReinLoop_GUI/{客户名称}/identification_config/identification_config.csv`
|
||||
- 客户名称来自 `api.py` 的 `the_folder`,生产环境中对应许可证的
|
||||
`customer` 字段。
|
||||
- `index.js` 第 318 行附近新增 `getIdentificationConfig(event)`:
|
||||
校验 `deviceId`,查询 `file_records` 中的固定记录,并返回腾讯云临时下载 URL。
|
||||
- `index.js` 第 380 行附近新增同名分发入口。
|
||||
- 仓库中的新版 `index.js` 已将固定查询文件改为 CSV;部署时应以仓库版本为准。
|
||||
- 公司端仍通过既有 `uploadDataFile` 接口获取直传凭证;同一路径再次上传时,
|
||||
云函数执行 upsert,客户端下一次辨识将读取覆盖后的版本。
|
||||
|
||||
#### 3.3 CSV 格式
|
||||
|
||||
`tool/identification_config.example.csv` 是公司端示例模板。实际客户配置应另存为
|
||||
公司内部文件,不要放进客户安装包;CSV 固定使用 `parameter,value` 两列:
|
||||
|
||||
```csv
|
||||
parameter,value
|
||||
q_in_val,50.0
|
||||
dt,0.1
|
||||
n_order,6
|
||||
t_c,2.5
|
||||
levels,"10,20,30,40,50,60,70,80"
|
||||
dead_area,240.0
|
||||
xa_full,1000.0
|
||||
V_val,5.0
|
||||
repeat,2
|
||||
```
|
||||
|
||||
| CSV 参数 | `start_identification()` 参数 | 校验要求 |
|
||||
| --- | --- | --- |
|
||||
| `q_in_val` | `q_in_val` | 有限数字,`>= 0` |
|
||||
| `dt` | `dt` | 有限数字,`> 0` |
|
||||
| `n_order` | `n_order` | 整数,`>= 2` |
|
||||
| `t_c` | `t_c` | 有限数字,`>= dt` |
|
||||
| `levels` | `levels` | 至少 2 项,长度为 2 的整数次幂,每项在 0~100 |
|
||||
| `dead_area` | `dead_area` | 有限数字,`0 <= dead_area < xa_full` |
|
||||
| `xa_full` | `xa_full` | 有限数字,`>= 1000` |
|
||||
| `V_val` | `V_val` | 有限数字,`> 0` |
|
||||
| `repeat` | `repeat` | 正整数 |
|
||||
|
||||
`xa_full >= 1000` 是因为辨识开始前的固定行程预扫描包含 1000;
|
||||
`levels` 的长度要求来自原始 `generate_prbs()` 多电平映射算法。
|
||||
|
||||
#### 3.4 客户端代码位置和执行流程
|
||||
|
||||
1. `core/identification_config.py`
|
||||
|
||||
- `REQUIRED_FIELDS`(第 7 行附近):定义 9 个必需字段。
|
||||
- `validate_identification_config()`(第 13 行附近):拒绝缺失字段、
|
||||
多余字段、布尔值、非有限数值和不安全的范围。
|
||||
- `parse_identification_config_csv()`:解析 `parameter,value` 两列,并将
|
||||
`levels` 的逗号分隔值恢复为 Python 列表。
|
||||
- `download_identification_config()`(第 79 行附近):调用云函数,
|
||||
获取临时 URL,下载 CSV,并在客户端再次校验。
|
||||
|
||||
2. `ui/main_window.py`
|
||||
|
||||
- `_Bridge.identification_config_loaded`(第 46 行附近):后台下载完成后,
|
||||
将结果安全地送回 Qt 主线程。
|
||||
- `_on_identify_start()`(第 555 行附近):点击辨识后启动后台下载线程,
|
||||
不阻塞界面,也不读取原有 UI 参数。
|
||||
- `_on_identification_config_loaded()`(第 578 行附近):同步
|
||||
`PcControl` 的 `xa_full`,再执行:
|
||||
|
||||
```python
|
||||
self.ident_mgr.start_identification(
|
||||
conn_mgr=self.conn_mgr,
|
||||
running_flag_check=lambda: self.engine.is_running,
|
||||
**config,
|
||||
)
|
||||
```
|
||||
|
||||
- `_on_identify_stop()`(第 597 行附近):停止辨识并使尚未完成的云端请求失效;
|
||||
即使旧请求稍后返回,也不会再启动设备。
|
||||
- `closeEvent()`(第 632 行附近):关闭软件时同样取消待处理请求并停止辨识。
|
||||
|
||||
3. `core/identification.py`
|
||||
|
||||
- `start_identification()`(第 274 行附近)的接口和 9 个业务参数保持不变。
|
||||
- 第 320 行附近仍先运行 `_run_initial_travel_scan()`,随后第 324 行附近
|
||||
调用原始 `collect_data_with_prbs()`;PRBS 调节方式没有替换。
|
||||
|
||||
4. `setup.py`
|
||||
|
||||
- 将 `core/identification_config.py`、`core/identification_feedback.py` 和
|
||||
`core/volume_config.py` 加入 Cython 核心模块清单,正式构建时不需要向
|
||||
客户交付这些模块的 Python 源码。
|
||||
|
||||
客户端完整流程:
|
||||
|
||||
```text
|
||||
点击开始辨识
|
||||
-> 后台调用 getIdentificationConfig(deviceId=许可证客户名称)
|
||||
-> 获取临时 URL 并下载 identification_config.csv
|
||||
-> 解析 parameter,value 两列
|
||||
-> 严格校验 9 个参数
|
||||
-> start_identification(conn_mgr, running_flag_check, **config)
|
||||
-> 1000 到 0 的稳态预扫描
|
||||
-> 原始 PRBS 动态辨识
|
||||
```
|
||||
|
||||
#### 3.5 公司端上传工具
|
||||
|
||||
- 新增 `tool/upload_identification_config.py`。
|
||||
- 第 41 行附近的 `upload_identification_config()` 在公司电脑上先使用与客户端
|
||||
相同的规则解析和校验 CSV,再规范化为 UTF-8 CSV,并调用既有 COS 直传流程。
|
||||
- 文件名和云端子目录由脚本固定,不能误传到模型目录。
|
||||
- 使用方式:
|
||||
|
||||
```powershell
|
||||
python tool/upload_identification_config.py "客户名称" "公司内部路径\identification_config.csv"
|
||||
```
|
||||
|
||||
- 同一客户再次执行会覆盖固定云端文件,用于多轮调整;已经运行中的一轮辨识
|
||||
不会被中途改参,客户下一次点击辨识才获取新版本。
|
||||
|
||||
#### 3.6 验证结果
|
||||
|
||||
- 新增 `tests/test_identification_config.py`,覆盖:合法配置归一化、缺少字段、
|
||||
CSV 解析、非 2 的整数次幂序列、`t_c < dt`、`xa_full < 1000`、死区越界。
|
||||
- 容积配置、辨识配置和预扫描输出共 16 个单元测试全部通过;其中包含云函数请求参数、
|
||||
临时 URL 下载和云端拒绝响应的模拟测试,不会访问真实网络。
|
||||
- 35 个 Python 文件通过 AST 语法解析。
|
||||
- `git diff --check` 通过,仅有 Git 的 LF/CRLF 转换提示。
|
||||
- 本机没有 Node.js,因此未运行 `node --check index.js`。
|
||||
- 当前 Python 环境未安装 `requests`(项目 `requirements.txt` 已声明该依赖),
|
||||
因此未向真实云环境上传配置;云端流程仅使用模拟响应完成单元测试。
|
||||
- 未连接 MT2-AM8 做完整硬件联调。
|
||||
|
||||
#### 3.7 安全边界和部署注意事项
|
||||
|
||||
- 客户 UI 不显示这 9 个参数,客户端本地也不需要保存配置文件;但 Python
|
||||
客户端解析 CSV 后,参数会在进程内存中存在,不能等同于绝对防提取。
|
||||
- `tool/identification_config.example.csv` 仅是字段模板;构建客户安装包时不要
|
||||
打包 `tool` 目录,也不要把填写了真实参数的公司内部 CSV 放进项目分发目录。
|
||||
- `config/identification_config.json` 仅是旧格式迁移提示,客户端不会读取;
|
||||
辨识配置只使用云端固定 CSV 文件。
|
||||
- 云函数返回的是有有效期的临时下载 URL,但源 CSV 会持续保存在云存储中;
|
||||
当前实现是“同路径覆盖”,不是“客户端下载后销毁”。
|
||||
- 当前 HTTP 云函数仅按 `deviceId` 查找文件,没有请求签名或设备身份认证。
|
||||
知道接口和其他客户名称的人理论上可能越权请求,因此正式发布前必须增加
|
||||
服务端许可证签名/设备令牌校验,不能只依赖 UI 隐藏。
|
||||
- 修改后的 `index.js` 必须重新部署到当前腾讯云环境,否则客户端会收到
|
||||
“无效的 type 字段”。
|
||||
|
||||
### 4. 容积测试通过请求指令获取本次云端 8 参数 JSON
|
||||
|
||||
- 客户点击“测试”后,客户端只调用一次 `createVolumeConfigRequest`,在云端创建
|
||||
一条带 `requestId` 的请求指令;请求有效期为 5 分钟。
|
||||
- 客户端随后每 2 秒调用 `getVolumeConfigRequest` 查询同一个 `requestId` 的状态。
|
||||
这些调用只是监听该请求是否已有文件,不会重复创建请求,也不会重复要求公司端上传。
|
||||
- 云端使用 `volume_config_requests` 集合保存等待上传、文件就绪和过期状态;创建新请求时
|
||||
会清理该客户遗留的旧请求及其临时 JSON,避免客户端读取旧参数。
|
||||
- 公司端工具调用 `getPendingVolumeConfigRequest` 等待客户请求,检测到请求后才校验并上传
|
||||
8 参数 JSON,再调用 `submitVolumeConfigFile` 把文件与本次 `requestId` 关联:
|
||||
|
||||
```powershell
|
||||
python tool/upload_volume_config.py "客户名称" "公司内部路径\volume.json" --wait-seconds 300
|
||||
```
|
||||
|
||||
- 云端只接受位于
|
||||
`{客户名称}/volume_config_requests/{requestId}/volume_measurement.json` 的上传记录,
|
||||
并检查上传时间处于本次请求的创建时间和过期时间之间;其他请求或旧目录中的文件不能关联。
|
||||
- 客户端检测到本次文件就绪后下载 JSON,严格校验 8 个字段,再执行一次
|
||||
`start_volume_measurement(conn_mgr, running_flag_check, **config)`。
|
||||
- 客户端加载完成、用户停止或请求超时后调用 `ackVolumeConfigRequest`,及时删除云端临时 JSON、
|
||||
`file_records` 记录和请求记录。测量期间不再监听参数变化,也不会自动开始新一轮测量。
|
||||
- 本地 `config/volume_measurement.json` 不提供业务参数,只保留迁移提示;公司端示例位于
|
||||
`tool/volume_measurement.example.json`。
|
||||
- 客户端要求 `q_in_val > 0`,避免容积计算除零;其他 7 个参数继续按原有范围严格校验。
|
||||
- 当前共 23 个单元测试,38 个 Python 文件通过 AST 语法解析;未连接真实云端和 MT2-AM8
|
||||
完成端到端联调。
|
||||
- 更新后的 `index.js` 必须重新部署,新的请求指令接口才会生效。
|
||||
|
||||
### 5. 辨识 CSV 的 0/1 审核与自动重测闭环
|
||||
|
||||
- `collect_data_with_prbs()` 生成的 `csv_data` 和 `.csv` 文件名现在直接上传到
|
||||
`{客户名称}/ind_data`,不再重新包装为辨识结果 JSON。
|
||||
- CSV 上传成功后,客户端调用 `registerIdentificationResult` 登记本轮文件名
|
||||
作为 `runId`,然后每 2 秒调用 `getIdentificationFeedback` 查询审核结果。
|
||||
- 云端使用 `identification_reviews` 集合;每个客户只保留当前一条待审核记录,
|
||||
新一轮登记会覆盖旧记录并删除重复项。
|
||||
- 审核结果严格使用数字:`1` 表示通过,`0` 表示未通过。其他值会被服务器和
|
||||
客户端拒绝,布尔值也不会被当作数字接受。
|
||||
- 客户端调试页新增持久状态显示:`正在辨识`、`等待反馈`、`已通过`、`未通过`、
|
||||
`上传失败` 等。
|
||||
- 收到 `1` 后显示“已通过”,停止反馈轮询并结束辨识流程。
|
||||
- 收到 `0` 后显示“未通过”,每 2 秒重新下载云端
|
||||
`identification_config.csv`;如果仍是本轮旧参数则继续等待,检测到 9 参数
|
||||
内容变化后才重新调用 `start_identification()`,防止旧参数重复执行。
|
||||
- 客户端消费 `0/1` 后调用 `ackIdentificationFeedback` 删除当前审核记录,避免
|
||||
旧反馈被下一轮误用。
|
||||
- 公司端或审核算法可调用 `setIdentificationFeedback`;人工测试命令为:
|
||||
|
||||
```powershell
|
||||
python tool/submit_identification_feedback.py "客户名称" 1
|
||||
python tool/submit_identification_feedback.py "客户名称" 0
|
||||
```
|
||||
|
||||
- 新增 `tests/test_identification_feedback.py`,并补充辨识管理器 CSV 直传测试;
|
||||
当前 20 个单元测试全部通过,38 个 Python 文件通过 AST 语法解析。
|
||||
- 尚未对真实云函数、审核程序和 MT2-AM8 进行端到端联调;更新后的 `index.js`
|
||||
必须重新部署。
|
||||
|
||||
- [x] 明确容积测试的云端 JSON 参数格式和传输流程。
|
||||
- [x] 明确辨识功能的云端 CSV 参数格式和传输流程。
|
||||
- [x] 明确绝对行程扫描与 PRBS 辨识的当前执行顺序。
|
||||
- [x] 隐藏客户调试页中的容积和辨识参数控件。
|
||||
- [x] 明确测试结果上传、公司端审核和多轮反馈流程。
|
||||
- [ ] 完成真实 MT2-AM8 硬件联调。
|
||||
|
||||
</details>
|
||||