#!/usr/bin/env python3 # -*- coding: utf-8 -*- """生成对接电机积分差归因 HTML(通俗说明 + 标注图)。""" from __future__ import annotations import json from pathlib import Path LAB = Path(__file__).resolve().parents[1] / "lab" SRC = LAB / "ecat_who_slow.json" OUT = LAB / "ecat_integral_report.html" data = json.loads(SRC.read_text(encoding="utf-8")) pa = data["phase_attribution"] strokes = data["strokes"] sample = data.get("plot_sample") or [] verdict = data.get("verdict") or {} # 用行程数据修正通俗结论(避免停车检测为 0 的误导) on_vals = pa["on_ms_n3_minus_n2"]["values"] # 剔除 |Δ|>50ms 的伪起动(上一段未停稳造成的重叠) on_clean = [v for v in on_vals if abs(v) <= 50] on_overlap = [v for v in on_vals if abs(v) > 50] I_on = [s["I_on"]["Isum"] for s in strokes] I_cr = [s["I_cruise"]["Isum"] for s in strokes] I_st = [s["I_stop"]["Isum"] for s in strokes] only2 = [s["stop_only_n2"]["Isum"] for s in strokes] only3 = [s["stop_only_n3"]["Isum"] for s in strokes] # 停止窗谁残速大:I_st>0 → 合速度偏正 → 镜像下更像 N3 仍正/N2 已减 # 直接用 only_* 贡献绝对值 sum_only2 = sum(abs(x) for x in only2) sum_only3 = sum(abs(x) for x in only3) n_only2 = sum(1 for x in only2 if abs(x) > 0.2) n_only3 = sum(1 for x in only3 if abs(x) > 0.2) cruise_v2 = pa["cruise_mean_abs_v2"]["mean"] cruise_v3 = pa["cruise_mean_abs_v3"]["mean"] cruise_bias = pa["cruise_bias_v2_minus_v3"]["mean"] mean_on_clean = sum(on_clean) / len(on_clean) if on_clean else None mean_Ion = sum(I_on) / len(I_on) if I_on else 0 mean_Icr = sum(I_cr) / len(I_cr) if I_cr else 0 mean_Ist = sum(I_st) / len(I_st) if I_st else 0 abs_Ist = sum(abs(x) for x in I_st) / len(I_st) if I_st else 0 abs_Ion = sum(abs(x) for x in I_on) / len(I_on) if I_on else 0 abs_Icr = sum(abs(x) for x in I_cr) / len(I_cr) if I_cr else 0 # SVG 折线 W, H = 960, 420 PAD_L, PAD_R, PAD_T = 56, 20, 36 def poly(xs, ys, y0, y1, top, h, color, w=1.3): if not xs: return "" lo, hi = y0, y1 if hi - lo < 1e-9: hi = lo + 1 def X(t, t0, t1): span = max(t1 - t0, 1e-6) return PAD_L + (t - t0) / span * (W - PAD_L - PAD_R) def Y(v): return top + (hi - v) / (hi - lo) * h pts = " ".join(f"{X(xs[i], xs[0], xs[-1]):.1f},{Y(ys[i]):.1f}" for i in range(len(xs))) return f'' if sample: ts = [p["t"] for p in sample] v2 = [p["v2"] for p in sample] v3 = [p["v3"] for p in sample] vsum = [p["vsum"] for p in sample] Isum = [p["Isum"] for p in sample] t0, t1 = ts[0], ts[-1] vmin = min(min(v2), min(v3)) vmax = max(max(v2), max(v3)) imin = min(min(Isum), 0) imax = max(max(Isum), 0) padv = (vmax - vmin) * 0.08 or 10 padI = (imax - imin) * 0.1 or 2 def X(t): return PAD_L + (t - t0) / max(t1 - t0, 1e-6) * (W - PAD_L - PAD_R) def Yv(v): return 48 + (vmax + padv - v) / (vmax - vmin + 2 * padv) * 100 def Ys(v): return 190 + (vmax + padv - v) / (vmax - vmin + 2 * padv) * 80 def YI(v): return 300 + (imax + padI - v) / (imax - imin + 2 * padI) * 80 def line(vals, yfn, color, w=1.3): pts = " ".join(f"{X(ts[i]):.1f},{yfn(vals[i]):.1f}" for i in range(len(ts))) return f'' # 行程分界竖线 vlines = [] for sm in data.get("stroke_marks") or []: if sm["t0"] <= t1: x = X(sm["t0"]) vlines.append( f'' ) vlines.append( f'#{sm["i"]}' ) else: ts = v2 = v3 = vsum = Isum = [] t0 = t1 = 0 vlines = [] # 标注箭头:在图上用文字+简单箭头路径 notes_svg = [] if sample: # 找几个典型点 # 1) 巡航:|v| 稳定处 # 2) 停车:vsum 尖峰 peak_i = max(range(len(sample)), key=lambda i: abs(sample[i]["vsum"])) # 理论零线 notes_svg.append( f'' f'对接电机 2/3:速度、合速度 v2+v3、积分 ∫(v2+v3)dt(虚线为理论 0)' ) # 表格行 rows_html = [] for s in strokes: cls_on = "bad" if abs(s["on_ms_n3_minus_n2"]) > 50 else "ok" cls_cr = "ok" cls_st = "warn" if abs(s["I_stop"]["Isum"]) > 3 else "ok" who = "" if abs(s["stop_only_n3"]["Isum"]) > abs(s["stop_only_n2"]["Isum"]) and abs(s["stop_only_n3"]["Isum"]) > 0.2: who = "N3 残速" elif abs(s["stop_only_n2"]["Isum"]) > 0.2: who = "N2 残速" else: who = "两台接近同步刹停" rows_html.append(f""" #{s['i']}{s['dir_n2']} {s['on_ms_n3_minus_n2']:+.2f} ms {s['cruise']['mean_abs_v2']:.2f} / {s['cruise']['mean_abs_v3']:.2f} {s['I_on']['Isum']:+.2f} {s['I_cruise']['Isum']:+.2f} {s['I_stop']['Isum']:+.2f} {s['stop_only_n2']['Isum']:+.2f} {s['stop_only_n3']['Isum']:+.2f} {who} {s['da2_plus_da3']:+.2f}° """) # 通俗结论文本 if abs_Ist > abs_Icr * 3 and abs_Ist > abs_Ion: main_src = "停车阶段" elif abs_Ion > abs_Ist and abs_Ion > abs_Icr: main_src = "行程衔接/起动窗口(含上一段未停稳的重叠)" else: main_src = "起动与停车都有贡献,巡航最干净" plain_html = f"""

通俗结论:到底是谁慢?

先说人话:两台电机并不是“有一台全程都慢”。 你看到的积分差,主要不是巡航时谁转得慢,而是快停下 / 刚要走的那几十一到几百毫秒里,两台对不齐, 把理论应为 0 的 ∫(v2+v3)dt 顶出 5~20° 左右的偏差。

  • 启动 干净起动时(|Δt|≤50ms 的行程),N3−N2 约为 {mean_on_clean if mean_on_clean is not None else '—'} ms 量级 → 几乎是同时启动,ECAT 同帧下发没有问题。 有 {len(on_overlap)} 个行程测出上百毫秒的“假早启动”,那是上一段还没停稳、N3 仍在动, 不是这一段命令发晚了。
  • 巡航 两边平均 |v|:N2 {cruise_v2:.2f}°/s,N3 {cruise_v3:.2f}°/s, 差值约 {cruise_bias:.3f}°/s → 稳态速度几乎一样,谈不上谁慢。 巡航段 ∫(v2+v3) 平均只有约 {mean_Icr:.2f}°,贴合理论 0。
  • 停车 / 换向 行程末端 ∫(v2+v3) 平均约 {mean_Ist:.2f}°,绝对值平均约 {abs_Ist:.2f}°, 明显大于巡航。停止窗口里: 出现「只有 N2 还在走」的行程约 {n_only2} 次(积分贡献合计 {sum_only2:.2f}°), 「只有 N3 还在走」约 {n_only3} 次(合计 {sum_only3:.2f}°)。 两边都有刹车尾巴,不是单边永久变慢;有的行程 N2 拖尾更明显,有的行程 N3 更明显。
一句话:积分差的大头在 {main_src}。 对接约束要求 v2 = −v3;只要刹停瞬间一台还带着速度、另一台已经接近 0, v2+v3 就会冒尖峰,积分就会被拉离理论 0。这属于动态不同步, 不等于某台电机“标定转速低、一直慢”。
正常:同时启动 / 巡航一致 / 中段积分≈0
注意:停车沿残速、行程衔接重叠、积分被顶开
偏大:该行程 ∫(v2+v3) 或编码器 Δ(a2+a3) 明显偏离 0
""" rows_joined = "\n".join(rows_html) # 标注框(放在 SVG 下方用 HTML 箭头说明) annot_html = f"""

图上怎么看(对照上方曲线)

↑ 巡航段(两条速度曲线几乎贴合)

N2 蓝线、N3 橙线在 |v|≈200°/s 附近重合 → 都不慢。 下方红线 v2+v3 贴 0,积分曲线几乎水平 → 对接约束在稳态是成立的。

↓ 停车沿 / 换向点(红线尖峰)

接近终点时,一台速度先掉下来、另一台还在走 → v2+v3 出现 ±100~200°/s 尖峰。 积分曲线在这里爬上或爬下,行程结束就停在理论 0 以外几度到二十度。 这里是积分差的主要来源。

↓ 行程刚衔接时(起动窗)

若上一段没完全停稳就反向,会看到“N3 早就动了”的假象(表格里 onΔ 为 −800ms 量级)。 这不是启动指令不同步,而是速度还在反向爬坡 / 残速, 也会把起动窗积分抬高一点。

↓ 紫色角度和 a2+a3

理论应为常数(理想为 0)。行程终点若偏到 ±10~25°,与该行程 ∫(v2+v3) 同量级 → 说明偏差是真实位移耦合误差,不只是速度噪声。

标定建议(若要压积分差): ① 先统一两台的减速段(同样斜率刹停),而不是去改巡航速度; ② 检查 N2/N3 负载与制动是否对称; ③ 控制器上对对接轴做 v2+v3 → 0 的纠偏,比单轴调 PID 更对症; ④ 序列 hold 不要短于“两台都确认停稳”的时间,避免重叠行程把积分搅浑。
""" # SVG 图 if sample: svg = f""" {''.join(vlines)} {line(v2, Yv, '#4da3ff')} {line(v3, Yv, '#f0a030')} 速度 deg/s — 蓝 N2 / 橙 N3 {line(vsum, Ys, '#ff5c5c')} 合速度 v2+v3(理论 0)— 停车尖峰在这里 {line(Isum, YI, '#b48cff')} 积分 ∫(v2+v3)dt(理论 0)— 每段末的偏离就是耦合误差 t_rel {t0:.1f}..{t1:.1f}s · ECAT 0.25ms · 竖线为行程起点 ↑ 巡航:两线贴合,都不慢 ↓ 停车沿:v_sum 尖峰 → 积分爬升 """ else: svg = "

(无绘图样本)

" html = f""" 对接电机 2/3 积分差归因报告

对接电机 2 / 3:积分差是谁造成的?

数据:ECAT 无源采集 lab/ecat_passive_latest.csv · 周期 0.25ms · 理论:刚性镜像对接时 v2+v3≡0,∫(v2+v3)dt≡0,a2+a3=常数
方法:对齐 cycles 后做梯形积分;先加后积与先积后加一致。

关键数字

巡航 |v| N2 / N3
{cruise_v2:.2f} / {cruise_v3:.2f}
巡航段 ∫(v2+v3) 平均
{mean_Icr:.2f}°
停车段 ∫(v2+v3) |均值|
{abs_Ist:.2f}°
干净起动 Δt 中位/均值
{(sorted(on_clean)[len(on_clean)//2] if on_clean else float('nan')):.2f} / {(mean_on_clean if mean_on_clean is not None else float('nan')):.2f} ms
{plain_html}

曲线(带标注)

{svg}
{annot_html}

各行程明细(积分从行程起点重算)

{rows_joined}
#方向起动Δ(N3-N2)巡航|v| N2/N3 ∫起动∫巡航∫停车 停时仅N2在动停时仅N3在动停车归因Δ(a2+a3)

单位:角度 deg(积分)· 速度 deg/s · 时间 ms。 「停时仅N2/N3在动」为该窗口内单边残速对 ∫(v2+v3) 的贡献。

原始结论脚本输出(备查)

{json.dumps(verdict, ensure_ascii=False, indent=2)}
""" OUT.write_text(html, encoding="utf-8") print("HTML ->", OUT) print("main_src:", main_src) print("on_clean", on_clean, "mean", mean_on_clean) print("I_on/I_cr/I_st abs mean", abs_Ion, abs_Icr, abs_Ist) print("only2", n_only2, sum_only2, "only3", n_only3, sum_only3)