Spaces:
Running
Running
| """Unit tests for DoA mapping and motion primitives (no hardware).""" | |
| import math | |
| import time | |
| import numpy as np | |
| from yrobot.motion import ( | |
| Choreographer, | |
| GazeSpring, | |
| SoundCompass, | |
| circular_mean, | |
| doa_to_yaw_delta, | |
| head_yaw_of, | |
| rpy_pose, | |
| weighted_circular_mean, | |
| ) | |
| def test_doa_angle_convention(): | |
| # XVF3800: 0 = left, pi/2 = front, pi = right (head-relative). | |
| assert math.isclose(doa_to_yaw_delta(0.0), math.pi / 2) # turn left | |
| assert math.isclose(doa_to_yaw_delta(math.pi), -math.pi / 2) # turn right | |
| assert math.isclose(doa_to_yaw_delta(math.pi / 2), 0.0) # already facing | |
| def test_circular_mean_handles_wraparound(): | |
| mean = circular_mean([math.pi - 0.1, -math.pi + 0.1]) | |
| assert math.isclose(abs(mean), math.pi, abs_tol=1e-6) | |
| def test_weighted_circular_mean_prioritizes_device_confirmed_samples(): | |
| mean = weighted_circular_mean([(0.0, 2.0), (math.pi / 2, 1.0)]) | |
| assert 0.0 < mean < math.pi / 4 | |
| def test_rpy_pose_yaw_roundtrip(): | |
| for yaw in (-1.2, 0.0, 0.7, 2.0): | |
| assert math.isclose(head_yaw_of(rpy_pose(0.05, -0.1, yaw, 0.002)), yaw, abs_tol=1e-9) | |
| def test_rpy_pose_shape_and_z(): | |
| pose = rpy_pose(0.0, 0.0, 0.0, 0.004) | |
| assert pose.shape == (4, 4) | |
| assert np.allclose(pose[:3, :3], np.eye(3)) | |
| assert pose[2, 3] == 0.004 | |
| def test_gaze_spring_converges_without_overshoot(): | |
| spring = GazeSpring() | |
| spring.target = 1.0 | |
| peak = 0.0 | |
| for _ in range(500): # 10 s at 50 Hz | |
| peak = max(peak, spring.step(0.02)) | |
| assert peak <= 1.0 + 1e-6 | |
| assert math.isclose(spring.pos, 1.0, abs_tol=0.01) | |
| def test_gaze_spring_velocity_clamp(): | |
| spring = GazeSpring(max_vel=1.0) | |
| spring.target = 100.0 | |
| spring.step(0.02) | |
| assert abs(spring.vel) <= 1.0 | |
| def test_sound_compass_survives_usb_errors(): | |
| # XVF3800 control reads throw transient USB I/O errors under bus | |
| # contention; the thread must back off, not die (hardware 2026-07-24). | |
| class FlakyMedia: | |
| def get_DoA(self): | |
| raise OSError(5, "Input/Output Error") | |
| compass = SoundCompass( | |
| FlakyMedia(), | |
| current_head_yaw=lambda: 0.0, | |
| user_active=lambda: True, | |
| on_target=lambda yaw: None, | |
| ) | |
| compass.start() | |
| time.sleep(0.5) | |
| try: | |
| assert compass.is_alive() # backed off instead of crashing | |
| finally: | |
| compass.close() | |
| compass.join(timeout=2) | |
| assert not compass.is_alive() | |
| def test_gaze_spring_freeze_brakes_smoothly_and_holds(): | |
| spring = GazeSpring() | |
| spring.target = 2.0 | |
| for _ in range(10): | |
| spring.step(0.02) # mid-turn | |
| assert abs(spring.vel) > 0.1 | |
| for _ in range(25): | |
| spring.step(0.02, freeze=1.0) # 0.5 s of hold-still | |
| assert abs(spring.vel) < 0.01 # braked, no jerk, no drive | |
| held = spring.pos | |
| spring.step(0.02, freeze=1.0) | |
| assert abs(spring.pos - held) < 1e-3 | |
| def test_idle_saccade_target_is_trajectory_limited(monkeypatch): | |
| class FakeMini: | |
| pass | |
| choreo = Choreographer(FakeMini()) | |
| def upper_bound(low, high): | |
| return high | |
| monkeypatch.setattr("yrobot.motion.random.uniform", upper_bound) | |
| pose, _ = choreo._compose(t=0.0, now=1.0, dt=0.02) | |
| yaw = head_yaw_of(pose) | |
| # The random target is +0.25 rad, but it must not appear in one 20 ms tick. | |
| assert 0.0 < yaw < 0.03 | |
| class PoseMini: | |
| def __init__(self, *, head_yaw: float, body_yaw: float = 0.0) -> None: | |
| self.head_pose = rpy_pose(0.0, 0.0, head_yaw, 0.0) | |
| self.body_yaw = body_yaw | |
| def get_current_head_pose(self): | |
| return self.head_pose | |
| def get_current_joint_positions(self): | |
| return [self.body_yaw, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0] | |
| def test_body_follower_ignores_head_only_idle_saccades(): | |
| choreo = Choreographer(PoseMini(head_yaw=0.25)) | |
| commanded = rpy_pose(0.0, 0.0, 0.25, 0.0) | |
| for _ in range(200): | |
| body_yaw = choreo._body_yaw_for(commanded, 0.02) | |
| assert math.isclose(body_yaw, 0.0, abs_tol=1e-9) | |
| def test_body_follower_catches_up_to_sustained_physical_head_yaw(): | |
| choreo = Choreographer(PoseMini(head_yaw=0.8)) | |
| commanded = rpy_pose(0.0, 0.0, 0.0, 0.0) | |
| samples = [choreo._body_yaw_for(commanded, 0.02) for _ in range(300)] | |
| assert samples[0] > 0.0 | |
| assert all(a <= b for a, b in zip(samples, samples[1:], strict=False)) | |
| assert 0.70 < samples[-1] < 0.81 | |
| def test_body_follower_starts_from_present_body_yaw(): | |
| choreo = Choreographer(PoseMini(head_yaw=0.4, body_yaw=0.4)) | |
| body_yaw = choreo._body_yaw_for(rpy_pose(0.0, 0.0, 0.4, 0.0), 0.02) | |
| assert math.isclose(body_yaw, 0.4, abs_tol=1e-9) | |