Files
flow_control/flow_control.py
T

568 lines
20 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.
"""流量闭环控制层。
本模块只负责“读取传感器 -> 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,
pid_far_gains=(0.5, 0.5, 0.0),
pid_near_gains=(0.05, 0.01, 0.0),
opening_rate_far_pct_s=20.0,
opening_rate_near_pct_s=2.0,
pid_near_error_min_slm=3.0,
pid_near_error_target_ratio=0.05,
pid_far_error_min_slm=12.0,
pid_far_error_target_ratio=0.20,
pid_mode_switch_confirm_cycles=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")
far_gains = tuple(float(value) for value in pid_far_gains)
near_gains = tuple(float(value) for value in pid_near_gains)
if len(far_gains) != 3 or len(near_gains) != 3:
raise ValueError("FAR/NEAR PID 参数必须各包含 Kp、Ki、Kd")
if any(
not math.isfinite(value) or value < 0
for value in far_gains + near_gains
):
raise ValueError("FAR/NEAR PID 参数必须是非负有限数值")
far_rate = float(opening_rate_far_pct_s)
near_rate = float(opening_rate_near_pct_s)
if (
not math.isfinite(far_rate)
or not math.isfinite(near_rate)
or far_rate <= 0
or near_rate <= 0
):
raise ValueError("FAR/NEAR 最大开度变化速度必须大于 0")
near_error_min = float(pid_near_error_min_slm)
far_error_min = float(pid_far_error_min_slm)
near_error_ratio = float(pid_near_error_target_ratio)
far_error_ratio = float(pid_far_error_target_ratio)
if any(
not math.isfinite(value) or value < 0
for value in (
near_error_min,
far_error_min,
near_error_ratio,
far_error_ratio,
)
):
raise ValueError("PID 模式切换阈值必须是非负有限数值")
if near_error_min >= far_error_min:
raise ValueError("PID NEAR 绝对误差阈值必须小于 FAR 阈值")
if near_error_ratio >= far_error_ratio:
raise ValueError("PID NEAR 相对误差阈值必须小于 FAR 阈值")
confirm_cycles = int(pid_mode_switch_confirm_cycles)
if confirm_cycles != pid_mode_switch_confirm_cycles or confirm_cycles < 1:
raise ValueError("PID 模式切换确认周期数必须至少为 1")
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.pid_far_gains = far_gains
self.pid_near_gains = near_gains
self.opening_rate_far_pct_s = far_rate
self.opening_rate_near_pct_s = near_rate
self.pid_near_error_min_slm = near_error_min
self.pid_near_error_target_ratio = near_error_ratio
self.pid_far_error_min_slm = far_error_min
self.pid_far_error_target_ratio = far_error_ratio
self.pid_mode_switch_confirm_cycles = confirm_cycles
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
self.pid_mode = "FAR"
self._pid_mode_confirm_count = 0
self._apply_pid_mode("FAR", force=True, log_change=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"
)
target_changed = target != self.target_flow_slm
self.target_flow_slm = target
if target_changed:
self._apply_pid_mode("FAR", reason="目标流量切换")
def start(self, initial_opening=100.0):
"""复位状态并启动控制;默认从全开开度开始。"""
initial_opening = self._bounded_opening(initial_opening)
self._apply_pid_mode("FAR", reason="控制启动")
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):
"""停止 PID;阀门保持当前开度(默认全开)。"""
self.running = False
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:
self._update_pid_mode(flow)
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 _update_pid_mode(self, flow):
"""按误差滞回切换 FAR/NEAR;参数只在模式变化时更新一次。"""
error_abs = abs(self.target_flow_slm - flow)
near_threshold = max(
self.pid_near_error_min_slm,
self.pid_near_error_target_ratio * self.target_flow_slm,
)
far_threshold = max(
self.pid_far_error_min_slm,
self.pid_far_error_target_ratio * self.target_flow_slm,
)
if self.pid_mode == "FAR":
condition_met = error_abs <= near_threshold
next_mode = "NEAR"
threshold = near_threshold
else:
condition_met = error_abs >= far_threshold
next_mode = "FAR"
threshold = far_threshold
if not condition_met:
self._pid_mode_confirm_count = 0
return
self._pid_mode_confirm_count += 1
if self._pid_mode_confirm_count < self.pid_mode_switch_confirm_cycles:
return
comparison = "<=" if next_mode == "NEAR" else ">="
self._apply_pid_mode(
next_mode,
reason=(
f"|误差|={error_abs:.3f} SLM {comparison} "
f"阈值={threshold:.3f} SLM,连续 "
f"{self.pid_mode_switch_confirm_cycles} 个周期"
),
)
def _apply_pid_mode(
self,
mode,
*,
reason=None,
force=False,
log_change=True,
):
if mode not in ("FAR", "NEAR"):
raise ValueError(f"未知 PID 模式: {mode!r}")
self._pid_mode_confirm_count = 0
if not force and mode == self.pid_mode:
return False
previous_mode = self.pid_mode
if mode == "FAR":
gains = self.pid_far_gains
opening_rate = self.opening_rate_far_pct_s
else:
gains = self.pid_near_gains
opening_rate = self.opening_rate_near_pct_s
self.pid.update_parameters(*gains)
self.pid.set_output_rate_limit(opening_rate)
self.pid_mode = mode
if log_change:
reason_text = "" if reason is None else f";原因={reason}"
self._log(
"info",
"PID 模式 %s -> %sKp=%.3f Ki=%.3f Kd=%.3f "
"最大开度速度=%.3f%%/s%s",
previous_mode,
mode,
gains[0],
gains[1],
gains[2],
opening_rate,
reason_text,
)
return True
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
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,
"pid_mode": self.pid_mode,
}
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