hermes_voice / scripts /preemption_probe.py
parichai's picture
0.3.2 - touch-wake via antenna push (head-tap disabled: idle motion out-signals a tap); HEADROOM measured (39% avg CPU, flat RAM, 47C); PREEMPTION documented (logic unit-tested, hardware probe reboots unit -> deferred); antenna-push false-positive fixed
165f43c verified
Raw
History Blame Contribute Delete
4.51 kB
#!/usr/bin/env python3
"""Measure whether cancelling a gesture mid-play interpolates or SNAPS, and write
PREEMPTION.md.
A child interrupts constantly, so preemption must ramp smoothly to idle, not jump.
This probe (run ON THE ROBOT, or in sim with --sim) does two runs:
1. RAW: start a move, call `cancel_move()` mid-play, and sample joint positions
across the cancel. A large single-sample jump == the SDK snaps.
2. WRAPPED: same, but let IdleMotion's ramp-to-idle take over after the cancel.
It reports the worst per-sample joint delta for each and writes the verdict into
PREEMPTION.md. Bounded, smooth deltas == good; a spike == the ramp-down wrapper is
doing real work.
"""
from __future__ import annotations
import argparse
import statistics
import threading
import time
from pathlib import Path
import numpy as np
RESULTS_START = "<!-- RESULTS:START -->"
RESULTS_END = "<!-- RESULTS:END -->"
PREEMPTION_MD = Path(__file__).resolve().parent.parent / "PREEMPTION.md"
SAMPLE_HZ = 100.0
def sample_joints(mini, out: list[np.ndarray], stop: threading.Event) -> None:
get = getattr(mini, "get_current_joint_positions", None)
if get is None:
return
dt = 1.0 / SAMPLE_HZ
while not stop.is_set():
try:
out.append(np.asarray(get(), dtype=float))
except Exception: # noqa: BLE001
pass
time.sleep(dt)
def max_step(samples: list[np.ndarray]) -> float:
"""Largest single-sample L-inf joint delta (rad) across the capture."""
if len(samples) < 2:
return float("nan")
steps = [np.max(np.abs(b - a)) for a, b in zip(samples, samples[1:]) if a.shape == b.shape]
return float(max(steps)) if steps else float("nan")
def run_once(mini, idle, use_wrapper: bool) -> float:
from hermes_voice.gestures import GestureLibrary
lib = GestureLibrary(mini, idle)
samples: list[np.ndarray] = []
stop = threading.Event()
sampler = threading.Thread(target=sample_joints, args=(mini, samples, stop), daemon=True)
sampler.start()
lib.request("celebrate") # a longer, expressive move
time.sleep(0.6) # let it get going
if use_wrapper:
lib.request("nod_gentle") # supersede -> cancel + idle ramp-to-idle
else:
cancel = getattr(mini, "cancel_move", None)
if cancel:
cancel()
time.sleep(1.2)
stop.set()
sampler.join(timeout=1.0)
lib.stop()
return max_step(samples)
def write_results(raw: float, wrapped: float) -> None:
verdict = "inconclusive"
if not np.isnan(raw) and not np.isnan(wrapped):
verdict = "wrapper smooths the cancel" if wrapped < raw * 0.75 else \
"SDK cancel already smooth" if raw < 0.05 else "check tuning"
block = (
f"{RESULTS_START}\n"
f"| run | worst single-sample joint step (rad) |\n"
f"|-----|--------------------------------------|\n"
f"| raw `cancel_move()` | {raw:.4f} |\n"
f"| wrapped (ramp-to-idle) | {wrapped:.4f} |\n\n"
f"**Verdict:** {verdict}. (Lower is smoother; a large raw step means the SDK "
f"snaps and the ramp-down wrapper is required.)\n"
f"{RESULTS_END}"
)
if PREEMPTION_MD.exists() and RESULTS_START in PREEMPTION_MD.read_text():
text = PREEMPTION_MD.read_text()
PREEMPTION_MD.write_text(text.split(RESULTS_START)[0] + block + text.split(RESULTS_END)[1])
print(f"Wrote results into {PREEMPTION_MD.name}")
print(f"raw={raw:.4f} rad wrapped={wrapped:.4f} rad -> {verdict}")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--sim", action="store_true")
args = ap.parse_args()
from reachy_mini import ReachyMini
from hermes_voice.idle import IdleMotion
# "no_media" = motion only: skip camera/audio/WebRTC init entirely, so the
# probe connects fast and never hangs on media bring-up (it only needs joints).
backend = "default" if args.sim else "no_media"
with ReachyMini(media_backend=backend, use_sim=args.sim) as mini:
try:
mini.enable_motors()
except Exception: # noqa: BLE001
pass
idle = IdleMotion(mini)
idle.start()
try:
raw = run_once(mini, idle, use_wrapper=False)
time.sleep(1.0)
wrapped = run_once(mini, idle, use_wrapper=True)
finally:
idle.stop()
write_results(raw, wrapped)
if __name__ == "__main__":
main()