#!/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"""
"""
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}
{annot_html}
各行程明细(积分从行程起点重算)
| # | 方向 | 起动Δ(N3-N2) | 巡航|v| N2/N3 |
∫起动 | ∫巡航 | ∫停车 |
停时仅N2在动 | 停时仅N3在动 | 停车归因 | Δ(a2+a3) |
{rows_joined}
单位:角度 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)