从 MT2-AM8 模拟量模块迁移到 Easy521 PLC(Modbus TCP)

- 读取 D700(阀后流量)、D710(阀后压力)、D720(阀前压力),
  读用功能码 03,0~32000 线性映射到各自量程(300 SLM / 400 kPa / 300 kPa)
- 输出 D730(电机位置,0~10V),写用功能码 06,行程仍为 0~1000、死区 800
- 复用 MT2 骨架,保留 get_flow/get_pressure/set_motor_position 接口,
  channel 参数改为直接 Modbus 地址
- config.py 新增 PLC 设置,保留 MT2AM8 设置为死代码(MT2AM8Client 类保留)
- 同步更新 README.md 中的硬件接线、配置默认值与模块职责描述
This commit is contained in:
2026-09-01 17:03:26 +08:00
parent abab25326a
commit 5888b4ccce
10 changed files with 507 additions and 374 deletions
+182 -44
View File
@@ -1,19 +1,26 @@
"""从开环扫点 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]
python identify_valve.py --csv open_loop_data/open_loop_xxx.csv --out valve_model.json
python identify_valve.py --csv "open_loop_data/*.csv" --out valve_model.json --plot
python identify_valve.py --plot # 省略 --csv 时默认合并 open_loop_data/ 下全部 CSV
流程:
1. 按 step_index 分组,每组取末尾 --tail 个采样点平均,得到稳态 (x, Q, P1, P2)
2. 对每个稳态点反解 A_eff = Q / (P1_abs * F(r))
3. 按行程升序构表,做单调性检查;
4. 用 ValveModel 存成 JSON,并打印摘要(可选画诊断图)。
1. --csv 支持多个路径与 glob 模式,全部 CSV 合并使用(文件名含时间戳,按名称
排序即按实验先后合并);省略时默认 open_loop_data/*.csv
2. 每个 CSV 按 step_index 分组,每组取末尾 --tail 个采样点平均,得到稳态
(x, Q, P1, P2),并按开度访问顺序标记逼近方向(上升/下降/该文件起始步);
3. 对每个稳态点反解 A_eff = Q / (P1_abs * F(r)),同一行程 x 的多个点
(来自不同次实验)先平均再构表;
4. 按行程升序构表,做单调性检查;
5. 用 ValveModel 存成 JSON,并打印摘要(可选画诊断图,实测点按逼近方向着色)。
"""
import argparse
import csv
import glob
import math
import os
from pathlib import Path
import config
@@ -21,6 +28,21 @@ from valve_model import CRITICAL_RATIO, ValveModel, abs_pressure, f_ratio
DEFAULT_TAIL = 20
# 逼近方向:按开度访问顺序,当前开度大于/小于上一开度。
DIRECTION_UP = "up" # 从更小开度升到当前开度
DIRECTION_DOWN = "down" # 从更大开度降到当前开度
DIRECTION_ORDER = [DIRECTION_UP, DIRECTION_DOWN, None]
DIRECTION_LABELS = {
DIRECTION_UP: "ascending",
DIRECTION_DOWN: "descending",
None: "start (unknown)",
}
DIRECTION_COLORS = {
DIRECTION_UP: "#1565C0",
DIRECTION_DOWN: "#D84315",
None: "#757575",
}
def _to_float(text):
"""把 CSV 单元格解析成有限浮点数;空/非法/非有限返回 None。"""
@@ -43,8 +65,14 @@ def _average(values):
def load_steady_points(csv_path, tail=DEFAULT_TAIL):
"""提取每个 step_index 的稳态均值,返回按 step 升序的 (x, q, p1, p2) 列表。"""
"""提取单个 CSV 每个 step_index 的稳态均值与逼近方向。
返回按 step(访问顺序)排列的 ``(x, q, p1, p2, direction)`` 列表。
direction 为 ``"up"``(开度上升逼近)/ ``"down"``(下降逼近)/
``None``(该文件第一步,无上一开度可比较)。
"""
rows_by_step = {}
opening_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()
@@ -55,9 +83,25 @@ def load_steady_points(csv_path, tail=DEFAULT_TAIL):
except (TypeError, ValueError):
continue
rows_by_step.setdefault(step, []).append(row)
if step not in opening_by_step:
opening_by_step[step] = _to_float(row.get("opening_pct"))
points = []
prev_opening = None
for step in sorted(rows_by_step):
opening = opening_by_step.get(step)
if prev_opening is not None and opening is not None:
if opening > prev_opening:
direction = DIRECTION_UP
elif opening < prev_opening:
direction = DIRECTION_DOWN
else:
direction = None
else:
direction = None
if opening is not None:
prev_opening = opening
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
@@ -69,19 +113,23 @@ def load_steady_points(csv_path, tail=DEFAULT_TAIL):
if None in (x, q, p1, p2):
continue
points.append((x, q, p1, p2))
points.append((x, q, p1, p2, direction))
return points
def compute_area_table(steady_points):
"""把稳态点换算成 (x, A_eff) 单调表,并返回丢弃点与阻塞/亚声速统计
"""把稳态点换算成 (x, A_eff) 单调表,并返回逐点测量值供诊断图着色
返回 ``(table, dropped, stats)``。``table`` 为按 x 升序的 (x, A_eff) 列表,
只保留最长的单调连续段;``dropped`` 为因非单调被丢弃的点。
返回 ``(table, dropped, stats, area_points)``
- ``area_points``:每个实测点的 ``(x, A_eff, direction)``,按 x 升序,未平均;
- ``table``:同一行程 x 的多个 A_eff 先平均,再按 x 升序取最长单调连续段;
- ``dropped``:因非单调被丢弃的 ``(x, A_eff)``
- ``stats``:阻塞/亚声速点统计(按原始测量点计数)。
"""
area_points = []
choked = subsonic = 0
for x, q, p1, p2 in steady_points:
for x, q, p1, p2, direction in steady_points:
p1_abs = abs_pressure(p1)
p2_abs = abs_pressure(p2)
if p1_abs <= 0.0:
@@ -95,21 +143,28 @@ def compute_area_table(steady_points):
choked += 1
else:
subsonic += 1
area_points.append((x, q / denom))
area_points.append((x, q / denom, direction))
area_points.sort(key=lambda item: item[0])
ys = [a for _, a in area_points]
# 合并多份 CSV 后同一行程会有多个点:先按 x 分组平均(CSV 以 6 位小数
# 记录行程,round 用于吸收极小浮点差异)。
by_x = {}
for x, a, _ in area_points:
by_x.setdefault(round(x, 6), []).append(a)
merged = [(float(x), sum(vals) / len(vals)) for x, vals in sorted(by_x.items())]
ys = [a for _, a in merged]
if _is_monotonic(ys):
table = area_points
table = merged
dropped = []
else:
start, end = _longest_monotonic_run(ys)
table = area_points[start:end + 1]
dropped = area_points[:start] + area_points[end + 1:]
table = merged[start:end + 1]
dropped = merged[:start] + merged[end + 1:]
stats = {"choked": choked, "subsonic": subsonic, "total": choked + subsonic}
return table, dropped, stats
return table, dropped, stats, area_points
def _is_monotonic(ys):
@@ -151,14 +206,22 @@ def _longest_monotonic_run(ys):
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)
def identify(csv_paths, tail=DEFAULT_TAIL, out_path=None):
"""从一份或多份开环扫点 CSV 端到端辨识。
返回 ``(model, steady_points, table, dropped, stats, area_points)``。
``csv_paths`` 为路径列表,其稳态点合并后统一拟合。
"""
steady = []
for path in csv_paths:
steady.extend(load_steady_points(path, tail))
table, dropped, stats, area_points = compute_area_table(steady)
if len(table) < 2:
raise ValueError(
f"有效稳态点不足({len(table)} 个),无法构表。请确认 CSV "
"pressure_before_kpa / pressure_after_kpa / flow_after_slm 列读数合理。"
f"有效稳态点不足({len(table)} 个),无法构表。请确认 CSV "
"open_loop.py 产出(需含 step_index 列),且 "
"pressure_before_kpa / pressure_after_kpa / flow_after_slm "
"列读数合理。"
)
model = ValveModel(
table,
@@ -167,11 +230,69 @@ def identify(csv_path, tail=DEFAULT_TAIL, out_path=None):
)
if out_path:
model.save(out_path)
return model, steady, table, dropped, stats
return model, steady, table, dropped, stats, area_points
def create_diagnostic_plot(steady_points, table, dropped, image_path):
"""画 x vs A_eff(散点+插值线)与 x vs Q_ss(散点)两张子图。"""
def resolve_csv_paths(items=None):
"""把 CLI 传入的路径/glob 展开为去重、按名称排序的 CSV 路径列表。
``items`` 为空时默认使用工程目录下 ``open_loop_data/*.csv``(文件名含
时间戳,排序后即按实验先后合并)。
"""
if not items:
default_dir = Path(__file__).resolve().parent / "open_loop_data"
items = [str(default_dir / "*.csv")]
paths = []
for item in items:
pattern = str(item).replace("\\", "/")
if glob.has_magic(pattern):
paths.extend(sorted(glob.glob(pattern)))
else:
paths.append(pattern)
# 去重:glob 结果与显式路径的斜杠方向可能不同,用 normcase 归一化比较
# (Windows 下同时统一大小写)。
unique = []
seen = set()
for path in paths:
key = os.path.normcase(path)
if key not in seen:
seen.add(key)
unique.append(path)
return unique
def _count_directions(steady_points):
counts = {DIRECTION_UP: 0, DIRECTION_DOWN: 0, None: 0}
for point in steady_points:
counts[point[4]] += 1
return counts
def _scatter_by_direction(axis, points):
"""按逼近方向分色画散点;``points`` 为 ``(x, y, direction)`` 列表。"""
by_direction = {d: [] for d in DIRECTION_ORDER}
for x, y, direction in points:
by_direction.setdefault(direction, []).append((x, y))
for direction in DIRECTION_ORDER:
pts = by_direction.get(direction, [])
if not pts:
continue
axis.scatter(
[p[0] for p in pts],
[p[1] for p in pts],
color=DIRECTION_COLORS[direction],
label=DIRECTION_LABELS[direction],
s=18,
alpha=0.85,
)
def create_diagnostic_plot(steady_points, table, dropped, area_points, image_path):
"""画 x vs A_eff 与 x vs Q_ss 两张子图,实测点按逼近方向着色。
同一行程下上升/下降两团点明显分离,说明阀门迟滞显著,前馈表应考虑
分方向处理;完全重叠则迟滞可忽略。
"""
import matplotlib
matplotlib.use("Agg")
@@ -183,13 +304,13 @@ def create_diagnostic_plot(steady_points, table, dropped, image_path):
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)
axes[0].plot(xs, areas, color="#263238", linewidth=1.2, label="fitted A_eff (per-x mean)")
_scatter_by_direction(axes[0], area_points)
if dropped:
axes[0].scatter(
[x for x, _ in dropped],
[a for _, a in dropped],
color="#D84315",
color="#B71C1C",
marker="x",
label="dropped (non-monotonic)",
)
@@ -198,9 +319,9 @@ def create_diagnostic_plot(steady_points, table, dropped, image_path):
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")
_scatter_by_direction(
axes[1], [(p[0], p[1], p[4]) for p in steady_points]
)
axes[1].set_ylabel("Flow (SLM)")
axes[1].set_xlabel("Motor stroke x")
axes[1].set_title("Steady flow vs stroke")
@@ -213,9 +334,12 @@ def create_diagnostic_plot(steady_points, table, dropped, image_path):
def parse_args(argv=None):
parser = argparse.ArgumentParser(
description="从开环扫点 CSV 拟合 A_eff(x) 阀特性表"
description="从开环扫点 CSV 拟合 A_eff(x) 阀特性表(支持多 CSV/glob 合并)"
)
parser.add_argument(
"--csv", nargs="+",
help="一个或多个 CSV 路径或 glob 模式;省略时默认合并 open_loop_data/*.csv",
)
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,
@@ -230,26 +354,40 @@ def parse_args(argv=None):
def main(argv=None):
args = parse_args(argv)
csv_path = Path(args.csv)
if not csv_path.exists():
print(f"CSV 不存在:{csv_path}")
csv_paths = resolve_csv_paths(args.csv)
if not csv_paths:
print("未找到任何 CSV:请确认 open_loop_data/ 下有开环扫点数据,")
print('或用 --csv 指定路径或 glob 模式(如 --csv "open_loop_data/*.csv")。')
return 2
for path in csv_paths:
if not Path(path).exists():
print(f"CSV 不存在:{path}")
return 2
try:
model, steady, table, dropped, stats = identify(
csv_path, tail=args.tail, out_path=args.out
model, steady, table, dropped, stats, area_points = identify(
csv_paths, tail=args.tail, out_path=args.out
)
except (ValueError, OSError) as exc:
print(f"辨识失败:{exc}")
return 2
if len(csv_paths) <= 8:
print("合并的 CSV")
for path in csv_paths:
print(f" {path}")
else:
print(f"合并的 CSV{csv_paths[0]} 等共 {len(csv_paths)} 个文件")
directions = _count_directions(steady)
print(
f"读取稳态点 {len(steady)};阻塞 {stats['choked']} 个,"
f"亚声速 {stats['subsonic']} 个,合计 {stats['total']} 个。"
f"读取稳态点 {len(steady)}:上升逼近 {directions[DIRECTION_UP]}"
f"下降逼近 {directions[DIRECTION_DOWN]}、起始点 {directions[None]}"
f"阻塞 {stats['choked']} 个,亚声速 {stats['subsonic']} 个,"
f"合计 {stats['total']} 个。"
)
print(
f"有效行程范围 {table[0][0]:.1f} ~ {table[-1][0]:.1f}"
f"{len(table)} 点)。"
f"{len(table)},同一行程已平均)。"
)
if dropped:
@@ -270,7 +408,7 @@ def main(argv=None):
if args.plot:
image_path = Path(args.out).with_suffix(".png")
try:
create_diagnostic_plot(steady, table, dropped, image_path)
create_diagnostic_plot(steady, table, dropped, area_points, image_path)
print(f"诊断图已保存:{image_path}")
except Exception as exc:
print(f"生成诊断图失败(不影响 JSON):{exc}")