update server

This commit is contained in:
2026-07-30 11:12:31 +08:00
commit 4312cb878c
99 changed files with 24034 additions and 0 deletions
+80
View File
@@ -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/
+818
View File
@@ -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 通讯类
- 默认 IP192.168.1.12,端口 502,模块地址(站号)默认为 1
- 输入寄存器(AI):地址 0x00~0x03(对应 PLC 地址 30001~30004
- 保持寄存器(AO):地址 0x00~0x03(对应 PLC 地址 40001~40004
- 模拟量值范围:0~4095(对应 0~10V 或 020mA
"""
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("程序结束。")
+85
View File
@@ -0,0 +1,85 @@
# ReinLoop V1.0 — 收敛有界
基于 Modbus 通讯的压力控制 GUI,支持 PID / 强化学习 / 手动三种控制模式。
## 项目结构
```
pressure_control_gui/
├── main.py # 应用入口
├── PcControl.py # Modbus 通讯类
│ # MT2AM8Client - MT2-AM8 模块 TCPAI 读压力/流量,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 压力寄存器 50432-bit float)、写线圈控制 |
| `MotorModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-BG02B0IX`,站号 4115200 | 通过 RS-485 直接读写电机驱动器寄存器 |
| `PressureModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-D30JITMY`,站号 1,9600 | 读压力变送器保持寄存器(备用) |
## 数据上传
控制数据(Episode)、辨识数据和容积测量结果通过 Express Server 的上传接口保存,
服务地址与设备目录配置在 `api.py`。许可证、模型和公司/产线等管理操作由
ControlPanel 完成,不由客户端工具执行。
+26
View File
@@ -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."
}
+3
View 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."
}
+145
View 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
+39
View File
@@ -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()
+132
View File
@@ -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)
+329
View File
@@ -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
+199
View File
@@ -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 = []
+20
View File
@@ -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")
+487
View File
@@ -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
+152
View File
@@ -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
+58
View File
@@ -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)
+139
View File
@@ -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
+154
View File
@@ -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)
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
name: RL
channels:
- conda-forge
- defaults
dependencies:
- python=3.10 # 建议固定版本
- pip
- pip:
- -r requirements.txt # 自动引用刚才生成的文件
+157
View File
@@ -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 的覆盖路径。 |
+193
View File
@@ -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 : 日志回调,默认 printGUI 可传入 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()
+250
View File
@@ -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 : 日志回调,默认 printGUI 可传入 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()
+656
View File
@@ -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 作为 contextreceiver),否则 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: 许可证 payloadcustomer, 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
+99
View File
@@ -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()
+25
View File
@@ -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": {}
}
+15
View File
@@ -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
+69
View File
@@ -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 输出目录
}
},
)
+53
View File
@@ -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"
}
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

+7
View File
@@ -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

+10
View File
@@ -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

+10
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+17
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.7 KiB

+4
View File
@@ -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

+9
View File
@@ -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

+5
View File
@@ -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

+3
View File
@@ -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

+8
View File
@@ -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

+9
View File
@@ -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

+569
View File
@@ -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
+41
View File
@@ -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()
+153
View File
@@ -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()
+122
View File
@@ -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()
+218
View File
@@ -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()
+167
View File
@@ -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 用 commandWindows/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()
+285
View File
@@ -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📊 超调01kpa 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)
+2024
View File
File diff suppressed because it is too large Load Diff
@@ -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
1 parameter value
2 q_in_val 50.0
3 dt 0.1
4 n_order 6
5 t_c 2.5
6 levels 10,20,30,40,50,60,70,80
7 dead_area 240.0
8 xa_full 1000.0
9 V_val 5.0
10 repeat 2
+61
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
# ui package - Pure PySide6 UI layer
+177
View File
@@ -0,0 +1,177 @@
# connection_tab.py
"""页面1Modbus 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"),
}
+593
View File
@@ -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)
# ============================================================
# 以下方法完全兼容旧版 APImain_window.py 无需变动
# ============================================================
# ---- 模式切换 ----
def _on_mode_changed_internal(self, btn):
if btn == self.radio_pid:
mode = "PID"
self.rl_panel.hide()
self.manual_panel.hide()
self.pid_panel.show()
self.collect_data_cb.setEnabled(True)
self.model_combobox.setEnabled(False)
self.load_model_btn.setEnabled(False)
self.refresh_models_btn.setEnabled(False)
elif btn == self.radio_rl:
mode = "RL"
self.pid_panel.hide()
self.manual_panel.hide()
self.rl_panel.show()
self.collect_data_cb.setEnabled(True)
self.model_combobox.setEnabled(True)
self.load_model_btn.setEnabled(True)
self.refresh_models_btn.setEnabled(True)
elif btn == self.radio_manual:
mode = "MANUAL"
self.rl_panel.hide()
self.pid_panel.hide()
self.manual_panel.show()
self.collect_data_cb.setChecked(False)
self.collect_data_cb.setEnabled(False)
else:
mode = "RL"
self.mode_changed.emit(mode)
def init_mode_ui(self):
self.rl_panel.show()
self.pid_panel.hide()
self.manual_panel.hide()
def set_mode_switch_enabled(self, enabled: bool):
self.radio_pid.setEnabled(enabled)
self.radio_rl.setEnabled(enabled)
self.radio_manual.setEnabled(enabled)
# ---- 信号处理 ----
def _on_set_target(self):
try:
target = float(self.target_entry.text())
if 0 <= target <= 3000:
self.target_set_requested.emit(target)
else:
self.target_set_requested.emit(-1)
except ValueError:
self.target_set_requested.emit(-1)
def _on_load_model(self):
selected = self.model_combobox.currentText()
self.model_load_requested.emit(selected)
def _on_refresh_models(self):
self.models_refresh_requested.emit()
def _on_update_pid(self):
try:
kp = float(self.Kp_entry.text())
ki = float(self.Ki_entry.text())
kd = float(self.Kd_entry.text())
self.pid_update_requested.emit(kp, ki, kd)
except ValueError:
self.log_message_requested.emit("错误: PID参数输入无效,请输入有效数字")
def _on_set_valve(self):
try:
valve = float(self.valve_entry.text())
if 0 <= valve <= 120:
self.manual_valve_set_requested.emit(valve)
else:
self.manual_valve_set_requested.emit(-1)
except ValueError:
self.manual_valve_set_requested.emit(-2)
def _on_toggle_control(self):
self.control_toggle_requested.emit()
def _on_plot(self):
self.plot_requested.emit()
# ---- 公开方法 (由 main_window 调用) ----
def set_control_running(self, running: bool):
if running:
self.start_btn.setText("停止控制")
self.start_btn.setIcon(QIcon())
self.start_btn.setProperty("cssClass", "danger")
self.start_btn.setStyleSheet(
"QPushButton { background-color: #EF4444; color: white; border: none;"
"border-radius: 6px; padding: 9px 24px; font-weight: bold; font-size: 14px; }"
"QPushButton:hover { background-color: #DC2626; }"
)
else:
self.start_btn.setText("开始控制")
self.start_btn.setIcon(QIcon(os.path.join(_SRC_DIR, "start_control.svg")))
self.start_btn.setIconSize(QSize(18, 18))
self.start_btn.setProperty("cssClass", "action")
self.start_btn.setStyleSheet(
"QPushButton { background-color: #0F955D; color: white; border: none;"
"border-radius: 6px; padding: 9px 24px; font-weight: bold; font-size: 14px; }"
"QPushButton:hover { background-color: #0D8250; }"
)
self.start_btn.style().unpolish(self.start_btn)
self.start_btn.style().polish(self.start_btn)
def update_display(self, pressure: float, target: float, valve: float):
self.current_pressure_lbl.setText(f"{pressure:.1f}")
self.target_pressure_lbl.setText(f"{target:.1f}")
self.valve_opening_lbl.setText(f"{valve:.1f}")
def update_pid_entries(self, kp: float, ki: float, kd: float):
self.Kp_entry.setText(f"{kp:.3f}")
self.Ki_entry.setText(f"{ki:.3f}")
self.Kd_entry.setText(f"{kd:.3f}")
def update_model_list(self, files: list):
self.model_combobox.clear()
if files:
self.model_combobox.addItems(files)
else:
self.model_combobox.addItem("无模型文件")
def get_mode(self) -> str:
if self.radio_pid.isChecked():
return "PID"
elif self.radio_manual.isChecked():
return "MANUAL"
return "RL"
def get_collect_data(self) -> bool:
return self.collect_data_cb.isChecked()
def get_control_params(self) -> dict:
return {
"volume": float(self.volume_entry.text() or "0"),
"flow": float(self.flow_entry.text() or "100"),
}
def get_pid_params(self) -> tuple:
return (
float(self.Kp_entry.text() or "1.0"),
float(self.Ki_entry.text() or "0.4"),
float(self.Kd_entry.text() or "0"),
)
def get_manual_valve(self) -> float:
return float(self.valve_entry.text() or "0")
def enable_plot_button(self, enable: bool):
self.plot_btn.setEnabled(enable)
def set_pid_entries_text(self, kp, ki, kd):
self.Kp_entry.setText(str(kp))
self.Ki_entry.setText(str(ki))
self.Kd_entry.setText(str(kd))
+427
View File
@@ -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"))
File diff suppressed because it is too large Load Diff
+189
View File
@@ -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)
+47
View File
@@ -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;"
)
+716
View File
@@ -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>