#!/usr/bin/env python3 # -*- coding: utf-8 -*- """跳拍率标定:固定时长采 /api/motor/dbg,输出可对比 JSON。 主判据: late_per_s, skip_per_s, send_stall_frac, rx_stall_frac 用法: python scripts/skip_rate_meter.py --tag base --secs 20 """ from __future__ import annotations import argparse import json import os import time import urllib.request BASE = os.environ.get("ECAT_BASE", "http://127.0.0.1:8090") OUTDIR = os.path.join(os.path.dirname(__file__), "..", "lab", "skip_meter") def post(path: str, body=None, timeout=5.0): data = json.dumps(body or {}).encode() req = urllib.request.Request( BASE + path, data=data, headers={"Content-Type": "application/json"} ) with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read().decode()) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--tag", default="sample") ap.add_argument("--secs", type=float, default=20.0) ap.add_argument("--hz", type=float, default=5.0) ap.add_argument("--out", default=None) args = ap.parse_args() st0 = post("/api/motor/state") period = float(st0.get("period_ms") or 1.0) t0 = time.time() pulses = [] last_t = None n_poll = 0 # dbg t0 uses now_s() (QPC-based), NOT unix time. Track by last-seen t0 only. while time.time() - t0 < args.secs: dbg = post("/api/motor/dbg") n_poll += 1 cyc = dbg.get("cyc") or [] if last_t is not None: new = [r for r in cyc if r[0] > last_t] else: # first poll: adopt the whole ring's last timestamp as cursor; # count only k>0 from now on via subsequent polls. Also count # k>0 already in ring as "pre-existing" (not in window rate). new = [] if cyc: last_t = cyc[-1][0] elif last_t is None: last_t = None for r in new: if len(r) < 8: continue k = int(r[5]) if k <= 0: continue send_us = (r[2] - r[1]) * 1e6 rx_us = (r[3] - r[2]) * 1e6 pulses.append( { "t0": r[0], "k": k, "send_us": send_us, "rx_us": rx_us, "work_us": (r[4] - r[0]) * 1e6, "sd": float(r[6]) if len(r) > 6 else None, "rxc": int(r[7]) if len(r) > 7 else None, "kind": ( "send" if send_us >= 500 else ("rx" if rx_us >= 500 else "other") ), } ) time.sleep(max(0.0, 1.0 / args.hz)) st1 = post("/api/motor/state") wall = time.time() - t0 n_late = len(pulses) n_skip = sum(p["k"] for p in pulses) by = {"send": 0, "rx": 0, "other": 0} for p in pulses: by[p["kind"]] += 1 # approximate expected beats in window # use cycles delta when available cyc0 = st0.get("cycles") or 0 cyc1 = st1.get("cycles") or 0 dcyc = max(0, cyc1 - cyc0) cu = st1.get("cyc_us") or {} result = { "tag": args.tag, "ts": time.time(), "wall_s": round(wall, 3), "period_ms": period, "pcore_mask": st1.get("pcore_mask"), "wkc": st1.get("wkc"), "spin": st1.get("spin"), "cycles_delta": dcyc, "achieved_hz": round(dcyc / wall, 2) if wall > 0 else None, "late_per_s": round(n_late / wall, 3) if wall else None, "skip_per_s": round(n_skip / wall, 3) if wall else None, "late_frac_pct": round(100.0 * n_late / dcyc, 4) if dcyc else None, "skip_frac_pct": round(100.0 * n_skip / dcyc, 4) if dcyc else None, "send_stall_frac": round(by["send"] / n_late, 4) if n_late else None, "rx_stall_frac": round(by["rx"] / n_late, 4) if n_late else None, "n_late": n_late, "n_skip": n_skip, "by_kind": by, "window_cyc_us": cu, "n_poll": n_poll, "pulses_sample": pulses[:40], } os.makedirs(OUTDIR, exist_ok=True) out = args.out or os.path.join(OUTDIR, f"{args.tag}_{int(time.time())}.json") with open(out, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) print(json.dumps({k: result[k] for k in result if k != "pulses_sample"}, ensure_ascii=False, indent=2)) print(f"WROTE {out}") return 0 if __name__ == "__main__": raise SystemExit(main())