Spaces:
Running
Running
RemiFabre
Sync from marionette-experimental: host-shell architecture + everything since 2026-05-19 r4
c425f8c | """Wireless-jitter baseline: compare current-Marionette-style streaming | |
| vs. true daemon-side playback for the same move. | |
| Two modes, N runs each, interleaved: | |
| - marionette_style: Python streams one combined set_target per tick at | |
| 50 Hz with lead-comp (mirrors what the JS app currently does on the | |
| data channel). | |
| - daemon_native: POST /move/play/recorded-move-dataset/{name}, which | |
| runs Backend.play_move on the robot at 100 Hz with no network in | |
| the inner loop. This is the target behavior of the upload-and-play | |
| rework -- same end state, different way of getting the move there. | |
| For each run we capture the actual head pose / antenna positions at | |
| 50 Hz from the cached SDK state. Output: a single summary printed to | |
| stdout and a CSV per run under OUT_DIR for later plotting. | |
| Usage: | |
| python wireless_baseline.py # default: head_tilt_roll | |
| python wireless_baseline.py <dance_name> # any from the dataset | |
| """ | |
| import csv | |
| import math | |
| import statistics | |
| import sys | |
| import threading | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import requests | |
| from reachy_mini import ReachyMini | |
| from reachy_mini.motion.recorded_move import RecordedMove, RecordedMoves | |
| DATASET = "pollen-robotics/reachy-mini-dances-library" | |
| DANCE = sys.argv[1] if len(sys.argv) > 1 else "head_tilt_roll" | |
| N_RUNS = 3 | |
| CAPTURE_HZ = 50.0 | |
| CAPTURE_PERIOD = 1.0 / CAPTURE_HZ | |
| HEAD_LEAD_S = 0.205 | |
| ANTENNA_LEAD_S = 0.090 | |
| STREAM_HZ = 50.0 | |
| STREAM_PERIOD = 1.0 / STREAM_HZ | |
| DAEMON_HOST = "http://reachy-mini.local:8000/api" | |
| OUT_DIR = Path("/Users/remi/Downloads/wireless-baseline") | |
| OUT_DIR.mkdir(parents=True, exist_ok=True) | |
| def sample_move_at(move: RecordedMove, t: float): | |
| t_clipped = min(max(0.0, t), move.timestamps[-1] - 1e-3) | |
| return move.evaluate(t_clipped) | |
| def capture_loop(mini: ReachyMini, samples: list, stop_event: threading.Event, t0: float): | |
| """Poll cached state at CAPTURE_HZ; append (t, head_flat16, antennas, perf_t).""" | |
| tick = 0 | |
| while not stop_event.is_set(): | |
| now = time.perf_counter() | |
| t = now - t0 | |
| try: | |
| head = mini.get_current_head_pose() | |
| ant = mini.get_present_antenna_joint_positions() | |
| samples.append((t, head.flatten().tolist(), list(ant), now)) | |
| except Exception as e: | |
| print(f" capture read error at t={t:.3f}: {e}") | |
| tick += 1 | |
| next_tick = t0 + tick * CAPTURE_PERIOD | |
| sleep_for = next_tick - time.perf_counter() | |
| if sleep_for > 0: | |
| time.sleep(sleep_for) | |
| def reset_to_base(mini: ReachyMini): | |
| base = np.eye(4) | |
| mini.goto_target(base, antennas=[-0.1745, 0.1745], duration=0.8) | |
| time.sleep(1.0) | |
| def head_pose_xyz_rpy(flat16: list) -> tuple: | |
| """Pull the translation (x,y,z) + roll/pitch/yaw from a 4×4.""" | |
| m = np.array(flat16).reshape(4, 4) | |
| x, y, z = m[0, 3], m[1, 3], m[2, 3] | |
| # ZYX intrinsic: roll = atan2(R32,R33), pitch = asin(-R31), yaw = atan2(R21,R11) | |
| pitch = math.asin(-max(-1.0, min(1.0, m[2, 0]))) | |
| if abs(math.cos(pitch)) > 1e-6: | |
| roll = math.atan2(m[2, 1], m[2, 2]) | |
| yaw = math.atan2(m[1, 0], m[0, 0]) | |
| else: | |
| roll = math.atan2(-m[1, 2], m[1, 1]) | |
| yaw = 0.0 | |
| return x, y, z, roll, pitch, yaw | |
| def actuator_smoothness(samples: list) -> dict: | |
| """Pure smoothness metric -- no reference trajectory needed. | |
| Take per-axis position from each captured sample, compute the | |
| second difference (proxy for acceleration), report its std and max. | |
| Smooth motion has small / uniform accelerations; jerky motion | |
| spikes. Independent of tracking error -- measures the actuator's | |
| own behavior directly. | |
| """ | |
| if len(samples) < 4: | |
| return {"head_jerk_rms_mrad": 0.0, "ant_jerk_rms_mrad": 0.0, | |
| "head_jerk_max_mrad": 0.0, "ant_jerk_max_mrad": 0.0} | |
| head_rpy = [] | |
| ants = [] | |
| for _, head_flat, ant, _ in samples: | |
| _, _, _, r, p, y = head_pose_xyz_rpy(head_flat) | |
| head_rpy.append((r, p, y)) | |
| ants.append(ant) | |
| def second_diff_rms(seq, axis_count): | |
| sq_sum = 0.0 | |
| n = 0 | |
| peak = 0.0 | |
| for i in range(2, len(seq)): | |
| for ax in range(axis_count): | |
| d2 = seq[i][ax] - 2 * seq[i-1][ax] + seq[i-2][ax] | |
| sq_sum += d2 * d2 | |
| if abs(d2) > peak: | |
| peak = abs(d2) | |
| n += 1 | |
| rms = math.sqrt(sq_sum / max(1, n)) | |
| return rms, peak | |
| h_rms, h_peak = second_diff_rms(head_rpy, 3) | |
| a_rms, a_peak = second_diff_rms(ants, 2) | |
| return { | |
| "head_jerk_rms_mrad": h_rms * 1000, | |
| "head_jerk_max_mrad": h_peak * 1000, | |
| "ant_jerk_rms_mrad": a_rms * 1000, | |
| "ant_jerk_max_mrad": a_peak * 1000, | |
| } | |
| def per_axis_lag_compensated_error(move: RecordedMove, samples: list) -> dict: | |
| """Compute commanded-vs-actual error stats with lead-comp applied. | |
| The marionette-style stream applies HEAD_LEAD/ANTENNA_LEAD to the | |
| *sampled-from-motion* time so the actuator at wall-clock t is | |
| expected to be at move(t). Daemon-native doesn't lead-comp so | |
| actuator lags by a fixed amount. Reporting both with and without | |
| lead compensation lets us compare apples to apples. | |
| """ | |
| head_err = [] | |
| ant_err = [] | |
| head_err_compensated = [] | |
| ant_err_compensated = [] | |
| for t, head_flat, ant, _ in samples: | |
| if t < 0 or t > move.duration - 0.05: | |
| continue | |
| # uncompensated: actuator at t vs ideal at t | |
| head_ideal, ant_ideal, _ = sample_move_at(move, t) | |
| x, y, z, roll, pitch, yaw = head_pose_xyz_rpy(head_flat) | |
| xi, yi, zi, ri, pi_, yi_ = head_pose_xyz_rpy(head_ideal.flatten().tolist()) | |
| head_err.append( | |
| math.hypot(roll - ri, math.hypot(pitch - pi_, yaw - yi_)) | |
| ) | |
| ant_err.append(math.hypot(ant[0] - ant_ideal[0], ant[1] - ant_ideal[1])) | |
| # compensated: actuator at t vs ideal at t - lead (i.e. what was commanded ~lead s ago) | |
| head_ideal2, _, _ = sample_move_at(move, max(0.0, t - HEAD_LEAD_S)) | |
| _, ant_ideal2, _ = sample_move_at(move, max(0.0, t - ANTENNA_LEAD_S)) | |
| xi, yi, zi, ri2, pi2, yi2 = head_pose_xyz_rpy(head_ideal2.flatten().tolist()) | |
| head_err_compensated.append( | |
| math.hypot(roll - ri2, math.hypot(pitch - pi2, yaw - yi2)) | |
| ) | |
| ant_err_compensated.append( | |
| math.hypot(ant[0] - ant_ideal2[0], ant[1] - ant_ideal2[1]) | |
| ) | |
| def stats(xs): | |
| if not xs: | |
| return (0.0, 0.0, 0.0) | |
| return (statistics.mean(xs), statistics.median(xs), max(xs)) | |
| return { | |
| "head_uncomp": stats(head_err), | |
| "ant_uncomp": stats(ant_err), | |
| "head_lag_comp": stats(head_err_compensated), | |
| "ant_lag_comp": stats(ant_err_compensated), | |
| "n": len(head_err), | |
| } | |
| def stream_period_stats(send_times_perf: list) -> tuple: | |
| """Tick interval mean/std/max from the streamer's perf_counter times.""" | |
| if len(send_times_perf) < 2: | |
| return (0.0, 0.0, 0.0) | |
| diffs = [send_times_perf[i+1] - send_times_perf[i] for i in range(len(send_times_perf)-1)] | |
| return (statistics.mean(diffs), statistics.stdev(diffs) if len(diffs) > 1 else 0.0, max(diffs)) | |
| def write_csv(path: Path, move: RecordedMove, samples: list): | |
| headers = ["t_s", "act_x", "act_y", "act_z", "act_roll", "act_pitch", "act_yaw", "act_ant_r", "act_ant_l", | |
| "cmd_x", "cmd_y", "cmd_z", "cmd_roll", "cmd_pitch", "cmd_yaw", "cmd_ant_r", "cmd_ant_l"] | |
| with open(path, "w", newline="") as f: | |
| w = csv.writer(f) | |
| w.writerow(headers) | |
| for t, head_flat, ant, _ in samples: | |
| if t < 0 or t > move.duration + 0.2: | |
| continue | |
| ax, ay, az, ar, ap, aw = head_pose_xyz_rpy(head_flat) | |
| head_cmd, ant_cmd, _ = sample_move_at(move, t) | |
| cx, cy, cz, cr, cp, cw = head_pose_xyz_rpy(head_cmd.flatten().tolist()) | |
| w.writerow([f"{t:.4f}", | |
| f"{ax:.5f}", f"{ay:.5f}", f"{az:.5f}", f"{ar:.5f}", f"{ap:.5f}", f"{aw:.5f}", | |
| f"{ant[0]:.5f}", f"{ant[1]:.5f}", | |
| f"{cx:.5f}", f"{cy:.5f}", f"{cz:.5f}", f"{cr:.5f}", f"{cp:.5f}", f"{cw:.5f}", | |
| f"{ant_cmd[0]:.5f}", f"{ant_cmd[1]:.5f}"]) | |
| def run_marionette_style(mini: ReachyMini, move: RecordedMove, run_idx: int) -> dict: | |
| samples = [] | |
| send_times = [] | |
| stop = threading.Event() | |
| t0 = time.perf_counter() | |
| capture_thread = threading.Thread( | |
| target=capture_loop, args=(mini, samples, stop, t0), daemon=True, | |
| ) | |
| capture_thread.start() | |
| try: | |
| tick = 0 | |
| while True: | |
| now = time.perf_counter() | |
| t = now - t0 | |
| if t > move.duration: | |
| break | |
| t_head = min(t + HEAD_LEAD_S, move.duration - 1e-3) | |
| t_ant = min(t + ANTENNA_LEAD_S, move.duration - 1e-3) | |
| head_for_head, _, body_yaw_h = sample_move_at(move, t_head) | |
| _, ant_for_ant, _ = sample_move_at(move, t_ant) | |
| mini.set_target( | |
| head=head_for_head, | |
| antennas=list(ant_for_ant), | |
| body_yaw=float(body_yaw_h), | |
| ) | |
| send_times.append(now) | |
| tick += 1 | |
| next_tick = t0 + tick * STREAM_PERIOD | |
| sleep_for = next_tick - time.perf_counter() | |
| if sleep_for > 0: | |
| time.sleep(sleep_for) | |
| time.sleep(0.3) | |
| finally: | |
| stop.set() | |
| capture_thread.join(timeout=1.0) | |
| out = OUT_DIR / f"{DANCE}-marionette_style-run{run_idx:02d}.csv" | |
| write_csv(out, move, samples) | |
| return { | |
| "mode": "marionette_style", | |
| "csv": out, | |
| "stream_stats": stream_period_stats(send_times), | |
| "err": per_axis_lag_compensated_error(move, samples), | |
| "smooth": actuator_smoothness(samples), | |
| "frames_sent": len(send_times), | |
| "frames_captured": len(samples), | |
| } | |
| def run_daemon_native(mini: ReachyMini, move: RecordedMove, dance_name: str, run_idx: int) -> dict: | |
| samples = [] | |
| stop = threading.Event() | |
| t0 = time.perf_counter() | |
| capture_thread = threading.Thread( | |
| target=capture_loop, args=(mini, samples, stop, t0), daemon=True, | |
| ) | |
| capture_thread.start() | |
| try: | |
| # Kick off the daemon-side playback via REST. It runs | |
| # Backend.play_move on the robot at 100 Hz with no network in | |
| # the inner loop. | |
| r = requests.post( | |
| f"{DAEMON_HOST}/move/play/recorded-move-dataset/{DATASET}/{dance_name}", | |
| timeout=5, | |
| ) | |
| r.raise_for_status() | |
| move_uuid = r.json()["uuid"] | |
| # Wait for the daemon to finish (poll the running-moves list). | |
| deadline = time.perf_counter() + move.duration + 5.0 | |
| while time.perf_counter() < deadline: | |
| time.sleep(0.05) | |
| try: | |
| running = requests.get(f"{DAEMON_HOST}/move/running", timeout=2).json() | |
| if not any(m["uuid"] == move_uuid for m in running): | |
| break | |
| except Exception: | |
| pass | |
| time.sleep(0.3) | |
| finally: | |
| stop.set() | |
| capture_thread.join(timeout=1.0) | |
| out = OUT_DIR / f"{DANCE}-daemon_native-run{run_idx:02d}.csv" | |
| write_csv(out, move, samples) | |
| return { | |
| "mode": "daemon_native", | |
| "csv": out, | |
| "stream_stats": (0.01, 0.0, 0.0), # not applicable | |
| "err": per_axis_lag_compensated_error(move, samples), | |
| "smooth": actuator_smoothness(samples), | |
| "frames_sent": int(move.duration * 100), # daemon ticks at 100 Hz | |
| "frames_captured": len(samples), | |
| } | |
| def fmt_stats(label: str, stats: tuple) -> str: | |
| mean, median, mx = stats | |
| return f"{label}: mean={mean*1000:.1f} ms, median={median*1000:.1f} ms, max={mx*1000:.1f} ms" | |
| def summarize(results: list): | |
| print("\n" + "=" * 70) | |
| print(f"SUMMARY -- dance={DANCE}, {N_RUNS} runs per mode") | |
| print("=" * 70) | |
| by_mode: dict[str, list] = {"marionette_style": [], "daemon_native": []} | |
| for r in results: | |
| by_mode[r["mode"]].append(r) | |
| for mode, rs in by_mode.items(): | |
| if not rs: | |
| continue | |
| # Aggregate err stats across runs. | |
| def agg(key, idx): | |
| vals = [r["err"][key][idx] for r in rs] | |
| return statistics.mean(vals) if vals else 0.0 | |
| # Stream interval stats (mean of means across runs) | |
| if mode == "marionette_style": | |
| tick_means = [r["stream_stats"][0] for r in rs] | |
| tick_stds = [r["stream_stats"][1] for r in rs] | |
| tick_maxes = [r["stream_stats"][2] for r in rs] | |
| tick_summary = ( | |
| f" send-tick: mean={statistics.mean(tick_means)*1000:.1f} ms, " | |
| f"std={statistics.mean(tick_stds)*1000:.1f} ms, " | |
| f"max={max(tick_maxes)*1000:.1f} ms (target 20 ms)" | |
| ) | |
| else: | |
| tick_summary = " send-tick: n/a (daemon-side loop)" | |
| print(f"\n[{mode}] runs={len(rs)}") | |
| print(tick_summary) | |
| # Smoothness (no reference trajectory needed -- pure actuator | |
| # behavior). Lower is smoother. | |
| sh = statistics.mean([r["smooth"]["head_jerk_rms_mrad"] for r in rs]) | |
| sh_max = max([r["smooth"]["head_jerk_max_mrad"] for r in rs]) | |
| sa = statistics.mean([r["smooth"]["ant_jerk_rms_mrad"] for r in rs]) | |
| sa_max = max([r["smooth"]["ant_jerk_max_mrad"] for r in rs]) | |
| print(f" smoothness (lower is better):") | |
| print(f" head rpy jerk rms={sh:.2f} mrad, max={sh_max:.2f} mrad") | |
| print(f" antennas jerk rms={sa:.2f} mrad, max={sa_max:.2f} mrad") | |
| print(f" tracking error (sanity, not the primary metric):") | |
| print(f" lag-comp head={agg('head_lag_comp', 0)*1000:.1f} mrad / " | |
| f"ant={agg('ant_lag_comp', 0)*1000:.1f} mrad") | |
| print() | |
| def main(): | |
| print(f"Loading dance: {DANCE} from {DATASET}") | |
| library = RecordedMoves(DATASET) | |
| if DANCE not in library.list_moves(): | |
| print(f"Dance '{DANCE}' not found. Available: {library.list_moves()}") | |
| sys.exit(2) | |
| move = library.get(DANCE) | |
| print(f" duration: {move.duration:.2f} s, frames: {len(move.timestamps)}") | |
| print("Connecting to robot…") | |
| with ReachyMini() as mini: | |
| print("Connected. Resetting to base…") | |
| reset_to_base(mini) | |
| results = [] | |
| for run_idx in range(1, N_RUNS + 1): | |
| print(f"\n--- Run {run_idx}/{N_RUNS}: marionette_style ---") | |
| r = run_marionette_style(mini, move, run_idx) | |
| print(f" sent {r['frames_sent']} frames, captured {r['frames_captured']}") | |
| results.append(r) | |
| reset_to_base(mini) | |
| print(f"--- Run {run_idx}/{N_RUNS}: daemon_native ---") | |
| r = run_daemon_native(mini, move, DANCE, run_idx) | |
| print(f" captured {r['frames_captured']} frames") | |
| results.append(r) | |
| reset_to_base(mini) | |
| summarize(results) | |
| print(f"\nCSVs in {OUT_DIR}") | |
| if __name__ == "__main__": | |
| main() | |