feat(ecat): 启动后把 ecat_native 提到 RealTime;跳拍优化实验脚本与报告

- esp32_server: Popen 后外部 SetPriorityClass(RT/High),native 自设会被钳成 High
- 入库 skip_* 标定/实验脚本与 REPORT_ecat_skip_opt_20260917
- 前端 WIP:layout/app/polling/boot_ecat/index
- 不含 lab/skip_meter 原始采样与 preview 截图
This commit is contained in:
cbb
2026-09-17 02:32:20 +08:00
parent b3a3a365bb
commit e91d53e0fc
22 changed files with 1956 additions and 61 deletions
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""模拟页面 pollStatus:5s 超时算失败,连续失败即离线。"""
import time
import urllib.request
import urllib.error
URL = "http://192.168.31.127/system/status"
TIMEOUT = 5.0
POLL = 1.0
WINDOW = 75
def probe():
t0 = time.time()
try:
with urllib.request.urlopen(URL, timeout=TIMEOUT) as r:
r.read(64)
return True, "HTTP %s" % r.status, time.time() - t0
except urllib.error.HTTPError as e:
return False, "HTTP %s" % e.code, time.time() - t0
except Exception as e:
return False, "%s: %s" % (type(e).__name__, str(e)[:80]), time.time() - t0
def main():
print("=== monitor start (mimic pollStatus) ===", flush=True)
prev = None
fails = 0
t_end = time.time() + WINDOW
while time.time() < t_end:
ok, msg, dt = probe()
now = time.strftime("%H:%M:%S")
if ok:
fails = 0
state = "ONLINE"
else:
fails += 1
state = "FAIL#%d" % fails + (" -> OFFLINE" if fails >= 1 else "")
mark = " *" if state != prev else ""
print("[%s] %s (%s, %.0fms)%s" % (now, state, msg, dt * 1000, mark), flush=True)
if state != prev:
prev = state
time.sleep(POLL)
print("=== monitor end ===", flush=True)
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""在「以太网 2」(I210) 上关闭非 EtherCAT 绑定,测跳拍率;可 --restore 还原。"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
LAB = os.path.join(ROOT, "lab", "skip_meter")
NIC = "以太网 2"
def ps(cmd, timeout=60):
r = subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout,
)
return (r.stdout or "").strip(), (r.stderr or "").strip()
def snapshot():
out, err = ps(
f"Get-NetAdapterBinding -Name '{NIC}' | "
"Select-Object DisplayName,ComponentID,Enabled | ConvertTo-Json -Compress"
)
data = json.loads(out)
return data if isinstance(data, list) else [data]
def disable(component_ids):
logs = []
for cid in component_ids:
out, err = ps(
f"Disable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false; "
f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | "
"Select-Object ComponentID,Enabled | ConvertTo-Json -Compress"
)
logs.append({"cid": cid, "out": out[:300], "err": err[:300]})
return logs
def enable(component_ids):
logs = []
for cid in component_ids:
out, err = ps(
f"Enable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false; "
f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | "
"Select-Object ComponentID,Enabled | ConvertTo-Json -Compress"
)
logs.append({"cid": cid, "out": out[:300], "err": err[:300]})
return logs
def set_prio(level="RealTime"):
p = int(subprocess.check_output(
["powershell", "-NoProfile", "-Command", "(Get-Process ecat_native).Id"],
text=True).strip())
subprocess.check_output(
["powershell", "-NoProfile", "-Command",
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"],
text=True)
return p
def meter(tag, secs=18):
r = subprocess.run(
[sys.executable, os.path.join(ROOT, "scripts", "skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag + "_") and f.endswith(".json"))
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
# Offloads that are useless/dangerous for raw EtherCAT on a dedicated NIC
OFFLOADS = [
"vmware_bridge", # VMware Bridge — intercepts/injects, classic ECAT killer
"ms_pacer", # QoS packet scheduler
"ms_lldp", # LLDP
"ms_rspndr", # LLTD responder
"ms_lltdio", # LLTD mapper
"ms_server", # file/printer sharing
"ms_msclient", # Microsoft network client
# keep ms_tcpip / ms_tcpip6 for now in case anything uses IP on this NIC
# Npcap must stay: INSECURE_NPCAP
]
def main():
os.makedirs(LAB, exist_ok=True)
snap = snapshot()
snap_path = os.path.join(LAB, f"nic_bind_{int(time.time())}.json")
json.dump(snap, open(snap_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print("bind snapshot", snap_path)
if "--restore" in sys.argv:
print(enable(OFFLOADS))
return 0
print("disable", OFFLOADS)
print(json.dumps(disable(OFFLOADS), ensure_ascii=False, indent=2))
print("set RealTime")
print("pid", set_prio("RealTime"))
# binding change usually doesn't reset link; brief settle
time.sleep(3)
data = meter("bind_off", 18)
row = {
"late_per_s": data["late_per_s"],
"skip_per_s": data["skip_per_s"],
"late_frac_pct": data["late_frac_pct"],
"achieved_hz": data["achieved_hz"],
"n_late": data["n_late"],
"send_stall_frac": data["send_stall_frac"],
"rx_stall_frac": data["rx_stall_frac"],
"work_p50": data["window_cyc_us"]["work"]["p50"],
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
"send_p50": data["window_cyc_us"]["send"]["p50"],
"work_p90": data["window_cyc_us"]["work"]["p90"],
}
print("ROW", json.dumps(row, ensure_ascii=False, indent=2))
out = os.path.join(LAB, f"summary_bind_off_{int(time.time())}.json")
json.dump({"row": row, "disabled": OFFLOADS, "snap": snap_path},
open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", out)
if __name__ == "__main__":
main()
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
import json, os, subprocess, sys, time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
NIC = "以太网 2"
def ps(cmd):
r = subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
text=True, encoding="utf-8", errors="replace")
return r.strip()
def set_prio(level="RealTime"):
p = int(ps("(Get-Process ecat_native).Id"))
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass")
return p
def meter(tag, secs=18):
subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json"))
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
def main():
restore = "--restore" in sys.argv
ids = ["ms_tcpip", "ms_tcpip6"]
for cid in ids:
if restore:
ps(f"Enable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false")
else:
ps(f"Disable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false")
print(ps(f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | Select-Object ComponentID,Enabled | ConvertTo-Json -Compress"))
if restore:
return
print("pid", set_prio("RealTime"))
time.sleep(3)
data = meter("bind_noip", 18)
row = {
"late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
"late_frac_pct": data["late_frac_pct"], "achieved_hz": data["achieved_hz"],
"n_late": data["n_late"],
"send_stall_frac": data["send_stall_frac"], "rx_stall_frac": data["rx_stall_frac"],
"work_p50": data["window_cyc_us"]["work"]["p50"],
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
"send_p50": data["window_cyc_us"]["send"]["p50"],
}
print(json.dumps(row, ensure_ascii=False, indent=2))
out = os.path.join(LAB, f"summary_bind_noip_{int(time.time())}.json")
json.dump(row, open(out,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", out)
if __name__ == "__main__":
main()
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""0.25ms 下 A/B:静默 vs 模拟 spi_test 前端轮询。"""
import json, time, urllib.request, os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
def post(p, b=None, timeout=5):
d = json.dumps(b or {}).encode()
r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"})
with urllib.request.urlopen(r, timeout=timeout) as resp:
return json.loads(resp.read().decode())
def snap():
st = post("/api/motor/state")
dbg = post("/api/motor/dbg")
return {
"cycles": st.get("cycles") or 0,
"skip_total": dbg.get("skip_total") or 0,
"gseq": (st.get("head") or {}).get("global_seq") or 0,
"period": st.get("period_ms"),
"wkc": st.get("wkc"),
"pcore": st.get("pcore_mask"),
"cyc_us": st.get("cyc_us"),
}
def rate(a, b, dt):
dc = b["cycles"] - a["cycles"]
ds = (b["skip_total"] - a["skip_total"]) # may wrap? long long, no
return {
"dt": round(dt, 3),
"pdo_hz": round(dc / dt, 1) if dt else None,
"late_per_s": round(ds / dt, 2) if dt else None,
"skip_frac": round(100.0 * ds / dc, 2) if dc else None,
"gseq_per_s": round(((b["gseq"] - a["gseq"]) & 0xFFFFFFFF) / dt, 1) if dt else None,
"dcyc": dc, "dskip": ds,
}
def arm_period(ms):
try:
post("/api/motor/stop")
except Exception:
pass
time.sleep(0.15)
post("/api/motor/period", {"period_ms": ms})
time.sleep(0.15)
post("/api/motor/start")
time.sleep(0.5)
print("ensure 0.25ms")
arm_period(0.25)
time.sleep(3)
# --- A: fully quiet ---
a0 = snap()
t0 = time.time()
# no HTTP
time.sleep(10)
a1 = snap()
A = rate(a0, a1, time.time() - t0)
print("QUIET", json.dumps(A, ensure_ascii=False))
print(" cyc_us", json.dumps(a1.get("cyc_us"), ensure_ascii=False))
# --- B: hammer like spi_test (state+load+dbg+rxraw) ---
time.sleep(1)
b0 = snap()
t0 = time.time()
n = 0
while time.time() - t0 < 10:
try:
post("/api/motor/state")
post("/api/app/load")
post("/api/motor/dbg")
post("/api/motor/rxraw")
n += 1
except Exception as e:
print("hammer err", e)
break
time.sleep(0.05) # ~20Hz full set, similar to busy panel
b1 = snap()
B = rate(b0, b1, time.time() - t0)
print("HAMMER", json.dumps(B, ensure_ascii=False), "polls", n)
print(" cyc_us", json.dumps(b1.get("cyc_us"), ensure_ascii=False))
# --- C: only motor/state at max (no dbg/rxraw) ---
time.sleep(1)
c0 = snap()
t0 = time.time()
n = 0
while time.time() - t0 < 10:
try:
post("/api/motor/state")
n += 1
except Exception:
break
time.sleep(0.05)
c1 = snap()
C = rate(c0, c1, time.time() - t0)
print("STATE_ONLY", json.dumps(C, ensure_ascii=False), "polls", n)
out = {"quiet": A, "hammer": B, "state_only": C,
"quiet_cyc": a1.get("cyc_us"), "hammer_cyc": b1.get("cyc_us")}
path = os.path.join(LAB, f"summary_fe_ab_{int(time.time())}.json")
json.dump(out, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", path)
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""快照当前主机侧可回滚现场:I210 高级属性、中断亲和、ecat 进程。"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
LAB = os.path.join(ROOT, "lab", "skip_meter")
def ps(cmd: str) -> str:
r = subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
return (r.stdout or "") + (("ERR:" + r.stderr) if r.stderr else "")
def main() -> int:
os.makedirs(LAB, exist_ok=True)
snap = {"ts": time.time(), "host": os.environ.get("COMPUTERNAME")}
snap["adapters"] = ps(
"Get-NetAdapter | Select-Object Name,InterfaceDescription,LinkSpeed,Status,"
"ifIndex,MacAddress | ConvertTo-Json -Compress"
)
# I210 specifically
snap["i210"] = ps(
"Get-NetAdapter | Where-Object {$_.InterfaceDescription -match 'I210'} | "
"Select-Object Name,ifIndex,Status,LinkSpeed | ConvertTo-Json -Compress"
)
# advanced properties (restore-able)
snap["i210_adv"] = ps(
"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | "
"Select-Object Name,DisplayName,DisplayValue,RegistryKeyword,RegistryValue | "
"ConvertTo-Json -Compress"
)
# interrupt moderation / RSS / power
for kw in (
"*InterruptModeration",
"*ReceiveBuffers",
"*TransmitBuffers",
"*EEE",
"*EnergyEfficient",
"*FlowControl",
"*PriorityVLANTag",
"*TCPChecksumOffload*",
"*IPChecksumOffload*",
"*VMQ",
"*RSS",
"*InterruptModerationRate",
):
snap[f"adv_{kw.strip('*').replace('*','_')}"] = ps(
f"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | "
f"Where-Object {{$_.RegistryKeyword -like '{kw}'}} | "
f"Select-Object RegistryKeyword,DisplayValue,RegistryValue | ConvertTo-Json -Compress"
)
snap["power"] = ps(
"Get-NetAdapterPowerManagement -Name '以太网 2' -ErrorAction SilentlyContinue | "
"Select-Object Name,AllowComputerToTurnOffDevice,WakeOnMagicPacket,"
"WakeOnPattern,DeviceSleepOnDisconnect | ConvertTo-Json -Compress"
)
snap["ecat_proc"] = ps(
"Get-Process ecat_native -ErrorAction SilentlyContinue | "
"Select-Object Id,Path,StartTime,CPU,WorkingSet | ConvertTo-Json -Compress"
)
snap["ecat_cmdline"] = ps(
"Get-CimInstance Win32_Process -Filter \"Name='ecat_native.exe'\" | "
"Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
)
# interrupt affinity via Get-NetAdapterInterrupt? not always available
snap["msi_affinity"] = ps(
"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | "
"Where-Object {$_.DisplayName -match 'Interrupt|Affinity|RSS|Moderation|Buffer|Energy|Green|Flow|Offload'} | "
"Select-Object DisplayName,DisplayValue,RegistryKeyword | ConvertTo-Json -Compress"
)
# CPU topology quick
snap["cpu"] = ps(
"$env:NUMBER_OF_PROCESSORS; (Get-CimInstance Win32_Processor | "
"Select-Object Name,NumberOfCores,NumberOfLogicalProcessors | ConvertTo-Json -Compress)"
)
out = os.path.join(LAB, f"host_snap_{int(time.time())}.json")
with open(out, "w", encoding="utf-8") as f:
json.dump(snap, f, ensure_ascii=False, indent=2)
# also human-readable restore snippet
lines = []
lines.append("# Host snapshot restore notes")
lines.append(f"time={snap['ts']}")
lines.append("## I210 adapter advanced (raw)")
lines.append(snap.get("i210_adv", "")[:8000])
lines.append("## power")
lines.append(snap.get("power", "")[:4000])
lines.append("## ecat cmdline")
lines.append(snap.get("ecat_cmdline", "")[:2000])
txt = os.path.join(LAB, f"host_snap_{int(snap['ts'])}.txt")
with open(txt, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print("I210:", snap.get("i210", "")[:500])
print("CMDLINE:", snap.get("ecat_cmdline", "")[:800])
print("ADV keys sample:")
print(snap.get("msi_affinity", "")[:2500])
print("WROTE", out)
print("WROTE", txt)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
import json, os, subprocess, time, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
NIC = "以太网 2"
def ps(cmd):
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
text=True, encoding="utf-8", errors="replace").strip()
def post(p, b=None):
d = json.dumps(b or {}).encode()
r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"})
return json.loads(urllib.request.urlopen(r, timeout=8).read().decode())
def wait_ok(t=30):
t0=time.time()
while time.time()-t0<t:
try:
h=post("/api/native/health")
if h.get("loop")==1 and int(h.get("wkc") or 0)>=3: return h
except Exception: pass
time.sleep(0.4)
return None
def set_rt():
p=int(ps("(Get-Process ecat_native).Id"))
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass")
return p
def period(ms):
post("/api/motor/stop"); time.sleep(0.12)
post("/api/motor/period", {"period_ms": ms}); time.sleep(0.12)
post("/api/motor/start")
def quiet(sec=10):
st=post("/api/motor/state"); dbg=post("/api/motor/dbg")
c0,s0=st.get("cycles") or 0, dbg.get("skip_total") or 0
t0=time.time(); time.sleep(sec)
st=post("/api/motor/state"); dbg=post("/api/motor/dbg")
dt=time.time()-t0
c1,s1=st.get("cycles") or 0, dbg.get("skip_total") or 0
cu=st.get("cyc_us") or {}
# also sample dbg ring k distribution
ring=dbg.get("cyc") or []
ks=[r[5] for r in ring if len(r)>=6]
works=[(r[4]-r[0])*1e6 for r in ring if len(r)>=5]
sends=[(r[2]-r[1])*1e6 for r in ring if len(r)>=3]
rxs=[(r[3]-r[2])*1e6 for r in ring if len(r)>=4]
def pct(a,q):
if not a: return None
a=sorted(a); return round(a[int(q*(len(a)-1))],1)
return {
"pdo_hz": round((c1-c0)/dt,1),
"late_per_s": round((s1-s0)/dt,2),
"achieved_vs_4k": round(100*(c1-c0)/dt/4000,1),
"cyc_us": cu,
"ring_n": len(ring),
"k_gt0": sum(1 for k in ks if k>0),
"k_sum": sum(ks),
"work_pct": {"p50":pct(works,.5),"p90":pct(works,.9),"p99":pct(works,.99),"max":pct(works,1.0) if works else None},
"send_pct": {"p50":pct(sends,.5),"p90":pct(sends,.9),"max":pct(sends,1.0) if sends else None},
"rx_pct": {"p50":pct(rxs,.5),"p90":pct(rxs,.9),"max":pct(rxs,1.0) if rxs else None},
}
# set ITR 200
ps("Set-NetAdapterAdvancedProperty -Name '以太网 2' -RegistryKeyword '*InterruptModeration' -RegistryValue '1' -NoRestart")
ps("Set-NetAdapterAdvancedProperty -Name '以太网 2' -RegistryKeyword 'ITR' -RegistryValue '200' -NoRestart")
ps("Restart-NetAdapter -Name '以太网 2' -Confirm:$false")
time.sleep(2)
print("health", wait_ok())
set_rt()
rows=[]
for ms in (0.25, 0.5, 1.0):
print(f"\n=== {ms} ms ITR=200 ===")
period(ms)
time.sleep(4)
set_rt()
r=quiet(10)
print(json.dumps(r, ensure_ascii=False, indent=2))
rows.append(r)
path=os.path.join(LAB, f"summary_itr200_periods_{int(time.time())}.json")
json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", path)
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""查 pdo 线程亲和 + 尝试 ITR 200/400 在 0.25ms 静默下的表现。"""
import json, os, subprocess, time, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
NIC = "以太网 2"
def ps(cmd, timeout=60):
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
text=True, encoding="utf-8", errors="replace",
timeout=timeout).strip()
def post(p, b=None):
d = json.dumps(b or {}).encode()
r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"})
return json.loads(urllib.request.urlopen(r, timeout=8).read().decode())
def wait_ok(timeout=35):
t0 = time.time()
while time.time()-t0 < timeout:
try:
h = post("/api/native/health")
if h.get("loop")==1 and int(h.get("wkc") or 0)>=3:
return h
except Exception:
pass
time.sleep(0.4)
return None
def set_rt():
p = int(ps("(Get-Process ecat_native).Id"))
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass")
return p
def quiet_rate(sec=8):
st = post("/api/motor/state")
dbg = post("/api/motor/dbg")
c0, s0 = st.get("cycles") or 0, dbg.get("skip_total") or 0
t0 = time.time()
time.sleep(sec)
st = post("/api/motor/state")
dbg = post("/api/motor/dbg")
dt = time.time()-t0
c1, s1 = st.get("cycles") or 0, dbg.get("skip_total") or 0
return {
"pdo_hz": round((c1-c0)/dt, 1),
"late_per_s": round((s1-s0)/dt, 2),
"cyc_us": st.get("cyc_us"),
"period": st.get("period_ms"),
}
def bus_restart_period(ms):
try:
post("/api/motor/stop")
except Exception:
pass
time.sleep(0.15)
post("/api/motor/period", {"period_ms": ms})
time.sleep(0.15)
post("/api/motor/start")
def nic_set(kw, val):
out, err = "", ""
r = subprocess.run(["powershell","-NoProfile","-Command",
f"Set-NetAdapterAdvancedProperty -Name '{NIC}' -RegistryKeyword '{kw}' -RegistryValue '{val}' -NoRestart"],
capture_output=True, text=True, encoding="utf-8", errors="replace")
return r.stdout.strip(), r.stderr.strip()
# affinity of ecat threads via .NET is limited; dump processor affinity mask
print("=== process affinity ===")
print(ps(r'''
$p = Get-Process ecat_native
"PID $($p.Id) Priority=$($p.PriorityClass)"
# ProcessorAffinity is a bitmask
"Affinity=$($p.ProcessorAffinity)"
'''))
print("=== I210 current ITR/moderation ===")
print(ps(r'''
Get-NetAdapterAdvancedProperty -Name '以太网 2' |
Where-Object { $_.RegistryKeyword -in 'ITR','*InterruptModeration','*TransmitBuffers' } |
Select-Object RegistryKeyword,DisplayValue | ConvertTo-Json -Compress
'''))
results = []
# try ITR values at 0.25ms quiet
for itr in ("200", "400", "950", "65535"):
print(f"\n===== ITR={itr} @0.25ms =====")
o, e = nic_set("*InterruptModeration", "1")
o2, e2 = nic_set("ITR", itr)
print("set", o2 or o, e2 or e)
# restart nic only if needed - ITR may need restart
ps(f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false")
time.sleep(2)
h = wait_ok(30)
print("health", bool(h))
if not h:
try:
post("/api/op/start"); post("/api/motor/start")
h = wait_ok(20)
except Exception as ex:
print(ex)
set_rt()
bus_restart_period(0.25)
time.sleep(3)
set_rt()
r = quiet_rate(8)
print(json.dumps({k:r[k] for k in ("pdo_hz","late_per_s","period")}, ensure_ascii=False))
print("work/send/rx", r["cyc_us"]["work"]["p50"], r["cyc_us"]["send"]["p50"], r["cyc_us"]["rx"]["p50"])
results.append({"itr": itr, **{k:r[k] for k in ("pdo_hz","late_per_s")},
"work_p50": r["cyc_us"]["work"]["p50"], "send_p50": r["cyc_us"]["send"]["p50"],
"rx_p50": r["cyc_us"]["rx"]["p50"]})
print("\nSUMMARY", json.dumps(results, ensure_ascii=False, indent=2))
# restore adaptive
nic_set("ITR", "65535")
ps(f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false")
time.sleep(2)
wait_ok(25)
set_rt()
# leave at 1ms for safety
bus_restart_period(1.0)
time.sleep(1)
print("restored 1ms", post("/api/native/health"))
path = os.path.join(LAB, f"summary_itr_4k_{int(time.time())}.json")
json.dump(results, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", path)
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
import json, os, subprocess, sys, time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
def set_prio(level="RealTime"):
p = int(subprocess.check_output(["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"], text=True).strip())
subprocess.check_output(["powershell","-NoProfile","-Command",
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"], text=True)
return p
def meter(tag, secs=15):
subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json"))
d = json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
return {
"tag": tag,
"late_per_s": d["late_per_s"], "skip_per_s": d["skip_per_s"],
"achieved_hz": d["achieved_hz"], "n_late": d["n_late"],
"work_p50": d["window_cyc_us"]["work"]["p50"],
"rx_p50": d["window_cyc_us"]["rx"]["p50"],
"send_p50": d["window_cyc_us"]["send"]["p50"],
"work_p90": d["window_cyc_us"]["work"]["p90"],
}
def main():
set_prio("RealTime")
rows = []
for i in range(1, 4):
print(f"round {i} settle...")
time.sleep(4)
r = meter(f"multirt_{i}", 15)
print(r)
rows.append(r)
lates = [x["late_per_s"] for x in rows]
print("mean late", sum(lates)/len(lates), "min", min(lates), "max", max(lates))
path = os.path.join(LAB, f"summary_multirt_{int(time.time())}.json")
json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", path)
if __name__ == "__main__":
main()
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""可回滚的 I210 网卡参数实验:停环 → 改高级属性 → 恢复 OP → 标定跳拍率。
默认只测、不永久破坏:原值会写入 restore json;`--restore` 按快照还原。
改网卡会触发短暂链路重协商,期间从站可能掉 OP,脚本会等 link+OP 恢复。
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
import urllib.request
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
LAB = os.path.join(ROOT, "lab", "skip_meter")
NIC = "以太网 2"
BASE = os.environ.get("ECAT_BASE", "http://127.0.0.1:8090")
def post(path, body=None, timeout=8.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 ps(cmd, timeout=60):
r = subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
return (r.stdout or "").strip(), (r.stderr or "").strip()
def nic_snapshot():
out, err = ps(
f"Get-NetAdapterAdvancedProperty -Name '{NIC}' | "
"Select-Object RegistryKeyword,DisplayValue,RegistryValue | "
"ConvertTo-Json -Compress"
)
if not out:
return []
data = json.loads(out)
if isinstance(data, dict):
return [data]
return data
def nic_set(keyword: str, value: str):
# value as string; Intel accepts "0"/"1"/numbers
cmd = (
f"Set-NetAdapterAdvancedProperty -Name '{NIC}' "
f"-RegistryKeyword '{keyword}' -RegistryValue '{value}' -NoRestart"
)
out, err = ps(cmd)
return out, err
def nic_restart():
out, err = ps(
f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false; Start-Sleep -Seconds 2; "
f"Get-NetAdapter -Name '{NIC}' | Select-Object Status,LinkSpeed | ConvertTo-Json -Compress"
)
return out, err
def wait_link(timeout=20.0):
t0 = time.time()
while time.time() - t0 < timeout:
out, _ = ps(
f"Get-NetAdapter -Name '{NIC}' | Select-Object Status,LinkSpeed | ConvertTo-Json -Compress"
)
try:
j = json.loads(out)
if isinstance(j, list):
j = j[0]
if str(j.get("Status", "")).lower() == "up":
return True, j
except Exception:
pass
time.sleep(0.5)
return False, None
def wait_ecat_healthy(timeout=25.0):
t0 = time.time()
last = None
while time.time() - t0 < timeout:
try:
h = post("/api/native/health")
last = h
if h.get("loop") == 1 and int(h.get("wkc") or 0) >= 3 and h.get("op") == 1:
# also require cycles advancing
c0 = h.get("cycles") or 0
time.sleep(0.4)
h2 = post("/api/native/health")
c1 = h2.get("cycles") or 0
if c1 > c0:
return True, h2
except Exception as e:
last = {"err": str(e)}
time.sleep(0.5)
return False, last
def stop_bus():
logs = []
for path in ("/api/motor/stop", "/api/op/stop"):
try:
r = post(path)
logs.append((path, r.get("ok"), (r.get("log") or r)[:2]))
except Exception as e:
logs.append((path, False, str(e)))
return logs
def start_bus():
logs = []
try:
r = post("/api/op/start")
logs.append(("/api/op/start", r.get("ok"), (r.get("log") or [""])[:2]))
except Exception as e:
logs.append(("/api/op/start", False, str(e)))
try:
r = post("/api/motor/start")
logs.append(("/api/motor/start", r.get("ok"), (r.get("log") or [""])[:2]))
except Exception as e:
logs.append(("/api/motor/start", False, str(e)))
return logs
def meter(tag: str, secs: float):
# reuse skip_rate_meter as subprocess for isolation
cmd = [
sys.executable,
os.path.join(ROOT, "scripts", "skip_rate_meter.py"),
"--tag",
tag,
"--secs",
str(secs),
]
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
# last json file for tag
files = sorted(
[f for f in os.listdir(LAB) if f.startswith(tag + "_") and f.endswith(".json")]
)
data = None
if files:
with open(os.path.join(LAB, files[-1]), encoding="utf-8") as f:
data = json.load(f)
return data, r.stdout, r.stderr
def apply_batch(changes: list[tuple[str, str]]):
results = []
for k, v in changes:
out, err = nic_set(k, v)
results.append({"key": k, "value": v, "out": out[:300], "err": err[:300]})
return results
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--restore", action="store_true", help="restore from snap json")
ap.add_argument("--snap", default=None, help="snapshot json for restore")
ap.add_argument("--exp", default=None, help="named experiment")
ap.add_argument("--secs", type=float, default=16.0)
ap.add_argument("--list-exp", action="store_true")
args = ap.parse_args()
os.makedirs(LAB, exist_ok=True)
# experiment table: list of (keyword, value)
EXPS = {
"itr_off": [
("*InterruptModeration", "0"),
("ITR", "0"),
],
"itr_low": [
("*InterruptModeration", "1"),
("ITR", "400"), # valid: 65535,3600,2000,950,400,200,0
],
"itr_200": [
("*InterruptModeration", "1"),
("ITR", "200"),
],
"itr_950": [
("*InterruptModeration", "1"),
("ITR", "950"),
],
"eee_off": [
("EEELinkAdvertisement", "0"),
],
"flow_off": [
("*FlowControl", "0"),
],
"txbuf_max": [
("*TransmitBuffers", "4096"),
],
"rxbuf_max": [
("*ReceiveBuffers", "1024"),
],
"combo_lowlat": [
("*InterruptModeration", "0"),
("ITR", "0"),
("EEELinkAdvertisement", "0"),
("*FlowControl", "0"),
("*TransmitBuffers", "2048"),
("*ReceiveBuffers", "512"),
],
}
if args.list_exp:
print(json.dumps({k: v for k, v in EXPS.items()}, indent=2))
return 0
# always keep a fresh snapshot before mutating
snap = nic_snapshot()
snap_path = os.path.join(LAB, f"nic_before_{int(time.time())}.json")
with open(snap_path, "w", encoding="utf-8") as f:
json.dump(snap, f, ensure_ascii=False, indent=2)
print(f"NIC snapshot → {snap_path}")
if args.restore:
path = args.snap or snap_path
with open(path, encoding="utf-8") as f:
data = json.load(f)
# stop, apply all, restart
print("restore: stop bus")
print(stop_bus())
n = 0
for row in data:
kw = row.get("RegistryKeyword")
val = row.get("RegistryValue")
if not kw or val is None:
continue
if isinstance(val, list):
val = val[0] if val else ""
val = str(val)
apply_batch([(kw, val)])
n += 1
print("restore applied", n, "keys; restarting NIC")
print(nic_restart())
print(wait_link())
print("start bus")
print(start_bus())
ok, h = wait_ecat_healthy()
print("healthy", ok, h)
return 0
exp = args.exp or "itr_off"
if exp not in EXPS:
print("unknown exp", exp, "use --list-exp")
return 2
changes = EXPS[exp]
print(f"\n=== EXP {exp} {changes} ===")
print("stop bus...")
print(stop_bus())
time.sleep(0.3)
print("apply...")
print(apply_batch(changes))
print("restart nic...")
print(nic_restart())
ok, info = wait_link()
print("link", ok, info)
print("start bus...")
print(start_bus())
ok, h = wait_ecat_healthy(timeout=30)
print("healthy", ok, h)
if not ok:
print("ABORT: bus not healthy after change")
return 1
# settle longer: NIC restart + re-enumeration leaves ~2–5s of elevated skip
time.sleep(8.0)
data, out, err = meter(f"exp_{exp}", args.secs)
print(out)
if err:
print("meter stderr", err[:500])
if data:
summary = {
"exp": exp,
"changes": changes,
"late_per_s": data.get("late_per_s"),
"skip_per_s": data.get("skip_per_s"),
"late_frac_pct": data.get("late_frac_pct"),
"send_stall_frac": data.get("send_stall_frac"),
"rx_stall_frac": data.get("rx_stall_frac"),
"achieved_hz": data.get("achieved_hz"),
"n_late": data.get("n_late"),
}
sp = os.path.join(LAB, f"summary_{exp}_{int(time.time())}.json")
with open(sp, "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
print("SUMMARY", json.dumps(summary, ensure_ascii=False))
print("WROTE", sp)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""High vs RealTime 多轮 A/B,输出对比。结束后设为 --final 指定档。"""
import json, os, subprocess, sys, time
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
LAB = os.path.join(ROOT, "lab", "skip_meter")
def ecat_pid():
out = subprocess.check_output(
["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"],
text=True).strip()
return int(out.splitlines()[0])
def set_prio(level):
p = ecat_pid()
subprocess.check_output(
["powershell","-NoProfile","-Command",
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"],
text=True)
return level
def meter(tag, secs=12):
r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")])
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
def row(level, data, n):
return {
"round": n, "level": level,
"late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
"send_stall_frac": data["send_stall_frac"], "rx_stall_frac": data["rx_stall_frac"],
"achieved_hz": data["achieved_hz"], "n_late": data["n_late"],
"send_p90": (data.get("window_cyc_us") or {}).get("send",{}).get("p90"),
"work_p90": (data.get("window_cyc_us") or {}).get("work",{}).get("p90"),
}
def main():
final = "RealTime"
if len(sys.argv) > 1:
final = sys.argv[1]
rows = []
# A-B-A-B
seq = ["High","RealTime","High","RealTime"]
for i, lv in enumerate(seq, 1):
set_prio(lv)
time.sleep(1.0)
data = meter(f"ab_{lv}_{i}", 12)
r = row(lv, data, i)
rows.append(r)
print(r)
set_prio(final)
print("FINAL", final, "pid", ecat_pid())
# summary means
by = {}
for r in rows:
by.setdefault(r["level"], []).append(r)
means = {}
for lv, rs in by.items():
means[lv] = {
"n": len(rs),
"late_per_s_mean": round(sum(x["late_per_s"] for x in rs)/len(rs), 3),
"skip_per_s_mean": round(sum(x["skip_per_s"] for x in rs)/len(rs), 3),
"send_p90_mean": round(sum(x["send_p90"] or 0 for x in rs)/len(rs), 1),
"work_p90_mean": round(sum(x["work_p90"] or 0 for x in rs)/len(rs), 1),
}
print("MEANS", json.dumps(means, ensure_ascii=False, indent=2))
path = os.path.join(LAB, f"summary_ab_prio_{int(time.time())}.json")
json.dump({"rows": rows, "means": means, "final": final}, open(path,"w",encoding="utf-8"),
ensure_ascii=False, indent=2)
print("WROTE", path)
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""进程优先级对照:不高/RealTime,各采 skip rate。不改网卡、不碰固件。"""
import json, os, subprocess, sys, time, urllib.request
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
LAB = os.path.join(ROOT, "lab", "skip_meter")
BASE = "http://127.0.0.1:8090"
def post(path, body=None):
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=5) as r:
return json.loads(r.read().decode())
def pid():
return post("/api/native/health") # health has no pid; use process name
# fallback below
def ecat_pid():
out = subprocess.check_output(
["powershell","-NoProfile","-Command",
"(Get-Process ecat_native).Id"],
text=True).strip()
return int(out.splitlines()[0])
def set_prio(level):
p = ecat_pid()
cmd = f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd], text=True).strip()
def meter(tag, secs=16):
r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")])
data = json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8")) if files else None
return data, r.stdout
def main():
p = ecat_pid()
print("ecat pid", p)
cur = subprocess.check_output(
["powershell","-NoProfile","-Command", f"(Get-Process -Id {p}).PriorityClass"],
text=True).strip()
print("current PriorityClass", cur)
results = []
for level in ("High", "RealTime"):
print(f"\n=== set {level} ===")
try:
print(set_prio(level))
except Exception as e:
print("set fail", e)
continue
time.sleep(1.5)
data, out = meter(f"prio_{level}", 16)
if data:
row = {"level": level, "late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
"late_frac_pct": data["late_frac_pct"], "send_stall_frac": data["send_stall_frac"],
"rx_stall_frac": data["rx_stall_frac"], "achieved_hz": data["achieved_hz"],
"n_late": data["n_late"]}
results.append(row)
print("ROW", row)
print(out[-800:] if out else "")
# restore High (safe default; pdo thread already TIME_CRITICAL)
try:
print("restore High")
print(set_prio("High"))
except Exception as e:
print("restore fail", e)
path = os.path.join(LAB, f"summary_prio_{int(time.time())}.json")
json.dump({"results": results, "original": cur}, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", path)
if __name__ == "__main__":
main()
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""静默多档标定:1ms / 2ms / 4ms,每档先停环再设周期再开环,避免混拍。"""
import json, os, subprocess, sys, time, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
BASE = "http://127.0.0.1:8090"
def post(p, b=None):
d = json.dumps(b or {}).encode()
r = urllib.request.Request(BASE+p, data=d, headers={"Content-Type":"application/json"})
return json.loads(urllib.request.urlopen(r, timeout=8).read().decode())
def set_prio(level="RealTime"):
p = int(subprocess.check_output(["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"], text=True).strip())
subprocess.check_output(["powershell","-NoProfile","-Command",
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"], text=True)
return p
def set_period(ms):
post("/api/motor/stop")
time.sleep(0.2)
r = post("/api/motor/period", {"period_ms": ms})
time.sleep(0.2)
post("/api/motor/start")
time.sleep(0.8)
return r
def meter(tag, secs=18):
r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")])
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
def main():
print("set RealTime")
print("pid", set_prio("RealTime"))
rows = []
for ms in (1.0, 2.0, 4.0):
print(f"\n=== period {ms} ms ===")
print(set_period(ms))
time.sleep(2.0) # settle
data = meter(f"quiet_{ms:g}ms", 18)
row = {
"period_ms": ms,
"late_per_s": data["late_per_s"],
"skip_per_s": data["skip_per_s"],
"late_frac_pct": data["late_frac_pct"],
"achieved_hz": data["achieved_hz"],
"n_late": data["n_late"],
"send_stall_frac": data["send_stall_frac"],
"rx_stall_frac": data["rx_stall_frac"],
"work_p50": data["window_cyc_us"]["work"]["p50"],
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
"send_p50": data["window_cyc_us"]["send"]["p50"],
"work_p90": data["window_cyc_us"]["work"]["p90"],
}
rows.append(row)
print(row)
# restore 1ms as user default
print("\nrestore period 1.0")
print(set_period(1.0))
path = os.path.join(LAB, f"summary_quiet_periods_{int(time.time())}.json")
json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", path)
print(json.dumps(rows, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+136
View File
@@ -0,0 +1,136 @@
#!/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())
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""重启 ecat_native 进程(让 esp32_server/父进程重拉),测 rx 是否回到 ~90µs。"""
import json, os, subprocess, sys, time, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
BASE = "http://127.0.0.1:8090"
def post(p, b=None, timeout=5):
d = json.dumps(b or {}).encode()
r = urllib.request.Request(BASE+p, data=d, headers={"Content-Type":"application/json"})
return json.loads(urllib.request.urlopen(r, timeout=timeout).read().decode())
def ps(cmd):
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
text=True, encoding="utf-8", errors="replace").strip()
def wait_health(timeout=40):
t0 = time.time()
while time.time()-t0 < timeout:
try:
h = post("/api/native/health")
if h.get("loop")==1 and int(h.get("wkc") or 0)>=3:
return True, h
except Exception:
pass
time.sleep(0.5)
return False, None
def meter(tag, secs=16):
subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json"))
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
def main():
# re-enable IP (keep VMware off — it's harmful and was only “enabled” by coincidence in morning)
for cid in ("ms_tcpip", "ms_tcpip6"):
try:
ps(f"Enable-NetAdapterBinding -Name '以太网 2' -ComponentID '{cid}' -Confirm:$false")
print("enabled", cid)
except Exception as e:
print("enable fail", cid, e)
info = ps("Get-CimInstance Win32_Process -Filter \"Name='ecat_native.exe'\" | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress")
print("before", info[:500])
print("kill ecat_native...")
ps("Stop-Process -Name ecat_native -Force -ErrorAction SilentlyContinue")
time.sleep(2)
# parent may be esp32_server — wait for auto-restart
ok, h = wait_health(45)
print("auto restart?", ok, h)
if not ok:
print("manual start")
exe = r"G:\Robot_v2\DCU\tools\ecat\build_native\ecat_native.exe"
cwd = r"G:\Robot_v2\DCU\tools\ecat"
subprocess.Popen(
[exe, "--substr", "I210", "--mask", "0x50", "--web", "src/web"],
cwd=cwd,
stdout=open(os.path.join(cwd,"build_native","native_stdout.log"),"a"),
stderr=subprocess.STDOUT,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
)
time.sleep(3)
ok, h = wait_health(30)
print("manual health", ok, h)
if ok:
try:
post("/api/op/start")
post("/api/motor/start")
except Exception as e:
print("start fail", e)
ok, h = wait_health(20)
print("after start", ok, h)
# set RealTime
try:
p = int(ps("(Get-Process ecat_native).Id"))
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass")
print("RealTime", p)
except Exception as e:
print("prio fail", e)
print("settle 10s")
time.sleep(10)
st = post("/api/motor/state")
print("cyc", st.get("cyc_us"))
print("period", st.get("period_ms"), "wkc", st.get("wkc"), "pcore", st.get("pcore_mask"))
data = meter("restart_rt", 16)
row = {
"late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
"achieved_hz": data["achieved_hz"], "n_late": data["n_late"],
"work_p50": data["window_cyc_us"]["work"]["p50"],
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
"send_p50": data["window_cyc_us"]["send"]["p50"],
"work_p90": data["window_cyc_us"]["work"]["p90"],
"send_stall_frac": data["send_stall_frac"],
"rx_stall_frac": data["rx_stall_frac"],
}
print(json.dumps(row, ensure_ascii=False, indent=2))
out = os.path.join(LAB, f"summary_restart_{int(time.time())}.json")
json.dump(row, open(out,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", out)
if __name__ == "__main__":
main()