From 84b2bdb35d0595e91bc8440b4a35234255e88a7a Mon Sep 17 00:00:00 2001 From: rangang Date: Wed, 12 Aug 2026 17:40:45 +0800 Subject: [PATCH] first commit --- 111.py | 7 + PcControl.py | 824 +++++++++++++++++++++++ __pycache__/PcControl.cpython-312.pyc | Bin 0 -> 32435 bytes __pycache__/config.cpython-312.pyc | Bin 0 -> 2870 bytes __pycache__/controllers.cpython-312.pyc | Bin 0 -> 9279 bytes __pycache__/flow_control.cpython-312.pyc | Bin 0 -> 17905 bytes config.py | 97 +++ controllers (2).py | 145 ++++ controllers.py | 211 ++++++ flow_control.py | 458 +++++++++++++ main.py | 213 ++++++ 问题.md | 3 + 12 files changed, 1958 insertions(+) create mode 100644 111.py create mode 100644 PcControl.py create mode 100644 __pycache__/PcControl.cpython-312.pyc create mode 100644 __pycache__/config.cpython-312.pyc create mode 100644 __pycache__/controllers.cpython-312.pyc create mode 100644 __pycache__/flow_control.cpython-312.pyc create mode 100644 config.py create mode 100644 controllers (2).py create mode 100644 controllers.py create mode 100644 flow_control.py create mode 100644 main.py create mode 100644 问题.md diff --git a/111.py b/111.py new file mode 100644 index 0000000..9309134 --- /dev/null +++ b/111.py @@ -0,0 +1,7 @@ +import os +# 示例 3: 末尾路径为空 +path = os.path.join("/home/user", "documents", "") +print(path) # 输出: /home/user/documents/ + +path = os.path.join("/home/user", "documents") +print(path) # 输出: /home/user/documents \ No newline at end of file diff --git a/PcControl.py b/PcControl.py new file mode 100644 index 0000000..59fd2ea --- /dev/null +++ b/PcControl.py @@ -0,0 +1,824 @@ +import time +import os +import sys +from pymodbus.client import ModbusTcpClient, ModbusSerialClient +from pymodbus.exceptions import ModbusException +from pymodbus.payload import BinaryPayloadDecoder, BinaryPayloadBuilder +from pymodbus.constants import Endian + + +def _motor_log(msg: str): + """电机操作日志,直接写文件 + 刷盘""" + try: + log_dir = os.path.join(os.path.dirname(sys.executable), "logs") + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, "control_debug.log") + with open(log_file, "a", encoding="utf-8") as f: + f.write(f"[{time.strftime('%H:%M:%S.%f')[:-3]}] [MOTOR] {msg}\n") + f.flush() + os.fsync(f.fileno()) + except Exception: + pass + +volthege_min = 819 # 模拟量映射最小值(对应 0V/4mA) +volthege_max = 4095 # 模拟量映射最大值(对应 10V/20mA) +x_max = 1000 # 最大行程 +impulse_max = 163840 # 电机脉冲最大值(对应 x_max) + + +def set_motor_limits(volthege_min_val=None, volthege_max_val=None, x_max_val=None): + """更新电机限幅参数(由 GUI 高级设置页面调用) + + Args: + volthege_min_val: 模拟量映射最小值,None 表示不更新 + volthege_max_val: 模拟量映射最大值,None 表示不更新 + x_max_val: 最大行程(对应 GUI 总限幅),None 表示不更新 + """ + global volthege_min, volthege_max, x_max + if volthege_min_val is not None: + volthege_min = volthege_min_val + if volthege_max_val is not None: + volthege_max = volthege_max_val + if x_max_val is not None: + x_max = x_max_val + +# ---------- 原有的 PLC Modbus TCP 客户端类 ---------- +class Easy521ModbusClient: + # 参数来源(GUI 页面1 Modbus TCP 区): + # host <- PLC地址 (默认 192.168.1.88) + # port <- 端口 (默认 502) + # current_p_addr <- 读取压力寄存器地址 (默认 504) + def __init__(self, host="192.168.1.88", port=502, slave_id=1, current_p_addr=504): + self.host = host + self.port = port + self.slave_id = slave_id + self.client = ModbusTcpClient( + host=host, + port=port, + timeout=3, + retries=3 + ) + self.connected = False + self.current_p_addr0 = current_p_addr + self.current_p_addr = current_p_addr + # self.current_p_addr = 18 + self.target_p_addr = 42 + self.u_addr = 514 + # self.u_addr = 40 + # self.output_postion = 514 + self.control_flag_addr = 100 + self.M901_ADDR = 901 + self.M902_ADDR = 902 + self.M903_ADDR = 903 + self.M904_ADDR = 904 + self.M905_ADDR = 905 + self.M906_ADDR = 906 + self.q_addr = 512 + + def connect(self): + try: + connection = self.client.connect() + if connection: + print(f"成功连接到 {self.host}:{self.port}") + self.connected = True + else: + print(f"无法连接到 {self.host}:{self.port}") + self.connected = False + return connection + except Exception as e: + print(f"连接错误: {e}") + self.connected = False + return False + + def disconnect(self): + self.client.close() + self.connected = False + print("连接已关闭") + + def read_float(self, address): + try: + address = int(address) + result = self.client.read_input_registers( + address=address, + count=2, + slave=self.slave_id + ) + if not result.isError(): + decoder = BinaryPayloadDecoder.fromRegisters( + result.registers, + byteorder=Endian.BIG, + wordorder=Endian.LITTLE + ) + return decoder.decode_32bit_float() + else: + print(f"读取寄存器错误: {result}") + return None + except Exception as e: + print(f"读取浮点数时发生错误: {e}") + return None + + def write_float(self, address, float_value): + try: + address = int(address) + builder = BinaryPayloadBuilder(byteorder=Endian.LITTLE, wordorder=Endian.BIG) + builder.add_32bit_float(float_value) + payload = builder.to_registers() + result = self.client.write_registers( + address=address, + values=payload, + slave=self.slave_id + ) + return not result.isError() + except Exception as e: + print(f"写入浮点数时发生错误: {e}") + return False + + def write_coil(self, address, value): + try: + address = int(address) + result = self.client.write_coil(address=address, value=value, slave=self.slave_id) + return not result.isError() + except Exception as e: + print(f"写入线圈时发生错误: {e}") + return False + + def get_current_p(self): + return self.read_float(self.current_p_addr) + + def get_current_p0(self): + return self.read_float(self.current_p_addr0) + + def get_target_p(self): + return self.read_float(self.target_p_addr) + + def get_current_q(self): + return self.read_float(self.q_addr) + + def write_u(self, float_value): + try: + address = int(self.u_addr) + builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.LITTLE) + builder.add_32bit_float(float_value) + payload = builder.to_registers() + result = self.client.write_registers( + address=address, + values=payload, + slave=self.slave_id + ) + return not result.isError() + except Exception as e: + print(f"写入浮点数时发生错误: {e}") + return False + + def start_control(self): + success = self.write_coil(self.control_flag_addr, True) + if success: + print("成功写入控制标志位True") + else: + print("写入控制标志位失败") + + def stop_control(self): + success = self.write_coil(self.control_flag_addr, False) + if success: + print("成功写入控制标志位False") + else: + print("写入控制标志位失败") + + def read_rtu_flow(self, port='COM3', slave_id=2, baudrate=9600, bytesize=8, parity='N', stopbits=1): + client = ModbusSerialClient( + port=port, + baudrate=baudrate, + bytesize=bytesize, + parity=parity, + stopbits=stopbits, + timeout=3 + ) + if not client.connect(): + print(f"无法连接到串口 {port}") + return None + + try: + result = client.read_holding_registers(address=22, count=2, slave=slave_id) + if result.isError(): + print(f"RTU 读取寄存器错误: {result}") + return None + decoder = BinaryPayloadDecoder.fromRegisters( + result.registers, + byteorder=Endian.BIG, + wordorder=Endian.BIG + ) + value = decoder.decode_32bit_uint() / 100 + return value + except Exception as e: + return None + finally: + client.close() + + def start_up(self): + if self.write_coil(self.M901_ADDR, True): + print("M901 置位 TRUE") + time.sleep(1) + if self.write_coil(self.M901_ADDR, False): + print("M901 复位 FALSE") + else: + print("警告:M901 复位失败") + else: + print("警告:M901 置位失败") + time.sleep(1) + + if self.write_coil(self.M902_ADDR, True): + print("M902 置位 TRUE") + time.sleep(1) + if self.write_coil(self.M902_ADDR, False): + print("M902 复位 FALSE") + else: + print("警告:M902 复位失败") + else: + print("警告:M902 置位失败") + time.sleep(1) + + if self.write_coil(self.M905_ADDR, True): + print("M905 (初始开度) 置位 TRUE") + time.sleep(1) + if self.write_coil(self.M905_ADDR, False): + print("M905 (初始开度) 复位 FALSE") + else: + print("警告:M905 (初始开度) 复位失败") + else: + print("警告:M905 (初始开度) 置位失败") + time.sleep(1) + + if self.write_coil(self.M903_ADDR, True): + print("M903 置位 TRUE(持续)") + else: + print("警告:M903 置位失败") + + if self.write_coil(self.M904_ADDR, True): + print("M904 置位 TRUE(持续)") + else: + print("警告:M904 置位失败") + time.sleep(1) + + if self.write_coil(self.M906_ADDR, True): + print("M906 (归零) 置位 TRUE") + time.sleep(1) + if self.write_coil(self.M906_ADDR, False): + print("M906 (归零) 复位 FALSE") + else: + print("警告:M906 (归零) 复位失败") + else: + print("警告:M906 (归零) 置位失败") + + print("初始化完成,M903 和 M904 已保持为 TRUE。") + + +# ---------- 新增:独立的电机 Modbus RTU 客户端类(含报文打印) ---------- +class MotorModbusRTUClient: + """电机 Modbus RTU 通讯客户端(增强调试报文打印)""" + # 参数来源(GUI 页面1 Modbus RTU 区): + # port <- 端口号 (下拉) + # baudrate <- 波特率 (默认 115200) + # slave_id <- 站号 (默认 4) + # bytesize <- 数据位 (默认 8) + # stopbits <- 停止位 (默认 1) + # parity <- 校验位 None/Odd/Even -> 'N'/'O'/'E' (默认 'N') + def __init__(self, port='/dev/cu.usbserial-BG02B0IX', slave_id=4, baudrate=115200, + bytesize=8, parity='N', stopbits=1): + # def __init__(self, port='/dev/cu.usbserial-D30JITMY', slave_id=4, baudrate=115200): + self.port = port + self.slave_id = slave_id + self.baudrate = baudrate + self.bytesize = bytesize + self.parity = parity + self.stopbits = stopbits + self.client = None + + def connect(self): + """连接电机串口""" + self.client = ModbusSerialClient( + port=self.port, + baudrate=self.baudrate, + bytesize=self.bytesize, + parity=self.parity, + stopbits=self.stopbits, + timeout=5 # 超时时间延长,便于观察 + ) + if self.client.connect(): + print(f"电机串口 {self.port} 连接成功") + return True + else: + print(f"电机串口 {self.port} 连接失败") + return False + + def disconnect(self): + """断开电机串口""" + if self.client: + self.client.close() + self.client = None + print("电机串口已关闭") + + @staticmethod + def _compute_crc(data: bytes) -> bytes: + """计算 Modbus CRC-16""" + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + if crc & 1: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return crc.to_bytes(2, byteorder='little') + + def _print_sent_message(self, address, function_code, data_bytes): + """构造完整报文并打印(含CRC)""" + raw = bytes([self.slave_id, function_code]) + address.to_bytes(2, byteorder='big') + data_bytes + crc = self._compute_crc(raw) + full_msg = raw + crc + hex_str = ' '.join(f'{b:02X}' for b in full_msg) + # print(f"[发送] {hex_str}") + + def _write_single_register(self, address, value): + """写单个寄存器(功能码 06)""" + data_bytes = value.to_bytes(2, byteorder='big') + self._print_sent_message(address, 0x06, data_bytes) + + try: + result = self.client.write_register(address, value, slave=self.slave_id) + if result.isError(): + print(f"[接收] 错误响应: {result}") + return False + else: + # print(f"[接收] 成功") + return True + except Exception as e: + print(f"[接收] 异常: {e}") + return False + + def _write_32bit(self, address, value): + """ + 写 32 位值到两个连续寄存器(功能码 10) + 字节序:大端(高字节在前,高字在前) + """ + builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.BIG) + builder.add_32bit_uint(value) + payload = builder.to_registers() + # 构造数据部分:字节计数 + 各寄存器大端两字节 + data_bytes = bytes([len(payload) * 2]) + for reg in payload: + data_bytes += reg.to_bytes(2, byteorder='big') + self._print_sent_message(address, 0x10, data_bytes) + + try: + result = self.client.write_registers(address, payload, slave=self.slave_id) + if result.isError(): + print(f"[接收] 错误响应: {result}") + return False + else: + # print(f"[接收] 成功") + return True + except Exception as e: + print(f"[接收] 异常: {e}") + return False + + def init(self): + """初始化电机参数(仅需调用一次)""" + if not self.client or not self.client.connected: + print("电机未连接,请先调用 connect()") + return False + + print("开始初始化电机参数...") + success = True + + # 1. 写模式 4 → 0x6007 + print("--- 步骤1: 写模式 4 到 0x6007 ---") + if not self._write_single_register(0x6007, 4): + success = False + print("模式写入失败") + else: + print("模式写入成功") + + # 2. 写速度 64000 → 0x6072 + print("--- 步骤2: 写速度 64000 到 0x6072 ---") + if not self._write_32bit(0x6072, 64000): + success = False + print("速度写入失败") + else: + print("速度写入成功") + + # 3. 写加速度 2400000 → 0x6067 + print("--- 步骤3: 写加速度 96000 到 0x6067 ---") + if not self._write_32bit(0x6067, 96000): + success = False + print("加速度写入失败") + else: + print("加速度写入成功") + + # 4. 写减速度 240000 → 0x6069 + print("--- 步骤4: 写减速度 96000 到 0x6069 ---") + if not self._write_32bit(0x6069, 96000): + success = False + print("减速度写入失败") + else: + print("减速度写入成功") + + if success: + print("电机初始化完成。") + else: + print("电机初始化过程中出现错误。") + return success + + def _read_single_register(self, address): + """读取单个16位寄存器""" + try: + result = self.client.read_holding_registers(address, 1, slave=self.slave_id) + if not result.isError(): + return result.registers[0] + else: + print(f"读取寄存器 0x{address:X} 失败: {result}") + return None + except Exception as e: + print(f"读取寄存器 0x{address:X} 异常: {e}") + return None + + def set_position(self, position): + """设置目标位置并立即启动(位置 0~impulse_max""" + if not self.client or not self.client.connected: + print("电机未连接,请先调用 connect()") + return False + + position = int(position / x_max * impulse_max) + + if not (0 <= position <= impulse_max): + print(f"位置值 {position} 超出范围 (0~{impulse_max})") + return False + + self._write_32bit(0x6074, position) + ret2 = self._write_single_register(0x6070, 112) + if not ret2: + print("第一次写入控制字失败,1ms后重试...") + time.sleep(0.001) + ret2 = self._write_single_register(0x6070, 112) + if ret2: + print("第二次重试成功") + else: + print("第二次重试仍然失败") + + return True + + def read_current_position(self): + """ + 读取电机当前位置(INT 型,32位有符号整数) + 从寄存器 0x600E 开始,连续读取 2 个保持寄存器 + 字节序:大端(与写操作一致) + :return: 当前位置值(int),读取失败返回 None + """ + if not self.client or not self.client.connected: + print("电机未连接,无法读取位置") + return None + + try: + result = self.client.read_holding_registers( + address=0x600E, + count=2, + slave=self.slave_id + ) + if result.isError(): + print(f"读取位置寄存器失败: {result}") + return None + + # 解码为 32 位有符号整数,使用与写操作相同的大端字节序 + decoder = BinaryPayloadDecoder.fromRegisters( + result.registers, + byteorder=Endian.BIG, + wordorder=Endian.BIG + ) + position = decoder.decode_32bit_int() + print(f"当前位置position: {position}") + position_x = position / impulse_max * x_max + return position_x + except Exception as e: + print(f"读取当前位置异常: {e}") + return None + + +# ---------- PC读取压力值 ---------- +class PressureModbusRTUClient: + """压力变送器 Modbus RTU 通讯客户端(读取16位压力值)""" + def __init__(self, port='/dev/cu.usbserial-D30JITMY', slave_id=1, baudrate=9600, bytesize=8, parity='N', stopbits=1, timeout=3): + """ + 初始化压力客户端 + :param port: 串口端口,如 COM3、/dev/ttyUSB0 + :param slave_id: 从站地址(站号),默认 1 + :param baudrate: 波特率,默认 9600 + :param bytesize: 数据位,默认 8 + :param parity: 校验位,默认 'N'(无校验) + :param stopbits: 停止位,默认 1 + :param timeout: 通讯超时时间(秒),默认 3 + """ + self.port = port + self.slave_id = slave_id + self.baudrate = baudrate + self.bytesize = bytesize + self.parity = parity + self.stopbits = stopbits + self.timeout = timeout + self.client = None + self.pressure_register_addr = 4 # 压力寄存器地址(04) + + def connect(self): + """连接压力变送器串口""" + self.client = ModbusSerialClient( + port=self.port, + baudrate=self.baudrate, + bytesize=self.bytesize, + parity=self.parity, + stopbits=self.stopbits, + timeout=self.timeout + ) + if self.client.connect(): + print(f"压力串口 {self.port} 连接成功 (站号 {self.slave_id})") + return True + else: + print(f"压力串口 {self.port} 连接失败") + return False + + def disconnect(self): + """断开压力串口""" + if self.client: + self.client.close() + self.client = None + print("压力串口已关闭") + + def get_current_p(self): + """ + 读取压力值 + :return: 压力值(整数),若读取失败返回 None + """ + if not self.client or not self.client.connected: + print("压力客户端未连接,请先调用 connect()") + return None + + try: + t1 = time.perf_counter() + # 读取保持寄存器(功能码03),地址4,个数1 + result = self.client.read_holding_registers( + address=self.pressure_register_addr, + count=1, + slave=self.slave_id + ) + if result.isError(): + print(f"压力读取错误: {result}") + return None + # 返回寄存器的第一个值(16位整数) + pressure_raw = result.registers[0] + # print(f"读压力用时:{time.perf_counter() - t1:.3f}s") + return pressure_raw + except ModbusException as e: + print(f"压力读取 Modbus 异常: {e}") + return None + except Exception as e: + print(f"压力读取未知异常: {e}") + return None + + +# ---------- 新增:MT2-AM8 模块 Modbus TCP 客户端 ---------- +class MT2AM8Client: + """ + 艾莫迅 MT2-AM8 模块的 Modbus TCP 通讯类 + - 默认 IP:192.168.1.12,端口 502,模块地址(站号)默认为 1 + - 输入寄存器(AI):地址 0x00~0x03(对应 PLC 地址 30001~30004) + - 保持寄存器(AO):地址 0x00~0x03(对应 PLC 地址 40001~40004) + - 模拟量值范围:0~4095(对应 0~10V 或 0~20mA) + """ + def __init__(self, host="192.168.1.12", port=502, slave_id=1, + pressure_range=400, flow_range=300): + self.host = host + self.port = port + self.slave_id = slave_id + self.pressure_range = pressure_range # 压力表量程上限 + self.flow_range = flow_range # 流量计量程上限 + self.client = ModbusTcpClient( + host=host, + port=port, + timeout=3, + retries=3 + ) + self.connected = False + + def connect(self): + """连接模块""" + try: + conn = self.client.connect() + if conn: + print(f"成功连接到 MT2-AM8 模块 {self.host}:{self.port}") + self.connected = True + else: + print(f"无法连接到 {self.host}:{self.port}") + self.connected = False + return conn + except Exception as e: + print(f"连接错误: {e}") + self.connected = False + return False + + def disconnect(self): + """断开连接""" + self.client.close() + self.connected = False + print("连接已关闭") + + def read_analog_input(self, channel): + """ + 读取单路模拟量输入原始值(16位无符号整数) + :param channel: 通道号 0~3(对应 AI1~AI4) + :return: 0~4095 的整数值,失败返回 None + """ + try: + result = self.client.read_input_registers( + address=channel, + count=1, + slave=self.slave_id + ) + if not result.isError(): + return result.registers[0] + else: + print(f"读取输入寄存器错误: {result}") + return None + except Exception as e: + print(f"读取模拟量输入异常: {e}") + return None + + # def read_all_analog_inputs(self): + # """ + # 一次性读取全部 4 路模拟量输入 + # :return: 长度为4的列表(int),失败返回 None + # """ + # try: + # result = self.client.read_input_registers( + # address=0, + # count=4, + # slave=self.slave_id + # ) + # if not result.isError(): + # return result.registers + # else: + # print(f"读取全部输入寄存器错误: {result}") + # return None + # except Exception as e: + # print(f"读取全部模拟量输入异常: {e}") + # return None + + def write_analog_output(self, channel, value): + """ + 写入单路模拟量输出(保持寄存器) + :param channel: 通道号 0~3(对应 AO1~AO4) + :param value: {volthege_min}~{volthege_max} 的整数值 + :return: True 成功,False 失败 + """ + # if not 0 <= channel <= 3: + # print("通道号必须为 0~3") + # return False + if not 0 <= value <= volthege_max: + print(f"值 {value} 超出范围 ({volthege_min}~{volthege_max})") + return False + try: + result = self.client.write_register( + address=channel, + value=value, + slave=self.slave_id + ) + return not result.isError() + except Exception as e: + print(f"写入模拟量输出异常: {e}") + return False + + # def write_all_analog_outputs(self, values): + # """ + # 一次性写入全部 4 路模拟量输出(用于批量设置) + # :param values: 长度为4的列表或元组,每个元素为 0~4095 + # :return: True 成功,False 失败 + # """ + # if len(values) != 4: + # print("需提供 4 个输出值") + # return False + # for v in values: + # if not 0 <= v <= 4095: + # print(f"值 {v} 超出范围 (0~4095)") + # return False + # try: + # result = self.client.write_registers( + # address=0, + # values=list(values), + # slave=self.slave_id + # ) + # return not result.isError() + # except Exception as e: + # print(f"批量写入模拟量输出异常: {e}") + # return False + + def get_pressure(self, channel): + """ + 读取压力值并转换为实际物理量 + 转换公式:raw / (volthege_max - volthege_min) * pressure_range + :param channel: 压力传感器模拟量通道地址 + :return: 实际压力值(kPa),失败返回 None + """ + raw = self.read_analog_input(channel) + if raw is None: + return None + pressure = (raw - volthege_min) / (volthege_max - volthege_min) * self.pressure_range + # print(f"读取压力通道 {channel} 原始值: {raw}, 转换后压力: {pressure:.2f} kPa") + return pressure + + def get_flow(self, channel): + """ + 读取流量值并转换为实际物理量 + 转换公式:raw / (volthege_max - volthege_min) * flow_range + :param channel: 流量计模拟量通道地址 + :return: 实际流量值(L/min),失败返回 None + """ + raw = self.read_analog_input(channel) + if raw is None: + return None + flow = (raw - volthege_min) / (volthege_max - volthege_min) * self.flow_range + # print(f"读取流量通道 {channel} 原始值: {raw}, 转换后流量: {flow:.2f} L/min") + return flow + + + # def set_motor_speed(self, voltage_percent): + # """ + # 通过模拟量输出控制电机(例如 0~100% 对应 0~10V) + # :param voltage_percent: 0~100 的浮点数,表示百分比 + # """ + # if not 0 <= voltage_percent <= 100: + # print("百分比需在 0~100 之间") + # return False + # # 将百分比映射到 0~4095 + # raw_value = int(voltage_percent / 100.0 * 4095) + # return self.write_analog_output(0, raw_value) # 假设电机接在 AO1 + + def set_motor_position(self, voltage_distance, channel=0): + """ + 通过模拟量输出控制电机(例如 0~1000 对应 0~10V) + :param voltage_distance: 0~1000 的浮点数,表示行程 + :param channel: 模拟量输出通道号,默认 0(AO1) + """ + if not (0 <= voltage_distance <= x_max): + print(f"行程需在 0~{x_max} 之间") + return False + # 将行程映射到 0~4095 + # Map the full travel range to the configured analog-output range. + # The lower endpoint must include volthege_min; otherwise position 0 + # produces raw value 0 and is rejected by write_analog_output(). + raw_value = int( + volthege_min + + voltage_distance / x_max * (volthege_max - volthege_min) + ) + # print(f"设置电机行程为 {voltage_distance},模拟量输出值 {raw_value}") + return self.write_analog_output(channel, raw_value) + + +# ---------- 主函数:测试示例 ---------- +# if __name__ == "__main__": +# # (可选)启用 pymodbus 详细日志,可观察底层收发帧 +# # logging.basicConfig() +# # logging.getLogger('pymodbus').setLevel(logging.DEBUG) +# +# motor = MotorModbusRTUClient() +# +# +# print("连接电机 (Modbus RTU)...") +# if not motor.connect(): +# print("电机连接失败,退出。") +# exit(1) +# +# # 增加短暂延时,等待驱动器接口就绪 +# time.sleep(1) +# +# print("初始化电机参数...") +# if not motor.init(): +# print("电机初始化失败,退出。") +# motor.disconnect() +# exit(1) +# +# print("\n========== 电机位置控制测试 ==========") +# print("输入目标位置 (0~60000),输入 'q' 退出。函数已修改,只需输入开度。\n") +# +# try: +# while True: +# user_input = input("目标位置: ").strip() +# if user_input.lower() in ('q', 'quit', 'exit'): +# break +# if not user_input: +# continue +# try: +# pos = int(user_input) +# motor.set_position(pos) +# except ValueError: +# print("错误:请输入有效的整数。") +# except KeyboardInterrupt: +# print("\n用户中断测试。") +# finally: +# motor.disconnect() +# print("程序结束。") diff --git a/__pycache__/PcControl.cpython-312.pyc b/__pycache__/PcControl.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d3305fb71b0300011024f7a61d16df21e33db547 GIT binary patch literal 32435 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 literal 0 HcmV?d00001 diff --git a/config.py b/config.py new file mode 100644 index 0000000..e5dadf2 --- /dev/null +++ b/config.py @@ -0,0 +1,97 @@ +"""气体流量闭环控制的集中配置。 + +首次连接真实设备前,请重点核对:MT2-AM8 地址、AI/AO 通道、压力上限、 +阀门打开端行程和关闭端行程。PID 参数是安全起步值,不是最终整定结果。 +""" + +# --------------------------------------------------------------------------- +# MT2-AM8 通讯与量程 +# --------------------------------------------------------------------------- + +MT2AM8_HOST = "192.168.1.12" +MT2AM8_PORT = 502 +MT2AM8_SLAVE_ID = 1 + +# MT2AM8Client 用这些量程把模拟量转换成物理量。 +PRESSURE_RANGE_KPA = 400.0 +FLOW_METER_RANGE_SLM = 300.0 + +# AI/AO 通道均从 0 开始。AI0 和 AO0 属于不同的寄存器区,可以同时使用。 +FLOW_INPUT_CHANNEL = 0 +PRESSURE_INPUT_CHANNEL = 1 # 没有压力传感器时改为 None +MOTOR_OUTPUT_CHANNEL = 0 + +# --------------------------------------------------------------------------- +# 控制目标和控制周期 +# --------------------------------------------------------------------------- + +TARGET_FLOW_MIN_SLM = 0.0 +TARGET_FLOW_MAX_SLM = 100.0 +ZERO_FLOW_THRESHOLD_SLM = 0.5 + +CONTROL_PERIOD_S = 0.1 # 100 ms,10 Hz +MAX_CONTROL_DT_S = 0.3 # 超过此值视为控制循环严重超时 +STATUS_PRINT_PERIOD_S = 1.0 + +# --------------------------------------------------------------------------- +# PID:输出统一定义为阀门开度百分比 +# --------------------------------------------------------------------------- + +# 第一版建议先按 PI 使用(KD=0),然后通过阶跃实验重新整定。 +PID_KP = 0.5 +PID_KI = 0.1 +PID_KD = 0.0 + +OPENING_MIN_PCT = 0.0 +OPENING_MAX_PCT = 100.0 +MAX_OPENING_RATE_PCT_S = 20.0 # 每秒最多改变 20% 开度 + +# --------------------------------------------------------------------------- +# 阀门执行机构 +# --------------------------------------------------------------------------- + +# 电机行程越大,阀门开度越小。 +# opening=0% -> MOTOR_CLOSED_POSITION(完全关闭) +# opening=100% -> MOTOR_OPEN_POSITION(打开端/机械死区边界) +MOTOR_CLOSED_POSITION = 1000.0 +MOTOR_OPEN_POSITION = 240.0 + +# --------------------------------------------------------------------------- +# 安全阈值 +# --------------------------------------------------------------------------- + +# 物理读数允许范围;超过范围立即关阀。 +FLOW_VALID_MIN_SLM = -2.0 +FLOW_VALID_MAX_SLM = 120.0 +MAX_PRESSURE_KPA = 350.0 # 必须按实际管路额定压力确认 + +# 短暂读取失败时保持上一输出,不下发新位置;连续达到阈值后关阀。 +MAX_CONSECUTIVE_FLOW_FAILURES = 3 +MAX_CONSECUTIVE_PRESSURE_FAILURES = 3 + +# 日志目录相对于工程目录。 +LOG_DIRECTORY = "logs" +LOG_FILE_NAME = "flow_control.log" + + +def validate_config(): + """在接触硬件前检查明显的配置错误。""" + if CONTROL_PERIOD_S <= 0: + raise ValueError("CONTROL_PERIOD_S 必须大于 0") + if MAX_CONTROL_DT_S < CONTROL_PERIOD_S: + raise ValueError("MAX_CONTROL_DT_S 不得小于 CONTROL_PERIOD_S") + if TARGET_FLOW_MIN_SLM > TARGET_FLOW_MAX_SLM: + raise ValueError("目标流量上下限配置错误") + if not 0 <= OPENING_MIN_PCT < OPENING_MAX_PCT <= 100: + raise ValueError("阀门开度范围必须位于 0~100%,且下限小于上限") + if MOTOR_OPEN_POSITION >= MOTOR_CLOSED_POSITION: + raise ValueError("本系统行程越大开度越小,因此打开端行程必须小于关闭端行程") + if MOTOR_OPEN_POSITION < 0 or MOTOR_CLOSED_POSITION > 1000: + raise ValueError("电机行程必须位于 MT2AM8Client 当前采用的 0~1000 范围") + if MAX_OPENING_RATE_PCT_S <= 0: + raise ValueError("MAX_OPENING_RATE_PCT_S 必须大于 0") + if MAX_CONSECUTIVE_FLOW_FAILURES < 1: + raise ValueError("MAX_CONSECUTIVE_FLOW_FAILURES 必须至少为 1") + if PRESSURE_INPUT_CHANNEL is not None and MAX_PRESSURE_KPA is None: + raise ValueError("启用压力通道时必须配置 MAX_PRESSURE_KPA") + diff --git a/controllers (2).py b/controllers (2).py new file mode 100644 index 0000000..cd007dc --- /dev/null +++ b/controllers (2).py @@ -0,0 +1,145 @@ +# controllers.py +import os, sys, time + + +def _pid_log(msg: str): + """PID 内部日志,直接写文件 + 刷盘""" + try: + log_dir = os.path.join(os.path.dirname(sys.executable), "logs") + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, "control_debug.log") + with open(log_file, "a", encoding="utf-8") as f: + f.write(f"[{time.strftime('%H:%M:%S.%f')[:-3]}] [PID] {msg}\n") + f.flush() + os.fsync(f.fileno()) + except Exception: + pass + + +class IncrementalPID: + """增量式PID控制器""" + + def __init__(self, kp: float, ki: float, kd: float, dt: float, + out_min: float, out_max: float, xa_full: float = 1062.5): + # PID参数 + self.kp = kp + self.ki = ki + self.kd = kd + self.dt = dt # 默认50HZ执行周期防止除零 + self.motor_max = 300.0 + self.du_max = self.motor_max * self.dt + self.dead_area = 240.0 + self.xa_full = xa_full # 总限幅(最大行程) + + # self.kp = 1.392 + # self.ki = 30.2 + # self.kd = 0.000485 + # self.dt = 0.0059 + + # 限幅设置 + self.out_min = out_min + self.out_max = out_max + + # 输入输出 + self.target_pressure = 0.0 # 参考值(设定压力大小) + self.current_pressure = 0.0 # 反馈值 + self.error = 0.0 # 当前误差 + + # 计算系数 + self.a0 = 0.0 + self.a1 = 0.0 + self.a2 = 0.0 + self._calculate_coefficients() + + # 控制器状态 + self.prev_error = 0.0 # 前次误差 e(k-1) + self.prev_error2 = 0.0 # 前前次误差 e(k-2) + self.output = 0.0 # 控制器总输出 + + def _calculate_coefficients(self): + """重新计算增量式PID系数""" + if self.dt <= 0: + return + self.a0 = self.kp + (self.ki * self.dt / 2.0) + (2.0 * self.kd / self.dt) + self.a1 = -self.kp + (self.ki * self.dt / 2.0) - (4.0 * self.kd / self.dt) + self.a2 = (2.0 * self.kd) / self.dt + + def update_pressure_values(self, current_pressure, target_pressure): + """更新当前压力和目标压力值""" + self.current_pressure = current_pressure + self.target_pressure = target_pressure + + def update(self, du_max=None): + # 计算当前误差 + self.error = -(self.target_pressure - self.current_pressure) + + # if abs(self.error) < 1: + # return self.output # 误差过小,直接返回当前输出 + + # 计算控制增量 + delta = (self.a0 * self.error + self.a1 * self.prev_error + self.a2 * self.prev_error2) + + if du_max is not None: + self.du_max = du_max + else: + self.du_max = self.get_du_max(self.target_pressure) + + # 纯 Python 限幅(替代 np.clip) + if delta > self.du_max: + delta = self.du_max + elif delta < -self.du_max: + delta = -self.du_max + + # 计算新输出 + new_output = self.output + delta + # print(f"output:{self.output}, delta:{delta}, new_output:{new_output}") + + # 应用输出限幅 + new_output = max(self.out_min, min(self.out_max, new_output)) + + # 更新历史状态 + self.prev_error2 = self.prev_error + self.prev_error = self.error + self.output = new_output + return new_output + + def reset(self): + """重置PID控制器状态(保留参数)""" + self.prev_error = 0.0 + self.prev_error2 = 0.0 + self.output = 0.0 + + def update_parameters(self, kp: float, ki: float, kd: float): + self.kp = kp + self.ki = ki + self.kd = kd + self._calculate_coefficients() + + def set_du_max(self, value): + """设置 du_max(供外部模块通过方法调用设置,避免跨 .pyd 属性写入 crash)""" + self.du_max = value + + def get_du_max(self, target_pressure): + """根据目标压力计算 PID 最大增量限幅(纯 Python 线性插值)""" + x = float(target_pressure) + dt = float(self.dt) + + if x <= 0.0: + val = 500.0 * dt + + elif x >= 200.0: + val = 250.0 * dt + + elif x <= 100.0: + # 0~100:从 500 线性下降到 300 + val = (500.0 - 2.0 * x) * dt + + else: + # 100~200:从 300 线性下降到 250 + val = (350.0 - 0.5 * x) * dt + + return val + + def init_v(self, position_x): + v = (self.xa_full - position_x) / (self.xa_full - self.dead_area) * 100 + return v diff --git a/controllers.py b/controllers.py new file mode 100644 index 0000000..6f92984 --- /dev/null +++ b/controllers.py @@ -0,0 +1,211 @@ +"""Generic incremental PID controller. + +The controller operates on a generic setpoint and measurement. Any actuator +mapping (for example, converting valve opening to motor travel) belongs in the +caller or hardware layer, not in this module. +""" + + +class IncrementalPID: + """Incremental PID controller with output and output-rate limits. + + ``error`` is always calculated as ``setpoint - measurement``. The output + is the accumulated controller command, bounded by ``out_min`` and + ``out_max``. + + ``output_rate_limit`` is expressed in output units per second. When it is + set, the maximum output change in one update is + ``output_rate_limit * dt``. ``du_max`` remains available as a legacy + per-update limit through :meth:`set_du_max` or the ``update`` keyword. + ``xa_full`` is accepted for compatibility with older callers but is not + used; actuator travel and dead-zone mapping do not belong in a PID. + """ + + def __init__( + self, + kp: float, + ki: float, + kd: float, + dt: float, + out_min: float, + out_max: float, + xa_full=None, + output_rate_limit=None, + ): + if dt <= 0: + raise ValueError("dt must be greater than zero") + if out_min > out_max: + raise ValueError("out_min must not be greater than out_max") + if output_rate_limit is not None and output_rate_limit < 0: + raise ValueError("output_rate_limit must not be negative") + + self.kp = float(kp) + self.ki = float(ki) + self.kd = float(kd) + self.dt = float(dt) + self.out_min = float(out_min) + self.out_max = float(out_max) + self.output_rate_limit = ( + None if output_rate_limit is None else float(output_rate_limit) + ) + + # Kept only as a compatibility attribute. It has no control meaning + # in this generic controller and is intentionally not used. + self.xa_full = xa_full + + self.setpoint = 0.0 + self.measurement = 0.0 + self.error = 0.0 + + self.a0 = 0.0 + self.a1 = 0.0 + self.a2 = 0.0 + self._calculate_coefficients() + + self.prev_error = 0.0 + self.prev_error2 = 0.0 + self.output = 0.0 + + # Legacy per-update increment limit. The newer + # output_rate_limit takes precedence when configured. + self.du_max = None + + def _calculate_coefficients(self): + """Calculate the discrete incremental-PID coefficients.""" + self.a0 = self.kp + (self.ki * self.dt / 2.0) + (2.0 * self.kd / self.dt) + self.a1 = -self.kp + (self.ki * self.dt / 2.0) - (4.0 * self.kd / self.dt) + self.a2 = (2.0 * self.kd) / self.dt + + def set_values(self, measurement, setpoint): + """Set the current measurement and desired setpoint.""" + self.measurement = float(measurement) + self.setpoint = float(setpoint) + + # Public aliases retained for older code that still reads these names. + # They are plain aliases and do not add pressure-specific control logic. + @property + def current_pressure(self): + return self.measurement + + @current_pressure.setter + def current_pressure(self, value): + self.measurement = float(value) + + @property + def target_pressure(self): + return self.setpoint + + @target_pressure.setter + def target_pressure(self, value): + self.setpoint = float(value) + + def update_values(self, measurement, setpoint): + """Compatibility-friendly alias for :meth:`set_values`.""" + self.set_values(measurement, setpoint) + + def update_pressure_values(self, current_pressure, target_pressure): + """Legacy alias; use :meth:`set_values` for new code.""" + self.set_values(current_pressure, target_pressure) + + def update( + self, + measurement=None, + setpoint=None, + *, + dt=None, + output_rate_limit=None, + du_max=None, + ): + """Run one controller step and return the bounded output. + + ``measurement`` and ``setpoint`` may be omitted when they were already + supplied with :meth:`set_values` (or the legacy alias). ``dt`` is an + optional per-step override and is measured in seconds. + + ``output_rate_limit`` is a per-second limit. The legacy ``du_max`` + keyword is a per-update limit and takes precedence for that call. + """ + if measurement is not None: + self.measurement = float(measurement) + if setpoint is not None: + self.setpoint = float(setpoint) + + if dt is not None: + if dt <= 0: + raise ValueError("dt must be greater than zero") + if float(dt) != self.dt: + self.dt = float(dt) + self._calculate_coefficients() + + if output_rate_limit is not None: + if output_rate_limit < 0: + raise ValueError("output_rate_limit must not be negative") + rate_limit = float(output_rate_limit) + else: + rate_limit = self.output_rate_limit + + self.error = self.setpoint - self.measurement + delta = ( + self.a0 * self.error + + self.a1 * self.prev_error + + self.a2 * self.prev_error2 + ) + + if du_max is not None: + if du_max < 0: + raise ValueError("du_max must not be negative") + self.du_max = float(du_max) + + if du_max is not None: + max_delta = float(du_max) + elif rate_limit is not None: + max_delta = rate_limit * self.dt + elif self.du_max is not None: + max_delta = abs(float(self.du_max)) + else: + max_delta = None + + if max_delta is not None: + delta = max(-max_delta, min(max_delta, delta)) + + new_output = self.output + delta + new_output = max(self.out_min, min(self.out_max, new_output)) + + self.prev_error2 = self.prev_error + self.prev_error = self.error + self.output = new_output + return new_output + + def reset(self, initial_output=0.0): + """Reset controller history and initialize the output command.""" + initial_output = float(initial_output) + self.output = max(self.out_min, min(self.out_max, initial_output)) + self.prev_error = 0.0 + self.prev_error2 = 0.0 + self.error = 0.0 + + def update_parameters(self, kp: float, ki: float, kd: float): + """Update PID gains and recalculate the discrete coefficients.""" + self.kp = float(kp) + self.ki = float(ki) + self.kd = float(kd) + self._calculate_coefficients() + + def set_dt(self, dt: float): + """Set the controller period in seconds.""" + if dt <= 0: + raise ValueError("dt must be greater than zero") + self.dt = float(dt) + self._calculate_coefficients() + + def set_output_rate_limit(self, value): + """Set or clear the output slew-rate limit in output units/second.""" + if value is not None and value < 0: + raise ValueError("output_rate_limit must not be negative") + self.output_rate_limit = None if value is None else float(value) + + def set_du_max(self, value): + """Legacy setter for a maximum output change per update.""" + if value is not None and value < 0: + raise ValueError("du_max must not be negative") + self.du_max = None if value is None else float(value) diff --git a/flow_control.py b/flow_control.py new file mode 100644 index 0000000..e5466a2 --- /dev/null +++ b/flow_control.py @@ -0,0 +1,458 @@ +"""流量闭环控制层。 + +本模块只负责“读取传感器 -> 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=100.0, + zero_flow_threshold_slm=0.5, + flow_valid_min_slm=-2.0, + flow_valid_max_slm=120.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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..642ba80 --- /dev/null +++ b/main.py @@ -0,0 +1,213 @@ +"""气体流量闭环控制程序入口。 + +示例: + python main.py --target 50 + +程序启动后会先下发关闭位置,再开始闭环。Ctrl+C、控制故障或其他异常都会 +再次尝试关阀并断开 MT2-AM8。 +""" + +import argparse +import logging +from pathlib import Path +import sys +import time + +import config +from controllers import IncrementalPID +from flow_control import FlowControlFault, FlowControlLoop + + +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) + logger.addHandler(console_handler) + return logger + + +def build_control_loop(hardware, logger): + pid = IncrementalPID( + kp=config.PID_KP, + ki=config.PID_KI, + kd=config.PID_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_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 + ), + 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 = 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 + 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, + }, + ) + + if not controller.safe_close("STARTUP"): + raise FlowControlFault( + "SAFE_CLOSE_FAILED", + "STARTUP", + "启动前无法确认阀门关闭命令已成功写入", + {"motor_closed_position": config.MOTOR_CLOSED_POSITION}, + ) + + controller.start(initial_opening=0.0) + logger.info("闭环控制已启动;按 Ctrl+C 停止") + + next_deadline = time.perf_counter() + next_status_time = next_deadline + while True: + now = time.perf_counter() + result = controller.step(now=now) + + 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: + logger.info("收到 Ctrl+C,正在停止控制") + except FlowControlFault as exc: + exit_code = 2 + logger.critical("控制故障:%s", exc) + except Exception: + exit_code = 3 + logger.exception("未处理异常,系统将进入安全关闭") + finally: + if connected: + close_ok = controller.safe_close("PROGRAM_EXIT") + if not close_ok: + exit_code = max(exit_code, 4) + logger.critical("程序退出时安全关阀失败,请立即人工检查") + try: + hardware.disconnect() + except Exception: + exit_code = max(exit_code, 5) + logger.exception("断开 MT2-AM8 时发生异常") + 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()) diff --git a/问题.md b/问题.md new file mode 100644 index 0000000..e3ef3c1 --- /dev/null +++ b/问题.md @@ -0,0 +1,3 @@ +1.PcControl.py中set_motor_position方法对于电机行程到模拟量的映射是否考虑了volthege_min不为0的情况?原版未考虑,现版暂时考虑了进去 +2.最大行程是1000还是1062.5?PcControl.py中是1000,而controllers.py中是1062.5 +3.init_v预置初始阀门开度是否有用到?read_motor_position读取电机行程,现在是否能做到? \ No newline at end of file