Files
flow_control/control_runtime.py
T
louis c1a4a2a229 完善流量控制 GUI 的开环扫点与手动操作流程
新增可配置开环扫点、多 CSV 前馈表拟合及结果文件生成;补充闭环停止、手动开度安全互锁和 PID 未启用状态显示,并同步更新自动化测试、使用文档、打包配置与 Windows 可执行程序。
2026-09-04 16:47:16 +08:00

1214 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""桌面 GUI 使用的设备与闭环运行层。
所有 Modbus 访问都在 :class:`ControlWorker` 的后台线程中执行。GUI 主线程只
发送命令并消费事件,避免网络超时阻塞界面。
"""
from __future__ import annotations
from dataclasses import dataclass
import json
import logging
from logging.handlers import RotatingFileHandler
import math
from pathlib import Path
from queue import Empty, Queue
import sys
import threading
import time
import config
from controllers import IncrementalPID
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)
class DeviceSettings:
"""PLC 连接参数。"""
host: str
port: int
slave_id: int
@dataclass(frozen=True)
class ModelInfo:
"""已经校验并加载的阀模型。"""
model: ValveModel
path: Path
display_name: str
point_count: int
is_builtin: bool
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):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
def resource_path(name: str) -> Path:
"""同时支持源码运行和 PyInstaller 单文件资源。"""
bundle_root = getattr(sys, "_MEIPASS", None)
base = Path(bundle_root) if bundle_root else Path(__file__).resolve().parent
direct = base / name
if direct.exists():
return direct
return base / "valve_model" / name
def build_runtime_logger() -> logging.Logger:
"""创建 GUI 运行日志,默认写到 EXE 旁的 logs 目录。"""
log_dir = application_directory() / config.LOG_DIRECTORY
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / config.LOG_FILE_NAME
logger = logging.getLogger("flow_control_gui")
logger.setLevel(logging.INFO)
logger.propagate = False
logger.handlers.clear()
handler = RotatingFileHandler(
log_path,
maxBytes=2 * 1024 * 1024,
backupCount=3,
encoding="utf-8",
)
handler.setFormatter(
logging.Formatter(
"%(asctime)s.%(msecs)03d %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
logger.addHandler(handler)
return logger
def validate_device_settings(host, port, slave_id) -> DeviceSettings:
"""解析并校验 UI 输入的 PLC 连接参数。"""
host_text = str(host).strip()
if not host_text or any(char.isspace() for char in host_text):
raise ValueError("PLC 地址不能为空或包含空格")
try:
port_value = int(str(port).strip())
except (TypeError, ValueError) as exc:
raise ValueError("端口必须是整数") from exc
try:
slave_value = int(str(slave_id).strip())
except (TypeError, ValueError) as exc:
raise ValueError("站号必须是整数") from exc
if not 1 <= port_value <= 65535:
raise ValueError("端口必须位于 165535")
if not 0 <= slave_value <= 247:
raise ValueError("站号必须位于 0247")
return DeviceSettings(host_text, port_value, slave_value)
def validate_target(target) -> float:
"""解析并校验目标流量。"""
try:
value = float(str(target).strip())
except (TypeError, ValueError) as exc:
raise ValueError("目标流量必须是数字") from exc
if not math.isfinite(value):
raise ValueError("目标流量必须是有限数值")
if not config.TARGET_FLOW_MIN_SLM <= value <= config.TARGET_FLOW_MAX_SLM:
raise ValueError(
f"目标流量必须位于 {config.TARGET_FLOW_MIN_SLM:g}"
f"{config.TARGET_FLOW_MAX_SLM:g} SLM"
)
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
path = resource_path(BUILTIN_MODEL_NAME) if is_builtin else Path(model_path)
path = path.expanduser().resolve()
if not path.is_file():
raise ValueError(f"阀模型文件不存在:{path}")
try:
with path.open("r", encoding="utf-8") as file:
raw = json.load(file)
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"无法读取阀模型:{exc}") from exc
try:
declared_open = float(raw["motor_open"])
declared_closed = float(raw["motor_closed"])
area_table = raw["area_table"]
except (KeyError, TypeError, ValueError) as exc:
raise ValueError("阀模型缺少有效的 motor_open、motor_closed 或 area_table") from exc
if not math.isclose(declared_open, config.MOTOR_OPEN_POSITION, abs_tol=1e-9):
raise ValueError(
f"模型打开端行程为 {declared_open:g},当前配置要求 "
f"{config.MOTOR_OPEN_POSITION:g}"
)
if not math.isclose(declared_closed, config.MOTOR_CLOSED_POSITION, abs_tol=1e-9):
raise ValueError(
f"模型关闭端行程为 {declared_closed:g},当前配置要求 "
f"{config.MOTOR_CLOSED_POSITION:g}"
)
if not isinstance(area_table, list) or len(area_table) < 2:
raise ValueError("阀模型 area_table 至少需要两个标定点")
try:
model = ValveModel.load(
str(path),
config.MOTOR_OPEN_POSITION,
config.MOTOR_CLOSED_POSITION,
)
except (OSError, KeyError, TypeError, ValueError) as exc:
raise ValueError(f"阀模型内容无效:{exc}") from exc
for stroke, _area in model.area_table:
if not config.MOTOR_OPEN_POSITION <= stroke <= config.MOTOR_CLOSED_POSITION:
raise ValueError(
f"模型行程 {stroke:g} 超出当前有效范围 "
f"{config.MOTOR_OPEN_POSITION:g}{config.MOTOR_CLOSED_POSITION:g}"
)
prefix = "内置" if is_builtin else "外部"
return ModelInfo(
model=model,
path=path,
display_name=f"{prefix} {path.name}{len(model.area_table)} 点)",
point_count=len(model.area_table),
is_builtin=is_builtin,
is_monotonic=model.is_monotonic,
)
def build_control_loop(hardware, valve_model, logger) -> FlowControlLoop:
"""按 config 构造闭环控制器,不引入命令行记录和绘图依赖。"""
pid = IncrementalPID(
kp=config.PID_FAR_KP,
ki=config.PID_FAR_KI,
kd=config.PID_FAR_KD,
dt=config.CONTROL_PERIOD_S,
out_min=config.OPENING_MIN_PCT,
out_max=config.OPENING_MAX_PCT,
output_rate_limit=config.MAX_OPENING_RATE_FAR_PCT_S,
)
return FlowControlLoop(
hardware=hardware,
pid=pid,
period_s=config.CONTROL_PERIOD_S,
flow_channel=config.FLOW_AFTER_ADDR,
pressure_channel=config.PRESSURE_AFTER_ADDR,
pressure_before_channel=config.PRESSURE_BEFORE_ADDR,
motor_channel=config.MOTOR_OUTPUT_ADDR,
motor_open_position=config.MOTOR_OPEN_POSITION,
motor_closed_position=config.MOTOR_CLOSED_POSITION,
target_min_slm=config.TARGET_FLOW_MIN_SLM,
target_max_slm=config.TARGET_FLOW_MAX_SLM,
zero_flow_threshold_slm=config.ZERO_FLOW_THRESHOLD_SLM,
flow_valid_min_slm=config.FLOW_VALID_MIN_SLM,
flow_valid_max_slm=config.FLOW_VALID_MAX_SLM,
max_pressure_kpa=config.MAX_PRESSURE_KPA,
max_control_dt_s=config.MAX_CONTROL_DT_S,
max_consecutive_flow_failures=config.MAX_CONSECUTIVE_FLOW_FAILURES,
max_consecutive_pressure_failures=config.MAX_CONSECUTIVE_PRESSURE_FAILURES,
pid_far_gains=(config.PID_FAR_KP, config.PID_FAR_KI, config.PID_FAR_KD),
pid_near_gains=(
config.PID_NEAR_KP,
config.PID_NEAR_KI,
config.PID_NEAR_KD,
),
opening_rate_far_pct_s=config.MAX_OPENING_RATE_FAR_PCT_S,
opening_rate_near_pct_s=config.MAX_OPENING_RATE_NEAR_PCT_S,
pid_near_error_min_slm=config.PID_NEAR_ERROR_MIN_SLM,
pid_near_error_max_slm=config.PID_NEAR_ERROR_MAX_SLM,
pid_near_error_target_ratio=config.PID_NEAR_ERROR_TARGET_RATIO,
pid_far_error_min_slm=config.PID_FAR_ERROR_MIN_SLM,
pid_far_error_max_slm=config.PID_FAR_ERROR_MAX_SLM,
pid_far_error_target_ratio=config.PID_FAR_ERROR_TARGET_RATIO,
pid_mode_switch_confirm_cycles=config.PID_MODE_SWITCH_CONFIRM_CYCLES,
valve_model=valve_model,
feedforward_enabled=config.FEEDFORWARD_ENABLED,
feedforward_correction_band_pct=config.FEEDFORWARD_CORRECTION_BAND_PCT,
feedforward_update_error_threshold_slm=(
config.FEEDFORWARD_UPDATE_ERROR_THRESHOLD_SLM
),
feedforward_update_window_s=config.FEEDFORWARD_UPDATE_WINDOW_S,
gain_schedule_enabled=config.GAIN_SCHEDULE_ENABLED,
gain_schedule_ref_abs_kpa=config.GAIN_SCHEDULE_REF_ABS_KPA,
gain_schedule_floor_abs_kpa=config.GAIN_SCHEDULE_FLOOR_ABS_KPA,
gain_schedule_scale_change_threshold=(
config.GAIN_SCHEDULE_SCALE_CHANGE_THRESHOLD
),
logger=logger,
)
def create_hardware(settings: DeviceSettings):
"""延迟导入硬件模块,便于离线测试 GUI 运行层。"""
from PcControl import Easy521ModbusClient
return Easy521ModbusClient(
host=settings.host,
port=settings.port,
slave_id=settings.slave_id,
flow_addr=config.FLOW_AFTER_ADDR,
flow_range=config.FLOW_AFTER_RANGE_SLM,
pressure_before_addr=config.PRESSURE_BEFORE_ADDR,
pressure_before_range=config.PRESSURE_BEFORE_RANGE_KPA,
pressure_after_addr=config.PRESSURE_AFTER_ADDR,
pressure_after_range=config.PRESSURE_AFTER_RANGE_KPA,
motor_output_addr=config.MOTOR_OUTPUT_ADDR,
)
class ControlWorker:
"""拥有设备连接的后台线程,通过命令/事件队列与 GUI 通信。"""
def __init__(
self,
logger,
*,
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(
target=self._run,
name="flow-control-worker",
daemon=False,
)
self._shutdown_requested = False
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,
"PRESSURE_BEFORE": 0,
"PRESSURE_AFTER": 0,
}
@property
def state(self):
return self._state
def start(self):
self.thread.start()
def is_alive(self):
return self.thread.is_alive()
def request_connect(self, settings: DeviceSettings, model_path=None):
self.commands.put(("CONNECT", {"settings": settings, "model_path": model_path}))
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", {}))
def _emit(self, kind, **payload):
payload["kind"] = kind
self.events.put(payload)
def _set_state(self, state, message):
self._state = state
self._emit("state", state=state, message=message)
def _run(self):
self._set_state("DISCONNECTED", "请选择模型并连接设备")
try:
while not self._shutdown_requested:
timeout = self._command_timeout()
command = None
try:
command = self.commands.get(timeout=timeout)
except Empty:
pass
if command is not None:
self._handle_command(*command)
if self._shutdown_requested:
break
now = time.perf_counter()
if self._state == "MONITORING" and now >= self._next_cycle:
self._monitor_once()
self._advance_deadline(now, MONITOR_PERIOD_S)
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:
self.logger.error("程序退出时安全断开存在问题:%s", "; ".join(errors))
self._emit("shutdown_complete", errors=errors)
def _command_timeout(self):
if self._state in {"MONITORING", "CONTROLLING", "OPEN_LOOP"}:
return max(0.0, min(0.05, self._next_cycle - time.perf_counter()))
return 0.1
def _advance_deadline(self, now, period):
self._next_cycle += period
if self._next_cycle <= now:
self._next_cycle = time.perf_counter() + period
def _handle_command(self, command, payload):
if command == "CONNECT":
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
def _connect(self, settings, model_path):
if self._state not in {"DISCONNECTED", "FAULT"}:
self._emit("warning", message="当前状态不能重复连接")
return
self._set_state("CONNECTING", f"正在连接 {settings.host}:{settings.port}…")
try:
config.validate_config()
model_info = self.model_loader(model_path)
hardware = self.hardware_factory(settings)
self._hardware = hardware
if not bool(hardware.connect()):
raise RuntimeError("无法连接 PLC")
if not bool(
hardware.set_motor_position(
config.MOTOR_OPEN_POSITION,
channel=config.MOTOR_OUTPUT_ADDR,
)
):
raise RuntimeError("连接后设置阀门 100% 开度失败")
self._controller = self.controller_factory(
hardware,
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()
self.logger.info(
"PLC 已连接 %s:%d,站号=%d;模型=%s",
settings.host,
settings.port,
settings.slave_id,
model_info.display_name,
)
self._set_state("MONITORING", "设备已连接,正在监测;请设置目标流量")
self._emit(
"model_active",
display_name=model_info.display_name,
point_count=model_info.point_count,
is_monotonic=model_info.is_monotonic,
)
except Exception as exc:
errors = self._safe_disconnect()
detail = str(exc)
if errors:
detail += ";安全断开:" + "".join(errors)
self.logger.exception("连接设备失败:%s", detail)
self._state = "FAULT"
self._emit("fault", message=detail, state="FAULT")
def _set_target(self, target):
if self._state not in {"MONITORING", "CONTROLLING"} or self._controller is None:
self._emit("warning", message="设备尚未连接,无法设置目标流量")
return
try:
value = validate_target(target)
self._controller.set_target_flow(value)
if not self._controller.running:
self._controller.start(initial_opening=config.INITIAL_OPENING_PCT)
self._next_cycle = time.perf_counter()
self.logger.info("目标流量设置为 %.3f SLM", value)
self._set_state("CONTROLLING", f"闭环运行中,目标 {value:g} SLM")
self._emit("target_applied", target=value)
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
self._set_state("DISCONNECTING", "正在恢复 100% 开度并断开设备…")
errors = self._safe_disconnect()
if errors:
message = "".join(errors)
self.logger.error("设备已断开,但安全收尾存在问题:%s", message)
self._emit("warning", message=message)
self._set_state("DISCONNECTED", "设备已断开(收尾存在警告)")
else:
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(
"FLOW", "get_flow", config.FLOW_AFTER_ADDR
)
pressure_before = self._read_monitor_value(
"PRESSURE_BEFORE", "get_pressure", config.PRESSURE_BEFORE_ADDR
)
pressure_after = self._read_monitor_value(
"PRESSURE_AFTER", "get_pressure", config.PRESSURE_AFTER_ADDR
)
if flow is not None and not (
config.FLOW_VALID_MIN_SLM <= flow <= config.FLOW_VALID_MAX_SLM
):
raise FlowControlFault(
"FLOW_OUT_OF_RANGE",
"MONITORING",
f"流量读数 {flow:.3f} SLM 超出允许范围",
{"measured_flow_slm": flow},
)
for name, value in (
("阀前压力", pressure_before),
("阀后压力", pressure_after),
):
if (
value is not None
and config.MAX_PRESSURE_KPA is not None
and value > config.MAX_PRESSURE_KPA
):
raise FlowControlFault(
"PRESSURE_OVER_LIMIT",
"MONITORING",
f"{name} {value:.3f} kPa 超过上限 "
f"{config.MAX_PRESSURE_KPA:.3f} kPa",
{"pressure_kpa": value, "sensor": name},
)
status = (
"MONITORING"
if all(
value is not None
for value in (flow, pressure_before, pressure_after)
)
else "SENSOR_READ_FAILED"
)
self._emit(
"telemetry",
measured_flow_slm=flow,
pressure_before_kpa=pressure_before,
pressure_kpa=pressure_after,
target_flow_slm=None,
opening_pct=self._monitor_opening_pct,
motor_position=self._monitor_motor_position,
feedforward_pct=None,
correction_pct=None,
pid_mode=self._monitor_mode,
status=status,
)
except Exception as exc:
self._handle_runtime_fault(exc)
def _read_monitor_value(self, sensor, method_name, address):
try:
method = getattr(self._hardware, method_name)
value = method(address)
if value is not None:
value = float(value)
if value is None or not math.isfinite(value):
raise ValueError(f"无效读数:{value!r}")
except Exception as exc:
self._monitor_failures[sensor] += 1
count = self._monitor_failures[sensor]
limit = (
config.MAX_CONSECUTIVE_FLOW_FAILURES
if sensor == "FLOW"
else config.MAX_CONSECUTIVE_PRESSURE_FAILURES
)
self.logger.error(
"监测阶段 %s 读取失败 %d/%d%r",
sensor,
count,
limit,
exc,
)
if count >= limit:
raise FlowControlFault(
f"{sensor}_READ_FAILED",
"MONITORING",
f"{sensor} 连续读取失败 {count} 次",
{"failure_detail": repr(exc)},
) from exc
return None
self._monitor_failures[sensor] = 0
return value
def _control_once(self, now):
try:
result = self._controller.step(now=now)
self._emit(
"telemetry",
**result.to_dict(),
pid_mode=self._controller.pid_mode,
)
except Exception as exc:
self._handle_runtime_fault(exc)
def _handle_runtime_fault(self, exc):
if isinstance(exc, FlowControlFault):
message = str(exc)
else:
message = f"未处理的控制异常:{exc}"
self.logger.exception("控制故障:%s", message)
errors = self._safe_disconnect()
if errors:
message += ";安全断开:" + "".join(errors)
self._state = "FAULT"
self._emit("fault", message=message, state="FAULT")
def _safe_disconnect(self):
errors = []
controller = self._controller
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:
controller.stop()
except Exception as exc:
errors.append(f"停止控制失败:{exc}")
if hardware is not None:
if bool(getattr(hardware, "connected", False)):
try:
success = hardware.set_motor_position(
config.MOTOR_OPEN_POSITION,
channel=config.MOTOR_OUTPUT_ADDR,
)
if not success:
errors.append("断开前设置阀门 100% 开度失败")
except Exception as exc:
errors.append(f"断开前设置阀门 100% 开度异常:{exc}")
try:
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