实现流量控制前馈 + 增益调度(MFC 结构)

按 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 完全一致。
This commit is contained in:
2026-08-27 16:00:51 +08:00
parent d7e712ede7
commit a03735387c
7 changed files with 958 additions and 20 deletions
+36
View File
@@ -83,6 +83,27 @@ MOTOR_OPEN_POSITION = 800.0
# 控制启动时的阀门初始开度;增量 PID 从该开度开始累加 Δu。
INITIAL_OPENING_PCT = 100.0
# ---------------------------------------------------------------------------
# 前馈 + 增益调度(默认关闭,便于与纯 PID 做 A/B 对比;接线确认后再开启)
# ---------------------------------------------------------------------------
# 阀前压力 P1(前馈缩放 / 判阻塞 / 增益调度用),按实际接线确认。
PRESSURE_BEFORE_CHANNEL = 2
# 阀特性表 JSONidentify_valve.py 产出)。空字符串表示关闭前馈。
VALVE_MODEL_PATH = ""
# 前馈反解打底 + PI 修残差(市面 MFC 结构)。
FEEDFORWARD_ENABLED = False
# 前馈开启时,PI 输出是“围绕前馈的修正量”,限幅为对称小范围(±%)。
FEEDFORWARD_CORRECTION_BAND_PCT = 20.0
# PI 增益按阀前压力 P1 缩放(摊平 K_x ∝ P1 的非线性)。
GAIN_SCHEDULE_ENABLED = False
GAIN_SCHEDULE_REF_ABS_KPA = 501.325 # 400 + 101.325,满压时调好的基准增益对应压力
GAIN_SCHEDULE_FLOOR_ABS_KPA = 60.0 # 绝对压力下限,防止 P1 很低时增益爆炸
GAIN_SCHEDULE_SCALE_CHANGE_THRESHOLD = 0.05 # scale 相对变化超过此值才重算增益
# ---------------------------------------------------------------------------
# 安全阈值
# ---------------------------------------------------------------------------
@@ -162,3 +183,18 @@ def validate_config():
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")
if PRESSURE_BEFORE_CHANNEL is not None:
if not isinstance(PRESSURE_BEFORE_CHANNEL, int):
raise ValueError("PRESSURE_BEFORE_CHANNEL 必须为整数通道号或 None")
if PRESSURE_BEFORE_CHANNEL < 0:
raise ValueError("PRESSURE_BEFORE_CHANNEL 不得为负数")
if MAX_PRESSURE_KPA is None:
raise ValueError("启用阀前压力通道时必须配置 MAX_PRESSURE_KPA")
if GAIN_SCHEDULE_REF_ABS_KPA <= 0:
raise ValueError("GAIN_SCHEDULE_REF_ABS_KPA 必须大于 0")
if GAIN_SCHEDULE_FLOOR_ABS_KPA <= 0:
raise ValueError("GAIN_SCHEDULE_FLOOR_ABS_KPA 必须大于 0")
if not 0 < FEEDFORWARD_CORRECTION_BAND_PCT <= 100:
raise ValueError("FEEDFORWARD_CORRECTION_BAND_PCT 必须位于 0~100")
if GAIN_SCHEDULE_SCALE_CHANGE_THRESHOLD <= 0:
raise ValueError("GAIN_SCHEDULE_SCALE_CHANGE_THRESHOLD 必须大于 0")
+25
View File
@@ -13,8 +13,12 @@ CSV_FIELDS = (
"measured_flow_slm",
"opening_pct",
"pressure_kpa",
"pressure_before_kpa",
"error_slm",
"motor_position",
"feedforward_pct",
"correction_pct",
"gain_scale",
"actual_dt_s",
"status",
)
@@ -59,8 +63,14 @@ class FlowRunRecorder:
),
"opening_pct": self._format_value(result.opening_pct),
"pressure_kpa": self._format_value(result.pressure_kpa),
"pressure_before_kpa": self._format_value(
result.pressure_before_kpa
),
"error_slm": self._format_value(result.error_slm),
"motor_position": self._format_value(result.motor_position),
"feedforward_pct": self._format_value(result.feedforward_pct),
"correction_pct": self._format_value(result.correction_pct),
"gain_scale": self._format_value(result.gain_scale),
"actual_dt_s": self._format_value(result.actual_dt_s),
"status": result.status,
}
@@ -93,6 +103,7 @@ class FlowRunRecorder:
target_flow = []
measured_flow = []
opening = []
feedforward = []
pressure = []
with self.csv_path.open("r", newline="", encoding="utf-8-sig") as file:
@@ -103,6 +114,9 @@ class FlowRunRecorder:
self._float_or_nan(row["measured_flow_slm"])
)
opening.append(self._float_or_nan(row["opening_pct"]))
feedforward.append(
self._float_or_nan(row.get("feedforward_pct"))
)
pressure.append(self._float_or_nan(row["pressure_kpa"]))
figure, axes = plt.subplots(
@@ -138,7 +152,18 @@ class FlowRunRecorder:
opening,
color="#2E7D32",
linewidth=1.4,
label="Opening",
)
if any(math.isfinite(value) for value in feedforward):
axes[1].plot(
time_s,
feedforward,
color="#EF6C00",
linewidth=1.2,
linestyle="--",
label="Feedforward",
)
axes[1].legend(loc="best")
axes[1].set_ylabel("Opening (%)")
axes[1].set_title("Valve opening")
axes[1].set_ylim(-2, 102)
+243 -15
View File
@@ -10,6 +10,10 @@ import time
from typing import Optional
# 标准大气压(kPa)。增益调度把表压换算成绝对压力时使用。
_P_ATM_KPA = 101.325
@dataclass
class ControlStepResult:
"""一个控制周期的可记录结果。"""
@@ -23,6 +27,10 @@ class ControlStepResult:
motor_position: float
actual_dt_s: float
status: str = "OK"
pressure_before_kpa: Optional[float] = None
feedforward_pct: Optional[float] = None
correction_pct: Optional[float] = None
gain_scale: Optional[float] = None
def to_dict(self):
return asdict(self)
@@ -89,6 +97,14 @@ class FlowControlLoop:
max_control_dt_s=None,
max_consecutive_flow_failures=3,
max_consecutive_pressure_failures=3,
pressure_before_channel=None,
valve_model=None,
feedforward_enabled=False,
feedforward_correction_band_pct=20.0,
gain_schedule_enabled=False,
gain_schedule_ref_abs_kpa=501.325,
gain_schedule_floor_abs_kpa=60.0,
gain_schedule_scale_change_threshold=0.05,
pid_far_gains=(0.5, 0.5, 0.0),
pid_near_gains=(0.05, 0.01, 0.0),
opening_rate_far_pct_s=20.0,
@@ -174,6 +190,53 @@ class FlowControlLoop:
self.max_consecutive_pressure_failures = int(
max_consecutive_pressure_failures
)
self.pressure_before_channel = (
None if pressure_before_channel is None else int(pressure_before_channel)
)
if self.pressure_before_channel is not None and self.max_pressure_kpa is None:
raise ValueError("启用阀前压力监测时必须设置 max_pressure_kpa")
self.valve_model = valve_model
self.feedforward_enabled = bool(feedforward_enabled)
feedforward_band = float(feedforward_correction_band_pct)
if not math.isfinite(feedforward_band) or not 0.0 < feedforward_band <= 100.0:
raise ValueError("feedforward_correction_band_pct 必须位于 0~100")
self.feedforward_correction_band_pct = feedforward_band
self.gain_schedule_enabled = bool(gain_schedule_enabled)
self.gain_schedule_ref_abs_kpa = float(gain_schedule_ref_abs_kpa)
self.gain_schedule_floor_abs_kpa = float(gain_schedule_floor_abs_kpa)
if not math.isfinite(self.gain_schedule_ref_abs_kpa) or self.gain_schedule_ref_abs_kpa <= 0.0:
raise ValueError("gain_schedule_ref_abs_kpa 必须大于 0")
if not math.isfinite(self.gain_schedule_floor_abs_kpa) or self.gain_schedule_floor_abs_kpa <= 0.0:
raise ValueError("gain_schedule_floor_abs_kpa 必须大于 0")
self.gain_schedule_scale_change_threshold = float(
gain_schedule_scale_change_threshold
)
if (
not math.isfinite(self.gain_schedule_scale_change_threshold)
or self.gain_schedule_scale_change_threshold <= 0.0
):
raise ValueError("gain_schedule_scale_change_threshold 必须大于 0")
# 前馈需要阀特性表 + 阀前压力 P1 + 阀后压力 P2 三样齐全才生效。
self._feedforward_active = bool(
self.feedforward_enabled
and self.valve_model is not None
and self.pressure_before_channel is not None
and self.pressure_channel is not None
)
# 前馈开启时 PID 输出是“围绕前馈的修正量”,限幅改为对称小范围。
if self._feedforward_active:
self.pid.out_min = -self.feedforward_correction_band_pct
self.pid.out_max = self.feedforward_correction_band_pct
# 增益调度当前 scale 与当前档位基础增益,供 _apply_effective_gains 使用。
self._current_scale = 1.0
self._current_base_gains = far_gains
self._last_feedforward_opening = None
self.pid_far_gains = far_gains
self.pid_near_gains = near_gains
self.opening_rate_far_pct_s = far_rate
@@ -188,11 +251,13 @@ class FlowControlLoop:
self.target_flow_slm = 0.0
self.last_flow_slm = None
self.last_pressure_kpa = None
self.last_pressure_before_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.pressure_before_failure_count = 0
self.running = False
self.faulted = False
self.pid_mode = "FAR"
@@ -243,7 +308,12 @@ class FlowControlLoop:
"""复位状态并启动控制;默认从全开开度开始。"""
initial_opening = self._bounded_opening(initial_opening)
self._apply_pid_mode("FAR", reason="控制启动")
self.pid.reset(initial_output=initial_opening)
if self._feedforward_active:
# 前馈打底,PID 从零修正量开始。
self.pid.reset(initial_output=0.0)
self._last_feedforward_opening = None
else:
self.pid.reset(initial_output=initial_opening)
self.last_opening_pct = initial_opening
self.last_motor_position = opening_to_motor_position(
initial_opening,
@@ -253,6 +323,7 @@ class FlowControlLoop:
self.last_step_time = None
self.flow_failure_count = 0
self.pressure_failure_count = 0
self.pressure_before_failure_count = 0
self.faulted = False
self.running = True
@@ -304,19 +375,29 @@ class FlowControlLoop:
flow = self._read_flow()
pressure = self._read_pressure()
pressure_before = self._read_pressure_before()
if flow is None:
return self._held_result(
actual_dt,
pressure,
pressure_before,
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,
pressure_before,
f"PRESSURE_READ_RETRY_{self.pressure_failure_count}",
)
if self.pressure_before_channel is not None and pressure_before is None:
return self._held_result(
actual_dt,
pressure,
pressure_before,
f"PRESSURE_BEFORE_READ_RETRY_{self.pressure_before_failure_count}",
)
if not self.flow_valid_min_slm <= flow <= self.flow_valid_max_slm:
self._trip(
@@ -337,24 +418,70 @@ class FlowControlLoop:
f"{self.max_pressure_kpa:.3f} kPa",
{"pressure_kpa": pressure},
)
if (
pressure_before is not None
and self.max_pressure_kpa is not None
and pressure_before > self.max_pressure_kpa
):
self._trip(
"PRESSURE_OVER_LIMIT",
"PRESSURE_SAFETY_CHECK",
f"阀前压力 {pressure_before:.3f} kPa 超过上限 "
f"{self.max_pressure_kpa:.3f} kPa",
{"pressure_before_kpa": pressure_before},
)
if self.target_flow_slm <= self.zero_flow_threshold_slm:
self.pid.reset(initial_output=0.0)
opening = 0.0
opening_ff = None
correction = None
scale = self._current_scale
else:
scale = self._apply_gain_schedule(pressure_before)
self._maybe_update_gain_schedule(scale)
self._update_pid_mode(flow)
opening = self.pid.update(
measurement=flow,
setpoint=self.target_flow_slm,
dt=actual_dt,
)
if not self._is_finite_number(opening):
self._trip(
"PID_OUTPUT_INVALID",
"PID_UPDATE",
f"PID 输出不是有限数值: {opening!r}",
opening_ff = None
correction = None
if self._feedforward_active:
opening_ff = self._compute_feedforward_opening(
self.target_flow_slm, pressure_before, pressure
)
opening = self._bounded_opening(opening)
if opening_ff is not None:
self._last_feedforward_opening = opening_ff
base = (
opening_ff
if opening_ff is not None
else self._last_feedforward_opening
)
if base is None:
base = 0.0
correction = self.pid.update(
measurement=flow,
setpoint=self.target_flow_slm,
dt=actual_dt,
)
if not self._is_finite_number(correction):
self._trip(
"PID_OUTPUT_INVALID",
"PID_UPDATE",
f"PID 修正量不是有限数值: {correction!r}",
)
opening = self._bounded_opening(base + correction)
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,
@@ -365,6 +492,7 @@ class FlowControlLoop:
self.last_flow_slm = flow
self.last_pressure_kpa = pressure
self.last_pressure_before_kpa = pressure_before
self.last_opening_pct = opening
self.last_motor_position = position
return ControlStepResult(
@@ -376,6 +504,10 @@ class FlowControlLoop:
opening_pct=opening,
motor_position=position,
actual_dt_s=actual_dt,
pressure_before_kpa=pressure_before,
feedforward_pct=opening_ff,
correction_pct=correction,
gain_scale=scale,
)
def _update_pid_mode(self, flow):
@@ -445,20 +577,25 @@ class FlowControlLoop:
gains = self.pid_near_gains
opening_rate = self.opening_rate_near_pct_s
self.pid.update_parameters(*gains)
self._current_base_gains = gains
self._apply_effective_gains(self._current_scale)
self.pid.set_output_rate_limit(opening_rate)
self.pid_mode = mode
if log_change:
reason_text = "" if reason is None else f";原因={reason}"
self._log(
"info",
"PID 模式 %s -> %sKp=%.3f Ki=%.3f Kd=%.3f "
"PID 模式 %s -> %s基础 Kp=%.3f Ki=%.3f Kd=%.3f"
"有效 Kp=%.3f Ki=%.3fscale=%.3f),"
"最大开度速度=%.3f%%/s%s",
previous_mode,
mode,
gains[0],
gains[1],
gains[2],
self.pid.kp,
self.pid.ki,
self._current_scale,
opening_rate,
reason_text,
)
@@ -490,11 +627,99 @@ class FlowControlLoop:
self.pressure_failure_count = 0
return float(value)
def _read_pressure_before(self):
if self.pressure_before_channel is None:
return None
try:
value = self.hardware.get_pressure(self.pressure_before_channel)
except Exception as exc:
self._register_read_failure("PRESSURE_BEFORE", exc)
return None
if value is None or not self._is_finite_number(value):
self._register_read_failure(
"PRESSURE_BEFORE", f"invalid value: {value!r}"
)
return None
self.pressure_before_failure_count = 0
return float(value)
def _compute_feedforward_opening(self, q_set, p1, p2):
"""计算前馈开度(0~100%);无法计算时返回 None(退化纯 PID)。"""
if self.valve_model is None or not self.feedforward_enabled:
return None
if not self._is_finite_number(q_set):
return None
if p1 is None or p2 is None:
return None
if not self._is_finite_number(p1) or not self._is_finite_number(p2):
return None
# 近零流量:直接全关,不依赖反解(阀门可能不严)。
if q_set <= self.zero_flow_threshold_slm:
return 0.0
try:
x_ff = self.valve_model.feedforward_stroke(
float(q_set), float(p1), float(p2)
)
except (ValueError, ZeroDivisionError, TypeError):
return None
if not self._is_finite_number(x_ff):
return None
span = self.motor_closed_position - self.motor_open_position
if span <= 0.0:
return None
opening_ff = 100.0 * (self.motor_closed_position - x_ff) / span
return self._bounded_opening(opening_ff)
def _apply_gain_schedule(self, p1):
"""按阀前压力计算增益缩放系数;关闭或 P1 无效时返回 1.0。"""
if not self.gain_schedule_enabled:
return 1.0
if p1 is None or not self._is_finite_number(p1):
return 1.0
p1_abs = float(p1) + _P_ATM_KPA
denominator = max(p1_abs, self.gain_schedule_floor_abs_kpa)
if denominator <= 0.0 or not math.isfinite(denominator):
return 1.0
scale = self.gain_schedule_ref_abs_kpa / denominator
if not math.isfinite(scale) or scale <= 0.0:
return 1.0
return scale
def _maybe_update_gain_schedule(self, scale):
"""scale 相对变化超过阈值时,重算并写入有效增益。"""
if scale is None or not math.isfinite(scale):
return
old_scale = self._current_scale
if old_scale is None or old_scale <= 0.0:
self._current_scale = scale
self._apply_effective_gains(scale)
return
relative_change = abs(scale - old_scale) / old_scale
if relative_change > self.gain_schedule_scale_change_threshold:
self._current_scale = scale
self._apply_effective_gains(scale)
self._log(
"info",
"增益调度 scale %.3f -> %.3f(相对变化 %.3f",
old_scale,
scale,
relative_change,
)
def _apply_effective_gains(self, scale):
"""把当前档位基础增益按 scale 缩放后写入 PID(Kd 不调度)。"""
base = self._current_base_gains
self.pid.update_parameters(base[0] * scale, base[1] * scale, base[2])
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
elif sensor == "PRESSURE_BEFORE":
self.pressure_before_failure_count += 1
count = self.pressure_before_failure_count
limit = self.max_consecutive_pressure_failures
else:
self.pressure_failure_count += 1
count = self.pressure_failure_count
@@ -554,7 +779,7 @@ class FlowControlLoop:
self._log("critical", "%s", fault)
raise fault
def _held_result(self, actual_dt, pressure, status):
def _held_result(self, actual_dt, pressure, pressure_before, status):
return ControlStepResult(
timestamp=time.time(),
target_flow_slm=self.target_flow_slm,
@@ -565,6 +790,7 @@ class FlowControlLoop:
motor_position=self.last_motor_position,
actual_dt_s=actual_dt,
status=status,
pressure_before_kpa=pressure_before,
)
def _context(self):
@@ -572,11 +798,13 @@ class FlowControlLoop:
"target_flow_slm": self.target_flow_slm,
"last_valid_flow_slm": self.last_flow_slm,
"last_pressure_kpa": self.last_pressure_kpa,
"last_pressure_before_kpa": self.last_pressure_before_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,
"pressure_before_failure_count": self.pressure_before_failure_count,
"pid_mode": self.pid_mode,
}
+284
View File
@@ -0,0 +1,284 @@
"""从开环扫点 CSV 拟合 A_eff(x) 表,输出 valve_model.json。
用法:
python identify_valve.py --csv open_loop_data/open_loop_xxx.csv \\
--out valve_model.json [--tail 20] [--plot]
流程:
1. 按 step_index 分组,每组取末尾 --tail 个采样点平均,得到稳态 (x, Q, P1, P2)
2. 对每个稳态点反解 A_eff = Q / (P1_abs * F(r))
3. 按行程升序构表,做单调性检查;
4. 用 ValveModel 存成 JSON,并打印摘要(可选画诊断图)。
"""
import argparse
import csv
import math
from pathlib import Path
import config
from valve_model import CRITICAL_RATIO, ValveModel, abs_pressure, f_ratio
DEFAULT_TAIL = 20
def _to_float(text):
"""把 CSV 单元格解析成有限浮点数;空/非法/非有限返回 None。"""
if text is None:
return None
text = str(text).strip()
if text == "":
return None
try:
value = float(text)
except (TypeError, ValueError):
return None
return value if math.isfinite(value) else None
def _average(values):
"""返回非 None 数值的平均;没有有效值时返回 None。"""
valid = [value for value in values if value is not None]
return None if not valid else sum(valid) / len(valid)
def load_steady_points(csv_path, tail=DEFAULT_TAIL):
"""提取每个 step_index 的稳态均值,返回按 step 升序的 (x, q, p1, p2) 列表。"""
rows_by_step = {}
with open(csv_path, "r", newline="", encoding="utf-8-sig") as file:
for row in csv.DictReader(file):
step_text = (row.get("step_index") or "").strip()
if step_text == "":
continue
try:
step = int(float(step_text))
except (TypeError, ValueError):
continue
rows_by_step.setdefault(step, []).append(row)
points = []
for step in sorted(rows_by_step):
rows = rows_by_step[step]
rows.sort(key=lambda r: _to_float(r.get("time_s")) or 0.0)
tail_rows = rows[-tail:] if tail > 0 else rows
x = _average(_to_float(r.get("motor_position")) for r in tail_rows)
q = _average(_to_float(r.get("flow_after_slm")) for r in tail_rows)
p1 = _average(_to_float(r.get("pressure_before_kpa")) for r in tail_rows)
p2 = _average(_to_float(r.get("pressure_after_kpa")) for r in tail_rows)
if None in (x, q, p1, p2):
continue
points.append((x, q, p1, p2))
return points
def compute_area_table(steady_points):
"""把稳态点换算成 (x, A_eff) 单调表,并返回丢弃点与阻塞/亚声速统计。
返回 ``(table, dropped, stats)``。``table`` 为按 x 升序的 (x, A_eff) 列表,
只保留最长的单调连续段;``dropped`` 为因非单调被丢弃的点。
"""
area_points = []
choked = subsonic = 0
for x, q, p1, p2 in steady_points:
p1_abs = abs_pressure(p1)
p2_abs = abs_pressure(p2)
if p1_abs <= 0.0:
continue
r = p2_abs / p1_abs
f = f_ratio(r)
denom = p1_abs * f
if denom <= 0.0 or q < 0.0:
continue
if r <= CRITICAL_RATIO:
choked += 1
else:
subsonic += 1
area_points.append((x, q / denom))
area_points.sort(key=lambda item: item[0])
ys = [a for _, a in area_points]
if _is_monotonic(ys):
table = area_points
dropped = []
else:
start, end = _longest_monotonic_run(ys)
table = area_points[start:end + 1]
dropped = area_points[:start] + area_points[end + 1:]
stats = {"choked": choked, "subsonic": subsonic, "total": choked + subsonic}
return table, dropped, stats
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 _longest_monotonic_run(ys):
"""返回最长单调连续段的闭区间下标 ``(start, end)``。"""
best_start = best_end = 0
for start in range(len(ys)):
direction = 0
end = start
for j in range(start + 1, len(ys)):
delta = ys[j] - ys[j - 1]
if delta == 0:
end = j
continue
sign = 1 if delta > 0 else -1
if direction == 0:
direction = sign
end = j
elif direction == sign:
end = j
else:
break
if end - start > best_end - best_start:
best_start, best_end = start, end
return best_start, best_end
def identify(csv_path, tail=DEFAULT_TAIL, out_path=None):
"""端到端辨识;返回 ``(model, steady_points, table, dropped, stats)``。"""
steady = load_steady_points(csv_path, tail)
table, dropped, stats = compute_area_table(steady)
if len(table) < 2:
raise ValueError(
f"有效稳态点不足({len(table)} 个),无法构表。请确认 CSV 的 "
"pressure_before_kpa / pressure_after_kpa / flow_after_slm 列读数合理。"
)
model = ValveModel(
table,
config.MOTOR_OPEN_POSITION,
config.MOTOR_CLOSED_POSITION,
)
if out_path:
model.save(out_path)
return model, steady, table, dropped, stats
def create_diagnostic_plot(steady_points, table, dropped, image_path):
"""画 x vs A_eff(散点+插值线)与 x vs Q_ss(散点)两张子图。"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
xs = [x for x, _ in table]
areas = [a for _, a in table]
figure, axes = plt.subplots(2, 1, figsize=(10, 8), constrained_layout=True)
figure.suptitle("Identified valve characteristic")
axes[0].scatter(xs, areas, color="#1565C0", label="measured A_eff")
axes[0].plot(xs, areas, color="#1565C0", linewidth=1.2)
if dropped:
axes[0].scatter(
[x for x, _ in dropped],
[a for _, a in dropped],
color="#D84315",
marker="x",
label="dropped (non-monotonic)",
)
axes[0].set_ylabel("A_eff (slm/kPa)")
axes[0].set_title("Effective area vs stroke")
axes[0].legend(loc="best")
axes[0].grid(True, alpha=0.3, linestyle="--")
qxs = [x for x, _, _, _ in steady_points]
qs = [q for _, q, _, _ in steady_points]
axes[1].scatter(qxs, qs, color="#2E7D32", label="measured Q_ss")
axes[1].set_ylabel("Flow (SLM)")
axes[1].set_xlabel("Motor stroke x")
axes[1].set_title("Steady flow vs stroke")
axes[1].legend(loc="best")
axes[1].grid(True, alpha=0.3, linestyle="--")
figure.savefig(image_path, dpi=config.PLOT_DPI, bbox_inches="tight")
plt.close(figure)
def parse_args(argv=None):
parser = argparse.ArgumentParser(
description="从开环扫点 CSV 拟合 A_eff(x) 阀特性表"
)
parser.add_argument("--csv", required=True, help="开环扫点 CSV 路径")
parser.add_argument("--out", default="valve_model.json", help="输出 JSON 路径")
parser.add_argument(
"--tail", type=int, default=DEFAULT_TAIL,
help=f"每组末尾用于平均的采样点数(默认 {DEFAULT_TAIL}",
)
parser.add_argument("--plot", action="store_true", help="生成诊断图 PNG")
args = parser.parse_args(argv)
if args.tail < 1:
parser.error("--tail 必须 >= 1")
return args
def main(argv=None):
args = parse_args(argv)
csv_path = Path(args.csv)
if not csv_path.exists():
print(f"CSV 不存在:{csv_path}")
return 2
try:
model, steady, table, dropped, stats = identify(
csv_path, tail=args.tail, out_path=args.out
)
except (ValueError, OSError) as exc:
print(f"辨识失败:{exc}")
return 2
print(
f"读取稳态点 {len(steady)} 个;阻塞 {stats['choked']} 个,"
f"亚声速 {stats['subsonic']} 个,合计 {stats['total']} 个。"
)
print(
f"有效行程范围 {table[0][0]:.1f} ~ {table[-1][0]:.1f}"
f"{len(table)} 点)。"
)
if dropped:
print(
"警告:A_eff(x) 非单调,以下点被丢弃"
"(建议缩小扫点范围到单调段重扫)。"
)
print("行程 x -> A_eff")
for x, a in table:
print(f" x={x:8.3f} A_eff={a:.6f}")
if dropped:
print("被丢弃的点:")
for x, a in dropped:
print(f" x={x:8.3f} A_eff={a:.6f} [DROPPED]")
print(f"已保存阀特性模型:{args.out}")
if args.plot:
image_path = Path(args.out).with_suffix(".png")
try:
create_diagnostic_plot(steady, table, dropped, image_path)
print(f"诊断图已保存:{image_path}")
except Exception as exc:
print(f"生成诊断图失败(不影响 JSON):{exc}")
return 0
if __name__ == "__main__":
import sys
sys.exit(main())
+51 -5
View File
@@ -23,6 +23,7 @@ from controllers import IncrementalPID
from data_logger import FlowRunRecorder
from flow_control import FlowControlFault, FlowControlLoop
from performance_reporter import PerformanceReporter
from valve_model import ValveModel
class ConsoleGate(logging.Filter):
@@ -61,7 +62,27 @@ def build_logger():
return logger, console_gate
def _load_valve_model(logger):
"""按配置加载阀特性模型;配置为空或文件缺失/损坏时返回 None(纯 PID)。"""
if not config.VALVE_MODEL_PATH:
return None
model_path = Path(__file__).resolve().parent / config.VALVE_MODEL_PATH
if not model_path.exists():
logger.warning("阀特性模型文件不存在:%s,退化为纯 PID", model_path)
return None
try:
return ValveModel.load(
str(model_path),
config.MOTOR_OPEN_POSITION,
config.MOTOR_CLOSED_POSITION,
)
except (ValueError, OSError) as exc:
logger.warning("加载阀特性模型失败(%s):%s,退化为纯 PID", model_path, exc)
return None
def build_control_loop(hardware, logger):
valve_model = _load_valve_model(logger)
pid = IncrementalPID(
kp=config.PID_FAR_KP,
ki=config.PID_FAR_KI,
@@ -77,6 +98,7 @@ def build_control_loop(hardware, logger):
period_s=config.CONTROL_PERIOD_S,
flow_channel=config.FLOW_INPUT_CHANNEL,
pressure_channel=config.PRESSURE_INPUT_CHANNEL,
pressure_before_channel=config.PRESSURE_BEFORE_CHANNEL,
motor_channel=config.MOTOR_OUTPUT_CHANNEL,
motor_open_position=config.MOTOR_OPEN_POSITION,
motor_closed_position=config.MOTOR_CLOSED_POSITION,
@@ -112,6 +134,15 @@ def build_control_loop(hardware, logger):
pid_mode_switch_confirm_cycles=(
config.PID_MODE_SWITCH_CONFIRM_CYCLES
),
valve_model=valve_model,
feedforward_enabled=config.FEEDFORWARD_ENABLED,
feedforward_correction_band_pct=config.FEEDFORWARD_CORRECTION_BAND_PCT,
gain_schedule_enabled=config.GAIN_SCHEDULE_ENABLED,
gain_schedule_ref_abs_kpa=config.GAIN_SCHEDULE_REF_ABS_KPA,
gain_schedule_floor_abs_kpa=config.GAIN_SCHEDULE_FLOOR_ABS_KPA,
gain_schedule_scale_change_threshold=(
config.GAIN_SCHEDULE_SCALE_CHANGE_THRESHOLD
),
logger=logger,
)
@@ -139,12 +170,27 @@ def format_status(result):
"--" 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"
pressure_before_text = (
"--" if result.pressure_before_kpa is None
else f"{result.pressure_before_kpa:.2f}"
)
parts = [
f"状态={result.status}",
f"目标={result.target_flow_slm:.2f} SLM",
f"实际={flow_text} SLM",
f"压力={pressure_text} kPa",
f"阀前压={pressure_before_text} kPa",
f"开度={result.opening_pct:.2f}%",
]
if result.feedforward_pct is not None:
parts.append(f"前馈={result.feedforward_pct:.2f}%")
if result.correction_pct is not None:
parts.append(f"修正={result.correction_pct:+.2f}%")
if result.gain_scale is not None and abs(result.gain_scale - 1.0) > 1e-9:
parts.append(f"scale={result.gain_scale:.3f}")
parts.append(f"行程={result.motor_position:.1f}")
parts.append(f"dt={result.actual_dt_s:.3f}s")
return " ".join(parts)
def run(target_flow_slm):
+124
View File
@@ -0,0 +1,124 @@
"""valve_model / identify_valve 离线自测(不碰硬件)。
运行:
python test_valve_model.py
"""
import csv
import math
import tempfile
from pathlib import Path
from valve_model import (
P_ATM,
CRITICAL_RATIO,
ValveModel,
abs_pressure,
f_ratio,
)
def _close(a, b, rel=1e-6):
return abs(a - b) <= rel * max(1.0, abs(a), abs(b))
def test_f_ratio():
assert f_ratio(0.4) == 1.0
assert f_ratio(CRITICAL_RATIO) == 1.0
assert f_ratio(1.0) == 0.0
assert f_ratio(1.1) == 0.0
expected = math.sqrt(
1.0 - ((0.7 - CRITICAL_RATIO) / (1.0 - CRITICAL_RATIO)) ** 2
)
assert _close(f_ratio(0.7), expected)
def test_abs_pressure():
assert _close(abs_pressure(0.0), P_ATM)
assert _close(abs_pressure(300.0), 300.0 + P_ATM)
def test_valve_model_roundtrip():
# A_eff 随行程线性递减(x 越大开度越小、面积越小)。
table = [(800.0, 0.5), (900.0, 0.25), (1000.0, 0.0)]
model = ValveModel(table, motor_open=800.0, motor_closed=1000.0)
assert _close(model.area_from_stroke(900.0), 0.25)
assert _close(model.area_from_stroke(850.0), 0.375)
assert _close(model.stroke_from_area(0.25), 900.0)
# 阻塞流(P2=0 表压 → r≈0.2 < 0.528),flow_ss 与 feedforward_stroke 互逆。
for x in (800.0, 850.0, 900.0, 1000.0):
q = model.flow_ss(x, 400.0, 0.0)
x_back = model.feedforward_stroke(q, 400.0, 0.0)
assert _close(x, x_back, rel=0.01), (x, q, x_back)
# 亚声速(P2=300 表压 → r≈0.8)。
q = model.flow_ss(900.0, 400.0, 300.0)
assert _close(model.feedforward_stroke(q, 400.0, 300.0), 900.0, rel=0.01)
def test_non_monotonic_raises():
table = [(800.0, 0.5), (900.0, 0.1), (1000.0, 0.3)]
try:
ValveModel(table, motor_open=800.0, motor_closed=1000.0)
except ValueError:
return
raise AssertionError("非单调表应抛 ValueError")
def test_identify_end_to_end():
import identify_valve
# 构造合成扫点 CSV:每个 step 30 个采样点,全程稳态。
rows = []
for step, x in enumerate((800.0, 900.0, 1000.0)):
a_eff = (1000.0 - x) / 400.0 # 单调递减的真值
q_ss = a_eff * (400.0 + P_ATM) # 阻塞流,F=1
for i in range(30):
rows.append({
"time_s": f"{step * 10 + i * 0.1:.6f}",
"step_index": step,
"opening_pct": 100.0 - step * 50.0,
"motor_position": x,
"flow_before_slm": "",
"flow_after_slm": q_ss,
"pressure_before_kpa": 400.0,
"pressure_after_kpa": 0.0,
"P_abs_ratio": "",
"is_ratio_smaller_than_0.528": "Y",
})
with tempfile.TemporaryDirectory() as tmp:
csv_path = Path(tmp) / "sweep.csv"
out_path = Path(tmp) / "model.json"
with csv_path.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
model, steady, table, dropped, stats = identify_valve.identify(
csv_path, tail=20, out_path=str(out_path)
)
assert len(table) == 3, table
assert stats["choked"] == 3
assert not dropped
for x, a in table:
assert _close(a, (1000.0 - x) / 400.0, rel=0.01), (x, a)
loaded = ValveModel.load(str(out_path), 800.0, 1000.0)
assert _close(loaded.area_from_stroke(900.0), 0.25, rel=0.01)
if __name__ == "__main__":
tests = [
test_f_ratio,
test_abs_pressure,
test_valve_model_roundtrip,
test_non_monotonic_raises,
test_identify_end_to_end,
]
for test in tests:
test()
print(f"PASS {test.__name__}")
print("全部通过")
+195
View File
@@ -0,0 +1,195 @@
"""阀特性模型 + 查找表 + 前馈反解(纯计算,不依赖硬件)。
把静态阀特性 ``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]