按 plan.md 新增三个交付物,全部默认关闭以保持与纯 PID 的 A/B 兼容: - valve_model.py:阀特性模型 Q_ss = A_eff(x)·P1_abs·F(r),含 ISO 6358 椭圆 F(r)、行程/面积互逆插值、单调性校验、JSON 存取。 - identify_valve.py:从 open_loop.py 扫点 CSV 拟合 A_eff 表并输出 JSON。 - 控制器集成:config/flow_control/main/data_logger 新增阀前压读取 (channel 2)、前馈打底 + PI 修残差、按 P1 增益调度,并记录/绘制前馈项。 新增配置 FEEDFORWARD_ENABLED / GAIN_SCHEDULE_ENABLED 默认 False, VALVE_MODEL_PATH 默认空,未加载模型时行为与原先纯 PID 完全一致。
196 lines
6.6 KiB
Python
196 lines
6.6 KiB
Python
"""阀特性模型 + 查找表 + 前馈反解(纯计算,不依赖硬件)。
|
||
|
||
把静态阀特性 ``Q_ss = A_eff(x) * P1_abs * F(r)`` 落成一个可离线测试的模型:
|
||
给定目标流量 Q_set 与当前阀前/阀后压力,反解出“应该给多大行程 x”。控制器在
|
||
运行时只调用 :meth:`ValveModel.flow_ss` 与 :meth:`ValveModel.feedforward_stroke`,
|
||
不跑任何动态过程。
|
||
"""
|
||
|
||
import json
|
||
import math
|
||
|
||
P_ATM = 101.325 # 标准大气压(kPa)
|
||
CRITICAL_RATIO = 0.528 # 空气(γ=1.4)的临界压力比
|
||
|
||
|
||
def abs_pressure(kpa_gauge):
|
||
"""把表压读数换算成绝对压力(kPa)。"""
|
||
return float(kpa_gauge) + P_ATM
|
||
|
||
|
||
def f_ratio(r):
|
||
"""压比函数 F(r)(ISO 6358 椭圆平滑,r 为绝对压比 P2_abs/P1_abs)。
|
||
|
||
阻塞流(r <= 0.528)时 F=1;亚声速(0.528 < r < 1)时按椭圆衰减;
|
||
r >= 1 时无正向流,F=0。
|
||
"""
|
||
r = float(r)
|
||
if not math.isfinite(r):
|
||
return math.nan
|
||
if r <= CRITICAL_RATIO:
|
||
return 1.0
|
||
if r >= 1.0:
|
||
return 0.0
|
||
width = 1.0 - CRITICAL_RATIO
|
||
return math.sqrt(1.0 - ((r - CRITICAL_RATIO) / width) ** 2)
|
||
|
||
|
||
class ValveModel:
|
||
"""静态阀特性模型:A_eff(x) 查找表 + 前馈反解。
|
||
|
||
表以电机行程 x 为键(物理真值),控制器里再用线性反算换回开度。
|
||
``area_table`` 是按 x 升序的 ``(x, A_eff)`` 列表。
|
||
"""
|
||
|
||
def __init__(self, area_table, motor_open, motor_closed):
|
||
motor_open = float(motor_open)
|
||
motor_closed = float(motor_closed)
|
||
if motor_open >= motor_closed:
|
||
raise ValueError("打开端行程必须小于关闭端行程")
|
||
|
||
table = [(float(x), float(a)) for (x, a) in area_table]
|
||
if not table:
|
||
raise ValueError("A_eff 表不能为空")
|
||
table.sort(key=lambda item: item[0])
|
||
# 去掉完全重复的 x(保留最后一个),避免插值除以零。
|
||
deduped = []
|
||
for x, a in table:
|
||
if deduped and deduped[-1][0] == x:
|
||
deduped[-1] = (x, a)
|
||
else:
|
||
deduped.append((x, a))
|
||
table = deduped
|
||
if len(table) < 2:
|
||
raise ValueError("A_eff 表至少需要两个不同行程的点")
|
||
|
||
for x, a in table:
|
||
if not (math.isfinite(x) and math.isfinite(a)):
|
||
raise ValueError(f"A_eff 表包含非有限值: x={x}, A_eff={a}")
|
||
if a < 0.0:
|
||
raise ValueError(f"A_eff 不得为负: x={x}, A_eff={a}")
|
||
|
||
ys = [a for (_, a) in table]
|
||
if not _is_monotonic(ys):
|
||
raise ValueError(
|
||
"A_eff(x) 非单调:反解无法唯一。请检查扫点数据,"
|
||
"缩小范围到单调段后重扫。"
|
||
)
|
||
|
||
self.area_table = table
|
||
self.motor_open = motor_open
|
||
self.motor_closed = motor_closed
|
||
self._xs = [x for (x, _) in table]
|
||
self._ys = ys
|
||
|
||
# ------------------------------------------------------------------ 查询
|
||
|
||
def area_from_stroke(self, x):
|
||
"""线性插值查 A_eff(x);越界钳位到表端点值。"""
|
||
return _interp(float(x), self._xs, self._ys)
|
||
|
||
def stroke_from_area(self, a):
|
||
"""反查 x(A_eff);表单调,越界钳位到端点行程。"""
|
||
a = float(a)
|
||
xs = self._xs
|
||
ys = self._ys
|
||
increasing = ys[0] <= ys[-1]
|
||
|
||
if increasing:
|
||
y_min, y_min_x = ys[0], xs[0]
|
||
y_max, y_max_x = ys[-1], xs[-1]
|
||
else:
|
||
y_min, y_min_x = ys[-1], xs[-1]
|
||
y_max, y_max_x = ys[0], xs[0]
|
||
|
||
if a <= y_min:
|
||
return y_min_x
|
||
if a >= y_max:
|
||
return y_max_x
|
||
|
||
for i in range(len(ys) - 1):
|
||
y0, y1 = ys[i], ys[i + 1]
|
||
if y0 == y1:
|
||
continue
|
||
if (y0 <= a <= y1) or (y1 <= a <= y0):
|
||
t = (a - y0) / (y1 - y0)
|
||
return xs[i] + t * (xs[i + 1] - xs[i])
|
||
# 理论走不到这里(a 严格落在 y_min/y_max 之间且表单调)。
|
||
return xs[0]
|
||
|
||
# ------------------------------------------------------------ 静态特性
|
||
|
||
def flow_ss(self, x, p1_kpa, p2_kpa):
|
||
"""给定行程 x 与阀前/阀后表压,返回稳态流量 Q_ss(slm)。"""
|
||
p1_abs = abs_pressure(p1_kpa)
|
||
if p1_abs <= 0.0:
|
||
return 0.0
|
||
p2_abs = abs_pressure(p2_kpa)
|
||
r = p2_abs / p1_abs
|
||
return self.area_from_stroke(x) * p1_abs * f_ratio(r)
|
||
|
||
def feedforward_stroke(self, q_set, p1_kpa, p2_kpa):
|
||
"""给定目标流量与阀前/阀后表压,反解行程 x(前馈)。"""
|
||
q_set = float(q_set)
|
||
p1_abs = abs_pressure(p1_kpa)
|
||
if p1_abs <= 0.0:
|
||
raise ValueError("阀前绝对压力必须大于 0")
|
||
p2_abs = abs_pressure(p2_kpa)
|
||
r = p2_abs / p1_abs
|
||
f = f_ratio(r)
|
||
denom = p1_abs * f
|
||
if denom <= 0.0:
|
||
raise ValueError(
|
||
f"压比 r={r:.4f} 下无正向流量(F={f:.4f}),无法前馈反解"
|
||
)
|
||
a_req = q_set / denom
|
||
return self.stroke_from_area(a_req)
|
||
|
||
# ------------------------------------------------------------------ 存取
|
||
|
||
def save(self, path):
|
||
data = {
|
||
"motor_open": self.motor_open,
|
||
"motor_closed": self.motor_closed,
|
||
"area_table": [[x, a] for (x, a) in self.area_table],
|
||
}
|
||
with open(path, "w", encoding="utf-8") as file:
|
||
json.dump(data, file, indent=2, ensure_ascii=False)
|
||
|
||
@classmethod
|
||
def load(cls, path, motor_open, motor_closed):
|
||
with open(path, "r", encoding="utf-8") as file:
|
||
data = json.load(file)
|
||
area_table = [tuple(item) for item in data["area_table"]]
|
||
return cls(area_table, motor_open, motor_closed)
|
||
|
||
|
||
def _is_monotonic(ys):
|
||
"""序列是否单调(允许相等,但不允许中途反向)。"""
|
||
direction = 0
|
||
for i in range(1, len(ys)):
|
||
delta = ys[i] - ys[i - 1]
|
||
if delta == 0:
|
||
continue
|
||
sign = 1 if delta > 0 else -1
|
||
if direction == 0:
|
||
direction = sign
|
||
elif direction != sign:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _interp(x, xs, ys):
|
||
"""在按 x 升序的表中线性插值;越界钳位到端点。"""
|
||
if x <= xs[0]:
|
||
return ys[0]
|
||
if x >= xs[-1]:
|
||
return ys[-1]
|
||
for i in range(len(xs) - 1):
|
||
x0, x1 = xs[i], xs[i + 1]
|
||
if x0 <= x <= x1:
|
||
if x1 == x0:
|
||
return ys[i]
|
||
t = (x - x0) / (x1 - x0)
|
||
return ys[i] + t * (ys[i + 1] - ys[i])
|
||
return ys[-1]
|