完善流量控制 GUI 的开环扫点与手动操作流程
新增可配置开环扫点、多 CSV 前馈表拟合及结果文件生成;补充闭环停止、手动开度安全互锁和 PID 未启用状态显示,并同步更新自动化测试、使用文档、打包配置与 Windows 可执行程序。
This commit is contained in:
+605
-5
@@ -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("手动开度必须位于 0~100%")
|
||||
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 s,CSV=%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=%s,PNG=%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
|
||||
|
||||
Reference in New Issue
Block a user