"""Python equivalent of the JS streaming benchmark. Runs the exact same motion (mirror-symmetric antennas + head Y sin) through the Python SDK while reading state at the same nominal rate. Output CSV matches the JS schema so the two can be compared head-to-head. Notes on parity vs JS: - No lead-compensation here. The JS Playback shifts antenna commands 40 ms ahead and head 150 ms ahead. This script sends the raw motion at wall-clock t. We're hunting for jitter/stutter events, which show up the same way regardless of mean phase lag. - The Python SDK exposes synchronous `get_current_head_pose()` / `get_present_antenna_joint_positions()` that read a client-side cache of the latest daemon state. Read timing is therefore set by *our* loop, not by daemon-pushed events as in JS. """ import csv import math import time import numpy as np from reachy_mini import ReachyMini # Match lib/benchmark.js BENCHMARK constants exactly. CENTER_DEG = 30.0 AMP_DEG = 20.0 PHASE1_DUR = 5.0 PHASE1_HZ = 0.5 HOLD_DUR = 2.0 PHASE3_DUR = 5.0 PHASE3_HZ = 1.5 HEAD_AMP_M = 0.03 HEAD_PHASE1_HZ = 0.3 HEAD_PHASE3_HZ = 0.5 TOTAL_DUR = PHASE1_DUR + HOLD_DUR + PHASE3_DUR LOOP_HZ = 50.0 PERIOD = 1.0 / LOOP_HZ def right_at(t: float) -> float: center = math.radians(CENTER_DEG) amp = math.radians(AMP_DEG) if t < 0: return center if t <= PHASE1_DUR: return center + amp * math.sin(2 * math.pi * PHASE1_HZ * t) if t <= PHASE1_DUR + HOLD_DUR: return center if t <= TOTAL_DUR: return center + amp * math.sin( 2 * math.pi * PHASE3_HZ * (t - PHASE1_DUR - HOLD_DUR) ) return center def left_at(t: float) -> float: return -right_at(t) def head_y_at(t: float) -> float: if t < 0: return 0.0 if t <= PHASE1_DUR: return HEAD_AMP_M * math.sin(2 * math.pi * HEAD_PHASE1_HZ * t) if t <= PHASE1_DUR + HOLD_DUR: return 0.0 if t <= TOTAL_DUR: return HEAD_AMP_M * math.sin( 2 * math.pi * HEAD_PHASE3_HZ * (t - PHASE1_DUR - HOLD_DUR) ) return 0.0 def head_pose_y(y_m: float) -> np.ndarray: H = np.eye(4) H[1, 3] = y_m return H def main() -> None: samples: list[tuple[float, float, float, float, float, float, float]] = [] print("Connecting to Reachy Mini…") with ReachyMini() as mini: print("Connected. Going to initial pose…") init_head = head_pose_y(head_y_at(0.0)) mini.goto_target( init_head, antennas=[left_at(0.0), right_at(0.0)], duration=1.5, ) time.sleep(0.2) print(f"Running benchmark: {TOTAL_DUR:.1f} s at {LOOP_HZ:.0f} Hz…") t0 = time.perf_counter() tick = 0 while True: now = time.perf_counter() t = now - t0 if t > TOTAL_DUR: break cmd_l = left_at(t) cmd_r = right_at(t) cmd_hy = head_y_at(t) mini.set_target( head=head_pose_y(cmd_hy), antennas=[cmd_l, cmd_r], ) # Read the latest cached state right after sending. This is # the closest analogue to what the JS path measures (where # state events are pushed asynchronously). Read time is our # wall-clock t. try: actual_head = mini.get_current_head_pose() actual_ant = mini.get_present_antenna_joint_positions() samples.append(( t, math.degrees(cmd_l), math.degrees(actual_ant[0]), math.degrees(cmd_r), math.degrees(actual_ant[1]), cmd_hy * 100.0, float(actual_head[1, 3]) * 100.0, )) except Exception as e: print(f" read error at t={t:.3f}: {e}") tick += 1 next_tick = t0 + tick * PERIOD sleep_for = next_tick - time.perf_counter() if sleep_for > 0: time.sleep(sleep_for) print("Done. Returning to base…") mini.goto_target( np.eye(4), antennas=[-0.1745, 0.1745], duration=1.0, ) time.sleep(1.1) stamp = time.strftime("%Y-%m-%dT%H-%M-%S") out = f"/Users/remi/Downloads/marionette-benchmark-python-{stamp}.csv" with open(out, "w", newline="") as f: w = csv.writer(f) w.writerow([ "t_s", "left_commanded_deg", "left_actual_deg", "right_commanded_deg", "right_actual_deg", "head_y_commanded_cm", "head_y_actual_cm", ]) for row in samples: w.writerow([ f"{row[0]:.4f}", f"{row[1]:.3f}", f"{row[2]:.3f}", f"{row[3]:.3f}", f"{row[4]:.3f}", f"{row[5]:.3f}", f"{row[6]:.3f}", ]) print(f"Wrote {out}") print(f"Samples: {len(samples)}") if samples: dts = [(samples[i][0] - samples[i - 1][0]) * 1000 for i in range(1, len(samples))] print( f"State interval: mean {sum(dts)/len(dts):.1f} ms, " f"max {max(dts):.0f} ms, " f"p99 {sorted(dts)[int(len(dts)*0.99)]:.0f} ms" ) if __name__ == "__main__": main()