Files
DCU_DesktopController/scripts/build_integral_report_html.py
T
cbb 27a8fc1797 docs(compose): 上位机代码质量评估 host-code-quality + 积分耦合报告产物
全仓冗余/复杂度审计(3.5/5),交付重构清单 R1–R16;
附带入库 ecat 积分耦合 HTML 报告与 N2/N3 成对测试脚本。
2026-09-21 02:12:04 +08:00

425 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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'<polyline fill="none" stroke="{color}" stroke-width="{w}" points="{pts}"/>'
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'<polyline fill="none" stroke="{color}" stroke-width="{w}" points="{pts}"/>'
# 行程分界竖线
vlines = []
for sm in data.get("stroke_marks") or []:
if sm["t0"] <= t1:
x = X(sm["t0"])
vlines.append(
f'<line x1="{x:.1f}" x2="{x:.1f}" y1="40" y2="390" stroke="#2a3340" stroke-width="1"/>'
)
vlines.append(
f'<text x="{x+3:.1f}" y="54" fill="#5c6b7e" font-size="9" '
f'font-family="Segoe UI,sans-serif">#{sm["i"]}</text>'
)
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'<text x="{PAD_L}" y="28" fill="#e8eef7" font-size="14" '
f'font-family="Segoe UI,Microsoft YaHei,sans-serif">'
f'对接电机 2/3:速度、合速度 v2+v3、积分 ∫(v2+v3)dt(虚线为理论 0)</text>'
)
# 表格行
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"""
<tr>
<td>#{s['i']}</td><td>{s['dir_n2']}</td>
<td class="{cls_on}">{s['on_ms_n3_minus_n2']:+.2f} ms</td>
<td class="{cls_cr}">{s['cruise']['mean_abs_v2']:.2f} / {s['cruise']['mean_abs_v3']:.2f}</td>
<td>{s['I_on']['Isum']:+.2f}</td>
<td class="ok">{s['I_cruise']['Isum']:+.2f}</td>
<td class="{cls_st}">{s['I_stop']['Isum']:+.2f}</td>
<td>{s['stop_only_n2']['Isum']:+.2f}</td>
<td>{s['stop_only_n3']['Isum']:+.2f}</td>
<td class="{cls_st}">{who}</td>
<td>{s['da2_plus_da3']:+.2f}°</td>
</tr>""")
# 通俗结论文本
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"""
<section class="card">
<h2>通俗结论:到底是谁慢?</h2>
<p class="lead">先说人话:<strong>两台电机并不是“有一台全程都慢”</strong>。
你看到的积分差,主要不是巡航时谁转得慢,而是<strong>快停下 / 刚要走的那几十一到几百毫秒里,两台对不齐</strong>,
把理论应为 0 的 <code>∫(v2+v3)dt</code> 顶出 5~20° 左右的偏差。</p>
<ul class="verdict">
<li><span class="tag ok">启动</span>
干净起动时(|Δt|≤50ms 的行程),N3−N2 约为
<strong>{mean_on_clean if mean_on_clean is not None else '—'} ms</strong> 量级 →
<strong>几乎是同时启动</strong>,ECAT 同帧下发没有问题。
有 {len(on_overlap)} 个行程测出上百毫秒的“假早启动”,那是<strong>上一段还没停稳、N3 仍在动</strong>,
不是这一段命令发晚了。</li>
<li><span class="tag ok">巡航</span>
两边平均 |v|:N2 <strong>{cruise_v2:.2f}</strong>°/s,N3 <strong>{cruise_v3:.2f}</strong>°/s,
差值约 <strong>{cruise_bias:.3f}</strong>°/s → <strong>稳态速度几乎一样,谈不上谁慢</strong>。
巡航段 ∫(v2+v3) 平均只有约 <strong>{mean_Icr:.2f}°</strong>,贴合理论 0。</li>
<li><span class="tag warn">停车 / 换向</span>
行程末端 ∫(v2+v3) 平均约 <strong>{mean_Ist:.2f}°</strong>,绝对值平均约 <strong>{abs_Ist:.2f}°</strong>,
明显大于巡航。停止窗口里:
出现「只有 N2 还在走」的行程约 <strong>{n_only2}</strong> 次(积分贡献合计 {sum_only2:.2f}°),
「只有 N3 还在走」约 <strong>{n_only3}</strong> 次(合计 {sum_only3:.2f}°)。
<strong>两边都有刹车尾巴,不是单边永久变慢</strong>;有的行程 N2 拖尾更明显,有的行程 N3 更明显。</li>
</ul>
<div class="callout">
<strong>一句话:</strong>积分差的大头在 <strong>{main_src}</strong>。
对接约束要求 <code>v2 = −v3</code>;只要刹停瞬间一台还带着速度、另一台已经接近 0,
<code>v2+v3</code> 就会冒尖峰,积分就会被拉离理论 0。这属于<strong>动态不同步</strong>,
不等于某台电机“标定转速低、一直慢”。
</div>
<div class="arrow-legend">
<div><span class="arrow ok-arrow"></span> 正常:同时启动 / 巡航一致 / 中段积分≈0</div>
<div><span class="arrow warn-arrow"></span> 注意:停车沿残速、行程衔接重叠、积分被顶开</div>
<div><span class="arrow bad-arrow"></span> 偏大:该行程 ∫(v2+v3) 或编码器 Δ(a2+a3) 明显偏离 0</div>
</div>
</section>
"""
rows_joined = "\n".join(rows_html)
# 标注框(放在 SVG 下方用 HTML 箭头说明)
annot_html = f"""
<section class="card">
<h2>图上怎么看(对照上方曲线)</h2>
<div class="annot-grid">
<div class="annot ok">
<div class="ann-title">↑ 巡航段(两条速度曲线几乎贴合)</div>
<p>N2 蓝线、N3 橙线在 |v|≈200°/s 附近重合 → <strong>都不慢</strong>。
下方红线 <code>v2+v3</code> 贴 0,积分曲线几乎水平 → 对接约束在稳态是成立的。</p>
</div>
<div class="annot warn">
<div class="ann-title">↓ 停车沿 / 换向点(红线尖峰)</div>
<p>接近终点时,一台速度先掉下来、另一台还在走 → <code>v2+v3</code> 出现 ±100~200°/s 尖峰。
积分曲线在这里<strong>爬上或爬下</strong>,行程结束就停在理论 0 以外几度到二十度。
<strong>这里是积分差的主要来源。</strong></p>
</div>
<div class="annot warn">
<div class="ann-title">↓ 行程刚衔接时(起动窗)</div>
<p>若上一段没完全停稳就反向,会看到“N3 早就动了”的假象(表格里 onΔ 为 −800ms 量级)。
这<strong>不是启动指令不同步</strong>,而是<strong>速度还在反向爬坡 / 残速</strong>,
也会把起动窗积分抬高一点。</p>
</div>
<div class="annot bad">
<div class="ann-title">↓ 紫色角度和 a2+a3</div>
<p>理论应为常数(理想为 0)。行程终点若偏到 ±10~25°,与该行程 ∫(v2+v3) 同量级 →
说明偏差是<strong>真实位移耦合误差</strong>,不只是速度噪声。</p>
</div>
</div>
<div class="callout small">
<strong>标定建议(若要压积分差):</strong>
① 先统一两台的减速段(同样斜率刹停),而不是去改巡航速度;
② 检查 N2/N3 负载与制动是否对称;
③ 控制器上对对接轴做 <code>v2+v3 → 0</code> 的纠偏,比单轴调 PID 更对症;
④ 序列 hold 不要短于“两台都确认停稳”的时间,避免重叠行程把积分搅浑。
</div>
</section>
"""
# SVG 图
if sample:
svg = f"""
<svg viewBox="0 0 {W} {H}" width="100%" xmlns="http://www.w3.org/2000/svg">
<rect width="{W}" height="{H}" fill="#0f1419"/>
{''.join(vlines)}
{line(v2, Yv, '#4da3ff')}
{line(v3, Yv, '#f0a030')}
<text x="{PAD_L}" y="44" fill="#8a9bb0" font-size="11" font-family="Segoe UI,Microsoft YaHei,sans-serif">速度 deg/s — 蓝 N2 / 橙 N3</text>
<line x1="{PAD_L}" x2="{W-PAD_R}" y1="{Yv(0):.1f}" y2="{Yv(0):.1f}" stroke="#2a3340" stroke-dasharray="4 3"/>
{line(vsum, Ys, '#ff5c5c')}
<text x="{PAD_L}" y="184" fill="#8a9bb0" font-size="11" font-family="Segoe UI,Microsoft YaHei,sans-serif">合速度 v2+v3(理论 0)— 停车尖峰在这里</text>
<line x1="{PAD_L}" x2="{W-PAD_R}" y1="{Ys(0):.1f}" y2="{Ys(0):.1f}" stroke="#3a4656" stroke-dasharray="4 3"/>
{line(Isum, YI, '#b48cff')}
<text x="{PAD_L}" y="294" fill="#8a9bb0" font-size="11" font-family="Segoe UI,Microsoft YaHei,sans-serif">积分 ∫(v2+v3)dt(理论 0)— 每段末的偏离就是耦合误差</text>
<line x1="{PAD_L}" x2="{W-PAD_R}" y1="{YI(0):.1f}" y2="{YI(0):.1f}" stroke="#3a4656" stroke-dasharray="4 3"/>
<text x="{PAD_L}" y="{H-12}" fill="#6b7a8d" font-size="11" font-family="Segoe UI,sans-serif">t_rel {t0:.1f}..{t1:.1f}s · ECAT 0.25ms · 竖线为行程起点</text>
<!-- 标注箭头 -->
<g font-family="Segoe UI,Microsoft YaHei,sans-serif" font-size="11">
<text x="{W-210}" y="70" fill="#3d8f5b">↑ 巡航:两线贴合,都不慢</text>
<text x="{W-230}" y="210" fill="#d4a017">↓ 停车沿:v_sum 尖峰 → 积分爬升</text>
</g>
</svg>"""
else:
svg = "<p>(无绘图样本)</p>"
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>对接电机 2/3 积分差归因报告</title>
<style>
:root {{
--bg:#0f1419; --card:#161c24; --ink:#e8eef7; --mut:#8a9bb0;
--ok:#3d8f5b; --warn:#d4a017; --bad:#e25555; --line:#2a3340;
}}
* {{ box-sizing:border-box; }}
body {{
margin:0; background:var(--bg); color:var(--ink);
font-family:"Segoe UI","Microsoft YaHei",system-ui,sans-serif;
line-height:1.55; padding:24px;
}}
h1 {{ font-size:22px; font-weight:600; margin:0 0 6px; }}
h2 {{ font-size:16px; font-weight:600; margin:0 0 12px; }}
.sub {{ color:var(--mut); font-size:13px; margin-bottom:18px; }}
.card {{
background:var(--card); border:1px solid var(--line); border-radius:12px;
padding:18px 20px; margin-bottom:16px;
}}
.lead {{ margin:0 0 12px; color:var(--ink); }}
code {{
background:#0c1014; padding:1px 6px; border-radius:4px;
font-family:Consolas,monospace; font-size:13px; color:#c5d4e8;
}}
ul.verdict {{ list-style:none; padding:0; margin:0 0 14px; }}
ul.verdict li {{
margin:0 0 10px; padding:10px 12px; background:#121820;
border-left:3px solid var(--line); border-radius:0 8px 8px 0;
}}
.tag {{
display:inline-block; font-size:12px; font-weight:600; padding:2px 8px;
border-radius:999px; margin-right:8px; vertical-align:middle;
}}
.tag.ok {{ background:rgba(61,143,91,.2); color:#6dca8f; }}
.tag.warn {{ background:rgba(212,160,23,.2); color:#f0c35a; }}
.callout {{
background:#121a14; border:1px solid #2a4a34; border-radius:8px;
padding:12px 14px; margin-top:8px;
}}
.callout.small {{ font-size:13px; background:#1a1520; border-color:#3a2a4a; }}
table {{
width:100%; border-collapse:collapse; font-size:12.5px; margin-top:8px;
}}
th, td {{
border-bottom:1px solid var(--line); padding:6px 8px; text-align:left;
}}
th {{ color:var(--mut); font-weight:500; }}
td.ok {{ color:#6dca8f; }}
td.warn {{ color:#f0c35a; }}
td.bad {{ color:#ff8a8a; }}
.annot-grid {{
display:grid; grid-template-columns:1fr 1fr; gap:12px;
}}
@media (max-width:800px) {{ .annot-grid {{ grid-template-columns:1fr; }} }}
.annot {{
background:#121820; border-radius:8px; padding:12px; border-left:3px solid var(--line);
}}
.annot.ok {{ border-left-color:var(--ok); }}
.annot.warn {{ border-left-color:var(--warn); }}
.annot.bad {{ border-left-color:var(--bad); }}
.ann-title {{ font-weight:600; margin-bottom:6px; font-size:13px; }}
.annot p {{ margin:0; font-size:13px; color:#c5d0dc; }}
.arrow-legend {{ margin-top:12px; font-size:13px; color:var(--mut); }}
.arrow-legend div {{ margin:4px 0; }}
.arrow {{ display:inline-block; width:18px; height:3px; margin-right:8px; vertical-align:middle; }}
.ok-arrow {{ background:var(--ok); }}
.warn-arrow {{ background:var(--warn); }}
.bad-arrow {{ background:var(--bad); }}
.metrics {{
display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-top:10px;
}}
@media (max-width:800px) {{ .metrics {{ grid-template-columns:1fr 1fr; }} }}
.metric {{
background:#121820; border:1px solid var(--line); border-radius:8px; padding:10px 12px;
}}
.metric .k {{ color:var(--mut); font-size:12px; }}
.metric .v {{ font-size:20px; font-weight:600; margin-top:2px; }}
.metric .v.ok {{ color:#6dca8f; }}
.metric .v.warn {{ color:#f0c35a; }}
</style>
</head>
<body>
<h1>对接电机 2 / 3:积分差是谁造成的?</h1>
<div class="sub">
数据:ECAT 无源采集 <code>lab/ecat_passive_latest.csv</code> · 周期 0.25ms ·
理论:刚性镜像对接时 <code>v2+v3≡0</code>,<code>∫(v2+v3)dt≡0</code>,<code>a2+a3=常数</code><br/>
方法:对齐 cycles 后做梯形积分;先加后积与先积后加一致。
</div>
<div class="card">
<h2>关键数字</h2>
<div class="metrics">
<div class="metric"><div class="k">巡航 |v| N2 / N3</div>
<div class="v ok">{cruise_v2:.2f} / {cruise_v3:.2f}</div></div>
<div class="metric"><div class="k">巡航段 ∫(v2+v3) 平均</div>
<div class="v ok">{mean_Icr:.2f}°</div></div>
<div class="metric"><div class="k">停车段 ∫(v2+v3) |均值|</div>
<div class="v warn">{abs_Ist:.2f}°</div></div>
<div class="metric"><div class="k">干净起动 Δt 中位/均值</div>
<div class="v ok">{(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</div></div>
</div>
</div>
{plain_html}
<section class="card">
<h2>曲线(带标注)</h2>
{svg}
</section>
{annot_html}
<section class="card">
<h2>各行程明细(积分从行程起点重算)</h2>
<table>
<thead>
<tr>
<th>#</th><th>方向</th><th>起动Δ(N3-N2)</th><th>巡航|v| N2/N3</th>
<th>∫起动</th><th>∫巡航</th><th>∫停车</th>
<th>停时仅N2在动</th><th>停时仅N3在动</th><th>停车归因</th><th>Δ(a2+a3)</th>
</tr>
</thead>
<tbody>
{rows_joined}
</tbody>
</table>
<p class="sub" style="margin-top:10px">
单位:角度 deg(积分)· 速度 deg/s · 时间 ms。
「停时仅N2/N3在动」为该窗口内单边残速对 ∫(v2+v3) 的贡献。
</p>
</section>
<section class="card">
<h2>原始结论脚本输出(备查)</h2>
<pre style="white-space:pre-wrap;font-size:12px;color:#a8b6c8;margin:0">{json.dumps(verdict, ensure_ascii=False, indent=2)}</pre>
</section>
</body>
</html>
"""
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)