完善流量控制 GUI 的开环扫点与手动操作流程

新增可配置开环扫点、多 CSV 前馈表拟合及结果文件生成;补充闭环停止、手动开度安全互锁和 PID 未启用状态显示,并同步更新自动化测试、使用文档、打包配置与 Windows 可执行程序。
This commit is contained in:
2026-09-04 16:47:16 +08:00
parent ead625bf2d
commit c1a4a2a229
23 changed files with 36300 additions and 7959 deletions
+605 -5
View File
@@ -19,13 +19,31 @@ import time
import config
from controllers import IncrementalPID
from flow_control import FlowControlFault, FlowControlLoop
from flow_control import FlowControlFault, FlowControlLoop, opening_to_motor_position
from open_loop import (
MAX_CONSECUTIVE_READ_FAILURES,
MIN_ABS_STD_SLM,
OPENING_MAX_PCT,
OPENING_MIN_PCT,
OPENING_STEP_PCT,
OUTPUT_DIRECTORY as OPEN_LOOP_OUTPUT_DIRECTORY,
RANDOM_SEED,
SAMPLE_PERIOD_S,
OpenLoopRecorder,
SteadyStateDetector,
check_safety,
generate_fine_strokes,
generate_openings,
motor_position_to_opening,
read_sensors,
)
from valve_model import ValveModel
APP_NAME = "FlowControl"
BUILTIN_MODEL_NAME = "valve_model_004.json"
MONITOR_PERIOD_S = 0.25
MAX_GUI_OPEN_LOOP_POINTS = 2000
@dataclass(frozen=True)
@@ -49,6 +67,40 @@ class ModelInfo:
is_monotonic: bool
@dataclass(frozen=True)
class OpenLoopSettings:
"""GUI 开环扫点参数。"""
min_opening_pct: float = OPENING_MIN_PCT
max_opening_pct: float = OPENING_MAX_PCT
opening_step_pct: float = OPENING_STEP_PCT
random_seed: int | None = RANDOM_SEED
sample_period_s: float = SAMPLE_PERIOD_S
steady_window_s: float = config.STEADY_STATE_WINDOW_S
steady_std_pct: float = config.STEADY_STATE_STD_PCT
max_wait_s: float = config.STEADY_STATE_MAX_WAIT_S
min_abs_std_slm: float = MIN_ABS_STD_SLM
include_fine_scan: bool = True
fine_stroke_start: float = config.OPEN_LOOP_FINE_STROKE_START
fine_stroke_step: float = config.OPEN_LOOP_FINE_STROKE_STEP
@dataclass
class _OpenLoopSession:
"""仅由后台线程访问的开环运行状态。"""
settings: OpenLoopSettings
points: list
recorder: OpenLoopRecorder
detector: SteadyStateDetector
run_start: float
step_index: int
step_start: float
opening_pct: float
motor_position: float
read_failures: int = 0
def application_directory() -> Path:
"""返回日志等可写文件的程序目录。"""
if getattr(sys, "frozen", False):
@@ -129,6 +181,158 @@ def validate_target(target) -> float:
return value
def validate_opening(opening) -> float:
"""解析并校验 GUI 手动开度。"""
try:
value = float(str(opening).strip())
except (TypeError, ValueError) as exc:
raise ValueError("手动开度必须是数字") from exc
if not math.isfinite(value):
raise ValueError("手动开度必须是有限数值")
if not 0.0 <= value <= 100.0:
raise ValueError("手动开度必须位于 0100%")
return value
def _finite_float(value, label) -> float:
try:
number = float(str(value).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"{label}必须是数字") from exc
if not math.isfinite(number):
raise ValueError(f"{label}必须是有限数值")
return number
def validate_open_loop_settings(
*,
min_opening_pct=OPENING_MIN_PCT,
max_opening_pct=OPENING_MAX_PCT,
opening_step_pct=OPENING_STEP_PCT,
random_seed=RANDOM_SEED,
sample_period_s=SAMPLE_PERIOD_S,
steady_window_s=config.STEADY_STATE_WINDOW_S,
steady_std_pct=config.STEADY_STATE_STD_PCT,
max_wait_s=config.STEADY_STATE_MAX_WAIT_S,
min_abs_std_slm=MIN_ABS_STD_SLM,
include_fine_scan=True,
fine_stroke_start=config.OPEN_LOOP_FINE_STROKE_START,
fine_stroke_step=config.OPEN_LOOP_FINE_STROKE_STEP,
) -> OpenLoopSettings:
"""解析弹窗参数并拒绝无法执行或规模异常的扫点配置。"""
min_opening = _finite_float(min_opening_pct, "起始开度")
max_opening = _finite_float(max_opening_pct, "结束开度")
opening_step = _finite_float(opening_step_pct, "开度间隔")
sample_period = _finite_float(sample_period_s, "采样周期")
steady_window = _finite_float(steady_window_s, "稳态窗口")
steady_std = _finite_float(steady_std_pct, "相对标准差阈值")
max_wait = _finite_float(max_wait_s, "单点最长等待时间")
min_abs_std = _finite_float(min_abs_std_slm, "绝对标准差下限")
fine_start = _finite_float(fine_stroke_start, "精扫起始行程")
fine_step = _finite_float(fine_stroke_step, "精扫行程间隔")
if not 0.0 <= min_opening < max_opening <= 100.0:
raise ValueError("开度范围必须满足 0 ≤ 起始开度 < 结束开度 ≤ 100")
if opening_step <= 0.0:
raise ValueError("开度间隔必须大于 0")
if sample_period <= 0.0:
raise ValueError("采样周期必须大于 0")
if steady_window <= sample_period:
raise ValueError("稳态窗口必须大于采样周期")
if steady_std < 0.0 or min_abs_std < 0.0:
raise ValueError("稳态标准差阈值不得为负数")
if max_wait < steady_window:
raise ValueError("单点最长等待时间不得小于稳态窗口")
seed_text = "" if random_seed is None else str(random_seed).strip()
if seed_text == "":
seed = None
else:
try:
seed = int(seed_text)
except (TypeError, ValueError) as exc:
raise ValueError("随机种子必须是整数或留空") from exc
include_fine = bool(include_fine_scan)
if include_fine:
if not config.MOTOR_OPEN_POSITION <= fine_start < config.MOTOR_CLOSED_POSITION:
raise ValueError(
"精扫起始行程必须位于 "
f"{config.MOTOR_OPEN_POSITION:g}{config.MOTOR_CLOSED_POSITION:g}"
)
if fine_step <= 0.0:
raise ValueError("精扫行程间隔必须大于 0")
settings = OpenLoopSettings(
min_opening_pct=min_opening,
max_opening_pct=max_opening,
opening_step_pct=opening_step,
random_seed=seed,
sample_period_s=sample_period,
steady_window_s=steady_window,
steady_std_pct=steady_std,
max_wait_s=max_wait,
min_abs_std_slm=min_abs_std,
include_fine_scan=include_fine,
fine_stroke_start=fine_start,
fine_stroke_step=fine_step,
)
point_count = len(build_open_loop_points(settings))
if point_count > MAX_GUI_OPEN_LOOP_POINTS:
raise ValueError(
f"扫描点数 {point_count} 超过上限 {MAX_GUI_OPEN_LOOP_POINTS}"
"请增大开度或精扫间隔"
)
return settings
def build_open_loop_points(settings: OpenLoopSettings):
"""按 GUI 参数生成随机开度点,并可选追加闭阀端精细行程点。"""
openings = generate_openings(
settings.min_opening_pct,
settings.max_opening_pct,
settings.opening_step_pct,
settings.random_seed,
)
points = [
(
opening,
opening_to_motor_position(
opening,
config.MOTOR_OPEN_POSITION,
config.MOTOR_CLOSED_POSITION,
),
)
for opening in openings
]
if settings.include_fine_scan:
fine_strokes = generate_fine_strokes(
settings.fine_stroke_start,
config.MOTOR_CLOSED_POSITION,
settings.fine_stroke_step,
)
points.extend(
(
motor_position_to_opening(
stroke,
config.MOTOR_OPEN_POSITION,
config.MOTOR_CLOSED_POSITION,
),
stroke,
)
for stroke in fine_strokes
)
return points
def create_open_loop_recorder():
"""创建写到程序目录的开环 CSV/PNG 记录器。"""
return OpenLoopRecorder(
application_directory() / OPEN_LOOP_OUTPUT_DIRECTORY,
plot_dpi=config.PLOT_DPI,
)
def load_model(model_path: str | Path | None = None) -> ModelInfo:
"""加载内置或外部阀模型,并检查其是否匹配当前行程配置。"""
is_builtin = model_path is None
@@ -280,11 +484,13 @@ class ControlWorker:
hardware_factory=create_hardware,
model_loader=load_model,
controller_factory=build_control_loop,
open_loop_recorder_factory=create_open_loop_recorder,
):
self.logger = logger
self.hardware_factory = hardware_factory
self.model_loader = model_loader
self.controller_factory = controller_factory
self.open_loop_recorder_factory = open_loop_recorder_factory
self.commands = Queue()
self.events = Queue()
self.thread = threading.Thread(
@@ -296,6 +502,10 @@ class ControlWorker:
self._state = "DISCONNECTED"
self._hardware = None
self._controller = None
self._open_loop = None
self._monitor_opening_pct = 100.0
self._monitor_motor_position = config.MOTOR_OPEN_POSITION
self._monitor_mode = "未启用"
self._next_cycle = 0.0
self._monitor_failures = {
"FLOW": 0,
@@ -319,9 +529,21 @@ class ControlWorker:
def request_target(self, target):
self.commands.put(("SET_TARGET", {"target": target}))
def request_stop_control(self):
self.commands.put(("STOP_CONTROL", {}))
def request_manual_opening(self, opening):
self.commands.put(("SET_MANUAL_OPENING", {"opening": opening}))
def request_disconnect(self):
self.commands.put(("DISCONNECT", {}))
def request_open_loop_start(self, settings: OpenLoopSettings):
self.commands.put(("START_OPEN_LOOP", {"settings": settings}))
def request_open_loop_stop(self):
self.commands.put(("STOP_OPEN_LOOP", {}))
def request_shutdown(self):
self.commands.put(("SHUTDOWN", {}))
@@ -356,6 +578,11 @@ class ControlWorker:
elif self._state == "CONTROLLING" and now >= self._next_cycle:
self._control_once(now)
self._advance_deadline(now, config.CONTROL_PERIOD_S)
elif self._state == "OPEN_LOOP" and now >= self._next_cycle:
period = self._open_loop.settings.sample_period_s
self._open_loop_once(now)
if self._state == "OPEN_LOOP":
self._advance_deadline(now, period)
finally:
errors = self._safe_disconnect()
if errors:
@@ -363,7 +590,7 @@ class ControlWorker:
self._emit("shutdown_complete", errors=errors)
def _command_timeout(self):
if self._state in {"MONITORING", "CONTROLLING"}:
if self._state in {"MONITORING", "CONTROLLING", "OPEN_LOOP"}:
return max(0.0, min(0.05, self._next_cycle - time.perf_counter()))
return 0.1
@@ -377,8 +604,16 @@ class ControlWorker:
self._connect(payload["settings"], payload.get("model_path"))
elif command == "SET_TARGET":
self._set_target(payload["target"])
elif command == "STOP_CONTROL":
self._stop_control()
elif command == "SET_MANUAL_OPENING":
self._set_manual_opening(payload["opening"])
elif command == "DISCONNECT":
self._disconnect()
elif command == "START_OPEN_LOOP":
self._start_open_loop(payload["settings"])
elif command == "STOP_OPEN_LOOP":
self._stop_open_loop()
elif command == "SHUTDOWN":
self._set_state("STOPPING", "正在安全退出…")
self._shutdown_requested = True
@@ -409,6 +644,9 @@ class ControlWorker:
model_info.model,
self.logger,
)
self._monitor_opening_pct = 100.0
self._monitor_motor_position = config.MOTOR_OPEN_POSITION
self._monitor_mode = "未启用"
for sensor in self._monitor_failures:
self._monitor_failures[sensor] = 0
self._next_cycle = time.perf_counter()
@@ -451,6 +689,88 @@ class ControlWorker:
except Exception as exc:
self._emit("warning", message=f"目标流量设置失败:{exc}")
def _stop_control(self):
if self._state != "CONTROLLING" or self._controller is None:
self._emit("warning", message="当前没有正在运行的闭环流量控制")
return
try:
self._controller.stop()
if not bool(
self._hardware.set_motor_position(
config.MOTOR_OPEN_POSITION,
channel=config.MOTOR_OUTPUT_ADDR,
)
):
raise FlowControlFault(
"CONTROL_STOP_OPEN_FAILED",
"STOP_CONTROL",
"停止流量控制后设置阀门 100% 开度失败",
)
self._monitor_opening_pct = 100.0
self._monitor_motor_position = config.MOTOR_OPEN_POSITION
self._monitor_mode = "未启用"
self._next_cycle = time.perf_counter()
self.logger.info("闭环流量控制已停止,阀门已设置为 100%% 开度")
self._emit(
"control_stopped",
opening_pct=self._monitor_opening_pct,
motor_position=self._monitor_motor_position,
)
self._set_state("MONITORING", "流量控制已停止,阀门为 100% 开度")
except Exception as exc:
self._handle_runtime_fault(exc)
def _set_manual_opening(self, opening):
if self._state == "CONTROLLING":
self._emit("warning", message="请先停止流量控制,再设置手动开度")
return
if self._state == "OPEN_LOOP":
self._emit("warning", message="请先停止开环扫点,再设置手动开度")
return
if self._state != "MONITORING" or self._hardware is None:
self._emit("warning", message="请先连接设备,再设置手动开度")
return
try:
value = validate_opening(opening)
position = opening_to_motor_position(
value,
config.MOTOR_OPEN_POSITION,
config.MOTOR_CLOSED_POSITION,
)
if not bool(
self._hardware.set_motor_position(
position,
channel=config.MOTOR_OUTPUT_ADDR,
)
):
raise FlowControlFault(
"MANUAL_OPENING_WRITE_FAILED",
"SET_MANUAL_OPENING",
f"手动开度 {value:.3f}% 写入失败",
)
self._monitor_opening_pct = value
self._monitor_motor_position = position
self._monitor_mode = "未启用"
self._next_cycle = time.perf_counter()
self.logger.info(
"手动开度已设置:%.3f%%,电机行程 %.3f",
value,
position,
)
self._emit(
"manual_opening_applied",
opening_pct=value,
motor_position=position,
)
self._set_state(
"MONITORING",
f"手动开度 {value:g}% 已生效,设备继续监测",
)
except ValueError as exc:
self._emit("warning", message=str(exc))
except Exception as exc:
self._handle_runtime_fault(exc)
def _disconnect(self):
if self._state == "DISCONNECTED":
return
@@ -465,6 +785,275 @@ class ControlWorker:
self.logger.info("设备已安全断开")
self._set_state("DISCONNECTED", "设备已断开")
def _start_open_loop(self, settings):
if self._state not in {"MONITORING", "CONTROLLING"}:
self._emit("warning", message="请先连接设备,再启动开环扫点")
return
if not isinstance(settings, OpenLoopSettings):
self._emit("warning", message="开环扫点参数类型无效")
return
try:
points = build_open_loop_points(settings)
if not points:
raise ValueError("没有生成任何扫描点")
if self._controller is not None and self._controller.running:
self._controller.stop()
if not bool(
self._hardware.set_motor_position(
config.MOTOR_OPEN_POSITION,
channel=config.MOTOR_OUTPUT_ADDR,
)
):
raise FlowControlFault(
"OPEN_LOOP_INITIAL_OPEN_FAILED",
"OPEN_LOOP_START",
"开环扫点前设置阀门 100% 开度失败",
)
recorder = self.open_loop_recorder_factory()
detector = SteadyStateDetector(
settings.steady_window_s,
settings.steady_std_pct,
sample_period_s=settings.sample_period_s,
min_abs_std_slm=settings.min_abs_std_slm,
)
now = time.perf_counter()
opening, position = points[0]
self._open_loop = _OpenLoopSession(
settings=settings,
points=points,
recorder=recorder,
detector=detector,
run_start=now,
step_index=0,
step_start=now,
opening_pct=opening,
motor_position=position,
)
if not bool(
self._hardware.set_motor_position(
position,
channel=config.MOTOR_OUTPUT_ADDR,
)
):
raise FlowControlFault(
"OPEN_LOOP_MOTOR_COMMAND_FAILED",
"OPEN_LOOP_START",
f"写入首个扫描点失败:开度 {opening:.3f}% / 行程 {position:.3f}",
)
self._next_cycle = now
self.logger.info(
"开环扫点启动:共 %d 点,开度 %.3f%%%.3f%%,间隔 %.3f%%"
"采样周期 %.3f s,单点最长 %.3f sCSV=%s",
len(points),
settings.min_opening_pct,
settings.max_opening_pct,
settings.opening_step_pct,
settings.sample_period_s,
settings.max_wait_s,
recorder.csv_path,
)
self._set_state(
"OPEN_LOOP",
f"开环扫点 1/{len(points)}:开度 {opening:.2f}% / 行程 {position:.1f}",
)
self._emit(
"open_loop_started",
total_steps=len(points),
csv_path=str(recorder.csv_path),
)
except Exception as exc:
self._handle_runtime_fault(exc)
def _stop_open_loop(self):
if self._state != "OPEN_LOOP" or self._open_loop is None:
self._emit("warning", message="当前没有正在运行的开环扫点")
return
try:
self._finish_open_loop(completed=False, reason="用户停止")
except Exception as exc:
self._handle_runtime_fault(exc)
def _open_loop_once(self, now):
session = self._open_loop
if session is None:
self._handle_runtime_fault(RuntimeError("开环扫点状态丢失"))
return
try:
pressure_before, flow_before, pressure_after, flow_after = read_sensors(
self._hardware
)
elapsed_s = now - session.run_start
session.recorder.record(
time_s=elapsed_s,
step_index=session.step_index,
opening_pct=session.opening_pct,
motor_position=session.motor_position,
flow_before_slm=flow_before,
flow_after_slm=flow_after,
pressure_before_kpa=pressure_before,
pressure_after_kpa=pressure_after,
)
fault = check_safety(
pressure_before,
flow_before,
pressure_after,
flow_after,
)
if fault is not None:
raise FlowControlFault(
"OPEN_LOOP_SAFETY_LIMIT",
"OPEN_LOOP_SAMPLE",
f"开环扫点安全阈值触发:{fault}",
)
if flow_after is None:
session.read_failures += 1
else:
session.read_failures = 0
if session.read_failures >= MAX_CONSECUTIVE_READ_FAILURES:
raise FlowControlFault(
"OPEN_LOOP_FLOW_READ_FAILED",
"OPEN_LOOP_SAMPLE",
f"阀后流量连续读取失败 {session.read_failures}",
)
step_elapsed = now - session.step_start
stable = session.detector.observe(elapsed_s, flow_after)
timed_out = step_elapsed >= session.settings.max_wait_s
total_steps = len(session.points)
self._emit(
"telemetry",
measured_flow_slm=flow_after,
pressure_before_kpa=pressure_before,
pressure_kpa=pressure_after,
target_flow_slm=None,
opening_pct=session.opening_pct,
motor_position=session.motor_position,
feedforward_pct=None,
correction_pct=None,
pid_mode="开环",
status="OPEN_LOOP",
)
self._emit(
"open_loop_progress",
current_step=session.step_index + 1,
total_steps=total_steps,
opening_pct=session.opening_pct,
motor_position=session.motor_position,
step_elapsed_s=step_elapsed,
)
if stable or timed_out:
completion = "流量已稳定" if stable else "达到单点最长等待时间"
self.logger.info(
"开环步 %d/%d 完成:开度 %.3f%% / 行程 %.3f%s,耗时 %.3f s",
session.step_index + 1,
total_steps,
session.opening_pct,
session.motor_position,
completion,
step_elapsed,
)
self._advance_open_loop_step(now)
except Exception as exc:
self._handle_runtime_fault(exc)
def _advance_open_loop_step(self, now):
session = self._open_loop
next_index = session.step_index + 1
if next_index >= len(session.points):
self._finish_open_loop(completed=True, reason="扫描完成")
return
opening, position = session.points[next_index]
if not bool(
self._hardware.set_motor_position(
position,
channel=config.MOTOR_OUTPUT_ADDR,
)
):
raise FlowControlFault(
"OPEN_LOOP_MOTOR_COMMAND_FAILED",
"OPEN_LOOP_STEP_CHANGE",
f"写入扫描点失败:开度 {opening:.3f}% / 行程 {position:.3f}",
)
session.step_index = next_index
session.step_start = now
session.opening_pct = opening
session.motor_position = position
session.detector.reset()
self._set_state(
"OPEN_LOOP",
f"开环扫点 {next_index + 1}/{len(session.points)}"
f"开度 {opening:.2f}% / 行程 {position:.1f}",
)
def _finish_open_loop(self, *, completed, reason):
if self._open_loop is None:
return
self._set_state("FINALIZING", "正在恢复 100% 开度并生成开环数据图…")
if not bool(
self._hardware.set_motor_position(
config.MOTOR_OPEN_POSITION,
channel=config.MOTOR_OUTPUT_ADDR,
)
):
raise FlowControlFault(
"OPEN_LOOP_FINAL_OPEN_FAILED",
"OPEN_LOOP_FINALIZE",
"开环扫点结束后设置阀门 100% 开度失败",
)
self._monitor_opening_pct = 100.0
self._monitor_motor_position = config.MOTOR_OPEN_POSITION
self._monitor_mode = "未启用"
errors, payload = self._finalize_open_loop_data(completed, reason)
if payload is not None:
self._emit("open_loop_finished", **payload)
if errors:
self._emit("warning", message="".join(errors))
self._next_cycle = time.perf_counter()
self._set_state("MONITORING", f"开环扫点已结束({reason}),设备保持连接")
def _finalize_open_loop_data(self, completed, reason):
session = self._open_loop
if session is None:
return [], None
self._open_loop = None
recorder = session.recorder
errors = []
image_path = None
try:
image = recorder.finalize()
if image is not None:
image_path = str(image)
except Exception as exc:
try:
recorder.close()
except Exception:
pass
errors.append(f"生成开环曲线图失败,CSV 已保留:{exc}")
self.logger.exception("生成开环曲线图失败")
payload = {
"completed": bool(completed),
"reason": str(reason),
"csv_path": str(recorder.csv_path),
"image_path": image_path,
"sample_count": int(getattr(recorder, "sample_count", 0)),
}
self.logger.info(
"开环扫点结束:%s,采样 %d 条,CSV=%sPNG=%s",
reason,
payload["sample_count"],
payload["csv_path"],
image_path or "未生成",
)
return errors, payload
def _monitor_once(self):
try:
flow = self._read_monitor_value(
@@ -515,11 +1104,11 @@ class ControlWorker:
pressure_before_kpa=pressure_before,
pressure_kpa=pressure_after,
target_flow_slm=None,
opening_pct=100.0,
motor_position=config.MOTOR_OPEN_POSITION,
opening_pct=self._monitor_opening_pct,
motor_position=self._monitor_motor_position,
feedforward_pct=None,
correction_pct=None,
pid_mode="--",
pid_mode=self._monitor_mode,
status=status,
)
except Exception as exc:
@@ -588,6 +1177,9 @@ class ControlWorker:
hardware = self._hardware
self._controller = None
self._hardware = None
self._monitor_opening_pct = 100.0
self._monitor_motor_position = config.MOTOR_OPEN_POSITION
self._monitor_mode = "未启用"
if controller is not None:
try:
@@ -610,4 +1202,12 @@ class ControlWorker:
hardware.disconnect()
except Exception as exc:
errors.append(f"断开 PLC 失败:{exc}")
finalize_errors, payload = self._finalize_open_loop_data(
completed=False,
reason="设备断开或程序退出",
)
errors.extend(finalize_errors)
if payload is not None:
self._emit("open_loop_finished", **payload)
return errors