#!/usr/bin/env python3 """Test antenna collisions on Reachy Mini. Standalone script (no Marionette). Commands the left antenna to collide with the right antenna at known timestamps, using non-periodic intervals so detection alignment is unambiguous. Collision setup: - Right antenna fixed at -0.68 rad - Left antenna moves from 0.0 to 0.70 rad (collision at ~0.60 rad) - Hold 100ms, then return to rest - Low PID + flexible antennas = safe, produces audible click Records present antenna positions at 50Hz on the robot side (no network latency), saves to JSON, SCPs back, and analyzes to detect collision times from the trajectory. Usage: python tests/test_antenna_collision.py [--host reachy-mini.local] """ from __future__ import annotations import argparse import json import subprocess import sys import tempfile import time from pathlib import Path import numpy as np # Collision parameters RIGHT_REST = -0.68 # right antenna fixed position (rad) LEFT_REST = 0.0 # left antenna rest position (rad) LEFT_COLLISION = 0.70 # left antenna collision target (past contact at ~0.60) HOLD_DURATION = 0.2 # seconds to hold at collision position # Non-periodic collision times — gaps are all different (1.3, 1.7, 2.3, 3.1s) # so there's no ambiguity when matching detected events to expected events. COLLISION_TIMES = [1.0, 2.3, 4.0, 6.3, 9.4] ROBOT_USER = "pollen" ROBOT_PYTHON = "/venvs/apps_venv/bin/python" REMOTE_RESULTS = "/tmp/collision_positions.json" ROBOT_COLLISION_SCRIPT = """\ import numpy as np import os, sys, time, json collision_times = json.loads(sys.argv[1]) right_rest = float(sys.argv[2]) left_rest = float(sys.argv[3]) left_collision = float(sys.argv[4]) hold_duration = float(sys.argv[5]) results_path = sys.argv[6] print(f"robot: connecting to ReachyMini", flush=True) from reachy_mini import ReachyMini from reachy_mini.utils import create_head_pose r = ReachyMini(media_backend="no_media") # Go to rest position r.goto_target(create_head_pose(), antennas=[left_rest, right_rest], duration=1.0) time.sleep(1.5) print(f"robot: rest position — left={left_rest}, right={right_rest}", flush=True) print(f"robot: will collide at times: {collision_times}", flush=True) print(f"robot: collision target: left={left_collision}, hold={hold_duration}s", flush=True) # State machine DT = 0.02 # 50Hz update rate total_duration = max(collision_times) + hold_duration + 1.0 n_steps = int(total_duration / DT) # Build timeline: for each step, determine left antenna target left_targets = np.full(n_steps, left_rest, dtype=np.float64) for ct in collision_times: start_step = int(ct / DT) end_step = int((ct + hold_duration) / DT) end_step = min(end_step, n_steps) left_targets[start_step:end_step] = left_collision # Recording arrays timestamps = [] left_present = [] right_present = [] left_target_log = [] print("robot: MARK_START", flush=True) t0 = time.monotonic() for i in range(n_steps): left = float(left_targets[i]) r.set_target( head=np.eye(4), body_yaw=0.0, antennas=np.array([left, right_rest]), ) # Read present position pos = r.get_present_antenna_joint_positions() elapsed = time.monotonic() - t0 timestamps.append(elapsed) left_present.append(pos[0]) right_present.append(pos[1]) left_target_log.append(left) # Real-time pacing target_time = (i + 1) * DT now = time.monotonic() - t0 if target_time > now: time.sleep(target_time - now) elapsed = time.monotonic() - t0 print(f"robot: finished {n_steps} steps in {elapsed:.3f}s (expected {total_duration:.3f}s)", flush=True) # Save results results = { "collision_times": collision_times, "left_collision_target": left_collision, "right_rest": right_rest, "hold_duration": hold_duration, "timestamps": timestamps, "left_present": left_present, "right_present": right_present, "left_target": left_target_log, } with open(results_path, "w") as f: json.dump(results, f) print(f"robot: saved {len(timestamps)} samples to {results_path}", flush=True) # Return to rest r.goto_target(create_head_pose(), antennas=[left_rest, right_rest], duration=1.0) time.sleep(1.5) print("robot: done", flush=True) os._exit(0) """ def scp_to_robot(local_path: Path, remote_path: str, host: str) -> None: """Copy a file to the robot via SCP.""" target = f"{ROBOT_USER}@{host}:{remote_path}" result = subprocess.run( ["scp", "-o", "ConnectTimeout=5", str(local_path), target], capture_output=True, text=True, timeout=15, ) if result.returncode != 0: raise RuntimeError(f"SCP failed: {result.stderr}") print(f" Copied to {target}") def scp_from_robot(remote_path: str, local_path: Path, host: str) -> None: """Copy a file from the robot via SCP.""" source = f"{ROBOT_USER}@{host}:{remote_path}" result = subprocess.run( ["scp", "-o", "ConnectTimeout=5", source, str(local_path)], capture_output=True, text=True, timeout=15, ) if result.returncode != 0: raise RuntimeError(f"SCP failed: {result.stderr}") print(f" Copied from {source}") def run_on_robot(host: str) -> subprocess.Popen: """SCP script to robot and start collision sequence (non-blocking).""" with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: f.write(ROBOT_COLLISION_SCRIPT) local_script = Path(f.name) remote_script = "/tmp/collision_test.py" try: scp_to_robot(local_script, remote_script, host) finally: local_script.unlink() args_str = ( f"{ROBOT_PYTHON} {remote_script} " f"'{json.dumps(COLLISION_TIMES)}' " f"{RIGHT_REST} {LEFT_REST} {LEFT_COLLISION} {HOLD_DURATION} " f"{REMOTE_RESULTS}" ) proc = subprocess.Popen( ["ssh", "-o", "ConnectTimeout=5", f"{ROBOT_USER}@{host}", args_str], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) return proc def detect_collisions(data: dict) -> list[dict]: """Detect collision events from present position trajectory. For each commanded collision, finds the position peak time (when the antenna reached its maximum excursion and reversed). This is the moment of collision impact or max overshoot. Returns list of dicts with detection details per event. """ ts = np.array(data["timestamps"]) left_pos = np.array(data["left_present"]) left_tgt = np.array(data["left_target"]) # Find rising edges in target (rest → collision command) tgt_diff = np.diff(left_tgt) command_indices = np.where(tgt_diff > 0.3)[0] events = [] for cmd_idx in command_indices: cmd_time = ts[cmd_idx] # Look 0-500ms after command for position peak window_start = cmd_idx window_end = min(cmd_idx + 25, len(ts)) # 500ms at 50Hz if window_end <= window_start + 2: continue window_pos = left_pos[window_start:window_end] window_ts = ts[window_start:window_end] # Position peak = moment of max excursion (collision or overshoot) peak_idx = np.argmax(window_pos) peak_time = window_ts[peak_idx] peak_pos = window_pos[peak_idx] # Check for velocity stall (plateau = physical stop at collision) dt = np.diff(window_ts) dt[dt < 1e-6] = 1e-6 vel = np.diff(window_pos) / dt # A stall is consecutive near-zero velocity while position > 0.1 stall_time = None for j in range(len(vel) - 1): if (abs(vel[j]) < 1.0 and abs(vel[j + 1]) < 1.0 and window_pos[j + 1] > 0.1): stall_time = window_ts[j + 1] break events.append({ "cmd_time": cmd_time, "peak_time": peak_time, "peak_pos": peak_pos, "latency_ms": (peak_time - cmd_time) * 1000, "stall_time": stall_time, }) return events def plot_results(data: dict, events: list[dict], output_path: Path) -> None: """Generate a plot of antenna trajectories with collision markers.""" import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt ts = np.array(data["timestamps"]) left_pos = np.array(data["left_present"]) right_pos = np.array(data["right_present"]) left_tgt = np.array(data["left_target"]) expected_times = data["collision_times"] fig, ax = plt.subplots(figsize=(14, 6)) # Plot antenna trajectories ax.plot(ts, left_pos, "b-", linewidth=1.5, label="Left antenna (present)", zorder=3) ax.plot(ts, right_pos, "r-", linewidth=1.5, label="Right antenna (present)", zorder=3) ax.plot(ts, left_tgt, "b--", linewidth=0.8, alpha=0.4, label="Left antenna (target)") # Expected collision times (green dashed) for i, ct in enumerate(expected_times): label = "Expected collision" if i == 0 else None ax.axvline(ct, color="green", linestyle="--", linewidth=1.5, alpha=0.7, label=label) # Detected collision times (red solid) for i, ev in enumerate(events): label = "Detected (peak)" if i == 0 else None ax.axvline(ev["peak_time"], color="red", linestyle="-", linewidth=1.5, alpha=0.7, label=label) # Annotate with peak position ax.annotate( f'{ev["peak_pos"]:.2f}r\n{ev["latency_ms"]:.0f}ms', xy=(ev["peak_time"], ev["peak_pos"]), xytext=(10, 10), textcoords="offset points", fontsize=8, color="red", arrowprops=dict(arrowstyle="->", color="red", lw=0.8), ) ax.set_xlabel("Time (s)") ax.set_ylabel("Position (rad)") ax.set_title("Antenna Collision Test — Present Positions + Detection") ax.legend(loc="upper right", fontsize=9) ax.grid(True, alpha=0.3) ax.set_xlim(ts[0] - 0.2, ts[-1] + 0.2) fig.tight_layout() fig.savefig(str(output_path), dpi=150) plt.close(fig) print(f" Plot saved to {output_path}") def analyze_results(data: dict) -> dict: """Analyze collision position data and print report.""" ts = np.array(data["timestamps"]) left_pos = np.array(data["left_present"]) left_tgt = np.array(data["left_target"]) expected_times = data["collision_times"] print(f"\n{'='*60}") print("Collision Detection Analysis") print(f"{'='*60}") print(f" Samples: {len(ts)} at ~{1/np.mean(np.diff(ts)):.0f}Hz") print(f" Duration: {ts[-1]:.2f}s") print(f" Left antenna range: [{left_pos.min():.3f}, {left_pos.max():.3f}] rad") print(f" Expected collisions: {len(expected_times)} at {expected_times}") # Detect collisions events = detect_collisions(data) # Per-collision detail print(f"\n--- Per-collision detail ---") for i, ev in enumerate(events): stall_info = f", stall at t={ev['stall_time']:.3f}s" if ev["stall_time"] else "" print(f" Collision {i+1}: cmd t={ev['cmd_time']:.1f}s → " f"peak {ev['peak_pos']:.3f} rad at t={ev['peak_time']:.3f}s " f"(latency {ev['latency_ms']:.0f}ms{stall_info})") # Match detected peaks to expected times detected_peak_times = [ev["peak_time"] for ev in events] print(f"\n--- Matching ---") matched = 0 for i, et in enumerate(expected_times): if not detected_peak_times: print(f" Expected {et:.1f}s: NO MATCH") continue nearest = min(detected_peak_times, key=lambda d: abs(d - et)) offset_ms = (nearest - et) * 1000 ok = abs(nearest - et) < 0.3 if ok: matched += 1 status = "OK" if ok else "MISS" print(f" Expected {et:.1f}s → peak at {nearest:.3f}s " f"(offset {offset_ms:+.0f}ms) [{status}]") success = matched >= len(expected_times) - 1 print(f"\n {'PASS' if success else 'FAIL'}: " f"{matched}/{len(expected_times)} collisions matched") # Generate plot plot_path = Path("tests/collision_trajectory.png") plot_results(data, events, plot_path) return { "success": success, "expected": len(expected_times), "detected": len(events), "matched": matched, "events": events, } def main(): parser = argparse.ArgumentParser(description="Antenna collision test") parser.add_argument("--host", default="reachy-mini.local", help="Robot hostname/IP") args = parser.parse_args() print(f"\n{'='*60}") print("Antenna Collision Test") print(f"{'='*60}") print(f" Right antenna fixed at {RIGHT_REST} rad") print(f" Left antenna: rest={LEFT_REST}, collision={LEFT_COLLISION} rad") print(f" Collision times: {COLLISION_TIMES}") gaps = [COLLISION_TIMES[i+1] - COLLISION_TIMES[i] for i in range(len(COLLISION_TIMES)-1)] print(f" Gaps between collisions: {[f'{g:.1f}s' for g in gaps]}") print(f" Hold duration: {HOLD_DURATION}s\n") # Stop any running app print("[1/4] Stopping any running app on robot...") subprocess.run( ["ssh", "-o", "ConnectTimeout=5", f"{ROBOT_USER}@{args.host}", "curl -sf -X POST http://127.0.0.1:8000/api/apps/stop-current-app >/dev/null 2>&1 || true"], capture_output=True, timeout=10, ) time.sleep(1) print(" Done") # Run collisions (with position recording) print("\n[2/4] Running collision sequence + recording positions...") proc = run_on_robot(args.host) # Stream robot output in real time print("\n--- Robot output ---") for line in iter(proc.stdout.readline, ""): line = line.rstrip() if line: print(f" {line}") proc.wait() print("--- End robot output ---") if proc.returncode != 0: print(f"\nFAILED — Return code: {proc.returncode}") return 1 # SCP results back print("\n[3/4] Fetching position data from robot...") local_results = Path("tests/collision_positions.json") scp_from_robot(REMOTE_RESULTS, local_results, args.host) print(f" Saved to {local_results}") # Load and analyze print("\n[4/4] Analyzing collision trajectory...") with open(local_results) as f: data = json.load(f) result = analyze_results(data) print(f"\n{'='*60}") if result["success"]: print("RESULT: PASS — Collisions detected from present position trajectory") else: print("RESULT: FAIL — Could not reliably detect collisions") print(f"{'='*60}\n") return 0 if result["success"] else 1 if __name__ == "__main__": sys.exit(main())