Spaces:
Running
Running
RemiFabre commited on
Commit ·
f851c34
1
Parent(s): 7659b58
Track previously untracked test support files
Browse files- HARDWARE_TEST_PLAN.md: hardware test design document
- check_audio.py: audio subsystem check utility
- check_robot.py: robot connectivity check
- pose_utils.py: trajectory comparison utilities for motion accuracy tests
- tests/HARDWARE_TEST_PLAN.md +143 -0
- tests/check_audio.py +33 -0
- tests/check_robot.py +42 -0
- tests/pose_utils.py +116 -0
tests/HARDWARE_TEST_PLAN.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hardware Test Plan — Comprehensive Robot Integration Tests
|
| 2 |
+
|
| 3 |
+
## Approach
|
| 4 |
+
|
| 5 |
+
Use the Wireless unit connected over the network. Tests run from the laptop
|
| 6 |
+
using the same code paths as production (Marionette `run()` loop + HTTP API).
|
| 7 |
+
The reachy_mini SDK handles wireless streaming transparently.
|
| 8 |
+
|
| 9 |
+
## Existing Tests (8 passing, 3 audio skipped)
|
| 10 |
+
|
| 11 |
+
- Startup reaches idle
|
| 12 |
+
- Silent recording captures motion at ~100Hz
|
| 13 |
+
- Playback of silent move completes
|
| 14 |
+
- Record and delete lifecycle
|
| 15 |
+
- Recording produces correct JSON (frame count, timestamps, pose structure)
|
| 16 |
+
- Full record → replay → delete lifecycle
|
| 17 |
+
- Stop cancels queued recording
|
| 18 |
+
- Recording transitions through countdown → recording → idle
|
| 19 |
+
|
| 20 |
+
## New Tests to Add
|
| 21 |
+
|
| 22 |
+
### 1. Record-while-playing (motion accuracy)
|
| 23 |
+
|
| 24 |
+
**Idea:** Play a known move and simultaneously record it. Compare the
|
| 25 |
+
recorded motion to the original. This exercises the full pipeline and
|
| 26 |
+
measures end-to-end accuracy.
|
| 27 |
+
|
| 28 |
+
**How it works:**
|
| 29 |
+
1. Load a reference move from disk (an existing recording in the test dataset)
|
| 30 |
+
2. Start a recording via POST /api/record (silent, same duration as reference)
|
| 31 |
+
3. Immediately start playback of the reference move via `_stream_playback()`
|
| 32 |
+
in a background thread (directly on the Marionette instance, bypassing
|
| 33 |
+
the API since the API only allows one operation at a time)
|
| 34 |
+
4. Wait for recording to complete
|
| 35 |
+
5. Load the newly recorded move from disk
|
| 36 |
+
6. Compare frame-by-frame using `distance_between_poses()`
|
| 37 |
+
|
| 38 |
+
**What to measure:**
|
| 39 |
+
- **Per-frame magic distance** between reference and recorded head poses
|
| 40 |
+
- **RMSE** of magic distances over the full trajectory
|
| 41 |
+
- **Max error** (worst single frame)
|
| 42 |
+
- **Mean error**
|
| 43 |
+
- **Antenna RMSE** (L2 of joint angle differences)
|
| 44 |
+
|
| 45 |
+
**Thresholds (tunable):**
|
| 46 |
+
- Mean magic distance < 50 (50mm or 50 degrees equivalent — generous)
|
| 47 |
+
- Max magic distance < 100
|
| 48 |
+
- RMSE < 60
|
| 49 |
+
|
| 50 |
+
These are deliberately loose — we want to catch "robot didn't move" or
|
| 51 |
+
"completely wrong pose" bugs, not sub-millimeter tracking.
|
| 52 |
+
|
| 53 |
+
**Challenge:** The API doesn't support simultaneous record + play.
|
| 54 |
+
**Solution:** We use the `hw_marionette` instance directly:
|
| 55 |
+
- Call `_stream_playback()` in a thread to move the robot
|
| 56 |
+
- Separately, the `_capture_motion()` records what actually happens
|
| 57 |
+
- OR: We do it in two phases: first play a reference move, then start
|
| 58 |
+
recording and play it again. The recording captures the actual motion.
|
| 59 |
+
|
| 60 |
+
Actually, simplest approach:
|
| 61 |
+
1. First, ensure a reference move exists (record a 3s silent move)
|
| 62 |
+
2. POST /api/record to start recording (3s, silent)
|
| 63 |
+
3. During the countdown + recording, play the reference move via the SDK directly
|
| 64 |
+
4. After recording completes, compare the two JSONs
|
| 65 |
+
|
| 66 |
+
### 2. Timing / performance benchmarks
|
| 67 |
+
|
| 68 |
+
**Tests:**
|
| 69 |
+
- **Startup time**: How long from `run()` start to `mode=idle`?
|
| 70 |
+
Already measured implicitly (STARTUP_TIMEOUT=30s), but add explicit timing.
|
| 71 |
+
- **Recording start latency**: Time from POST /api/record to mode=countdown.
|
| 72 |
+
Should be < 200ms.
|
| 73 |
+
- **Playback start latency**: Time from POST /api/play to actual motor movement.
|
| 74 |
+
Measured by comparing first frame timestamp to request time.
|
| 75 |
+
- **Recording frame rate**: Verify actual ~100Hz (already tested via frame count).
|
| 76 |
+
- **Playback smoothness**: During playback, poll pose at high rate, verify
|
| 77 |
+
it changes continuously (no freezes > 200ms).
|
| 78 |
+
|
| 79 |
+
### 3. Audio tests (expanded, with duration comparison)
|
| 80 |
+
|
| 81 |
+
**Tests:**
|
| 82 |
+
- **Duration match**: Record with mic, verify WAV duration matches requested
|
| 83 |
+
duration within 0.5s tolerance.
|
| 84 |
+
- **Waveform not silent**: Play a known sound file on the robot speaker while
|
| 85 |
+
recording with mic. Verify the recorded WAV has energy (RMS > threshold),
|
| 86 |
+
not just zeros.
|
| 87 |
+
- **Playback-with-audio completes**: Play a move that has audio, verify timing.
|
| 88 |
+
|
| 89 |
+
### 4. Pose comparison utilities
|
| 90 |
+
|
| 91 |
+
Create a test helper module `tests/pose_utils.py` with:
|
| 92 |
+
|
| 93 |
+
```python
|
| 94 |
+
from reachy_mini.utils.interpolation import distance_between_poses
|
| 95 |
+
import numpy as np
|
| 96 |
+
|
| 97 |
+
def compare_trajectories(ref_times, ref_frames, rec_times, rec_frames):
|
| 98 |
+
"""Compare two recorded trajectories frame-by-frame.
|
| 99 |
+
|
| 100 |
+
Interpolates the recorded trajectory to match reference timestamps.
|
| 101 |
+
Returns dict with RMSE, max_error, mean_error, per-frame distances.
|
| 102 |
+
"""
|
| 103 |
+
...
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
### 5. Multi-move benchmark
|
| 107 |
+
|
| 108 |
+
Record and play back 3 different moves:
|
| 109 |
+
- A short move (1s)
|
| 110 |
+
- A medium move (3s)
|
| 111 |
+
- A longer move (5s)
|
| 112 |
+
|
| 113 |
+
For each, measure recording frame rate, playback completion time, and
|
| 114 |
+
verify the move data is well-formed.
|
| 115 |
+
|
| 116 |
+
## File Changes
|
| 117 |
+
|
| 118 |
+
| Action | File |
|
| 119 |
+
|--------|------|
|
| 120 |
+
| Create | `tests/pose_utils.py` — trajectory comparison helpers |
|
| 121 |
+
| Modify | `tests/test_hardware.py` — add new test classes |
|
| 122 |
+
| Modify | `tests/run_tests.py` — update class descriptions |
|
| 123 |
+
| Modify | `TESTING.md` — document new test classes |
|
| 124 |
+
|
| 125 |
+
## Test Classes (proposed)
|
| 126 |
+
|
| 127 |
+
| Class | Tests | Description |
|
| 128 |
+
|-------|-------|-------------|
|
| 129 |
+
| TestMotionAccuracy | 2-3 | Play reference move while recording, compare trajectories |
|
| 130 |
+
| TestPerformance | 3-4 | Startup timing, recording latency, frame rate, playback smoothness |
|
| 131 |
+
| TestHardwareAudio | 3 | (existing) Audio recording and playback |
|
| 132 |
+
|
| 133 |
+
## Open Questions
|
| 134 |
+
|
| 135 |
+
1. Can we play a move via the SDK while the Marionette API is recording?
|
| 136 |
+
The run() loop handles one job at a time, but `set_target_head_pose()`
|
| 137 |
+
is a direct SDK call that should work independently.
|
| 138 |
+
|
| 139 |
+
2. What's a reasonable accuracy threshold? Need to calibrate on one run,
|
| 140 |
+
then set thresholds with margin.
|
| 141 |
+
|
| 142 |
+
3. Should benchmarks be hard-fail or just print results? Suggest: print
|
| 143 |
+
results always, fail only on extreme regressions.
|
tests/check_audio.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Quick check: can the robot play audio from this laptop?"""
|
| 3 |
+
|
| 4 |
+
import sys
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from reachy_mini import ReachyMini
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def main() -> int:
|
| 11 |
+
wav = "/home/remi/reachy_mini_apps/reachy_mini/examples/recorded_audio.wav"
|
| 12 |
+
print("Connecting to robot...")
|
| 13 |
+
with ReachyMini() as r:
|
| 14 |
+
mode = r.connection_mode
|
| 15 |
+
has_media = hasattr(r, "media") and r.media is not None
|
| 16 |
+
print(f"Connection mode: {mode}")
|
| 17 |
+
print(f"Has media: {has_media}")
|
| 18 |
+
if not has_media:
|
| 19 |
+
print("FAIL: no media backend")
|
| 20 |
+
return 1
|
| 21 |
+
try:
|
| 22 |
+
print(f"Playing {wav} ...")
|
| 23 |
+
r.media.play_sound(wav)
|
| 24 |
+
time.sleep(3)
|
| 25 |
+
print("OK: play_sound completed")
|
| 26 |
+
return 0
|
| 27 |
+
except Exception as e:
|
| 28 |
+
print(f"FAIL: {e}")
|
| 29 |
+
return 1
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
if __name__ == "__main__":
|
| 33 |
+
sys.exit(main())
|
tests/check_robot.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Quick check: is the robot reachable?
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python tests/check_robot.py
|
| 6 |
+
|
| 7 |
+
Exits 0 if the robot is connected, 1 otherwise.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
import threading
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def main() -> int:
|
| 15 |
+
result = [None, None] # [reachy, error]
|
| 16 |
+
|
| 17 |
+
def _connect():
|
| 18 |
+
try:
|
| 19 |
+
from reachy_mini import ReachyMini
|
| 20 |
+
reachy = ReachyMini(media_backend="no_media")
|
| 21 |
+
pose = reachy.get_current_head_pose()
|
| 22 |
+
result[0] = pose
|
| 23 |
+
except Exception as exc:
|
| 24 |
+
result[1] = str(exc)
|
| 25 |
+
|
| 26 |
+
t = threading.Thread(target=_connect, daemon=True)
|
| 27 |
+
t.start()
|
| 28 |
+
t.join(timeout=10)
|
| 29 |
+
|
| 30 |
+
if t.is_alive():
|
| 31 |
+
print("FAIL: connection timed out after 10s")
|
| 32 |
+
return 1
|
| 33 |
+
if result[1]:
|
| 34 |
+
print(f"FAIL: {result[1]}")
|
| 35 |
+
return 1
|
| 36 |
+
|
| 37 |
+
print(f"OK: robot connected, head pose shape = {result[0].shape}")
|
| 38 |
+
return 0
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
if __name__ == "__main__":
|
| 42 |
+
sys.exit(main())
|
tests/pose_utils.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pose comparison utilities for hardware integration tests.
|
| 2 |
+
|
| 3 |
+
Provides trajectory comparison using distance_between_poses() from
|
| 4 |
+
the reachy_mini SDK. Returns per-frame and aggregate metrics (RMSE,
|
| 5 |
+
max, mean) for translation, rotation, and "magic distance".
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
|
| 11 |
+
from reachy_mini.utils.interpolation import distance_between_poses
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class TrajectoryMetrics:
|
| 16 |
+
"""Aggregate metrics from comparing two trajectories."""
|
| 17 |
+
|
| 18 |
+
n_frames: int
|
| 19 |
+
# Magic distance (mm + degrees combined)
|
| 20 |
+
magic_mean: float
|
| 21 |
+
magic_rmse: float
|
| 22 |
+
magic_max: float
|
| 23 |
+
magic_p95: float
|
| 24 |
+
# Translation (meters)
|
| 25 |
+
trans_mean: float
|
| 26 |
+
trans_rmse: float
|
| 27 |
+
trans_max: float
|
| 28 |
+
# Rotation (radians)
|
| 29 |
+
rot_mean: float
|
| 30 |
+
rot_rmse: float
|
| 31 |
+
rot_max: float
|
| 32 |
+
# Per-frame arrays
|
| 33 |
+
magic_distances: np.ndarray
|
| 34 |
+
trans_distances: np.ndarray
|
| 35 |
+
rot_distances: np.ndarray
|
| 36 |
+
|
| 37 |
+
def summary(self) -> str:
|
| 38 |
+
return (
|
| 39 |
+
f"Trajectory comparison ({self.n_frames} frames):\n"
|
| 40 |
+
f" Magic: mean={self.magic_mean:.1f} rmse={self.magic_rmse:.1f} "
|
| 41 |
+
f"max={self.magic_max:.1f} p95={self.magic_p95:.1f}\n"
|
| 42 |
+
f" Trans: mean={self.trans_mean*1000:.1f}mm "
|
| 43 |
+
f"rmse={self.trans_rmse*1000:.1f}mm max={self.trans_max*1000:.1f}mm\n"
|
| 44 |
+
f" Rot: mean={np.degrees(self.rot_mean):.1f}deg "
|
| 45 |
+
f"rmse={np.degrees(self.rot_rmse):.1f}deg max={np.degrees(self.rot_max):.1f}deg"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def frames_to_poses(frames: list[dict]) -> list[np.ndarray]:
|
| 50 |
+
"""Extract head poses (4x4 matrices) from recorded frames."""
|
| 51 |
+
poses = []
|
| 52 |
+
for f in frames:
|
| 53 |
+
head = f["head"]
|
| 54 |
+
poses.append(np.array(head, dtype=np.float64))
|
| 55 |
+
return poses
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def compare_trajectories(
|
| 59 |
+
ref_times: list[float],
|
| 60 |
+
ref_frames: list[dict],
|
| 61 |
+
rec_times: list[float],
|
| 62 |
+
rec_frames: list[dict],
|
| 63 |
+
) -> TrajectoryMetrics:
|
| 64 |
+
"""Compare two recorded trajectories frame-by-frame.
|
| 65 |
+
|
| 66 |
+
Interpolates the recorded trajectory timestamps to match the reference
|
| 67 |
+
timestamps using nearest-neighbor lookup, then computes per-frame
|
| 68 |
+
distances using distance_between_poses().
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
ref_times: timestamps from the reference recording
|
| 72 |
+
ref_frames: frame dicts from the reference recording
|
| 73 |
+
rec_times: timestamps from the new recording
|
| 74 |
+
rec_frames: frame dicts from the new recording
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
TrajectoryMetrics with per-frame and aggregate distances.
|
| 78 |
+
"""
|
| 79 |
+
ref_poses = frames_to_poses(ref_frames)
|
| 80 |
+
rec_poses = frames_to_poses(rec_frames)
|
| 81 |
+
rec_times_arr = np.array(rec_times)
|
| 82 |
+
|
| 83 |
+
trans_dists = []
|
| 84 |
+
rot_dists = []
|
| 85 |
+
magic_dists = []
|
| 86 |
+
|
| 87 |
+
for i, (t, ref_pose) in enumerate(zip(ref_times, ref_poses)):
|
| 88 |
+
# Find nearest recorded frame by timestamp
|
| 89 |
+
idx = int(np.argmin(np.abs(rec_times_arr - t)))
|
| 90 |
+
rec_pose = rec_poses[idx]
|
| 91 |
+
|
| 92 |
+
d_trans, d_rot, d_magic = distance_between_poses(ref_pose, rec_pose)
|
| 93 |
+
trans_dists.append(d_trans)
|
| 94 |
+
rot_dists.append(d_rot)
|
| 95 |
+
magic_dists.append(d_magic)
|
| 96 |
+
|
| 97 |
+
trans_arr = np.array(trans_dists)
|
| 98 |
+
rot_arr = np.array(rot_dists)
|
| 99 |
+
magic_arr = np.array(magic_dists)
|
| 100 |
+
|
| 101 |
+
return TrajectoryMetrics(
|
| 102 |
+
n_frames=len(ref_times),
|
| 103 |
+
magic_mean=float(np.mean(magic_arr)),
|
| 104 |
+
magic_rmse=float(np.sqrt(np.mean(magic_arr**2))),
|
| 105 |
+
magic_max=float(np.max(magic_arr)),
|
| 106 |
+
magic_p95=float(np.percentile(magic_arr, 95)),
|
| 107 |
+
trans_mean=float(np.mean(trans_arr)),
|
| 108 |
+
trans_rmse=float(np.sqrt(np.mean(trans_arr**2))),
|
| 109 |
+
trans_max=float(np.max(trans_arr)),
|
| 110 |
+
rot_mean=float(np.mean(rot_arr)),
|
| 111 |
+
rot_rmse=float(np.sqrt(np.mean(rot_arr**2))),
|
| 112 |
+
rot_max=float(np.max(rot_arr)),
|
| 113 |
+
magic_distances=magic_arr,
|
| 114 |
+
trans_distances=trans_arr,
|
| 115 |
+
rot_distances=rot_arr,
|
| 116 |
+
)
|