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 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
+ )