Files
flow_control/flow_control.py
T
2026-08-13 14:34:01 +08:00

459 lines
16 KiB
Python

"""流量闭环控制层。
本模块只负责“读取传感器 -> PID -> 开度/行程映射 -> 下发电机位置”以及
安全处理;底层 Modbus 通讯仍由 PcControl.MT2AM8Client 完成。
"""
from dataclasses import asdict, dataclass
import math
import time
from typing import Optional
@dataclass
class ControlStepResult:
"""一个控制周期的可记录结果。"""
timestamp: float
target_flow_slm: float
measured_flow_slm: Optional[float]
pressure_kpa: Optional[float]
error_slm: Optional[float]
opening_pct: float
motor_position: float
actual_dt_s: float
status: str = "OK"
def to_dict(self):
return asdict(self)
class FlowControlFault(RuntimeError):
"""包含故障代码、阶段和现场数据的控制故障。"""
def __init__(self, code, stage, message, context=None):
super().__init__(message)
self.code = str(code)
self.stage = str(stage)
self.context = dict(context or {})
def __str__(self):
return (
f"[{self.code}] 阶段={self.stage}: {super().__str__()} | "
f"上下文={self.context}"
)
def opening_to_motor_position(
opening_pct,
motor_open_position,
motor_closed_position,
):
"""把 0~100% 阀门开度换算成反向作用的电机行程。"""
opening = float(opening_pct)
open_position = float(motor_open_position)
closed_position = float(motor_closed_position)
if not math.isfinite(opening):
raise ValueError("opening_pct 必须是有限数值")
if not 0.0 <= opening <= 100.0:
raise ValueError("opening_pct 必须位于 0~100%")
if open_position >= closed_position:
raise ValueError("打开端行程必须小于关闭端行程")
return closed_position - opening / 100.0 * (
closed_position - open_position
)
class FlowControlLoop:
"""基于 MT2AM8Client 和 IncrementalPID 的单回路流量控制器。"""
def __init__(
self,
hardware,
pid,
*,
period_s,
flow_channel,
motor_channel,
motor_open_position,
motor_closed_position,
target_min_slm=0.0,
target_max_slm=300.0,
zero_flow_threshold_slm=0.5,
flow_valid_min_slm=0.0,
flow_valid_max_slm=300.0,
pressure_channel=None,
max_pressure_kpa=None,
max_control_dt_s=None,
max_consecutive_flow_failures=3,
max_consecutive_pressure_failures=3,
logger=None,
):
if period_s <= 0:
raise ValueError("period_s 必须大于 0")
if target_min_slm > target_max_slm:
raise ValueError("目标流量上下限配置错误")
if flow_valid_min_slm > flow_valid_max_slm:
raise ValueError("流量有效范围配置错误")
if pressure_channel is not None and max_pressure_kpa is None:
raise ValueError("启用压力监测时必须设置 max_pressure_kpa")
self.hardware = hardware
self.pid = pid
self.period_s = float(period_s)
self.flow_channel = int(flow_channel)
self.motor_channel = int(motor_channel)
self.motor_open_position = float(motor_open_position)
self.motor_closed_position = float(motor_closed_position)
self.target_min_slm = float(target_min_slm)
self.target_max_slm = float(target_max_slm)
self.zero_flow_threshold_slm = float(zero_flow_threshold_slm)
self.flow_valid_min_slm = float(flow_valid_min_slm)
self.flow_valid_max_slm = float(flow_valid_max_slm)
self.pressure_channel = (
None if pressure_channel is None else int(pressure_channel)
)
self.max_pressure_kpa = (
None if max_pressure_kpa is None else float(max_pressure_kpa)
)
self.max_control_dt_s = (
None if max_control_dt_s is None else float(max_control_dt_s)
)
self.max_consecutive_flow_failures = int(max_consecutive_flow_failures)
self.max_consecutive_pressure_failures = int(
max_consecutive_pressure_failures
)
self.logger = logger
self.target_flow_slm = 0.0
self.last_flow_slm = None
self.last_pressure_kpa = None
self.last_opening_pct = 0.0
self.last_motor_position = self.motor_closed_position
self.last_step_time = None
self.flow_failure_count = 0
self.pressure_failure_count = 0
self.running = False
self.faulted = False
def set_target_flow(self, target_flow_slm):
"""设置目标流量;超出允许范围时拒绝,而不是静默截断。"""
target = float(target_flow_slm)
if not math.isfinite(target):
raise ValueError("目标流量必须是有限数值")
if not self.target_min_slm <= target <= self.target_max_slm:
raise ValueError(
f"目标流量 {target} SLM 超出 "
f"{self.target_min_slm}~{self.target_max_slm} SLM"
)
self.target_flow_slm = target
def start(self, initial_opening=0.0):
"""复位状态并启动控制;默认从关闭开度开始。"""
initial_opening = self._bounded_opening(initial_opening)
self.pid.reset(initial_output=initial_opening)
self.last_opening_pct = initial_opening
self.last_motor_position = opening_to_motor_position(
initial_opening,
self.motor_open_position,
self.motor_closed_position,
)
self.last_step_time = None
self.flow_failure_count = 0
self.pressure_failure_count = 0
self.faulted = False
self.running = True
def stop(self, close_valve=True):
"""停止 PID;默认同时安全关阀。"""
self.running = False
self.pid.reset(initial_output=0.0)
if close_valve:
return self.safe_close("STOP_REQUESTED")
return True
def step(self, now=None):
"""执行一个控制周期。短暂读取失败会保持上一输出。"""
if not self.running:
raise FlowControlFault(
"CONTROL_NOT_RUNNING",
"PRECHECK",
"控制器尚未 start()",
self._context(),
)
if self.faulted:
raise FlowControlFault(
"CONTROL_FAULTED",
"PRECHECK",
"控制器处于故障锁定状态,需要重新 start()",
self._context(),
)
if not bool(getattr(self.hardware, "connected", False)):
self._trip(
"DEVICE_DISCONNECTED",
"PRECHECK",
"MT2-AM8 未连接",
)
current_time = time.perf_counter() if now is None else float(now)
actual_dt = (
self.period_s
if self.last_step_time is None
else current_time - self.last_step_time
)
self.last_step_time = current_time
if actual_dt <= 0:
actual_dt = self.period_s
if self.max_control_dt_s is not None and actual_dt > self.max_control_dt_s:
self._trip(
"CONTROL_LOOP_OVERRUN",
"TIMING",
f"实际控制周期 {actual_dt:.3f}s 超过上限 "
f"{self.max_control_dt_s:.3f}s",
{"actual_dt_s": actual_dt},
)
flow = self._read_flow()
pressure = self._read_pressure()
if flow is None:
return self._held_result(
actual_dt,
pressure,
f"FLOW_READ_RETRY_{self.flow_failure_count}",
)
if self.pressure_channel is not None and pressure is None:
return self._held_result(
actual_dt,
pressure,
f"PRESSURE_READ_RETRY_{self.pressure_failure_count}",
)
if not self.flow_valid_min_slm <= flow <= self.flow_valid_max_slm:
self._trip(
"FLOW_OUT_OF_RANGE",
"FLOW_SAFETY_CHECK",
f"流量读数 {flow:.3f} SLM 超出允许范围",
{"measured_flow_slm": flow},
)
if (
pressure is not None
and self.max_pressure_kpa is not None
and pressure > self.max_pressure_kpa
):
self._trip(
"PRESSURE_OVER_LIMIT",
"PRESSURE_SAFETY_CHECK",
f"压力 {pressure:.3f} kPa 超过上限 "
f"{self.max_pressure_kpa:.3f} kPa",
{"pressure_kpa": pressure},
)
if self.target_flow_slm <= self.zero_flow_threshold_slm:
self.pid.reset(initial_output=0.0)
opening = 0.0
else:
opening = self.pid.update(
measurement=flow,
setpoint=self.target_flow_slm,
dt=actual_dt,
)
if not self._is_finite_number(opening):
self._trip(
"PID_OUTPUT_INVALID",
"PID_UPDATE",
f"PID 输出不是有限数值: {opening!r}",
)
opening = self._bounded_opening(opening)
position = opening_to_motor_position(
opening,
self.motor_open_position,
self.motor_closed_position,
)
self._write_motor_position(position, opening)
self.last_flow_slm = flow
self.last_pressure_kpa = pressure
self.last_opening_pct = opening
self.last_motor_position = position
return ControlStepResult(
timestamp=time.time(),
target_flow_slm=self.target_flow_slm,
measured_flow_slm=flow,
pressure_kpa=pressure,
error_slm=self.target_flow_slm - flow,
opening_pct=opening,
motor_position=position,
actual_dt_s=actual_dt,
)
def safe_close(self, reason="UNSPECIFIED"):
"""尝试把阀门置于关闭端;失败时返回 False 并详细记录。"""
try:
success = bool(
self.hardware.set_motor_position(
self.motor_closed_position,
channel=self.motor_channel,
)
)
except Exception as exc:
self._log(
"critical",
"SAFE_CLOSE_FAILED reason=%s exception=%s context=%s",
reason,
repr(exc),
self._context(),
)
return False
if success:
self.last_opening_pct = 0.0
self.last_motor_position = self.motor_closed_position
self._log("warning", "阀门已安全关闭,原因=%s", reason)
return True
self._log(
"critical",
"SAFE_CLOSE_FAILED reason=%s hardware_return=False context=%s",
reason,
self._context(),
)
return False
def _read_flow(self):
try:
value = self.hardware.get_flow(self.flow_channel)
except Exception as exc:
self._register_read_failure("FLOW", exc)
return None
if value is None or not self._is_finite_number(value):
self._register_read_failure("FLOW", f"invalid value: {value!r}")
return None
self.flow_failure_count = 0
return float(value)
def _read_pressure(self):
if self.pressure_channel is None:
return None
try:
value = self.hardware.get_pressure(self.pressure_channel)
except Exception as exc:
self._register_read_failure("PRESSURE", exc)
return None
if value is None or not self._is_finite_number(value):
self._register_read_failure("PRESSURE", f"invalid value: {value!r}")
return None
self.pressure_failure_count = 0
return float(value)
def _register_read_failure(self, sensor, detail):
if sensor == "FLOW":
self.flow_failure_count += 1
count = self.flow_failure_count
limit = self.max_consecutive_flow_failures
else:
self.pressure_failure_count += 1
count = self.pressure_failure_count
limit = self.max_consecutive_pressure_failures
self._log(
"error",
"%s_READ_FAILED count=%d/%d detail=%r context=%s",
sensor,
count,
limit,
detail,
self._context(),
)
if count >= limit:
self._trip(
f"{sensor}_READ_FAILED",
f"READ_{sensor}",
f"{sensor} 连续读取失败 {count} 次",
{"failure_detail": repr(detail), "failure_count": count},
)
def _write_motor_position(self, position, opening):
try:
success = self.hardware.set_motor_position(
position,
channel=self.motor_channel,
)
except Exception as exc:
self._trip(
"MOTOR_COMMAND_FAILED",
"WRITE_MOTOR",
"下发电机行程时发生异常",
{
"exception": repr(exc),
"requested_opening_pct": opening,
"requested_motor_position": position,
},
)
if not success:
self._trip(
"MOTOR_COMMAND_FAILED",
"WRITE_MOTOR",
"MT2-AM8 返回电机位置写入失败",
{
"requested_opening_pct": opening,
"requested_motor_position": position,
},
)
def _trip(self, code, stage, message, extra_context=None):
context = self._context()
context.update(extra_context or {})
self.faulted = True
self.running = False
close_ok = self.safe_close(code)
context["safe_close_success"] = close_ok
fault = FlowControlFault(code, stage, message, context)
self._log("critical", "%s", fault)
raise fault
def _held_result(self, actual_dt, pressure, status):
return ControlStepResult(
timestamp=time.time(),
target_flow_slm=self.target_flow_slm,
measured_flow_slm=None,
pressure_kpa=pressure,
error_slm=None,
opening_pct=self.last_opening_pct,
motor_position=self.last_motor_position,
actual_dt_s=actual_dt,
status=status,
)
def _context(self):
return {
"target_flow_slm": self.target_flow_slm,
"last_valid_flow_slm": self.last_flow_slm,
"last_pressure_kpa": self.last_pressure_kpa,
"opening_pct": self.last_opening_pct,
"motor_position": self.last_motor_position,
"device_connected": bool(getattr(self.hardware, "connected", False)),
"flow_failure_count": self.flow_failure_count,
"pressure_failure_count": self.pressure_failure_count,
}
def _bounded_opening(self, opening):
value = float(opening)
if not math.isfinite(value):
raise ValueError("阀门开度必须是有限数值")
return max(0.0, min(100.0, value))
def _log(self, level, message, *args):
if self.logger is not None:
getattr(self.logger, level)(message, *args)
@staticmethod
def _is_finite_number(value):
try:
return math.isfinite(float(value))
except (TypeError, ValueError):
return False