Files
flow_control/main.py

365 lines
13 KiB
Python
Raw Permalink 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.
"""气体流量闭环控制程序入口。
示例:
python main.py --target 50
程序启动后直接从当前阀门开度(默认全开)开始闭环。Ctrl+C、控制故障或
其他异常会断开 MT2-AM8;阀门不被驱动时默认回到全开。
"""
import argparse
import logging
from pathlib import Path
import sys
import time
try:
import msvcrt # Windows 专用:非阻塞键盘输入,用于运行中切换目标流量
except ImportError: # 非 Windows 平台无此模块,退化为仅周期控制
msvcrt = None
import config
from controllers import IncrementalPID
from data_logger import FlowRunRecorder
from flow_control import FlowControlFault, FlowControlLoop
from performance_reporter import PerformanceReporter
class ConsoleGate(logging.Filter):
"""终端输出闸门:输入模式下挡住终端日志,文件日志不受影响。"""
def __init__(self):
super().__init__()
self.blocked = False
def filter(self, _record):
return not self.blocked
def build_logger():
log_dir = Path(__file__).resolve().parent / config.LOG_DIRECTORY
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / config.LOG_FILE_NAME
logger = logging.getLogger("flow_control")
logger.setLevel(logging.INFO)
logger.handlers.clear()
formatter = logging.Formatter(
"%(asctime)s.%(msecs)03d %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
console_gate = ConsoleGate()
console_handler.addFilter(console_gate)
logger.addHandler(console_handler)
return logger, console_gate
def build_control_loop(hardware, logger):
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_INPUT_CHANNEL,
pressure_channel=config.PRESSURE_INPUT_CHANNEL,
motor_channel=config.MOTOR_OUTPUT_CHANNEL,
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_target_ratio=(
config.PID_NEAR_ERROR_TARGET_RATIO
),
pid_far_error_min_slm=config.PID_FAR_ERROR_MIN_SLM,
pid_far_error_target_ratio=config.PID_FAR_ERROR_TARGET_RATIO,
pid_mode_switch_confirm_cycles=(
config.PID_MODE_SWITCH_CONFIRM_CYCLES
),
logger=logger,
)
def parse_args(argv=None):
parser = argparse.ArgumentParser(description="MT2-AM8 气体流量 PID 控制")
parser.add_argument(
"--target",
type=float,
required=True,
help=(
"目标流量 SLM,允许范围 "
f"{config.TARGET_FLOW_MIN_SLM}~{config.TARGET_FLOW_MAX_SLM}"
),
)
return parser.parse_args(argv)
def format_status(result):
flow_text = (
"--" if result.measured_flow_slm is None
else f"{result.measured_flow_slm:.2f}"
)
pressure_text = (
"--" if result.pressure_kpa is None
else f"{result.pressure_kpa:.2f}"
)
return (
f"状态={result.status} 目标={result.target_flow_slm:.2f} SLM "
f"实际={flow_text} SLM 压力={pressure_text} kPa "
f"开度={result.opening_pct:.2f}% "
f"行程={result.motor_position:.1f} dt={result.actual_dt_s:.3f}s"
)
def run(target_flow_slm):
config.validate_config()
logger, console_gate = build_logger()
try:
from PcControl import MT2AM8Client
except ModuleNotFoundError as exc:
if exc.name and exc.name.startswith("pymodbus"):
logger.critical(
"缺少 PcControl.py 所需的 pymodbus;请在运行本工程的 Python "
"环境中安装与现有硬件代码兼容的 pymodbus 版本"
)
return 6
raise
hardware = MT2AM8Client(
host=config.MT2AM8_HOST,
port=config.MT2AM8_PORT,
slave_id=config.MT2AM8_SLAVE_ID,
pressure_range=config.PRESSURE_RANGE_KPA,
flow_range=config.FLOW_METER_RANGE_SLM,
)
controller = build_control_loop(hardware, logger)
controller.set_target_flow(target_flow_slm)
connected = False
recorder = None
reporter = None
exit_code = 0
try:
logger.info(
"正在连接 MT2-AM8 %s:%s,目标流量=%.3f SLM",
config.MT2AM8_HOST,
config.MT2AM8_PORT,
target_flow_slm,
)
connected = bool(hardware.connect())
if not connected:
raise FlowControlFault(
"DEVICE_CONNECT_FAILED",
"STARTUP",
"无法连接 MT2-AM8",
{
"host": config.MT2AM8_HOST,
"port": config.MT2AM8_PORT,
"slave_id": config.MT2AM8_SLAVE_ID,
},
)
# 控制前读取一次当前流量,作为超调量方向的基准。
try:
initial_flow_slm = hardware.get_flow(config.FLOW_INPUT_CHANNEL)
except Exception:
initial_flow_slm = None
reporter = PerformanceReporter(
controller.pid,
window_s=config.STEADY_STATE_WINDOW_S,
sample_period_s=config.CONTROL_PERIOD_S,
band_pct=config.STEADY_STATE_BAND_PCT,
std_pct=config.STEADY_STATE_STD_PCT,
flow_range_min_slm=config.TARGET_FLOW_MIN_SLM,
flow_range_max_slm=config.TARGET_FLOW_MAX_SLM,
initial_flow_slm=initial_flow_slm,
logger=logger,
)
controller.start(initial_opening=config.INITIAL_OPENING_PCT)
recorder = FlowRunRecorder(
Path(__file__).resolve().parent / config.DATA_DIRECTORY,
plot_dpi=config.PLOT_DPI,
)
logger.info("逐周期数据将保存到 %s", recorder.csv_path)
logger.info(
"闭环控制已启动;按 Ctrl+C 停止,按 %s 切换目标流量",
config.TARGET_SWITCH_KEY.upper(),
)
input_mode = False
input_buf = ""
def apply_target():
"""把输入缓冲区解析为新的目标流量并生效。"""
raw = input_buf.strip()
if not raw:
logger.warning("未输入目标流量,已忽略")
return
try:
value = float(raw)
except ValueError:
logger.warning("无法解析目标流量 %r,已忽略", raw)
return
try:
controller.set_target_flow(value)
except ValueError as exc:
logger.warning("目标流量设置失败:%s", exc)
return
reporter.reset()
logger.info("目标流量已切换为 %.3f SLM", value)
def process_keys():
"""非阻塞处理键盘输入;输入模式下控制循环照常运行。"""
nonlocal input_mode, input_buf
if msvcrt is None:
return
while msvcrt.kbhit():
ch = msvcrt.getch()
if not input_mode:
if ch == b"\x03": # Ctrl+C,交给外层 KeyboardInterrupt 处理
raise KeyboardInterrupt
key = ch.decode("ascii", errors="ignore").lower()
if key == config.TARGET_SWITCH_KEY:
input_mode = True
input_buf = ""
console_gate.blocked = True
print(
f"当前目标={controller.target_flow_slm:.2f} SLM"
f"请输入新目标流量 (回车确认, Esc 取消): ",
end="",
flush=True,
)
else:
if ch in (b"\r", b"\n"):
print()
console_gate.blocked = False
input_mode = False
apply_target()
elif ch == b"\x1b": # Esc 取消
console_gate.blocked = False
input_mode = False
print(" (已取消)")
elif ch in (b"\x08", b"\x7f"): # 退格删除最后一位
if input_buf:
input_buf = input_buf[:-1]
print("\b \b", end="", flush=True)
else:
text = ch.decode("ascii", errors="ignore")
if text and text.isprintable():
input_buf += text
print(text, end="", flush=True)
next_deadline = time.perf_counter()
next_status_time = next_deadline
while True:
now = time.perf_counter()
process_keys()
result = controller.step(now=now)
recorder.record(result)
reporter.observe(result)
if config.AUTO_STOP_ON_STEADY and reporter.steady_reached:
logger.info("已达到稳态,自动停止控制")
break
if now >= next_status_time or result.status != "OK":
logger.info(format_status(result))
next_status_time = now + config.STATUS_PRINT_PERIOD_S
next_deadline += config.CONTROL_PERIOD_S
sleep_s = next_deadline - time.perf_counter()
if sleep_s > 0:
time.sleep(sleep_s)
else:
# 丢弃已经错过的节拍,避免连续快速补跑 PID。
next_deadline = time.perf_counter()
except KeyboardInterrupt:
console_gate.blocked = False
logger.info("收到 Ctrl+C,正在停止控制")
except FlowControlFault as exc:
console_gate.blocked = False
exit_code = 2
logger.critical("控制故障:%s", exc)
except Exception:
console_gate.blocked = False
exit_code = 3
logger.exception("未处理异常,系统将退出")
finally:
console_gate.blocked = False
if connected:
try:
hardware.disconnect()
except Exception:
exit_code = max(exit_code, 5)
logger.exception("断开 MT2-AM8 时发生异常")
if recorder is not None:
try:
image_path = recorder.finalize()
logger.info("控制数据已保存:%s", recorder.csv_path)
if image_path is not None:
logger.info("三联曲线图已保存:%s", image_path)
else:
logger.warning("本次运行没有采样点,因此未生成曲线图")
except Exception:
recorder.close()
exit_code = max(exit_code, 7)
logger.exception("保存控制数据曲线图失败;CSV 数据仍保留")
if reporter is not None:
try:
reporter.finalize()
except Exception:
logger.exception("生成控制性能报告时发生异常")
logger.info("程序结束,退出码=%d", exit_code)
return exit_code
def main(argv=None):
args = parse_args(argv)
return run(args.target)
if __name__ == "__main__":
sys.exit(main())