Files
DCU_DesktopController/scripts/calibrate_ecat_metrics.py
T

419 lines
19 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 -*-
"""Phase 0:ECAT 指标现场标定 —— 跑速度扫描之前,先确认每个计数器还有没有意义。
为什么必须有这一步:固件与主站都在被反复改(当前 ecat_native.c 有 239 行未提交改动,
批量区已从"变长紧排 3 帧/拍"换成 1.6.15 定长覆盖窗口),任何沿用历史结论的判据都可能
已经失效。本脚本只认现场读数,不认文档和历史。
标定八件事:
1 总线能否正常起(open + motor/start)
2 板上固件身份(台账 sha256 对 Release/*.hex 展平后的哈希)
3 批量区实际布局(rxraw.layout,不假设)
4 每拍入窗帧数分布(取 cadence 环,按拍采样,不受 HTTP 轮询速率影响)
5 丢帧率的主判据:**seq 值域 − 实测 distinct seq**(对槽位顺序不敏感)
对账判据:固件 OVF / bridge.drop —— 二者与主判据是否一致
6 撕裂类计数器是否还有功效(rx_torn / pdo_reentry,恒零即"测不到"≠"没坏")
7 主站错拍 skip_total 与达成率、cyc_us 占空
8 CPU 两口径(GD32 load_perm / 主机 native)+ 进程 PID 稳定性
用法:
python scripts/calibrate_ecat_metrics.py
python scripts/calibrate_ecat_metrics.py --seconds 60 --json lab/ecat_calib.json
python scripts/calibrate_ecat_metrics.py --no-start # 总线没起时不自动拉起
"""
import argparse
import collections
import hashlib
import json
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ecat_sweep_common import delta_saturate, delta_wrap, percentile, post
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_URL = "http://127.0.0.1:8090"
# 1.6.15 定长覆盖窗口槽布局(对应 ecat_native.c RXB_WIN_OFF_* / 固件 ECAT_RXW_OFF_*)
WIN_SLOT_SIZE, WIN_SLOTS = 44, 5
WIN_OFF_FLAGS, WIN_OFF_ID, WIN_OFF_SEQ, WIN_OFF_DLC = 2, 4, 8, 1
FLAG_WRITTEN = 0x01
def parse_win_slots(rawhex):
"""从 rxraw.raw 解出每个槽的 (id, seq)(跳过 bit0=未写过的空槽)。
parsed 字段不带 seq,而 seq 是这个布局的丢帧判据,必须自己解。
"""
try:
buf = bytes.fromhex(rawhex or "")
except ValueError:
return []
out = []
for k in range(WIN_SLOTS):
o = k * WIN_SLOT_SIZE
if o + WIN_SLOT_SIZE > len(buf):
break
if not buf[o + WIN_OFF_FLAGS] & FLAG_WRITTEN:
continue
out.append((int.from_bytes(buf[o + WIN_OFF_ID:o + WIN_OFF_ID + 4], "little"),
int.from_bytes(buf[o + WIN_OFF_SEQ:o + WIN_OFF_SEQ + 4], "little")))
return out
def window_coverage(seqmap, slot_slots=WIN_SLOTS):
"""覆盖窗口观测到的 seq 值域 / distinct seq 数(**描述性指标,不是丢帧判据**)。
曾用「值域 − distinct」当丢帧主判据,已被现场证伪:窗口只有 5 槽、HTTP 又只几
Hz,值域内根本不可能观测到全部 seq,实测报出 99.686% "丢帧"而固件 OVF=0,自相
矛盾 —— 它量的是**采样覆盖率**。真丢帧主判据是 bridge.drop(固件
canfd.h:53 rx_drop_count,已实测确认为回绕计数器且增量与 rx_count 增量相等)。
"""
out = {}
for fid, obs in seqmap.items():
if not obs:
continue
lo, hi = min(obs), max(obs)
out[fid] = {"n_obs": len(obs), "span": hi - lo + 1,
"coverage_pct": round(len(obs) / (hi - lo + 1) * 100, 3)}
return {"per_id": out, "ids": len(out),
"note": "覆盖率仅说明观测密度,不可当丢帧率引用"}
def cadence_rings(url):
"""按拍采到的环形缓冲:每拍 count/lost/pending/wkc 的真实分布。
HTTP 轮询只有几 Hz,用它看"每拍几帧"是欠采样;cadence 环由 native 自己在
每拍写,512 拍一段,才是每拍口径的真值。
"""
try:
c = post(url, "/api/can/cadence")
except Exception as e:
return {"error": str(e)[:120]}
cols, rows = c.get("cols") or [], c.get("rows") or []
if not rows:
return {"error": "环为空"}
ix = {k: i for i, k in enumerate(cols)}
def col(name):
return [r[ix[name]] for r in rows] if name in ix else []
out = {"n": len(rows), "period_ms": c.get("period_ms")}
for name in ("count", "lost", "pending", "backlog", "wkc"):
v = col(name)
if v:
out[name + "_hist"] = {str(k): n for k, n in sorted(collections.Counter(v).items())}
out[name + "_max"] = max(v)
span_ms = (col("t")[-1] - col("t")[0]) if len(rows) > 1 else 0
out["span_ms"] = round(span_ms, 1)
if span_ms > 0 and "rxcnt" in ix:
out["lambda_per_s"] = round((col("rxcnt")[-1] - col("rxcnt")[0]) / (span_ms / 1000.0), 1)
out["gseq_per_s"] = round((col("gseq")[-1] - col("gseq")[0]) / (span_ms / 1000.0), 1) \
if "gseq" in ix else None
return out
def find_native_pid(url):
"""反查占用该端口的进程 PID。PID 变了 = 换了实例 = 之前采的全部作废。"""
try:
out = subprocess.check_output(["netstat", "-ano", "-p", "TCP"],
text=True, errors="replace")
except Exception as e:
return None, f"netstat 失败: {e}"
port = url.rsplit(":", 1)[-1].strip("/")
for line in out.splitlines():
cols = line.split()
if len(cols) >= 5 and cols[0] == "TCP" and cols[3] == "LISTENING" \
and cols[1].endswith(":" + port):
return int(cols[-1]), None
return None, f"没人监听 :{port}"
def firmware_identity():
"""台账 sha256 对固件产物。
口径必须与烧写脚本一致:`lab/_last_gd32_sha.txt` 记的是 Intel-Hex **展平成
APP 基址连续 blob 后**的 sha256(flash_gd32.py:91-92),不是 .hex 文件本身的哈希,
产物也是 `.hex` 而非 `.bin`。按原始字节算或按 .bin 过滤会一个都扫不到,
把"本可判定"误报成"无法判定"。
即便命中也只能说明"板上镜像与当前产物同源";产物比台账新时必然不等。
**不能据此推断板上正在运行的版本** —— 那要设备回报版本字。
"""
shafile = os.path.join(REPO, "lab", "_last_gd32_sha.txt")
rec = {"ledger": None, "ledger_path": shafile, "match": None,
"candidates": [], "method": "hex_to_flat + sha256"}
if not os.path.isfile(shafile):
rec["error"] = "台账不存在"
return rec
with open(shafile, encoding="utf-8", errors="replace") as f:
rec["ledger"] = f.read().strip()
try:
from flash_gd32 import hex_to_flat
except Exception as e:
rec["error"] = f"无法复用烧写脚本的展平算法({e})"
return rec
app = os.path.abspath(os.path.join(REPO, "..", "..", "DCU_APP", "out"))
if not os.path.isdir(app):
rec["error"] = f"固件产物目录不存在: {app}"
return rec
hexes = []
for dirpath, _d, files in os.walk(app):
hexes += [os.path.join(dirpath, fn) for fn in files if fn.endswith(".hex")]
if not hexes:
rec["error"] = "DCU_APP/out 下没有 .hex 产物"
return rec
for p in sorted(hexes):
try:
d = hashlib.sha256(hex_to_flat(p)).hexdigest()
except (OSError, SystemExit, ValueError) as e:
rec["candidates"].append({"path": p, "error": str(e)[:120]})
continue
rec["candidates"].append({"path": p, "sha256": d[:16], "mtime": int(os.path.getmtime(p))})
if d == rec["ledger"]:
rec["match"] = p
rec["candidates"].sort(key=lambda c: -c.get("mtime", 0))
rec["candidates"] = rec["candidates"][:5]
return rec
def ensure_loop(url, autostart, substr):
st = post(url, "/api/motor/state")
if st.get("ok") and st.get("cycles"):
return st, "已在跑"
if not autostart:
return st, "循环未跑(--no-start)"
op = post(url, "/api/open", {"substr": substr}, timeout=25.0)
if not op.get("ok"):
return op, "open 失败: %s" % (op.get("log") or [])
ms = post(url, "/api/motor/start", {"period_ms": st.get("period_ms") or 1.0}, timeout=40.0)
if not ms.get("ok"):
return ms, "motor/start 失败(典型=SSC 邮箱楔死): %s" % (ms.get("log") or [])
return post(url, "/api/motor/state"), "已自动 open+start"
def main():
ap = argparse.ArgumentParser(description="ECAT 指标现场标定")
ap.add_argument("--url", default=DEFAULT_URL)
ap.add_argument("--seconds", type=float, default=30.0)
ap.add_argument("--hz", type=float, default=10.0,
help="HTTP 采样频率。u16 的 drop 在 4000/s 下 16s 就绕一圈,"
"采样间隔须远小于回绕周期")
ap.add_argument("--no-start", action="store_true")
ap.add_argument("--substr", default="I210")
ap.add_argument("--json", default=None)
a = ap.parse_args()
rep = {"url": a.url, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"seconds": a.seconds, "sample_hz": a.hz, "logical_cpus": os.cpu_count()}
pid, perr = find_native_pid(a.url)
rep["native_pid"], rep["native_pid_error"] = pid, perr
rep["firmware"] = firmware_identity()
rep["cadence"] = cadence_rings(a.url)
st, how = ensure_loop(a.url, not a.no_start, a.substr)
rep["loop_start"] = {"how": how, "ok": bool(st.get("ok"))}
if not st.get("ok"):
print("总线起不来,标定终止:", how)
return 2
rep["period_ms_set"] = st.get("period_ms")
rep["pcore_mask"] = st.get("pcore_mask")
proc = None
try:
import psutil
for p in psutil.process_iter(["name", "pid"]):
if p.info["pid"] == pid:
proc = psutil.Process(pid)
proc.cpu_percent(None)
break
rep["psutil"] = proc is not None
except Exception as e:
rep["psutil"] = f"不可用({e})"
interval = 1.0 / a.hz
seqmap = collections.defaultdict(set)
counts, loads, hc1, hctot, cyc_hz = collections.Counter(), [], [], [], []
drop_rate, rx_rate, cyc_samples = [], [], []
drop_untrusted = 0
skip_first = skip_last = None
torn_c = reentry_c = 0
torn_cap = reentry_cap = False
torn_prev = reentry_prev = None
prev = None
prev_t = None
layout = collections.Counter()
err = iters = 0
t_end = time.perf_counter() + a.seconds
while time.perf_counter() < t_end:
tc = time.perf_counter()
try:
s = post(a.url, "/api/motor/state")
L = post(a.url, "/api/app/load")
R = post(a.url, "/api/motor/rxraw")
D = post(a.url, "/api/motor/dbg")
except Exception:
err += 1
time.sleep(interval)
continue
iters += 1
b = s.get("bridge") or {}
rb = s.get("rx_batch") or {}
cur = {"cycles": s.get("cycles"), "drop": b.get("drop"), "rx": b.get("rx_count")}
if prev is not None and prev_t is not None and tc > prev_t:
dt = tc - prev_t
if cur["cycles"] is not None and prev["cycles"] is not None:
hz = (cur["cycles"] - prev["cycles"]) / dt
if 0 < hz < 1e6:
cyc_hz.append(hz)
if cur["drop"] is not None and prev["drop"] is not None:
d = delta_wrap(prev["drop"], cur["drop"], 16)
# u16 回绕计数器:单次增量逼近模数一半即说明两次采样间已绕圈,
# 该次不可信,只能丢弃并计数暴露采样率不足(真值下降同理)。
if d < 32768:
drop_rate.append(d / dt)
else:
drop_untrusted += 1
if cur["rx"] is not None and prev["rx"] is not None:
dr = delta_wrap(prev["rx"], cur["rx"], 32)
if dr < (1 << 31):
rx_rate.append(dr / dt)
for fid, sq in parse_win_slots(R.get("raw")):
seqmap[fid].add(sq)
counts[rb.get("count")] += 1
layout[R.get("layout")] += 1
if L.get("valid") and L.get("load_perm") is not None:
loads.append(L["load_perm"] / 10.0)
if L.get("rx_torn") is not None:
if torn_prev is not None:
d, capped = delta_saturate(torn_prev, L["rx_torn"])
torn_c += d
torn_cap |= capped or L["rx_torn"] >= 65535
torn_prev = L["rx_torn"]
if L.get("pdo_reentry") is not None:
if reentry_prev is not None:
d, capped = delta_saturate(reentry_prev, L["pdo_reentry"])
reentry_c += d
reentry_cap |= capped or L["pdo_reentry"] >= 65535
reentry_prev = L["pdo_reentry"]
sk = D.get("skip_total")
if sk is not None:
if skip_first is None:
skip_first = sk
skip_last = sk
if s.get("cyc_us"):
cyc_samples.append(s["cyc_us"])
if proc is not None:
try:
c = proc.cpu_percent(None)
hc1.append(c)
hctot.append(c / max(1, rep["logical_cpus"] or 1))
except Exception:
pass
prev, prev_t = cur, tc
time.sleep(max(0.0, interval - (time.perf_counter() - tc)))
dur = time.perf_counter() - (t_end - a.seconds)
stf = post(a.url, "/api/motor/state")
pid2, _ = find_native_pid(a.url)
cad = rep["cadence"]
lam = percentile(rx_rate, 0.5) or cad.get("lambda_per_s") or 0
dropr = percentile(drop_rate, 0.5) or 0
rep["dur_s"] = round(dur, 2)
rep["iters"] = iters
rep["errors"] = err
rep["drop_untrusted_samples"] = drop_untrusted
rep["period_ms_effective"] = stf.get("period_ms")
rep["wkc"] = [stf.get("wkc"), stf.get("expected_wkc")]
rep["wr_end"] = stf.get("wr")
rep["native_pid_end"] = pid2
rep["pid_stable"] = (pid == pid2)
rep["layout_seen"] = dict(layout)
rep["rx_batch_count_hist_http"] = {str(k): v for k, v in sorted(counts.items(), key=lambda x: (x[0] is None, x[0]))}
rep["window_coverage"] = window_coverage(seqmap)
rep["can_lambda_per_s"] = round(lam, 1)
rep["drop_per_s"] = round(dropr, 1)
rep["drop_pct_of_lambda"] = round(dropr / lam * 100, 2) if lam else None
rep["achieved_hz_p50"] = round(percentile(cyc_hz, 0.5) or 0, 1) if cyc_hz else None
pm = rep["period_ms_effective"] or 1
rep["achieve_pct"] = round((rep["achieved_hz_p50"] or 0) / (1000.0 / pm) * 100, 1)
rep["skip_delta"] = (skip_last - skip_first) if (skip_first is not None and skip_last is not None) else None
rep["skip_per_s"] = round((rep["skip_delta"] or 0) / dur, 2) if rep["skip_delta"] is not None else None
rep["skip_total_cum"] = skip_last
rep["rx_torn_delta"] = torn_c
rep["rx_torn_capped"] = torn_cap
rep["pdo_reentry_delta"] = reentry_c
rep["pdo_reentry_capped"] = reentry_cap
tot = [(c.get("total") or {}) for c in cyc_samples if c.get("total")]
rep["cyc_us_total"] = {"p50": percentile([t.get("p50") for t in tot if t.get("p50") is not None], 0.5),
"p90": percentile([t.get("p90") for t in tot if t.get("p90") is not None], 0.5),
"max": max([t.get("max") or 0 for t in tot], default=None)}
rep["cyc_us_late_lost_sample"] = tot[0] if tot else None
rep["gd32_load_pct_p50"] = round(percentile(loads, 0.5) or 0, 1)
rep["host_cpu_1core_p50"] = round(percentile(hc1, 0.5) or 0, 1)
rep["host_cpu_total_p50"] = round(percentile(hctot, 0.5) or 0, 2)
cap = cad.get("count_max") or 0
rep["per_cycle_cap_observed"] = cap
rep["capacity_model"] = {
"lambda_per_s": round(lam, 1), "per_cycle_cap": cap,
"drain_per_s_at_current_T": round(cap / pm * 1000, 0) if pm else None,
"knee_ms_if_linear": round(cap / lam * 1000, 3) if lam and cap else None,
"note": "cap 取自 cadence 环实测 count 峰值;拐点以扫描实测为准,此值仅供事后对照"}
rep["power"] = {
"drop_rx_queue": dropr > 0,
"skip_total": (rep["skip_delta"] or 0) > 0,
"achieve_deficit": (rep["achieve_pct"] or 100) < 99.0,
"rx_torn": torn_c > 0,
"pdo_reentry": reentry_c > 0,
"cadence_lost_or_pending": bool((cad.get("lost_max") or 0) or (cad.get("pending_max") or 0)),
"cyc_us_late_lost": bool((rep["cyc_us_late_lost_sample"] or {}).get("late")
or (rep["cyc_us_late_lost_sample"] or {}).get("lost"))}
print(f"=== Phase 0 标定 · {dur:.0f}s · HTTP 采样 {iters} 轮(错 {err}) @ {a.hz:g}Hz ===")
print(f"native PID {pid} -> {pid2} 稳定={rep['pid_stable']} 钉核 mask={rep['pcore_mask']}")
fw = rep["firmware"]
print(f"固件台账 {str(fw.get('ledger'))[:16]}… 匹配={os.path.basename(fw['match']) if fw.get('match') else fw.get('error') or '无'}")
print(f"周期 设定 {rep['period_ms_set']}ms -> 生效 {pm}ms 实测 {rep['achieved_hz_p50']}Hz 达成率 {rep['achieve_pct']}%")
print(f"批量区布局 {dict(layout)}")
print(f"每拍入窗帧数(cadence 环 {cad.get('n')}拍): count={cad.get('count_hist')} "
f"lost={cad.get('lost_hist')} pending={cad.get('pending_hist')}")
print(f" ↑ 每拍口径必须用环,HTTP 轮询看 count 是欠采样")
print(f"【主判据】λ={lam:.0f}/s drop={dropr:.0f}/s 丢帧占 λ = {rep['drop_pct_of_lambda']}%"
f" (drop=固件 canfd.h rx_drop_count,CAN RX 队列(kRxQueueDepth=16)满即丢)")
if drop_untrusted:
print(f" !! {drop_untrusted} 个采样点单次增量 >32768,说明已绕圈 -> 采样率不足,提高 --hz")
print(f" 对账: cadence lost_max={cad.get('lost_max')} pending_max={cad.get('pending_max')}"
f"(覆盖窗口下这两个旧计数器语义已改,仅供对账)")
print(f" 窗口 seq 覆盖率(非丢帧率!): {rep['window_coverage']['per_id']}")
print(f"rx_torn +{torn_c}(封顶={torn_cap}) pdo_reentry +{reentry_c}(封顶={reentry_cap})")
print(f"主站错拍 skip_total Δ={rep['skip_delta']} = {rep['skip_per_s']}/s(累计 {rep['skip_total_cum']})")
print(f"cyc_us.total p50={rep['cyc_us_total']['p50']} p90={rep['cyc_us_total']['p90']} "
f"max={rep['cyc_us_total']['max']} -> 占空 {round((rep['cyc_us_total']['p50'] or 0)/(pm*1000)*100,1)}%")
print(f"CPU: GD32 load_perm {rep['gd32_load_pct_p50']}% 主机 native 单核 {rep['host_cpu_1core_p50']}% "
f"/ 整机 {rep['host_cpu_total_p50']}%(逻辑核 {rep['logical_cpus']})")
print(f"wkc {rep['wkc']} wr={rep['wr_end']}")
km = rep["capacity_model"]
print(f"\n容量对照: cap={km['per_cycle_cap']} λ={km['lambda_per_s']:.0f} "
f"当前档排空 {km['drain_per_s_at_current_T']} 线性外推拐点 {km['knee_ms_if_linear']}ms"
f"(仅事后对照,拐点以扫描实测为准)")
dead = [k for k, v in rep["power"].items() if not v]
live = [k for k, v in rep["power"].items() if v]
print("有功效:", live or "无")
if dead:
print(f"无功效: {dead}")
print(" → 这些轴在报告里只能写「本次测不到」,**禁止**写成「无故障/已修好」")
if a.json:
os.makedirs(os.path.dirname(os.path.abspath(a.json)), exist_ok=True)
with open(a.json, "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(f"\n标定结果: {a.json}")
return 0
if __name__ == "__main__":
sys.exit(main())