"""EtherCAT 电机控制台后端自测(不接硬件)。 用一个模拟 GD32 buildInput/processOutput 行为的假从站,验证: 1. DLC 32 的电机 CMD 能完整下发(旧 24B 布局会静默截断后 8 字节) 2. 后台 PDO 循环靠滚动窗口扫完 27 个槽 3. STATUS 完整 32B 回传(旧 8B 短快照根本装不下) 4. 命令队列按 seq 沿下发,不互相覆盖 5. 循环可干净停止 6. 【1.5.56】批量发送区:多帧在**同一个周期**发出(同步下发) 7. 【1.5.56】批量接收区:一周期回收多帧,含 DLC64 CANFD 8. 【1.5.56】兼容性:批量区为空时,字节布局与 1.5.55 完全一致 9. 窗口自适应:稀疏总线(27 槽只有 2 个有主)下单槽每周期都刷新, 而不是旧实现的每 4 周期一次 10. 发现扫描:热插上来的电机仍能被找到并纳入常规窗口 (含 slot 21..26 这段尾部 —— 朴素的"+7 到越界归零"游标会永久漏掉它) 11. 自旋节拍源:自动阈值/显式覆盖,以及 1ms 档确实跑得准 12. 自旋不得持 GIL 空转(否则 HTTP 线程被饿死,真机实测劣化到 40ms) 13. P 核识别与钉核:混合架构下自旋钉到快核(P 核)组,且钉核是线程级的 14. api_motor_start 的 OP 状态核实:已在 OP 能正常启动,从站掉出 OP 能发现 跑法: python test_motor_backend.py """ import struct import sys import threading import time import types import os BASE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, BASE) # pysoem 依赖 Npcap 与真网卡,自测里用桩替掉 _fake = types.ModuleType("pysoem") _fake.find_adapters = lambda: [] _fake.INIT_STATE, _fake.PREOP_STATE = 1, 2 _fake.SAFEOP_STATE, _fake.OP_STATE = 4, 8 _fake.STATE_ACK = 0x10 _fake.Master = object sys.modules["pysoem"] = _fake import ecat_server as S # noqa: E402 SLOT = S.MOT_SLOT_SIZE NSLOT = S.MOT_SLOTS_PER_PDO class FakeSlave: """按 comm/ethercat_can_bridge.cpp 的语义模拟从站侧。 state/al_status 是 api_motor_start 核实"是否真在 OP"时要读的字段(pysoem 的 Slave 也有这两个),默认给 OP,掉线场景由测试临时改。 """ state = 8 # OP_STATE al_status = 0 def __init__(self): self._out = bytes(S.PDO_OUT_LEN) self.rx_frames = [] # 从站"发出"的帧 (ch,id,dlc,hexdata) self.tx_cycles = [] # 每个 seq 沿发出的帧数,用来证明"同一周期多帧" self.last_seq = None # 对应固件 s_lastSeq / s_seqPrimed self.tx_count = 0 self.pending_rx = [] # 待经批量接收区回给主站的帧 self.rx_lost = 0 @property def output(self): return self._out @output.setter def output(self, v): self._out = bytes(v) seq = self._out[0] # 固件语义:首次只记基准不发送;之后仅 seq 变化沿触发一次发送 if self.last_seq is not None and seq != self.last_seq: n = 0 # 段1 单帧区(dlc=0 表示本次只发批量) dlc = self._out[2] if dlc: self.rx_frames.append(( self._out[1], struct.unpack_from(" S.PDO_OUT_LEN: break d = self._out[off + S.FRM_OFF_DLC] if off + S.FRM_HDR_SIZE + d > S.PDO_OUT_LEN: break self.rx_frames.append(( self._out[off + S.FRM_OFF_CH], struct.unpack_from("= S.MOTOR_SLOT_COUNT: break off = S.MOTW_OFF_SLOTS + i * SLOT b[off] = idx // S.MOTOR_CHANNELS + 1 # node 1..9 b[off + 1] = idx % S.MOTOR_CHANNELS # ch 0..2 b[off + 2] = 1 # online b[off + 3] = 32 # dlc struct.pack_into(" S.PDO_IN_LEN: break self.pending_rx.pop(0) b[off + S.FRM_OFF_CH] = ch b[off + S.FRM_OFF_DLC] = dlc b[off + S.FRM_OFF_FLAGS] = 0x01 struct.pack_into(" 被窗口盖住并回传的次数 @property def input(self): b = bytearray(super().input) # 覆盖掉父类填的"全满"槽区,改按 present 填 win = self._out[S.TX_OFF_WIN] for i in range(NSLOT): off = S.MOTW_OFF_SLOTS + i * SLOT b[off:off + SLOT] = bytes(SLOT) # 先清成空槽 cnt = 0 for i in range(NSLOT): idx = win + i if idx >= S.MOTOR_SLOT_COUNT: break cnt += 1 if idx not in self.present: continue # 空槽: node_id 留 0 off = S.MOTW_OFF_SLOTS + i * SLOT b[off] = idx // S.MOTOR_CHANNELS + 1 b[off + 1] = idx % S.MOTOR_CHANNELS b[off + 2] = 1 b[off + 3] = 32 # seq 每次命中都要变,否则前端的 seq 活性判断看不出在刷新 self.slot_hits[idx] = self.slot_hits.get(idx, 0) + 1 struct.pack_into(" 从站收到 {len(sent)} 帧," f"各 seq 沿帧数={slave.tx_cycles}") assert len(sent) == 9, f"批量下发丢帧: {len(sent)}/9" assert max(slave.tx_cycles) > 1, \ f"仍是每周期一帧({slave.tx_cycles}),批量区没生效" # 关键:9 帧 DLC32 应在 1 个周期内发完(单帧区1 + 批量区8),而非排队 9 个周期 assert len(slave.tx_cycles) <= 2, \ f"9 帧用了 {len(slave.tx_cycles)} 个周期,未达到同步下发" ids = [f"0x{f[1]:X}" for f in sent] print(f" 帧序 ={ids}") assert [f[2] for f in sent] == [32] * 9, "批量帧 DLC 有截断" print(f" [OK] 9 帧在 {len(slave.tx_cycles)} 个周期内发完(旧实现需 9 个周期)") print(f"\n=== 7. 批量接收:一周期回收多帧 (容量 {S.RXB_BYTES}B) ===") # 含一帧满长 DLC64,验证 CANFD 全量能过 slave.pending_rx = [ (0, 0x10100, 32, bytes(range(32))), (1, 0x20100, 32, bytes(range(32, 64))), (2, 0x30100, 64, bytes(range(64))), # CANFD 满长 (0, 0x40100, 8, bytes(range(8))), ] S._motor_rx_log.clear() S._motor_thread_start(period_ms=2) time.sleep(0.25) snap = S.api_motor_state({}) S._motor_thread_stop() got = S.api_motor_rx({}) frames = got["frames"] print(f" 回收 {len(frames)} 帧, rx_batch.count={snap['rx_batch']['count']}, " f"pending={snap['rx_batch']['pending']}, lost={snap['rx_batch']['lost']}") for f in frames: print(f" ch={f['ch']} id=0x{f['id']:X} dlc={f['dlc']}") assert len(frames) == 4, f"批量接收丢帧: {len(frames)}/4" assert [f["dlc"] for f in frames] == [32, 32, 64, 8], "接收 DLC 有错" fd = next(f for f in frames if f["dlc"] == 64) assert bytes.fromhex(fd["data"]) == bytes(range(64)), "DLC64 payload 不一致" assert got["remain"] == 0, "取走后缓冲应清空" print(" [OK] 4 帧含 DLC64 满长 CANFD 全部完整回收") print("\n=== 8. 兼容性:批量区为空时布局与 1.5.55 一致 ===") S._fill_can_out(1, 0x30200, 32, bytes(range(32))) out = bytes(S._can_out) assert out[1] == 1 and out[2] == 32 assert struct.unpack_from(" 0.90, f"slot {slot} 命中率仅 {ratio*100:.0f}%,窗口没盖住" # 空槽不该再白占窗口:整体扫描量应远低于旧实现的 27 槽/4 周期 print(f" [OK] 在线电机每周期都刷新(旧实现每 4 周期才 1 次 = 25%)") print("\n=== 10. 发现扫描:新插上的电机仍能被找到 ===") # 自适应窗口只盯"已知的槽",必须靠周期性发现扫描找到新电机, # 否则热插拔上来的电机永远不出现 —— 这是自适应的必要配套。 sparse2 = SparseSlave({2}) S.g_master.slaves[0] = sparse2 sparse2.output = bytes(S.PDO_OUT_LEN) S._motor_snap = None S._motor_thread_start(period_ms=1) time.sleep(0.3) sparse2.present.add(24) # 热插:node9/ch0 上线 deadline = time.time() + 5.0 found = False while time.time() < deadline: time.sleep(0.1) snap = S.api_motor_state({}) if any(m["slot"] == 24 for m in (snap.get("motors") or [])): found = True break S._motor_thread_stop() print(f" 发现间隔 MOTOR_DISCOVERY_EVERY={S.MOTOR_DISCOVERY_EVERY} 周期") assert found, "发现扫描没找到新上线的 slot 24(自适应窗口把它永久漏掉了)" print(f" bases={snap['win_bases']} → 新槽已纳入常规覆盖") assert any(b <= 24 < b + S.MOT_SLOTS_PER_PDO for b in snap["win_bases"]), \ "slot 24 被发现了但没纳入常规窗口,下一拍又会漏" print(" [OK] 热插的电机能被发现并纳入每周期刷新") print("\n=== 11. 自旋节拍源:策略 + 实际精度 ===") # 分段实测表明一次周期的总线开销只有 ~165us,1.36ms 里 1.2ms 是 # Windows 内核睡眠的粒度超调。自旋能把周期跑准,代价是占满一个核, # 所以"什么时候自旋"和"自旋到底准不准"都得有测试守着。 S._motor_spin = None S._motor_period_ms = 4 assert S._spin_effective() is False, "4ms 默认不该自旋(内核睡眠够用,别白烧 CPU)" # 显式开一次之后,下一次不带 spin 的请求必须回到"自动",不能沿用 True。 # 早先 _motor_thread_start 写成 `if spin is not None` 才赋值,于是手动开过 # 一次自旋就永久粘住,长周期也照样烧一个核,自动阈值形同失效。 S._motor_thread_start(4, True) assert S._spin_effective() is True, "显式 spin=True 必须生效" S._motor_thread_stop() S._motor_thread_start(4, None) assert S._spin_effective() is False, \ "省略 spin 必须回到自动模式(4ms→不自旋),不能粘住上一次的 True" S._motor_thread_stop() S._motor_period_ms = 2 S._motor_period_ms = 2 assert S._spin_effective() is True, "2ms 该自动自旋(内核睡眠在这个档睡不准)" S._motor_period_ms = 1 assert S._spin_effective() is True, "1ms 该自动自旋" S._motor_period_ms = 1 S._motor_spin = False assert S._spin_effective() is False, "用户显式关掉就必须关(省 CPU 优先)" S._motor_period_ms = 8 S._motor_spin = True assert S._spin_effective() is True, "用户显式打开就必须在长周期也打开" S._motor_spin = None print(" [OK] 自动阈值 / 显式覆盖 都按预期") # 真量一次精度。**只断言自旋档达标,不断言"自旋比睡眠快"** —— # Windows 的定时器分辨率是全局且粘滞的(别的进程调过 timeBeginPeriod 就会 # 一直生效),粗粒度时睡眠能慢到 700Hz、细粒度时睡眠也能跑到 ~1000Hz。 # 拿两者比值当判据会让这条测试随机翻脸,真正要保证的是: # 1) 自旋档能稳定跑到设定周期(下面第 12 项再保证它不饿死别人) S.g_master.slaves[0] = SparseSlave({2}) S.g_master.slaves[0].output = bytes(S.PDO_OUT_LEN) def rate(spin, period=1): S._motor_spin = spin S._motor_period_ms = period S._motor_snap = None S._cyc_t.clear() S._motor_thread_start() time.sleep(1.0) # 先让周期稳定下来 a = S._motor_snapshot()["cycles"]; ta = time.perf_counter() time.sleep(1.5) b = S._motor_snapshot()["cycles"]; tb = time.perf_counter() S._motor_thread_stop() return (b - a) / (tb - ta) h_sleep = rate(False) h_spin = rate(True) print(f" 内核睡眠: {h_sleep:6.1f} Hz 自旋: {h_spin:6.1f} Hz (目标 1000 Hz)") print(f" (睡眠档快慢取决于本机定时器分辨率, 只作参考不作判据)") assert h_spin > 850, f"1ms 自旋档只跑到 {h_spin:.0f}Hz,离目标太远" print(f" [OK] 自旋稳定跑到设定周期的 {h_spin/10:.0f}%(1ms 档)") print("\n=== 12. 自旋不得饿死其它线程(GIL) ===") # 真机踩过的坑:第一版自旋写成 `while time.perf_counter() < d: pass`, # 于是读快照的 HTTP p50 从 0.6ms 劣化到 40ms —— 页面直接废了。 # 原因是 CPython GIL 切换间隔默认 5ms,纯 Python 循环可以连拿好几个 5ms。 # 这一项量"自旋在跑时,别的线程被延迟多久",是那条修复的回归守卫。 S.g_master.slaves[0] = SparseSlave({2}) S.g_master.slaves[0].output = bytes(S.PDO_OUT_LEN) S._motor_spin = True S._motor_period_ms = 1 S._motor_snap = None S._cyc_t.clear() S._motor_thread_start() time.sleep(0.6) # 让自旋先跑起来 lat = [] t_end = time.perf_counter() + 1.5 while time.perf_counter() < t_end: # 模拟 HTTP 线程的活: 醒来干点事再睡 t0 = time.perf_counter() time.sleep(0.0002) lat.append((time.perf_counter() - t0) * 1000) S._motor_thread_stop() S._motor_spin = None lat.sort() p50, p90, mx = lat[len(lat)//2], lat[int(len(lat)*.9)], lat[-1] print(f" 自旋跑 1ms 周期时,其它线程 sleep(0.2ms) 实测 " f"p50 {p50:.2f}ms p90 {p90:.2f}ms max {mx:.2f}ms (n={len(lat)})") # 纯 Python 自旋时 p50 约 5.7ms(=GIL 切换间隔);放 GIL 的自旋回到 ~0.5ms 基线 assert p50 < 2.0, ( f"自旋把其它线程饿到 p50 {p50:.1f}ms —— 自旋循环一定又在持 GIL 空转," "必须走 _spin_until(ctypes QPC),否则 HTTP 路由会全排在 GIL 后面") print(" [OK] 自旋不持 GIL,HTTP 线程不受影响") print("\n=== 13. P 核识别与钉核 ===") # 自旋要占满一个逻辑处理器,钉到 P 核(快核组)让节拍跑在单核性能最高的核上。 # 分类逻辑单独测(不跑 1.6s 的真实探测),用本机实测到的两组数字喂它。 def bits(m): return [b for b in range(64) if m >> b & 1] # 本机 i7-14700K 的真实测量值:P 核 11.9~16.7ms(含被 SMT 拖累的),E 核 24.7~29.7ms real = ([(lp, 13.0 + (lp % 4)) for lp in range(16)] + [(lp, 25.0 + (lp % 5)) for lp in range(16, 28)]) got = bits(S._classify_fast(real)) assert got == list(range(16)), f"本机拓扑应识别出 P 核 0..15,实际 {got}" print(f" [OK] 真实混合拓扑 → P 核 {got[0]}..{got[-1]}(16 个)") # 关键回归:固定倍数阈值会把慢一点的 P 核误判成 E 核,间隙法不会 —— # 被 SMT 拖累的 16.5ms 核必须仍留在 P 组里 smt = [(lp, 11.0 if lp < 8 else 16.5) for lp in range(16)] + \ [(lp, 28.0) for lp in range(16, 24)] got = bits(S._classify_fast(smt)) assert got == list(range(16)), f"被 SMT 拖累的 P 核不该被踢出快核组,实际 {got}" print(" [OK] 被 SMT 拖慢的 P 核没被误判成 E 核") # 纯对称 CPU(全同速)必须老实承认分不出来 → 返回 0 = 不钉 flat = [(lp, 15.0 + lp * 0.01) for lp in range(8)] assert S._classify_fast(flat) == 0, "对称 CPU 不该硬分出一个'快核'集合" assert S._classify_fast([(0, 1.0), (1, 9.0)]) == 0, "样本太少不该下结论" only_one_fast = [(0, 10.0)] + [(lp, 30.0) for lp in range(1, 8)] assert S._classify_fast(only_one_fast) == 0, "快核只剩 1 个说明分错了" only_one_slow = [(lp, 10.0) for lp in range(7)] + [(7, 30.0)] assert S._classify_fast(only_one_slow) == 0, "慢核只剩 1 个说明分错了(离群噪声不是拓扑)" print(" [OK] 对称/样本不足/单核离群 都安全返回不钉核") # 钉核只影响 PDO 线程自己,不能把整个进程(含 HTTP 线程)钉死 S.g_pcore_mask = 0xF0 S.g_pcore_probe_done.set() box = {} def _pinned(): S.g_pcore_probe_done.wait(2.0) if S._pcore_pin_enabled() and S.g_pcore_probe_done.is_set() and S.g_pcore_mask: S._set_thread_affinity_mask(S.g_pcore_mask) box["m"] = S._get_thread_affinity_mask() th = threading.Thread(target=_pinned); th.start(); th.join(timeout=5) assert bits(box.get("m", 0)) == [4, 5, 6, 7], f"线程亲和性没生效: {box}" assert bits(S._get_thread_affinity_mask()) != [4, 5, 6, 7], \ "主线程亲和性被改了 —— 钉核必须是线程级的" print(" [OK] 钉核是线程级的,HTTP 线程与主线程不受影响") S.g_pcore_mask = 0 print("\n=== 14. api_motor_start 的 OP 状态核实 ===") # 真机踩过的坑:给 api_motor_start 加了"读从站真实状态"的核实逻辑后, # 函数里出现了对 g_op_active 的赋值却没声明 global —— 整个路由直接抛 # UnboundLocalError,电机页 EtherCAT 链路全挂。假从站自测原先都绕开这个 # 函数(直接调 _motor_thread_start),所以完全没抓到,这里补上。 S.g_master.slaves[0] = SparseSlave({2}) S.g_master.slaves[0].output = bytes(S.PDO_OUT_LEN) S.g_op_active = True S._motor_spin = None r = S.api_motor_start({"period_ms": 2}) assert r["ok"], f"已在 OP 时重复 start 不该失败: {r.get('log')}" assert r["spin"] is True, "2ms 档应自动自旋" print(f" [OK] 已在 OP 时 start 正常(spin={r['spin']},无 UnboundLocalError)") S._motor_thread_stop() # 从站被看门狗踢出 OP 后,g_op_active 是过期缓存;必须现读状态发现并走重进, # 否则新循环每拍都撞"未进 OP",界面绿着却全是 --。 # 这里只验证"检测到 + 走了重进流程",不验证重进成功 —— 完整 OP 序列要 # config_init/config_map/config_dc/state_check/write_state/sdo_read/ # set_watchdog/dc_sync/_fprd 一整套,假从站撑不起来,硬凑会变成在测桩本身。 S.g_op_active = True S.g_master.slaves[0].state = 4 # SAFEOP r = S.api_motor_start({"period_ms": 4}) joined = " / ".join(r.get("log") or []) assert "掉出 OP" in joined, f"没检测到从站已掉出 OP,仍拿过期标志空转: {joined[:160]}" assert not r["ok"], "重进失败时必须如实报错,不能假装启动成功" print(" [OK] 检测到从站掉出 OP 并走重进流程(不拿过期标志空转)") S.g_master.slaves[0].state = 8 S._motor_thread_stop() S.g_op_active = True S._motor_spin = None print("\n=== 15. 亚毫秒周期 + /api/motor/period 热调 ===") # 需求:内核级要能跑 250us(4kHz),且周期随时可调。守两条: # a) period 允许亚毫秒 float,并按 [MIN,MAX] 钳位; # b) 热调路由**不停循环**、也不进总线互斥表 —— 停循环 = 掐断喂狗, # 正是把真机板子弄楔死的那条路(见 api_motor_spin 注释)。 assert "/api/motor/period" not in S._BUS_EXCLUSIVE_PATHS, \ "周期热调绝不能进总线互斥表:dispatcher 停循环会掐断喂狗" assert S._parse_period_ms({"period_ms": 0.25}) == 0.25 assert S._parse_period_ms({"period_us": 500}) == 0.5 assert S._parse_period_ms({"period_ms": 0.0001}) == S.MOTOR_PERIOD_MIN_MS assert S._parse_period_ms({"period_ms": 5000}) == S.MOTOR_PERIOD_MAX_MS assert S._parse_period_ms({"period_ms": 0}) is None assert S._parse_period_ms({"period_ms": float("nan")}) is None assert S._parse_period_ms({}) is None print(" [OK] period_ms/period_us 解析与钳位(0/NaN/缺省视为没给)") # 热调速测:假从站的收发是纯内存操作(µs 级),这里量的是 # "自旋+float 周期"这套机制能不能把循环真正驱动到 4kHz 档, # 不含真机 ~165us 的总线时间(那部分由 rate_probe 在板上量)。 S.g_master.slaves[0] = SparseSlave({2, 5}) S.g_master.slaves[0].output = bytes(S.PDO_OUT_LEN) S._motor_spin = None S._motor_thread_start(period_ms=4) time.sleep(0.15) r = S.api_motor_period({"period_ms": 0.25}) assert r["ok"] and r["period_ms"] == 0.25, r assert r["spin"] is True, "0.25ms 周期在自动档下必须自旋(睡眠粒度睡不出)" assert S._motor_thread.is_alive(), "热调周期把循环弄停了" def _rate(sample_s=1.0): t0 = time.perf_counter() c0 = (S._motor_snapshot() or {}).get("cycles", 0) time.sleep(sample_s) c1 = (S._motor_snapshot() or {}).get("cycles", 0) return (c1 - c0) / (time.perf_counter() - t0) hz = _rate(1.0) assert hz > 3000, f"0.25ms 周期只跑出 {hz:.0f}Hz,机制没到 4kHz 档" snap = S._motor_snapshot() assert snap["period_ms"] == 0.25 and snap["slot_period_ms"] == 0.25, snap print(f" [OK] 热调 4ms→0.25ms 循环不停,实测 {hz:.0f}Hz(假从站 ≥3000)") # 强制睡眠 + 亚毫秒周期 → 必须明确警告(睡眠粒度 > 目标周期,跑不到) S._motor_spin = False r = S.api_motor_period({"period_us": 250}) assert r["ok"] and r["spin"] is False assert "内核睡眠" in " ".join(r["log"]), f"该警告的没警告: {r['log']}" S._motor_spin = None # 换回长周期:自动档应退回内核睡眠(不白烧一个核) r = S.api_motor_period({"period_ms": 4}) assert r["ok"] and r["spin"] is False assert S._motor_thread.is_alive() print(" [OK] 换回 4ms 自动退回内核睡眠,循环全程没停过") S._motor_thread_stop() # float 周期也要能从 motor/start 一路传进来(前端首启路径) S._motor_spin = None assert S._motor_thread_start(period_ms=0.5) is True assert S._motor_period_ms == 0.5 S._motor_thread_stop() S._motor_period_ms = 4 S._motor_spin = None print(" [OK] motor/start 也收 float 周期(0.5ms)") print("\n=== 16. 真机总线耗时下的网格收敛(4kHz 保底的忠实代理)===") # 第 15 项的假从站 send/receive 是 µs 级内存桩,**没建模真机 165µs 总线时间**, # 所以它证明不了"166us 工作 + 250us 网格"这个真机核心交互能不能收敛到 4000Hz。 # 这里给收发注入按真机分段实测拆开的耗时(send 48us + receive 118us = 166us), # 且用 _spin_until 释放 GIL —— 和真机 pysoem 在 Npcap 调用期间放 GIL 的行为一致。 # 这是不接硬件能产出的最强预测:工作 166us < 周期 250us,网格不该丢拍, # 平均率应收敛到 ~4000Hz;若收敛不了,说明缺口是结构性的,得上真机前就预警。 BUS_SEND_US, BUS_RX_US = 48.0, 118.0 class BusCostMaster(FakeMaster): def send_processdata(self): S._spin_until(time.perf_counter() + BUS_SEND_US / 1e6) def receive_processdata(self, timeout): S._spin_until(time.perf_counter() + BUS_RX_US / 1e6) return 3 S.g_master = BusCostMaster() S.g_master.slaves[0] = SparseSlave({2, 5}) S.g_master.slaves[0].output = bytes(S.PDO_OUT_LEN) S._motor_spin = None S._motor_thread_start(period_ms=0.25) time.sleep(0.2) def _rate(sample_s): t0 = time.perf_counter() c0 = (S._motor_snapshot() or {}).get("cycles", 0) time.sleep(sample_s) c1 = (S._motor_snapshot() or {}).get("cycles", 0) return (c1 - c0) / (time.perf_counter() - t0) hz = _rate(1.5) # 分位数改读侧现算(_cyc_fresh),从 api_motor_state 拿顺带验证这条路径 cyc = S.api_motor_state({}).get("cyc_us") or {} tot = (cyc.get("total") or {}).get("p50") print(f" 166us 总线负载下 250us 网格实测 {hz:.0f}Hz (cyc_us.total p50={tot}us)") # 工作 166us 远小于 250us 周期,网格不该丢拍 → 应收敛到接近 4000。 # 阈值取 3700 留 GIL/调度噪声余量;真机若明显低于这个值才需要动 send/receive 合并。 assert hz > 3700, \ f"166us 工作负载下网格只收敛到 {hz:.0f}Hz,缺口是结构性的,真机大概率也到不了 4kHz" print(" [OK] 真机同等总线负载下网格仍收敛到 ~4kHz → 保底 4kHz 的强预测") # 防回归:分位数排序一旦爬回 PDO 循环,每第 8 拍必超时(真机实测 86.6%)。 # 用源码级检查钉死,比任何数值断言都稳。 import inspect assert "_cyc_stats(" not in inspect.getsource(S._pdo_worker), \ "_cyc_stats 又回到 PDO 热循环了 —— 必须走读侧 _cyc_fresh" S._motor_thread_stop() S.g_master = FakeMaster() S._motor_period_ms = 4 S._motor_spin = None print("\n全部通过 ✅") return 0 if __name__ == "__main__": sys.exit(main())