first commit
This commit is contained in:
@@ -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
|
||||||
+824
@@ -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("程序结束。")
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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")
|
||||||
|
|
||||||
@@ -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
|
||||||
+211
@@ -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)
|
||||||
+458
@@ -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
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user