| """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 |
|
|
| |
| 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], |
| ) |
| |
| |
| |
| |
| 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() |
|
|