update server
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# core/__init__.py
|
||||
"""core 包 —— 业务逻辑层。
|
||||
|
||||
模块级许可证校验:import 此包的瞬间自动执行验签。
|
||||
两个文件均编译为 .pyd → 无法被篡改绕过。
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
# ============================================================
|
||||
# 模块级验签 —— 每次 import core.xxx 必然触发
|
||||
# 效果等同于在 main.py 中调用 check_license(),
|
||||
# 但此文件编译进 .pyd,攻击者无法删除或修改。
|
||||
# ============================================================
|
||||
_LICENSE_CHECKED = False
|
||||
|
||||
|
||||
def _init_license():
|
||||
"""在模块加载时自动调用一次,验证许可证。"""
|
||||
global _LICENSE_CHECKED
|
||||
if _LICENSE_CHECKED:
|
||||
return
|
||||
|
||||
# 开发环境(非 PyInstaller 打包)→ 直接跳过,不打扰
|
||||
# if not getattr(sys, 'frozen', False):
|
||||
# print("[core] 开发环境:跳过许可证校验")
|
||||
# _LICENSE_CHECKED = True
|
||||
# return
|
||||
|
||||
# 生产环境(exe 打包)→ 严格执行验签
|
||||
from license_utils import check_license
|
||||
|
||||
check_license() # 验签并启动唯一的后台巡检线程,失败直接退出
|
||||
|
||||
_LICENSE_CHECKED = True
|
||||
|
||||
|
||||
# 导入时立即执行
|
||||
_init_license()
|
||||
@@ -0,0 +1,132 @@
|
||||
# connection_manager.py
|
||||
"""连接管理器:负责 MT2-AM8 模块 (Modbus TCP) 的连接/断开。
|
||||
|
||||
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
||||
"""
|
||||
|
||||
import time
|
||||
from PcControl import MT2AM8Client
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""管理 MT2-AM8 模块连接的生命周期"""
|
||||
|
||||
def __init__(self):
|
||||
self.modbus_client = None # MT2AM8Client (TCP 读压力/流量,控电机)
|
||||
self._pressure_addr = 0 # 压力传感器模拟量通道地址
|
||||
self._flowmeter_addr = None # 流量计模拟量通道地址(None=使用手动输入)
|
||||
self._motor_addr = 0 # 电机模拟量输出通道地址
|
||||
self._on_log = None # 日志回调
|
||||
self._on_status_change = None # 状态变化回调
|
||||
|
||||
def set_log_callback(self, callback):
|
||||
"""设置日志回调: callback(message: str)"""
|
||||
self._on_log = callback
|
||||
|
||||
def set_status_callback(self, callback):
|
||||
"""设置状态变化回调: callback(connected: bool, status_text: str)"""
|
||||
self._on_status_change = callback
|
||||
|
||||
def log(self, message):
|
||||
"""内部日志输出"""
|
||||
if self._on_log:
|
||||
self._on_log(message)
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""检查是否已连接"""
|
||||
return self.modbus_client is not None and self.modbus_client.connected
|
||||
|
||||
def connect(self, tcp_ip: str, tcp_port: int, pressure_addr: int,
|
||||
motor_addr: int, flowmeter_addr: int,
|
||||
pressure_range: float = 400, flow_range: float = 100) -> bool:
|
||||
"""连接到 MT2-AM8 模块
|
||||
|
||||
Args:
|
||||
tcp_ip: 模块 IP 地址
|
||||
tcp_port: TCP 端口
|
||||
pressure_addr: 压力传感器模拟量通道地址
|
||||
motor_addr: 电机模拟量输出通道地址
|
||||
flowmeter_addr: 流量计模拟量通道地址
|
||||
pressure_range: 压力表量程上限
|
||||
flow_range: 流量计量程上限
|
||||
|
||||
Returns:
|
||||
是否连接成功
|
||||
"""
|
||||
try:
|
||||
self.log("正在连接设备...")
|
||||
|
||||
# 保存地址配置
|
||||
self._pressure_addr = pressure_addr
|
||||
self._motor_addr = motor_addr
|
||||
self._flowmeter_addr = flowmeter_addr
|
||||
|
||||
# 创建 MT2-AM8 客户端
|
||||
self.modbus_client = MT2AM8Client(
|
||||
host=tcp_ip,
|
||||
port=tcp_port,
|
||||
pressure_range=pressure_range,
|
||||
flow_range=flow_range,
|
||||
)
|
||||
|
||||
if not self.modbus_client.connect():
|
||||
self.log(f"连接 MT2-AM8 模块失败: {tcp_ip}:{tcp_port}")
|
||||
return False
|
||||
|
||||
self.log(f"成功连接到 MT2-AM8 模块: {tcp_ip}:{tcp_port}")
|
||||
self.log(f"压力地址: {pressure_addr}, 电机地址: {motor_addr}, 流量计地址: {flowmeter_addr}")
|
||||
|
||||
if self._on_status_change:
|
||||
self._on_status_change(True, "已连接")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"连接异常: {str(e)}")
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""断开连接"""
|
||||
if self.modbus_client:
|
||||
self.modbus_client.disconnect()
|
||||
self.modbus_client = None
|
||||
|
||||
self.log("已断开连接")
|
||||
|
||||
if self._on_status_change:
|
||||
self._on_status_change(False, "未连接")
|
||||
|
||||
def read_pressure(self):
|
||||
"""读取当前压力值(转换为实际物理量)
|
||||
|
||||
Returns:
|
||||
实际压力值 (kPa), 读取失败返回 None
|
||||
"""
|
||||
if not self.is_connected():
|
||||
return None
|
||||
return self.modbus_client.get_pressure(self._pressure_addr)
|
||||
|
||||
def read_flow(self):
|
||||
"""读取当前流量值(转换为实际物理量)
|
||||
|
||||
若未配置流量计地址,直接返回 None,由调用方使用控制栏手动输入值。
|
||||
|
||||
Returns:
|
||||
实际流量值 (L/min), 未配置地址或读取失败返回 None
|
||||
"""
|
||||
if not self.is_connected() or self._flowmeter_addr is None:
|
||||
return None
|
||||
return self.modbus_client.get_flow(self._flowmeter_addr)
|
||||
|
||||
def set_motor_position(self, xa: float) -> bool:
|
||||
"""设置电机位置(通过模拟量输出控制阀门开度)
|
||||
|
||||
Args:
|
||||
xa: 目标行程 (0~x_max)
|
||||
|
||||
Returns:
|
||||
是否设置成功
|
||||
"""
|
||||
if not self.is_connected():
|
||||
return False
|
||||
return self.modbus_client.set_motor_position(xa, channel=self._motor_addr)
|
||||
@@ -0,0 +1,329 @@
|
||||
# control_engine.py
|
||||
"""控制引擎:管理控制主循环,支持 PID / RL / MANUAL 三种模式。
|
||||
|
||||
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
||||
|
||||
设计原则:所有操作在主线程执行(QTimer 驱动),避免 Cython 编译后
|
||||
在 PyInstaller 子线程中 segfault。
|
||||
"""
|
||||
|
||||
import time
|
||||
import os
|
||||
import traceback
|
||||
import sys
|
||||
import logging
|
||||
import numpy as np
|
||||
from controllers import IncrementalPID
|
||||
|
||||
|
||||
logger = logging.getLogger("ReinLoop.ControlEngine")
|
||||
|
||||
|
||||
class ControlEngine:
|
||||
"""控制主引擎"""
|
||||
|
||||
def __init__(self, pid: IncrementalPID):
|
||||
self.pid = pid
|
||||
self._running = False
|
||||
self._cycle_count = 0
|
||||
self._last_tick = 0.0
|
||||
|
||||
# 缓存参数(start 时从 UI 获取)
|
||||
self.mode = "PID"
|
||||
self.flow = 0.0
|
||||
self.volume = 0.0
|
||||
self.target_pressure = 80.0
|
||||
self.manual_valve = 0.0
|
||||
self.dz = None
|
||||
self.motor_max = None
|
||||
self.xa_full = 1062.5
|
||||
self.collect_data = False
|
||||
|
||||
# 压力 EMA 滤波
|
||||
self.pressure_alpha = 1 # 平滑系数(0~1),越小越平滑
|
||||
self._pressure_filtered = None # 滤波后的压力值
|
||||
|
||||
# 外部依赖
|
||||
self._conn_mgr = None
|
||||
self._model_mgr = None
|
||||
self._data_collector = None
|
||||
|
||||
# RL 模式相关
|
||||
self.last_target_rl = None
|
||||
self.Kp_0 = 1.0
|
||||
self.Ki_0 = 0.4
|
||||
|
||||
# 回调
|
||||
self._on_log = None
|
||||
self._on_display_update = None
|
||||
self._on_pid_ui_update = None
|
||||
self._on_started = None
|
||||
self._on_stopped = None
|
||||
|
||||
# ---- 依赖注入 ----
|
||||
def set_connection_manager(self, mgr):
|
||||
self._conn_mgr = mgr
|
||||
|
||||
def set_model_manager(self, mgr):
|
||||
self._model_mgr = mgr
|
||||
|
||||
def set_data_collector(self, collector):
|
||||
self._data_collector = collector
|
||||
|
||||
# ---- 回调设置 ----
|
||||
def set_log_callback(self, cb):
|
||||
self._on_log = cb
|
||||
|
||||
def set_display_update_callback(self, cb):
|
||||
self._on_display_update = cb
|
||||
|
||||
def set_pid_ui_update_callback(self, cb):
|
||||
self._on_pid_ui_update = cb
|
||||
|
||||
def set_started_callback(self, cb):
|
||||
self._on_started = cb
|
||||
|
||||
def set_stopped_callback(self, cb):
|
||||
self._on_stopped = cb
|
||||
|
||||
def log(self, message):
|
||||
if self._on_log:
|
||||
self._on_log(message)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
# ---- 启动/停止 ----
|
||||
def start(self):
|
||||
"""启动控制循环(主线程调用)"""
|
||||
self.log("正在启动控制循环...")
|
||||
|
||||
if not self._conn_mgr or not self._conn_mgr.is_connected():
|
||||
self.log("启动失败: 请先连接压力表")
|
||||
return
|
||||
|
||||
if self.mode == "RL":
|
||||
if not self._model_mgr or not self._model_mgr.is_model_loaded():
|
||||
self.log("启动失败: 模型未加载,请先选择工况并点击【加载模型】按钮")
|
||||
return
|
||||
|
||||
# 预置初始阀位(读取当前电机位置)
|
||||
# try:
|
||||
# position_x = self._conn_mgr.read_motor_position()
|
||||
# initial_valve = self.pid.init_v(position_x)
|
||||
# self.pid.output = initial_valve
|
||||
# self.log(f"预置初始阀位 {initial_valve:.1f}%")
|
||||
# except Exception as e:
|
||||
# self.log(f"读取初始开度失败,将使用 80% 启动: {e}")
|
||||
# self.pid.output = 80.0
|
||||
|
||||
self.pid.output = 100.0
|
||||
|
||||
# 设置死区
|
||||
if self.dz is not None:
|
||||
self.pid.dead_area = self.dz
|
||||
|
||||
# RL 模式:在主线程预先完成模型预测
|
||||
if self.mode == "RL":
|
||||
try:
|
||||
current_p = self._conn_mgr.read_pressure()
|
||||
if current_p is None:
|
||||
current_p = 0.0
|
||||
self._rl_predict(current_p, self.target_pressure)
|
||||
self.log(f"RL 初始预测: Kp={self.pid.kp:.4f}, Ki={self.pid.ki:.4f}")
|
||||
except Exception as e:
|
||||
self.log(f"模型调用异常,使用默认pid: {e}")
|
||||
|
||||
# 重置状态
|
||||
self.last_target_rl = None
|
||||
self._pressure_filtered = None # 复位滤波器
|
||||
self._cycle_count = 0
|
||||
self._last_tick = time.perf_counter()
|
||||
|
||||
if self._data_collector:
|
||||
self._data_collector.reset()
|
||||
|
||||
self._running = True
|
||||
|
||||
if self._on_started:
|
||||
self._on_started()
|
||||
|
||||
self.log(f"控制循环已启动 (模式: {self.mode}, 目标: {self.target_pressure} kPa)")
|
||||
|
||||
def control_tick(self):
|
||||
"""主线程 QTimer 每次触发时调用——执行一个控制周期"""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
cycle_start = time.perf_counter()
|
||||
|
||||
try:
|
||||
# 1. 读取当前压力(原始值)
|
||||
raw_pressure = self._conn_mgr.read_pressure()
|
||||
if raw_pressure is None:
|
||||
self.log("读取当前压力失败,检查地址和连接")
|
||||
return
|
||||
|
||||
# EMA 低通滤波:平滑毛刺
|
||||
if self._pressure_filtered is None:
|
||||
self._pressure_filtered = raw_pressure
|
||||
else:
|
||||
self._pressure_filtered = (self.pressure_alpha * raw_pressure
|
||||
+ (1 - self.pressure_alpha) * self._pressure_filtered)
|
||||
current_pressure = self._pressure_filtered
|
||||
|
||||
target_pressure = self.target_pressure
|
||||
mode = self.mode
|
||||
|
||||
# 2. 根据模式计算阀门开度
|
||||
if mode == "PID":
|
||||
valve_opening = self._pid_step(current_pressure, target_pressure)
|
||||
|
||||
elif mode == "RL":
|
||||
valve_opening = self._rl_step(current_pressure, target_pressure)
|
||||
|
||||
elif mode == "MANUAL":
|
||||
valve_opening = self._manual_step()
|
||||
|
||||
else:
|
||||
self.log("错误!未知控制模式")
|
||||
valve_opening = 0.0
|
||||
|
||||
# 3. 数据采集
|
||||
if self.collect_data and self._data_collector:
|
||||
self._data_collector.record_step(
|
||||
cycle_count=self._cycle_count,
|
||||
current_pressure=current_pressure,
|
||||
target_pressure=target_pressure,
|
||||
valve_opening=valve_opening,
|
||||
kp=self.pid.kp, ki=self.pid.ki, kd=self.pid.kd,
|
||||
q_in=self.flow, v=self.volume
|
||||
)
|
||||
|
||||
# 4. 更新 UI 显示
|
||||
if self._on_display_update:
|
||||
self._on_display_update(current_pressure, target_pressure, valve_opening)
|
||||
|
||||
self._cycle_count += 1
|
||||
|
||||
# 5. 周期精确计时:若本周期用时不满 dt,sleep 补足
|
||||
elapsed = time.perf_counter() - cycle_start
|
||||
dt = self.pid.dt
|
||||
# dt = 0.2
|
||||
if elapsed < dt:
|
||||
time.sleep(dt - elapsed)
|
||||
|
||||
# 6. 记录实际周期时长
|
||||
now = time.perf_counter()
|
||||
tick_time = now - cycle_start
|
||||
# print(f"本周期用时 {tick_time*1000:.1f}ms (目标 {dt*1000:.0f}ms)")
|
||||
self._last_tick = now
|
||||
|
||||
except Exception as e:
|
||||
# control_tick 原本会捕获异常,因此异常不会进入 main.py 的
|
||||
# sys.excepthook。这里必须主动把完整 traceback 打到控制台。
|
||||
err_detail = traceback.format_exc()
|
||||
|
||||
print("\n" + "=" * 80, file=sys.stderr, flush=True)
|
||||
print("ControlEngine.control_tick 发生异常:", file=sys.stderr, flush=True)
|
||||
print(err_detail, file=sys.stderr, flush=True)
|
||||
print("=" * 80, file=sys.stderr, flush=True)
|
||||
|
||||
# main.py 已配置控制台和文件日志;这里会同步写入 logs 目录。
|
||||
logger.error("控制周期错误:\n%s", err_detail)
|
||||
|
||||
# UI 中保留一行简要信息,避免多行文本被控件截断。
|
||||
self.log(
|
||||
f"控制周期错误: {type(e).__name__}: {e};"
|
||||
f"完整 traceback 请看运行控制台或 logs 日志"
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
"""停止控制循环"""
|
||||
self._running = False
|
||||
|
||||
if self._data_collector:
|
||||
self._data_collector.finalize_and_upload(self.flow, self.volume)
|
||||
|
||||
self.log("控制循环已停止")
|
||||
|
||||
if self._on_stopped:
|
||||
self._on_stopped()
|
||||
|
||||
# ---- PID 模式 ----
|
||||
def _pid_step(self, current_pressure, target_pressure):
|
||||
"""PID 控制单步"""
|
||||
self.pid.update_pressure_values(current_pressure, target_pressure)
|
||||
valve_opening = self.pid.update()
|
||||
xa = self.xa_full * (100 - valve_opening) / 100
|
||||
self._conn_mgr.set_motor_position(xa)
|
||||
return valve_opening
|
||||
|
||||
# ---- RL 模式 ----
|
||||
def _rl_step(self, current_pressure, target_pressure):
|
||||
"""RL 增强控制单步"""
|
||||
# 跟踪目标压力变化,触发 RL 重预测
|
||||
if self.last_target_rl is None:
|
||||
self.last_target_rl = target_pressure
|
||||
elif self.last_target_rl != target_pressure:
|
||||
self.last_target_rl = target_pressure
|
||||
try:
|
||||
self._rl_predict(current_pressure, target_pressure)
|
||||
except Exception as e:
|
||||
self.log(f"模型调用异常,使用默认pid: {e}")
|
||||
|
||||
# 检查高级设置中的单步限幅是否有填入,如果有,使用填入的值;如果没有,使用默认函数
|
||||
# if self.motor_max is not None:
|
||||
# self.pid.set_du_max(self.motor_max * self.pid.dt)
|
||||
# else:
|
||||
# self.pid.get_du_max(target_pressure)
|
||||
self.pid.update_pressure_values(current_pressure, target_pressure)
|
||||
if self.motor_max is not None:
|
||||
du_max = self.motor_max * self.pid.dt
|
||||
else:
|
||||
du_max = None
|
||||
# PID 计算
|
||||
valve_opening = self.pid.update(du_max)
|
||||
|
||||
# 位置换算(考虑死区)
|
||||
xa = self.pid.dead_area + (100 - valve_opening) * (self.xa_full - self.pid.dead_area) / 100
|
||||
self._conn_mgr.set_motor_position(xa)
|
||||
|
||||
return valve_opening
|
||||
|
||||
def _rl_predict(self, current_p, target_p):
|
||||
"""执行 RL 模型预测并更新 PID 参数(只在主线程调用)"""
|
||||
model = self._model_mgr.rl_model
|
||||
if model is None:
|
||||
print("[RL] 错误: rl_model 为 None,跳过预测")
|
||||
return
|
||||
|
||||
obs = np.array([
|
||||
self.flow / 100,
|
||||
current_p / 100,
|
||||
(target_p - current_p) / 100
|
||||
], dtype=np.float32)
|
||||
print(f"[RL] 预测 obs={obs}", flush=True)
|
||||
action, _ = model.predict(obs, deterministic=True)
|
||||
print(f"[RL] model.predict 完成, action={action}")
|
||||
|
||||
action_space = model.action_space
|
||||
Kp_0 = float(action_space.high[0])
|
||||
Ki_0 = float(action_space.high[1])
|
||||
kp = float(Kp_0 + action[0])
|
||||
ki = float(Ki_0 + action[1])
|
||||
|
||||
self.Kp_0 = Kp_0
|
||||
self.Ki_0 = Ki_0
|
||||
self.pid.update_parameters(kp, ki, self.pid.kd)
|
||||
print(f"[RL] PID 参数已更新: Kp={kp:.4f}, Ki={ki:.4f}")
|
||||
if self._on_pid_ui_update:
|
||||
self._on_pid_ui_update(kp, ki, self.pid.kd)
|
||||
|
||||
# ---- MANUAL 模式 ----
|
||||
def _manual_step(self):
|
||||
"""手动模式单步"""
|
||||
xa = self.pid.dead_area + (100 - self.manual_valve) * (self.xa_full - self.pid.dead_area) / 100
|
||||
self._conn_mgr.set_motor_position(xa)
|
||||
return self.manual_valve
|
||||
@@ -0,0 +1,199 @@
|
||||
# data_collector.py
|
||||
"""数据采集器:管理 Episode 数据记录与上上传。
|
||||
|
||||
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import pickle
|
||||
import datetime
|
||||
import threading
|
||||
import requests
|
||||
|
||||
from api import base_url, data_record_url, the_folder
|
||||
|
||||
|
||||
class DataCollector:
|
||||
"""管理控制过程中的 Episode 数据采集与保存"""
|
||||
|
||||
def __init__(self):
|
||||
self.episode_data_raw = [] # 所有已完成的 Episode
|
||||
self.current_episode = None # 当前正在记录的 Episode
|
||||
self.last_target_record = None
|
||||
self._on_log = None
|
||||
|
||||
def set_log_callback(self, callback):
|
||||
"""设置日志回调"""
|
||||
self._on_log = callback
|
||||
|
||||
def log(self, message):
|
||||
if self._on_log:
|
||||
self._on_log(message)
|
||||
|
||||
def _upload_to_cos(self, data_bytes: bytes, filename: str, folder: str) -> bool:
|
||||
"""通过云函数获取直传凭证,再将数据直传到腾讯云 COS。"""
|
||||
try:
|
||||
resp = requests.post(data_record_url, json={
|
||||
"type": "uploadDataFile",
|
||||
"fileName": filename,
|
||||
"folder": folder,
|
||||
}, timeout=30)
|
||||
result = resp.json()
|
||||
except Exception as e:
|
||||
self.log(f"向云函数申请凭证异常: {e}")
|
||||
return False
|
||||
|
||||
if not result.get("success"):
|
||||
self.log(f"申请上传凭证失败: {result.get('errMsg', result)}")
|
||||
return False
|
||||
|
||||
meta = result.get("uploadMetadata")
|
||||
if not meta or "url" not in meta or "authorization" not in meta:
|
||||
self.log("云端未返回有效的上传元数据")
|
||||
return False
|
||||
|
||||
try:
|
||||
form_data = {
|
||||
"key": meta["cosFileId"],
|
||||
"Signature": meta["authorization"],
|
||||
"x-cos-security-token": meta["token"],
|
||||
"x-cos-meta-fileid": meta["fileId"],
|
||||
}
|
||||
files = {"file": (filename, io.BytesIO(data_bytes))}
|
||||
cos_resp = requests.post(meta["url"], data=form_data, files=files, timeout=60)
|
||||
|
||||
if cos_resp.status_code in [200, 204]:
|
||||
return True
|
||||
else:
|
||||
self.log(f"COS 直传失败,状态码: {cos_resp.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.log(f"COS 直传异常: {e}")
|
||||
return False
|
||||
|
||||
def reset(self):
|
||||
"""重置所有采集状态(控制启动时调用)"""
|
||||
self.episode_data_raw = []
|
||||
self.current_episode = None
|
||||
self.last_target_record = None
|
||||
|
||||
def record_step(self, cycle_count: int, current_pressure: float,
|
||||
target_pressure: float, valve_opening: float,
|
||||
kp: float, ki: float, kd: float,
|
||||
q_in: float, v: float):
|
||||
"""记录一个控制周期的数据点
|
||||
|
||||
Args:
|
||||
cycle_count: 控制周期计数
|
||||
current_pressure: 当前压力
|
||||
target_pressure: 目标压力
|
||||
valve_opening: 阀门开度
|
||||
kp, ki, kd: PID 参数
|
||||
q_in: 流量
|
||||
v: 容积
|
||||
"""
|
||||
# 目标压力变化时自动切分 Episode
|
||||
if self.current_episode is None or target_pressure != self.last_target_record:
|
||||
if self.current_episode is not None:
|
||||
self.episode_data_raw.append(self.current_episode)
|
||||
self.log(f"Episode 结束,已记录 {len(self.current_episode['pressures'])} 个点")
|
||||
|
||||
self.current_episode = {
|
||||
'pid': [float(kp), float(ki), float(kd)],
|
||||
'target_pressure': target_pressure,
|
||||
'Q_in': q_in,
|
||||
'V': v,
|
||||
'steps': [],
|
||||
'pressures': [],
|
||||
'errors': [],
|
||||
'valves': []
|
||||
}
|
||||
self.last_target_record = target_pressure
|
||||
|
||||
# 记录当前步数据
|
||||
error = -(target_pressure - current_pressure)
|
||||
self.current_episode['steps'].append(cycle_count)
|
||||
self.current_episode['pressures'].append(current_pressure)
|
||||
self.current_episode['errors'].append(error)
|
||||
self.current_episode['valves'].append(float(valve_opening))
|
||||
|
||||
def finalize_and_upload(self, flow: float, vol: float):
|
||||
"""停止控制时:闭合最后一个 Episode,分片上传到云存储。
|
||||
|
||||
单文件超过 5MB 时自动拆分为多个分片,
|
||||
同时上传一个 manifest.json 记录所有分片信息。
|
||||
|
||||
Args:
|
||||
flow: 流量值 (用于文件名/路径)
|
||||
vol: 容积值 (用于文件名/路径)
|
||||
"""
|
||||
# 闭合最后一个 Episode
|
||||
if self.current_episode and len(self.current_episode['pressures']) > 0:
|
||||
self.episode_data_raw.append(self.current_episode)
|
||||
self.current_episode = None
|
||||
|
||||
if not self.episode_data_raw:
|
||||
return
|
||||
|
||||
try:
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
base_folder = f"{the_folder}/data_record/data_{flow}SLM_{vol}L"
|
||||
|
||||
# 拆成每片尽量不超过 5MB 的 episode 分组
|
||||
MAX_CHUNK_BYTES = 5 * 1024 * 1024 # 5MB
|
||||
|
||||
chunks = [] # [(chunk_index, episodes_subset)]
|
||||
current_chunk = []
|
||||
for ep in self.episode_data_raw:
|
||||
current_chunk.append(ep)
|
||||
if pickle.dumps(current_chunk).__len__() >= MAX_CHUNK_BYTES:
|
||||
# 当前片已满,回退一个 episode 后保存
|
||||
current_chunk.pop()
|
||||
chunks.append(current_chunk)
|
||||
current_chunk = [ep]
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
|
||||
total_chunks = len(chunks)
|
||||
self.log(f"控制数据共 {len(self.episode_data_raw)} 个 Episode,"
|
||||
f"拆为 {total_chunks} 个分片上传")
|
||||
|
||||
def upload_all():
|
||||
part_files = []
|
||||
for idx, chunk_eps in enumerate(chunks):
|
||||
data_bytes = pickle.dumps(chunk_eps)
|
||||
size_kb = len(data_bytes) / 1024
|
||||
part_filename = f'episode_raw_data_{timestamp}_part{idx + 1}of{total_chunks}.pkl'
|
||||
self.log(f" 上传分片 {idx + 1}/{total_chunks} ({size_kb:.0f} KB)...")
|
||||
if self._upload_to_cos(data_bytes, part_filename, base_folder):
|
||||
part_files.append(part_filename)
|
||||
else:
|
||||
self.log(f" 分片 {idx + 1} 上传失败")
|
||||
|
||||
# 上传 manifest
|
||||
manifest = {
|
||||
"timestamp": timestamp,
|
||||
"total_chunks": total_chunks,
|
||||
"uploaded_chunks": len(part_files),
|
||||
"part_files": part_files,
|
||||
"total_episodes": len(self.episode_data_raw),
|
||||
"flow": flow,
|
||||
"volume": vol,
|
||||
}
|
||||
manifest_str = json.dumps(manifest, indent=2, ensure_ascii=False)
|
||||
manifest_bytes = manifest_str.encode('utf-8')
|
||||
manifest_filename = f'episode_raw_data_{timestamp}_manifest.json'
|
||||
self._upload_to_cos(manifest_bytes, manifest_filename, base_folder)
|
||||
|
||||
if len(part_files) == total_chunks:
|
||||
self.log(f"控制数据上传成功 ({total_chunks} 个分片)")
|
||||
else:
|
||||
self.log(f"控制数据部分上传失败 ({len(part_files)}/{total_chunks})")
|
||||
|
||||
threading.Thread(target=upload_all, daemon=True).start()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"保存收集数据时发生错误: {e}")
|
||||
finally:
|
||||
self.episode_data_raw = []
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Report the ReinLoop application's Server reachability for Panel status."""
|
||||
|
||||
|
||||
def heartbeat_device(timeout=5):
|
||||
"""Refresh the current device's Server heartbeat and return its timestamp."""
|
||||
import requests
|
||||
from api import data_record_url, the_folder
|
||||
|
||||
try:
|
||||
response = requests.post(data_record_url, json={
|
||||
"type": "deviceHeartbeat",
|
||||
"deviceId": the_folder,
|
||||
}, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except Exception as exc:
|
||||
raise ValueError(f"设备心跳请求失败: {exc}") from exc
|
||||
if not result.get("success"):
|
||||
raise ValueError(result.get("errMsg", "设备心跳被服务端拒绝"))
|
||||
return result.get("lastSeenAt")
|
||||
@@ -0,0 +1,487 @@
|
||||
# identification.py
|
||||
"""辨识与容积测量管理器。
|
||||
|
||||
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
||||
"""
|
||||
|
||||
import io
|
||||
import time
|
||||
import threading
|
||||
import datetime
|
||||
import json
|
||||
import traceback
|
||||
import requests
|
||||
from collections import deque
|
||||
|
||||
from get_V import measure_volume
|
||||
from ind_collector import collect_data_with_prbs
|
||||
|
||||
from api import base_url, data_record_url, the_folder
|
||||
|
||||
|
||||
class IdentificationManager:
|
||||
"""管理系统辨识与容积测量任务"""
|
||||
|
||||
def __init__(self):
|
||||
self._identifying = False
|
||||
self._task_thread = None
|
||||
self._on_log = None
|
||||
self._on_sample = None # 采样回调: (valve_cmd, pressure)
|
||||
self._on_volume_result = None # 容积结果回调: (volume_L: float)
|
||||
self._on_identification_upload = None
|
||||
|
||||
# ---- 回调设置 ----
|
||||
def set_log_callback(self, callback):
|
||||
"""设置日志回调"""
|
||||
self._on_log = callback
|
||||
|
||||
def set_sample_callback(self, callback):
|
||||
"""设置采样时段 UI 更新回调: callback(valve_cmd, pressure)"""
|
||||
self._on_sample = callback
|
||||
|
||||
def set_volume_result_callback(self, callback):
|
||||
"""设置容积测量结果回调: callback(volume_L: float)"""
|
||||
self._on_volume_result = callback
|
||||
|
||||
def set_identification_upload_callback(self, callback):
|
||||
"""设置辨识 CSV 上传结果回调: callback(success, filename, error)"""
|
||||
self._on_identification_upload = callback
|
||||
|
||||
def log(self, message):
|
||||
if self._on_log:
|
||||
self._on_log(message)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""当前是否正在辨识/测量中"""
|
||||
thread_alive = (
|
||||
self._task_thread is not None and self._task_thread.is_alive()
|
||||
)
|
||||
return self._identifying or thread_alive
|
||||
|
||||
def _upload_to_cos(self, content, filename: str, folder: str) -> bool:
|
||||
"""通过云函数获取直传凭证,再将文本或字节数据直传到 COS。
|
||||
|
||||
返回 True 表示上传成功,False 表示失败(已内部记 log)。
|
||||
"""
|
||||
# Step 1: 向云函数申请直传凭证(不传文件内容)
|
||||
try:
|
||||
resp = requests.post(data_record_url, json={
|
||||
"type": "uploadDataFile",
|
||||
"fileName": filename,
|
||||
"folder": folder,
|
||||
}, timeout=30)
|
||||
result = resp.json()
|
||||
except Exception as e:
|
||||
self.log(f"向云函数申请凭证异常: {e}")
|
||||
return False
|
||||
|
||||
if not result.get("success"):
|
||||
self.log(f"申请上传凭证失败: {result.get('errMsg', result)}")
|
||||
return False
|
||||
|
||||
meta = result.get("uploadMetadata")
|
||||
if not meta or "url" not in meta or "authorization" not in meta:
|
||||
self.log("云端未返回有效的上传元数据")
|
||||
return False
|
||||
|
||||
# Step 2: 直传到 COS
|
||||
try:
|
||||
form_data = {
|
||||
"key": meta["cosFileId"],
|
||||
"Signature": meta["authorization"],
|
||||
"x-cos-security-token": meta["token"],
|
||||
"x-cos-meta-fileid": meta["fileId"],
|
||||
}
|
||||
content_bytes = (
|
||||
content if isinstance(content, bytes)
|
||||
else str(content).encode("utf-8")
|
||||
)
|
||||
files = {"file": (filename, io.BytesIO(content_bytes))}
|
||||
cos_resp = requests.post(meta["url"], data=form_data, files=files, timeout=60)
|
||||
|
||||
if cos_resp.status_code in [200, 204]:
|
||||
return True
|
||||
else:
|
||||
self.log(f"COS 直传失败,状态码: {cos_resp.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.log(f"COS 直传异常: {e}")
|
||||
return False
|
||||
|
||||
def _run_initial_travel_scan(self, conn_mgr):
|
||||
"""Scan 1000..0 and upload each travel's stable pressure as JSON.
|
||||
|
||||
This is an independent pre-scan. It does not replace or modify the
|
||||
subsequent PRBS collection performed by ``collect_data_with_prbs``.
|
||||
"""
|
||||
distances = list(range(1000, -1, -100))
|
||||
settings = {
|
||||
"min_wait_time": 5.0,
|
||||
"sample_interval": 0.1,
|
||||
"stable_window": 5.0,
|
||||
"pressure_tolerance": 0.5,
|
||||
"slope_tolerance": 0.05,
|
||||
"stable_duration": 3.0,
|
||||
"max_wait_time": 60.0,
|
||||
}
|
||||
stable_pressure_records = []
|
||||
stopped = False
|
||||
|
||||
def slope(points):
|
||||
mean_t = sum(point[0] for point in points) / len(points)
|
||||
mean_p = sum(point[1] for point in points) / len(points)
|
||||
denominator = sum((point[0] - mean_t) ** 2 for point in points)
|
||||
if denominator == 0:
|
||||
return 0.0
|
||||
return sum(
|
||||
(point[0] - mean_t) * (point[1] - mean_p)
|
||||
for point in points
|
||||
) / denominator
|
||||
|
||||
try:
|
||||
for distance in distances:
|
||||
if not self._identifying:
|
||||
stopped = True
|
||||
break
|
||||
if not conn_mgr.set_motor_position(float(distance)):
|
||||
self.log(f"行程 {distance} 写入失败")
|
||||
continue
|
||||
|
||||
self.log(f"行程 {distance} 已写入,等待压力稳态")
|
||||
stage_start = time.monotonic()
|
||||
window = deque()
|
||||
stable_since = None
|
||||
stable_pressure = None
|
||||
pressure_range = None
|
||||
pressure_slope = None
|
||||
|
||||
while time.monotonic() - stage_start < settings["max_wait_time"]:
|
||||
if not self._identifying:
|
||||
stopped = True
|
||||
break
|
||||
|
||||
sample_start = time.monotonic()
|
||||
elapsed = sample_start - stage_start
|
||||
pressure = conn_mgr.read_pressure()
|
||||
if pressure is not None:
|
||||
pressure = float(pressure)
|
||||
if self._on_sample:
|
||||
# Do not expose the confidential travel command.
|
||||
self._on_sample(None, pressure)
|
||||
|
||||
if elapsed >= settings["min_wait_time"]:
|
||||
window.append([elapsed, pressure])
|
||||
cutoff = elapsed - settings["stable_window"]
|
||||
while window and window[0][0] < cutoff:
|
||||
window.popleft()
|
||||
|
||||
window_span = (
|
||||
window[-1][0] - window[0][0]
|
||||
if len(window) > 1 else 0
|
||||
)
|
||||
if window_span >= (
|
||||
settings["stable_window"] -
|
||||
settings["sample_interval"] * 1.5):
|
||||
pressures = [point[1] for point in window]
|
||||
pressure_range = max(pressures) - min(pressures)
|
||||
pressure_slope = slope(window)
|
||||
stable_now = (
|
||||
pressure_range <= settings["pressure_tolerance"] and
|
||||
abs(pressure_slope) <= settings["slope_tolerance"]
|
||||
)
|
||||
if stable_now:
|
||||
if stable_since is None:
|
||||
stable_since = sample_start
|
||||
elif sample_start - stable_since >= settings["stable_duration"]:
|
||||
stable_pressure = sum(pressures) / len(pressures)
|
||||
break
|
||||
else:
|
||||
stable_since = None
|
||||
|
||||
remaining = (
|
||||
settings["sample_interval"] -
|
||||
(time.monotonic() - sample_start)
|
||||
)
|
||||
if remaining > 0:
|
||||
time.sleep(remaining)
|
||||
|
||||
if stopped:
|
||||
break
|
||||
if stable_pressure is None:
|
||||
self.log(f"行程 {distance} 在 60 秒内未达到稳态")
|
||||
continue
|
||||
|
||||
stable_pressure_records.append({
|
||||
"distance": distance,
|
||||
"pressure": float(stable_pressure),
|
||||
})
|
||||
self.log(
|
||||
f"行程 {distance} 达到稳态,压力 {stable_pressure:.3f} kPa"
|
||||
)
|
||||
finally:
|
||||
# The requested sequence ends at zero; also return there on stop.
|
||||
conn_mgr.set_motor_position(0)
|
||||
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"travel_stability_pressures_{timestamp}.json"
|
||||
payload = {"stable_pressures": stable_pressure_records}
|
||||
uploaded = self._upload_to_cos(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
filename,
|
||||
f"{the_folder}/ind_data",
|
||||
)
|
||||
if uploaded:
|
||||
self.log("行程稳态压力 JSON 上传成功,继续执行 PRBS 辨识")
|
||||
else:
|
||||
self.log("行程稳态压力 JSON 上传失败,继续执行 PRBS 辨识")
|
||||
return payload
|
||||
|
||||
# ---- 系统辨识 ----
|
||||
def start_identification(self, *,
|
||||
conn_mgr,
|
||||
running_flag_check,
|
||||
q_in_val: float, dt: float,
|
||||
n_order: int, t_c: float,
|
||||
levels: list, dead_area: float,
|
||||
xa_full: float, V_val: float,
|
||||
repeat: int = 2):
|
||||
"""启动辨识数据采集(在后台线程中运行)
|
||||
|
||||
Args:
|
||||
conn_mgr: ConnectionManager 实例
|
||||
running_flag_check: 检查是否应该停止的可调用对象, 返回 bool
|
||||
q_in_val: 流量 (L/min)
|
||||
dt: 控制周期
|
||||
n_order: 阶数
|
||||
t_c: 周期 (s)
|
||||
levels: 序列 (阀门开度列表)
|
||||
dead_area: 死区
|
||||
xa_full: 总限幅
|
||||
V_val: 容积 (L)
|
||||
repeat: 整段复合序列重复次数,默认 2
|
||||
"""
|
||||
if running_flag_check():
|
||||
self.log("错误:请先停止控制再进行辨识")
|
||||
return False
|
||||
|
||||
if self.is_running:
|
||||
self.log("辨识正在进行中,请等待完成")
|
||||
return False
|
||||
|
||||
if not conn_mgr or not conn_mgr.is_connected():
|
||||
self.log("错误:请先连接设备")
|
||||
return False
|
||||
|
||||
self._identifying = True
|
||||
self.log("开始辨识数据采集...")
|
||||
|
||||
def _on_sample_point(t, u_cmd, p):
|
||||
if self._on_sample:
|
||||
self._on_sample(u_cmd, p)
|
||||
|
||||
def collect_thread():
|
||||
try:
|
||||
# Independent pre-scan. The PRBS call below is intentionally
|
||||
# left unchanged and starts after the travel scan completes.
|
||||
self._run_initial_travel_scan(conn_mgr)
|
||||
if not self._identifying:
|
||||
return
|
||||
|
||||
result = collect_data_with_prbs(
|
||||
conn_mgr,
|
||||
q_in_val=q_in_val, dt=dt,
|
||||
n_order=n_order, t_c=t_c,
|
||||
levels=levels, dead_area=dead_area,
|
||||
xa_full=xa_full,
|
||||
V_val=V_val,
|
||||
should_stop=lambda: not self._identifying,
|
||||
log=self.log,
|
||||
on_sample=_on_sample_point,
|
||||
repeat=repeat,
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
csv_data = result.get("csv_data")
|
||||
csv_filename = result.get("filename")
|
||||
if not csv_data or not csv_filename:
|
||||
error = "辨识采集结果缺少 CSV 数据或文件名"
|
||||
self.log(error)
|
||||
if self._on_identification_upload:
|
||||
self._on_identification_upload(False, None, error)
|
||||
elif self._upload_to_cos(
|
||||
csv_data, csv_filename, f"{the_folder}/ind_data"):
|
||||
self.log("辨识数据上传成功")
|
||||
if self._on_identification_upload:
|
||||
self._on_identification_upload(
|
||||
True, csv_filename, None
|
||||
)
|
||||
else:
|
||||
self.log("辨识数据上传失败")
|
||||
if self._on_identification_upload:
|
||||
self._on_identification_upload(
|
||||
False, csv_filename, "辨识 CSV 上传失败"
|
||||
)
|
||||
else:
|
||||
self.log("辨识未采集到数据")
|
||||
if self._on_identification_upload:
|
||||
self._on_identification_upload(
|
||||
False, None, "辨识未采集到数据"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"辨识数据采集详细错误: {traceback.format_exc()}")
|
||||
self.log(f"辨识数据采集失败: {e}")
|
||||
if self._on_identification_upload:
|
||||
self._on_identification_upload(False, None, str(e))
|
||||
finally:
|
||||
self._identifying = False
|
||||
# self.log("辨识结束")
|
||||
|
||||
thread = threading.Thread(target=collect_thread, daemon=True)
|
||||
self._task_thread = thread
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
# ---- 容积测量 ----
|
||||
def start_volume_measurement(self, *,
|
||||
conn_mgr,
|
||||
running_flag_check,
|
||||
q_in_val: float, dt: float,
|
||||
p_max: float, fit_low: float,
|
||||
fit_high: float, T_delta: float,
|
||||
xa_full: float = 1000,
|
||||
num_runs: int = 3):
|
||||
"""启动容积测量(在后台线程中运行)"""
|
||||
if running_flag_check():
|
||||
self.log("错误:请先停止控制再进行测试")
|
||||
return False
|
||||
|
||||
if self.is_running:
|
||||
self.log("测试正在进行中,请等待完成")
|
||||
return False
|
||||
|
||||
if not conn_mgr or not conn_mgr.is_connected():
|
||||
self.log("错误:请先连接设备")
|
||||
return False
|
||||
|
||||
self._identifying = True
|
||||
self.log("开始测量容积...")
|
||||
|
||||
def _on_vol_sample(t, p):
|
||||
if self._on_sample:
|
||||
self._on_sample(None, p)
|
||||
|
||||
def volume_thread():
|
||||
all_results = [] # 存储每次成功的结果
|
||||
|
||||
try:
|
||||
for run_idx in range(num_runs):
|
||||
if not self._identifying:
|
||||
break
|
||||
|
||||
print(f"--- 第 {run_idx + 1}/{num_runs} 次测量 ---")
|
||||
|
||||
# 非首次测量前,等待压力回落
|
||||
if run_idx > 0:
|
||||
print("等待压力回落...")
|
||||
wait_start = time.time()
|
||||
while time.time() - wait_start < 60: # 最多等 60 秒
|
||||
p = conn_mgr.read_pressure()
|
||||
if p is not None and p < fit_low:
|
||||
print(f"压力已回落至 {p:.1f} kPa,等待 10 秒稳定...")
|
||||
time.sleep(10)
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
print("等待压力回落超时,跳过剩余测量")
|
||||
break
|
||||
|
||||
result = measure_volume(
|
||||
conn_mgr,
|
||||
q_in_slm=q_in_val,
|
||||
dt=dt,
|
||||
xa=xa_full,
|
||||
p_max=p_max,
|
||||
fit_low=fit_low,
|
||||
fit_high=fit_high,
|
||||
T_delta=T_delta,
|
||||
should_stop=lambda: not self._identifying,
|
||||
log=self.log,
|
||||
on_sample=_on_vol_sample,
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
all_results.append(result)
|
||||
print(f"第 {run_idx + 1} 次测量成功,V = {result['volume_L']:.4f} L")
|
||||
else:
|
||||
print(f"第 {run_idx + 1} 次测量失败")
|
||||
|
||||
# ---- 汇总 ----
|
||||
if all_results:
|
||||
n = len(all_results)
|
||||
|
||||
# 平均关键参数
|
||||
avg_vol = sum(r['volume_L'] for r in all_results) / n
|
||||
avg_slope = sum(r['slope'] for r in all_results) / n
|
||||
avg_intercept = sum(r['intercept'] for r in all_results) / n
|
||||
avg_c1 = sum(r['c1'] for r in all_results) / n
|
||||
|
||||
# 构建上传数据
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
individual_runs = []
|
||||
for i, r in enumerate(all_results):
|
||||
individual_runs.append({
|
||||
"run": i + 1,
|
||||
"volume_L": r['volume_L'],
|
||||
"slope": r['slope'],
|
||||
"intercept": r['intercept'],
|
||||
"c1": r['c1'],
|
||||
"valid_points": r['valid_points'],
|
||||
"record_time": r.get('record_time', []),
|
||||
"p_actual": r.get('p_actual', []),
|
||||
})
|
||||
|
||||
full_data = {
|
||||
"num_runs_total": num_runs,
|
||||
"num_runs_successful": n,
|
||||
"averaged": {
|
||||
"volume_L": avg_vol,
|
||||
"slope": avg_slope,
|
||||
"intercept": avg_intercept,
|
||||
"c1": avg_c1,
|
||||
},
|
||||
"individual_runs": individual_runs,
|
||||
"q_in_slm": all_results[0]['payload_data'].get('q_in_slm'),
|
||||
"T_delta": T_delta,
|
||||
}
|
||||
json_str = json.dumps(full_data, indent=2, ensure_ascii=False)
|
||||
filename = f"volume_test_avg{avg_vol:.2f}L_{timestamp}.json"
|
||||
|
||||
if self._upload_to_cos(json_str, filename, f"{the_folder}/V_config"):
|
||||
self.log("体积测量数据上传成功")
|
||||
self.log(f"成功:{n}/{num_runs},平均等效体积 V = {avg_vol:.4f} L")
|
||||
|
||||
else:
|
||||
self.log("体积测量数据上传失败")
|
||||
|
||||
if self._on_volume_result:
|
||||
self._on_volume_result(avg_vol)
|
||||
else:
|
||||
self.log("所有测量均失败:有效数据点不足,无法计算体积")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"容积测量详细错误: {traceback.format_exc()}")
|
||||
self.log(f"容积测量失败: {e}")
|
||||
finally:
|
||||
self._identifying = False
|
||||
# self.log("测量结束")
|
||||
|
||||
thread = threading.Thread(target=volume_thread, daemon=True)
|
||||
self._task_thread = thread
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
"""停止当前辨识/测量任务"""
|
||||
self._identifying = False
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Download CSV and validate the nine PRBS identification parameters."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import math
|
||||
|
||||
|
||||
REQUIRED_FIELDS = {
|
||||
"q_in_val", "dt", "n_order", "t_c", "levels",
|
||||
"dead_area", "xa_full", "V_val", "repeat",
|
||||
}
|
||||
|
||||
|
||||
def validate_identification_config(config) -> dict:
|
||||
"""Validate a parsed config mapping and normalize numeric values."""
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError("辨识配置必须是参数映射")
|
||||
actual = set(config)
|
||||
if actual != REQUIRED_FIELDS:
|
||||
missing = sorted(REQUIRED_FIELDS - actual)
|
||||
extra = sorted(actual - REQUIRED_FIELDS)
|
||||
raise ValueError(f"辨识配置字段错误,缺少={missing},多余={extra}")
|
||||
|
||||
scalar_fields = {
|
||||
"q_in_val", "dt", "t_c", "dead_area", "xa_full", "V_val"
|
||||
}
|
||||
for field in scalar_fields:
|
||||
value = config[field]
|
||||
if (isinstance(value, bool) or not isinstance(value, (int, float))
|
||||
or not math.isfinite(float(value))):
|
||||
raise ValueError(f"辨识参数 {field} 必须是有限数字")
|
||||
|
||||
for field in ("n_order", "repeat"):
|
||||
value = config[field]
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"辨识参数 {field} 必须是整数")
|
||||
|
||||
levels = config["levels"]
|
||||
if not isinstance(levels, list) or len(levels) < 2:
|
||||
raise ValueError("levels 必须是至少包含 2 项的数组")
|
||||
if len(levels) & (len(levels) - 1):
|
||||
raise ValueError("levels 长度必须是 2 的整数次幂")
|
||||
normalized_levels = []
|
||||
for value in levels:
|
||||
if (isinstance(value, bool) or not isinstance(value, (int, float))
|
||||
or not math.isfinite(float(value)) or not 0 <= value <= 100):
|
||||
raise ValueError("levels 中的开度必须是 0 到 100 的有限数字")
|
||||
normalized_levels.append(float(value))
|
||||
|
||||
if config["q_in_val"] < 0:
|
||||
raise ValueError("q_in_val 不能小于 0")
|
||||
if config["dt"] <= 0 or config["t_c"] <= 0:
|
||||
raise ValueError("dt 和 t_c 必须大于 0")
|
||||
if config["t_c"] < config["dt"]:
|
||||
raise ValueError("t_c 必须大于等于 dt,确保每个码元至少采样一次")
|
||||
if config["n_order"] < 2:
|
||||
raise ValueError("n_order 必须大于等于 2")
|
||||
if config["repeat"] <= 0:
|
||||
raise ValueError("repeat 必须是正整数")
|
||||
if config["dead_area"] < 0 or config["xa_full"] <= config["dead_area"]:
|
||||
raise ValueError("必须满足 0 <= dead_area < xa_full")
|
||||
if config["xa_full"] < 1000:
|
||||
raise ValueError("xa_full 不能小于前置行程扫描上限 1000")
|
||||
if config["V_val"] <= 0:
|
||||
raise ValueError("V_val 必须大于 0")
|
||||
|
||||
return {
|
||||
"q_in_val": float(config["q_in_val"]),
|
||||
"dt": float(config["dt"]),
|
||||
"n_order": config["n_order"],
|
||||
"t_c": float(config["t_c"]),
|
||||
"levels": normalized_levels,
|
||||
"dead_area": float(config["dead_area"]),
|
||||
"xa_full": float(config["xa_full"]),
|
||||
"V_val": float(config["V_val"]),
|
||||
"repeat": config["repeat"],
|
||||
}
|
||||
|
||||
|
||||
def parse_identification_config_csv(csv_text: str) -> dict:
|
||||
"""Parse a two-column CSV into the validated identification config.
|
||||
|
||||
The CSV must use ``parameter,value`` as its header. ``levels`` is one
|
||||
quoted comma-separated value, for example ``"10,20,30,40"``.
|
||||
"""
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(csv_text))
|
||||
fieldnames = [name.strip() for name in (reader.fieldnames or [])]
|
||||
if fieldnames != ["parameter", "value"]:
|
||||
raise ValueError("CSV 表头必须为 parameter,value")
|
||||
|
||||
raw = {}
|
||||
for row in reader:
|
||||
if None in row:
|
||||
raise ValueError("CSV 每行只能包含 parameter 和 value 两列")
|
||||
parameter = (row.get("parameter") or "").strip()
|
||||
value = (row.get("value") or "").strip()
|
||||
if not parameter:
|
||||
raise ValueError("CSV 存在空参数名")
|
||||
if parameter in raw:
|
||||
raise ValueError(f"CSV 参数重复: {parameter}")
|
||||
raw[parameter] = value
|
||||
except csv.Error as exc:
|
||||
raise ValueError(f"辨识配置 CSV 格式错误: {exc}") from exc
|
||||
|
||||
actual = set(raw)
|
||||
if actual != REQUIRED_FIELDS:
|
||||
missing = sorted(REQUIRED_FIELDS - actual)
|
||||
extra = sorted(actual - REQUIRED_FIELDS)
|
||||
raise ValueError(f"辨识配置字段错误,缺少={missing},多余={extra}")
|
||||
|
||||
try:
|
||||
levels = [float(value.strip()) for value in raw["levels"].split(",")]
|
||||
config = {
|
||||
"q_in_val": float(raw["q_in_val"]),
|
||||
"dt": float(raw["dt"]),
|
||||
"n_order": int(raw["n_order"]),
|
||||
"t_c": float(raw["t_c"]),
|
||||
"levels": levels,
|
||||
"dead_area": float(raw["dead_area"]),
|
||||
"xa_full": float(raw["xa_full"]),
|
||||
"V_val": float(raw["V_val"]),
|
||||
"repeat": int(raw["repeat"]),
|
||||
}
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"辨识配置 CSV 参数值无效: {exc}") from exc
|
||||
return validate_identification_config(config)
|
||||
|
||||
|
||||
def download_identification_config(timeout=20) -> dict:
|
||||
"""Download the current customer's CSV config through the cloud function."""
|
||||
import requests
|
||||
from api import data_record_url, the_folder
|
||||
|
||||
try:
|
||||
response = requests.post(data_record_url, json={
|
||||
"type": "getIdentificationConfig",
|
||||
"deviceId": the_folder,
|
||||
}, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except Exception as exc:
|
||||
raise ValueError(f"连接云端辨识配置服务失败: {exc}") from exc
|
||||
|
||||
if not result.get("success"):
|
||||
raise ValueError(result.get("errMsg", "云端未返回辨识配置"))
|
||||
try:
|
||||
config_response = requests.get(result["url"], timeout=timeout)
|
||||
config_response.raise_for_status()
|
||||
return parse_identification_config_csv(config_response.text)
|
||||
except (KeyError, ValueError, requests.RequestException) as exc:
|
||||
raise ValueError(f"下载或解析辨识配置 CSV 失败: {exc}") from exc
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Register identification CSV results and poll the server for 0/1 review."""
|
||||
|
||||
|
||||
def _post(payload, timeout=10):
|
||||
import requests
|
||||
from api import data_record_url
|
||||
|
||||
try:
|
||||
response = requests.post(data_record_url, json=payload, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except Exception as exc:
|
||||
raise ValueError(f"辨识反馈服务请求失败: {exc}") from exc
|
||||
if not result.get("success"):
|
||||
raise ValueError(result.get("errMsg", "辨识反馈服务拒绝请求"))
|
||||
return result
|
||||
|
||||
|
||||
def register_identification_result(run_id: str, timeout=10) -> None:
|
||||
"""Register one uploaded CSV as the customer's current review target."""
|
||||
from api import the_folder
|
||||
|
||||
if not run_id:
|
||||
raise ValueError("辨识结果缺少 run_id")
|
||||
_post({
|
||||
"type": "registerIdentificationResult",
|
||||
"deviceId": the_folder,
|
||||
"runId": run_id,
|
||||
"fileName": run_id,
|
||||
}, timeout=timeout)
|
||||
|
||||
|
||||
def get_identification_feedback(run_id: str, timeout=10):
|
||||
"""Return None while pending, otherwise return the integer 0 or 1."""
|
||||
from api import the_folder
|
||||
|
||||
result = _post({
|
||||
"type": "getIdentificationFeedback",
|
||||
"deviceId": the_folder,
|
||||
"runId": run_id,
|
||||
}, timeout=timeout)
|
||||
if not result.get("ready"):
|
||||
return None
|
||||
feedback = result.get("result")
|
||||
if isinstance(feedback, bool) or feedback not in (0, 1):
|
||||
raise ValueError("云端辨识反馈必须是数字 0 或 1")
|
||||
return int(feedback)
|
||||
|
||||
|
||||
def acknowledge_identification_feedback(run_id: str, timeout=10) -> None:
|
||||
"""Delete the consumed review record so stale feedback cannot be reused."""
|
||||
from api import the_folder
|
||||
|
||||
_post({
|
||||
"type": "ackIdentificationFeedback",
|
||||
"deviceId": the_folder,
|
||||
"runId": run_id,
|
||||
}, timeout=timeout)
|
||||
@@ -0,0 +1,139 @@
|
||||
# model_manager.py
|
||||
"""RL 模型管理器:从云端扫描和加载强化学习模型。
|
||||
|
||||
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
||||
"""
|
||||
|
||||
import threading
|
||||
import io
|
||||
import requests
|
||||
import torch
|
||||
from stable_baselines3 import SAC
|
||||
|
||||
# 关键:禁用 PyTorch 内部多线程,防止在 PyInstaller daemon 线程中 segfault
|
||||
torch.set_num_threads(1)
|
||||
|
||||
from api import base_url, data_record_url, the_folder
|
||||
|
||||
|
||||
class ModelManager:
|
||||
"""管理 RL 模型的云端扫描与加载"""
|
||||
|
||||
def __init__(self):
|
||||
self.model_file_map = {} # 文件名 → fileID 映射
|
||||
self.rl_model = None # 加载的 SAC 模型实例
|
||||
self._on_log = None
|
||||
self._on_models_loaded = None
|
||||
self._on_load_complete = None
|
||||
|
||||
# ---- 回调设置 ----
|
||||
def set_log_callback(self, callback):
|
||||
"""设置日志回调: callback(message: str)"""
|
||||
self._on_log = callback
|
||||
|
||||
def set_models_loaded_callback(self, callback):
|
||||
"""设置模型列表加载完成回调: callback(file_names: list)"""
|
||||
self._on_models_loaded = callback
|
||||
|
||||
def set_load_complete_callback(self, callback):
|
||||
"""设置模型加载完成回调: callback(success: bool, message: str)"""
|
||||
self._on_load_complete = callback
|
||||
|
||||
def log(self, message):
|
||||
if self._on_log:
|
||||
self._on_log(message)
|
||||
|
||||
# ---- 模型扫描 ----
|
||||
def scan_models(self):
|
||||
"""异步扫描云端模型文件夹,完成后回调通知"""
|
||||
|
||||
def fetch_models():
|
||||
try:
|
||||
payload = {"type": "listModels", "folder": f"{the_folder}/model_config"}
|
||||
resp = requests.post(data_record_url, json=payload, timeout=10)
|
||||
result = resp.json()
|
||||
|
||||
if result.get("success"):
|
||||
files = result.get("files", [])
|
||||
file_list = result.get("fileList", [])
|
||||
|
||||
self.model_file_map = {
|
||||
item.get("fileName"): item.get("fileID")
|
||||
for item in file_list if item.get("fileName")
|
||||
}
|
||||
|
||||
self.log("模型列表刷新成功")
|
||||
|
||||
if self._on_models_loaded:
|
||||
self._on_models_loaded(files)
|
||||
else:
|
||||
err = result.get('errMsg', '未知错误')
|
||||
self.log(f"获取模型列表失败: {err}")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"扫描模型异常: {str(e)}")
|
||||
|
||||
threading.Thread(target=fetch_models, daemon=True).start()
|
||||
|
||||
# ---- 模型加载 ----
|
||||
def load_model(self, model_name: str):
|
||||
"""异步从云端加载指定的 RL 模型
|
||||
|
||||
Args:
|
||||
model_name: 模型文件名
|
||||
"""
|
||||
|
||||
if not model_name or model_name == "无模型文件":
|
||||
self.log("错误:请先选择一个有效的模型")
|
||||
return
|
||||
|
||||
def download_and_load():
|
||||
try:
|
||||
file_id = self.model_file_map.get(model_name)
|
||||
if not file_id:
|
||||
self.log("模型加载失败: 缺少 fileID,请先刷新模型列表")
|
||||
return
|
||||
|
||||
self.log(f"正在加载模型: {model_name}...")
|
||||
|
||||
# 获取临时下载 URL
|
||||
payload = {"type": "downloadModel", "fileID": file_id}
|
||||
resp = requests.post(data_record_url, json=payload, timeout=15)
|
||||
result = resp.json()
|
||||
|
||||
if not result.get("success"):
|
||||
err = result.get('errMsg', '未知错误')
|
||||
self.log(f"模型加载异常: {err}")
|
||||
return
|
||||
|
||||
url = result['url']
|
||||
|
||||
# 下载模型文件
|
||||
model_resp = requests.get(url, timeout=30)
|
||||
if model_resp.status_code != 200:
|
||||
self.log(f"模型加载异常: HTTP {model_resp.status_code}")
|
||||
return
|
||||
|
||||
model_bytes = model_resp.content
|
||||
|
||||
# 直接加载到内存
|
||||
model_stream = io.BytesIO(model_bytes)
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
self.rl_model = SAC.load(model_stream, device=device)
|
||||
|
||||
self.log(f"成功加载模型: {model_name}")
|
||||
|
||||
if self._on_load_complete:
|
||||
self._on_load_complete(True, f"成功加载模型: {model_name}")
|
||||
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
self.log(f"加载模型失败: {msg}")
|
||||
if self._on_load_complete:
|
||||
self._on_load_complete(False, f"加载失败: {msg}")
|
||||
|
||||
threading.Thread(target=download_and_load, daemon=True).start()
|
||||
|
||||
def is_model_loaded(self) -> bool:
|
||||
"""检查模型是否已加载"""
|
||||
return self.rl_model is not None
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Load, download, and validate the volume-measurement configuration."""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
REQUIRED_FIELDS = {
|
||||
"q_in_val", "dt", "p_max", "fit_low", "fit_high",
|
||||
"T_delta", "xa_full", "num_runs",
|
||||
}
|
||||
|
||||
|
||||
def default_config_path() -> Path:
|
||||
"""Return the config path without exposing a file picker in the GUI."""
|
||||
override = os.environ.get("REINLOOP_VOLUME_CONFIG")
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
base_dir = (Path(sys.executable).resolve().parent
|
||||
if getattr(sys, "frozen", False)
|
||||
else Path(__file__).resolve().parent.parent)
|
||||
return base_dir / "config" / "volume_measurement.json"
|
||||
|
||||
|
||||
def load_volume_config(path=None) -> dict:
|
||||
"""Read exactly eight validated parameters from a JSON object."""
|
||||
config_path = Path(path).resolve() if path else default_config_path()
|
||||
try:
|
||||
with config_path.open("r", encoding="utf-8") as file_obj:
|
||||
config = json.load(file_obj)
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(f"容积测试配置文件不存在: {config_path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"容积测试配置不是有效 JSON: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise ValueError(f"无法读取容积测试配置: {exc}") from exc
|
||||
|
||||
return validate_volume_config(config)
|
||||
|
||||
|
||||
def validate_volume_config(config) -> dict:
|
||||
"""Validate exactly eight parameters and normalize numeric values."""
|
||||
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError("容积测试配置必须是 JSON 对象")
|
||||
actual_fields = set(config)
|
||||
if actual_fields != REQUIRED_FIELDS:
|
||||
missing = sorted(REQUIRED_FIELDS - actual_fields)
|
||||
extra = sorted(actual_fields - REQUIRED_FIELDS)
|
||||
raise ValueError(f"配置字段错误,缺少={missing},多余={extra}")
|
||||
|
||||
for field in REQUIRED_FIELDS - {"num_runs"}:
|
||||
value = config[field]
|
||||
if (isinstance(value, bool) or not isinstance(value, (int, float))
|
||||
or not math.isfinite(float(value))):
|
||||
raise ValueError(f"参数 {field} 必须是有限数字")
|
||||
|
||||
runs = config["num_runs"]
|
||||
if isinstance(runs, bool) or not isinstance(runs, int) or runs <= 0:
|
||||
raise ValueError("参数 num_runs 必须是正整数")
|
||||
if config["q_in_val"] <= 0:
|
||||
raise ValueError("q_in_val 必须大于 0")
|
||||
if config["dt"] <= 0 or config["p_max"] <= 0:
|
||||
raise ValueError("dt 和 p_max 必须大于 0")
|
||||
if config["fit_low"] < 0 or config["fit_high"] <= config["fit_low"]:
|
||||
raise ValueError("必须满足 0 <= fit_low < fit_high")
|
||||
if config["fit_high"] > config["p_max"]:
|
||||
raise ValueError("fit_high 不能大于 p_max")
|
||||
if config["xa_full"] <= 0:
|
||||
raise ValueError("xa_full 必须大于 0")
|
||||
|
||||
return {
|
||||
"q_in_val": float(config["q_in_val"]),
|
||||
"dt": float(config["dt"]),
|
||||
"p_max": float(config["p_max"]),
|
||||
"fit_low": float(config["fit_low"]),
|
||||
"fit_high": float(config["fit_high"]),
|
||||
"T_delta": float(config["T_delta"]),
|
||||
"xa_full": float(config["xa_full"]),
|
||||
"num_runs": runs,
|
||||
}
|
||||
|
||||
|
||||
def _post_volume_request(payload, timeout=10):
|
||||
import requests
|
||||
from api import data_record_url
|
||||
|
||||
try:
|
||||
response = requests.post(data_record_url, json=payload, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except Exception as exc:
|
||||
raise ValueError(f"连接云端容积参数服务失败: {exc}") from exc
|
||||
|
||||
if not result.get("success"):
|
||||
raise ValueError(result.get("errMsg", "云端拒绝容积参数请求"))
|
||||
return result
|
||||
|
||||
|
||||
def create_volume_config_request(timeout=10) -> dict:
|
||||
"""Create exactly one cloud request after the customer clicks Test."""
|
||||
from api import the_folder
|
||||
|
||||
result = _post_volume_request({
|
||||
"type": "createVolumeConfigRequest",
|
||||
"deviceId": the_folder,
|
||||
}, timeout=timeout)
|
||||
if not result.get("requestId") or not result.get("expiresAtMs"):
|
||||
raise ValueError("云端未返回有效的容积参数请求编号")
|
||||
return {
|
||||
"request_id": result["requestId"],
|
||||
"expires_at_ms": int(result["expiresAtMs"]),
|
||||
}
|
||||
|
||||
|
||||
def poll_volume_config_request(request_id: str, timeout=10) -> dict:
|
||||
"""Poll one request; download and validate JSON only when it is ready."""
|
||||
import requests
|
||||
from api import the_folder
|
||||
|
||||
result = _post_volume_request({
|
||||
"type": "getVolumeConfigRequest",
|
||||
"deviceId": the_folder,
|
||||
"requestId": request_id,
|
||||
}, timeout=timeout)
|
||||
if result.get("expired"):
|
||||
return {"ready": False, "expired": True}
|
||||
if not result.get("ready"):
|
||||
return {"ready": False, "expired": False}
|
||||
|
||||
try:
|
||||
response = requests.get(result["url"], timeout=timeout)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
except Exception as exc:
|
||||
raise ValueError(f"下载或解析容积参数 JSON 失败: {exc}") from exc
|
||||
return {
|
||||
"ready": True,
|
||||
"expired": False,
|
||||
"config": validate_volume_config(config),
|
||||
}
|
||||
|
||||
|
||||
def acknowledge_volume_config_request(request_id: str, timeout=10) -> None:
|
||||
"""Delete the consumed/abandoned request and its temporary JSON file."""
|
||||
from api import the_folder
|
||||
|
||||
_post_volume_request({
|
||||
"type": "ackVolumeConfigRequest",
|
||||
"deviceId": the_folder,
|
||||
"requestId": request_id,
|
||||
}, timeout=timeout)
|
||||
Reference in New Issue
Block a user