"""从开环扫点 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())