""" test_docking.py — Unit tests for Issue #158 docking modules. Covers: IRBeaconDetector — envelope follower, threshold, reset VisualServo — velocity commands, clamping, alignment declaration ChargeMonitor — event detection, edge-triggering, threshold crossing DockingStateMachine — all state transitions and guard conditions """ import math import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import pytest from saltybot_docking.dock_detector import DockPose, IRBeaconDetector from saltybot_docking.visual_servo import VisualServo, VisualServoResult from saltybot_docking.charge_monitor import ChargeEvent, ChargeMonitor from saltybot_docking.docking_state_machine import ( DockingInputs, DockingState, DockingStateMachine, ) # ── Helpers ─────────────────────────────────────────────────────────────────── def _make_ir(**kw): defaults = dict(threshold=0.5, smoothing_alpha=1.0) defaults.update(kw) return IRBeaconDetector(**defaults) def _make_servo(**kw): defaults = dict( k_linear=1.0, k_angular=1.0, target_distance_m=0.02, lateral_tol_m=0.005, contact_distance_m=0.05, max_linear_ms=0.5, max_angular_rads=1.0, ) defaults.update(kw) return VisualServo(**defaults) def _make_charge(**kw): defaults = dict( low_threshold_pct=15.0, high_threshold_pct=80.0, charging_current_threshold_a=0.10, ) defaults.update(kw) return ChargeMonitor(**defaults) def _make_fsm(**kw): defaults = dict( battery_low_pct=15.0, battery_high_pct=80.0, servo_range_m=1.0, undock_speed_ms=-0.20, undock_ticks_max=5, lost_ticks_max=3, contact_timeout_ticks=3, ) defaults.update(kw) return DockingStateMachine(**defaults) def _inp(**kw): return DockingInputs(**kw) def _near_pose(dist=0.5): """Dock pose within servo range (default 0.5 m).""" return DockPose(distance_m=dist, yaw_rad=0.0, lateral_m=0.0, source="aruco") def _far_pose(dist=3.0): """Dock pose outside servo range.""" return DockPose(distance_m=dist, yaw_rad=0.0, lateral_m=0.0, source="aruco") # ══════════════════════════════════════════════════════════════════════════════ # IRBeaconDetector # ══════════════════════════════════════════════════════════════════════════════ class TestIRBeaconDetector: def test_zero_signal_not_detected(self): ir = _make_ir() assert ir.update(0.0) is False assert ir.detected is False def test_above_threshold_detected(self): ir = _make_ir(threshold=0.5, smoothing_alpha=1.0) assert ir.update(0.6) is True assert ir.detected is True def test_below_threshold_not_detected(self): ir = _make_ir(threshold=0.5, smoothing_alpha=1.0) assert ir.update(0.4) is False def test_exactly_at_threshold_detected(self): ir = _make_ir(threshold=0.5, smoothing_alpha=1.0) assert ir.update(0.5) is True def test_envelope_smoothing_alpha_one(self): """With alpha=1, envelope equals |sample| immediately.""" ir = _make_ir(smoothing_alpha=1.0) ir.update(0.7) assert ir.envelope == pytest.approx(0.7, abs=1e-9) def test_envelope_smoothing_below_one(self): """With alpha=0.5, envelope is a running average.""" ir = _make_ir(threshold=999.0, smoothing_alpha=0.5) ir.update(1.0) # envelope = 0.5 assert ir.envelope == pytest.approx(0.5, abs=1e-6) ir.update(1.0) # envelope = 0.75 assert ir.envelope == pytest.approx(0.75, abs=1e-6) def test_reset_clears_envelope(self): ir = _make_ir(smoothing_alpha=1.0) ir.update(0.8) ir.reset() assert ir.envelope == pytest.approx(0.0, abs=1e-9) assert ir.detected is False def test_negative_sample_uses_abs(self): """update() should use absolute value of sample.""" ir = _make_ir(threshold=0.5, smoothing_alpha=1.0) assert ir.update(-0.7) is True # ══════════════════════════════════════════════════════════════════════════════ # VisualServo # ══════════════════════════════════════════════════════════════════════════════ class TestVisualServo: def test_far_dock_gives_forward_velocity(self): servo = _make_servo(k_linear=1.0, target_distance_m=0.02) pose = DockPose(distance_m=0.5, yaw_rad=0.0, lateral_m=0.0, source="aruco") result = servo.update(pose) assert result.linear_x > 0.0 def test_at_target_distance_zero_linear(self): servo = _make_servo(k_linear=1.0, target_distance_m=0.02) pose = DockPose(distance_m=0.02, yaw_rad=0.0, lateral_m=0.0, source="aruco") result = servo.update(pose) assert result.linear_x == pytest.approx(0.0, abs=1e-9) def test_behind_target_no_reverse(self): """When distance < target, linear velocity must be 0 (no reversing).""" servo = _make_servo(k_linear=1.0, target_distance_m=0.10) pose = DockPose(distance_m=0.01, yaw_rad=0.0, lateral_m=0.0, source="aruco") result = servo.update(pose) assert result.linear_x == pytest.approx(0.0, abs=1e-9) def test_yaw_right_gives_negative_angular(self): """yaw_rad = atan2(tx, tz); tx > 0 → dock is right in camera → turn right (ω < 0).""" servo = _make_servo(k_angular=1.0) pose = DockPose(distance_m=0.5, yaw_rad=0.3, lateral_m=0.0, source="aruco") result = servo.update(pose) assert result.angular_z < 0.0 def test_yaw_left_gives_positive_angular(self): """tx < 0 → dock is left in camera → turn left (ω > 0).""" servo = _make_servo(k_angular=1.0) pose = DockPose(distance_m=0.5, yaw_rad=-0.3, lateral_m=0.0, source="aruco") result = servo.update(pose) assert result.angular_z > 0.0 def test_zero_yaw_zero_angular(self): servo = _make_servo() pose = DockPose(distance_m=0.5, yaw_rad=0.0, lateral_m=0.0, source="aruco") result = servo.update(pose) assert result.angular_z == pytest.approx(0.0, abs=1e-9) def test_max_linear_clamped(self): servo = _make_servo(k_linear=100.0, max_linear_ms=0.1) pose = DockPose(distance_m=5.0, yaw_rad=0.0, lateral_m=0.0, source="aruco") result = servo.update(pose) assert result.linear_x <= 0.1 + 1e-9 def test_max_angular_clamped(self): servo = _make_servo(k_angular=100.0, max_angular_rads=0.3) pose = DockPose(distance_m=0.5, yaw_rad=5.0, lateral_m=0.0, source="aruco") result = servo.update(pose) assert abs(result.angular_z) <= 0.3 + 1e-9 def test_aligned_when_within_tolerances(self): servo = _make_servo(lateral_tol_m=0.005, contact_distance_m=0.05) pose = DockPose(distance_m=0.03, yaw_rad=0.0, lateral_m=0.002, source="aruco") result = servo.update(pose) assert result.aligned is True def test_not_aligned_lateral_too_large(self): servo = _make_servo(lateral_tol_m=0.005, contact_distance_m=0.05) pose = DockPose(distance_m=0.03, yaw_rad=0.0, lateral_m=0.010, source="aruco") result = servo.update(pose) assert result.aligned is False def test_not_aligned_too_far(self): servo = _make_servo(lateral_tol_m=0.005, contact_distance_m=0.05) pose = DockPose(distance_m=0.10, yaw_rad=0.0, lateral_m=0.001, source="aruco") result = servo.update(pose) assert result.aligned is False def test_stop_returns_zero_velocity(self): servo = _make_servo() result = servo.stop() assert result.linear_x == pytest.approx(0.0, abs=1e-9) assert result.angular_z == pytest.approx(0.0, abs=1e-9) assert result.aligned is False def test_proportional_linear(self): """Linear velocity scales with (distance − target).""" servo = _make_servo(k_linear=2.0, target_distance_m=0.0, max_linear_ms=999.0) pose = DockPose(distance_m=0.3, yaw_rad=0.0, lateral_m=0.0, source="aruco") result = servo.update(pose) assert result.linear_x == pytest.approx(2.0 * 0.3, rel=1e-6) # ══════════════════════════════════════════════════════════════════════════════ # ChargeMonitor # ══════════════════════════════════════════════════════════════════════════════ class TestChargeMonitor: def test_initially_not_charging(self): cm = _make_charge() assert cm.is_charging is False def test_charging_started_event(self): cm = _make_charge(charging_current_threshold_a=0.1) events = cm.update(50.0, 0.5) assert ChargeEvent.CHARGING_STARTED in events def test_charging_stopped_event(self): cm = _make_charge(charging_current_threshold_a=0.1) cm.update(50.0, 0.5) # start charging events = cm.update(50.0, 0.0) # stop assert ChargeEvent.CHARGING_STOPPED in events def test_no_duplicate_charging_started(self): """CHARGING_STARTED fires only once per rising edge.""" cm = _make_charge(charging_current_threshold_a=0.1) cm.update(50.0, 0.5) # fires CHARGING_STARTED events = cm.update(50.0, 0.5) # still charging — no duplicate assert ChargeEvent.CHARGING_STARTED not in events def test_low_threshold_event_falling_edge(self): """THRESHOLD_LOW fires when SOC first drops below low_threshold_pct.""" cm = _make_charge(low_threshold_pct=20.0) events = cm.update(19.0, 0.0) assert ChargeEvent.THRESHOLD_LOW in events def test_no_duplicate_low_threshold(self): cm = _make_charge(low_threshold_pct=20.0) cm.update(19.0, 0.0) # fires once events = cm.update(18.0, 0.0) # still below — no repeat assert ChargeEvent.THRESHOLD_LOW not in events def test_high_threshold_event_rising_edge(self): """THRESHOLD_HIGH fires when SOC rises above high_threshold_pct.""" cm = _make_charge(low_threshold_pct=15.0, high_threshold_pct=80.0) # Start below high threshold cm.update(50.0, 0.5) # sets was_high = False (50 < 80) events = cm.update(81.0, 0.5) # rises above 80 assert ChargeEvent.THRESHOLD_HIGH in events def test_no_high_event_if_always_above(self): """THRESHOLD_HIGH should not fire if SOC starts above threshold.""" cm = _make_charge(high_threshold_pct=80.0) # Default _was_high=True; remains above → no edge events = cm.update(90.0, 0.0) assert ChargeEvent.THRESHOLD_HIGH not in events def test_is_depleted_property(self): cm = _make_charge(low_threshold_pct=20.0) cm.update(15.0, 0.0) assert cm.is_depleted is True def test_is_charged_property(self): cm = _make_charge(high_threshold_pct=80.0) cm.update(85.0, 0.0) assert cm.is_charged is True def test_percentage_tracking(self): cm = _make_charge() cm.update(42.5, 0.0) assert cm.percentage == pytest.approx(42.5, abs=1e-6) def test_reset_clears_state(self): cm = _make_charge(charging_current_threshold_a=0.1) cm.update(10.0, 0.5) # is_charging=True, is_depleted=True cm.reset() assert cm.is_charging is False assert cm.is_depleted is False assert cm.percentage == pytest.approx(100.0, abs=1e-6) # ══════════════════════════════════════════════════════════════════════════════ # DockingStateMachine # ══════════════════════════════════════════════════════════════════════════════ class TestDockingStateMachineBasic: def test_initial_state_is_idle(self): fsm = _make_fsm() assert fsm.state == DockingState.IDLE def test_idle_stays_idle_on_good_battery(self): fsm = _make_fsm() out = fsm.tick(_inp(battery_pct=80.0)) assert fsm.state == DockingState.IDLE assert out.state_changed is False def test_battery_low_triggers_detecting(self): fsm = _make_fsm() out = fsm.tick(_inp(battery_pct=10.0)) assert fsm.state == DockingState.DETECTING assert out.mission_interrupted is True assert out.state_changed is True def test_dock_request_triggers_detecting(self): fsm = _make_fsm() out = fsm.tick(_inp(battery_pct=80.0, dock_requested=True)) assert fsm.state == DockingState.DETECTING assert out.mission_interrupted is False def test_dock_request_not_duplicated_mission_interrupted(self): """Explicit dock request should not set mission_interrupted.""" fsm = _make_fsm() out = fsm.tick(_inp(battery_pct=80.0, dock_requested=True)) assert out.mission_interrupted is False class TestDockingStateMachineDetecting: def test_no_dock_stays_detecting(self): fsm = _make_fsm() fsm.tick(_inp(dock_requested=True)) out = fsm.tick(_inp()) # no dock_pose assert fsm.state == DockingState.DETECTING def test_far_dock_goes_to_nav2(self): fsm = _make_fsm(servo_range_m=1.0) fsm.tick(_inp(dock_requested=True)) out = fsm.tick(_inp(dock_pose=_far_pose())) assert fsm.state == DockingState.NAV2_APPROACH assert out.request_nav2 is True def test_near_dock_skips_nav2_to_servo(self): fsm = _make_fsm(servo_range_m=1.0) fsm.tick(_inp(dock_requested=True)) out = fsm.tick(_inp(dock_pose=_near_pose())) assert fsm.state == DockingState.VISUAL_SERVO def test_undock_request_aborts_detecting(self): fsm = _make_fsm() fsm.tick(_inp(dock_requested=True)) fsm.tick(_inp(undock_requested=True)) assert fsm.state == DockingState.IDLE class TestDockingStateMachineNav2Approach: def _reach_nav2(self, fsm): fsm.tick(_inp(dock_requested=True)) fsm.tick(_inp(dock_pose=_far_pose())) assert fsm.state == DockingState.NAV2_APPROACH def test_nav2_arrived_enters_visual_servo(self): fsm = _make_fsm() self._reach_nav2(fsm) fsm.tick(_inp(nav2_arrived=True)) assert fsm.state == DockingState.VISUAL_SERVO def test_dock_close_during_nav2_enters_servo(self): fsm = _make_fsm(servo_range_m=1.0) self._reach_nav2(fsm) out = fsm.tick(_inp(dock_pose=_near_pose())) assert fsm.state == DockingState.VISUAL_SERVO assert out.cancel_nav2 is True def test_nav2_failed_back_to_detecting(self): fsm = _make_fsm() self._reach_nav2(fsm) fsm.tick(_inp(nav2_failed=True)) assert fsm.state == DockingState.DETECTING def test_undock_during_nav2_cancels_and_idles(self): fsm = _make_fsm() self._reach_nav2(fsm) out = fsm.tick(_inp(undock_requested=True)) assert fsm.state == DockingState.IDLE assert out.cancel_nav2 is True class TestDockingStateMachineVisualServo: def _reach_servo(self, fsm): fsm.tick(_inp(dock_requested=True)) fsm.tick(_inp(dock_pose=_near_pose())) assert fsm.state == DockingState.VISUAL_SERVO def test_aligned_enters_contact(self): fsm = _make_fsm() self._reach_servo(fsm) fsm.tick(_inp(dock_pose=_near_pose(), servo_aligned=True)) assert fsm.state == DockingState.CONTACT def test_not_aligned_stays_in_servo(self): fsm = _make_fsm() self._reach_servo(fsm) fsm.tick(_inp(dock_pose=_near_pose(), servo_aligned=False)) assert fsm.state == DockingState.VISUAL_SERVO def test_lost_detection_timeout_enters_detecting(self): fsm = _make_fsm(lost_ticks_max=3) self._reach_servo(fsm) for _ in range(3): # dock_pose=None each tick fsm.tick(_inp()) assert fsm.state == DockingState.DETECTING def test_lost_counter_resets_on_rediscovery(self): """Lost count should reset when pose reappears.""" fsm = _make_fsm(lost_ticks_max=3) self._reach_servo(fsm) fsm.tick(_inp()) # 1 lost tick fsm.tick(_inp(dock_pose=_near_pose())) # rediscovered # Need 3 more lost ticks to time out fsm.tick(_inp()) fsm.tick(_inp()) assert fsm.state == DockingState.VISUAL_SERVO fsm.tick(_inp()) assert fsm.state == DockingState.DETECTING def test_undock_aborts_visual_servo(self): fsm = _make_fsm() self._reach_servo(fsm) fsm.tick(_inp(undock_requested=True)) assert fsm.state == DockingState.IDLE class TestDockingStateMachineContact: def _reach_contact(self, fsm): fsm.tick(_inp(dock_requested=True)) fsm.tick(_inp(dock_pose=_near_pose())) fsm.tick(_inp(dock_pose=_near_pose(), servo_aligned=True)) assert fsm.state == DockingState.CONTACT def test_charging_enters_charging_state(self): fsm = _make_fsm() self._reach_contact(fsm) fsm.tick(_inp(is_charging=True)) assert fsm.state == DockingState.CHARGING def test_contact_timeout_retries_servo(self): fsm = _make_fsm(contact_timeout_ticks=3) self._reach_contact(fsm) for _ in range(3): fsm.tick(_inp(is_charging=False)) assert fsm.state == DockingState.VISUAL_SERVO def test_undock_from_contact_to_undocking(self): fsm = _make_fsm() self._reach_contact(fsm) fsm.tick(_inp(undock_requested=True)) assert fsm.state == DockingState.UNDOCKING class TestDockingStateMachineCharging: def _reach_charging(self, fsm): fsm.tick(_inp(dock_requested=True)) fsm.tick(_inp(dock_pose=_near_pose())) fsm.tick(_inp(dock_pose=_near_pose(), servo_aligned=True)) fsm.tick(_inp(is_charging=True)) assert fsm.state == DockingState.CHARGING def test_full_battery_triggers_undocking(self): fsm = _make_fsm(battery_high_pct=80.0) self._reach_charging(fsm) fsm.tick(_inp(is_charging=True, battery_pct=85.0)) assert fsm.state == DockingState.UNDOCKING def test_undock_request_during_charging(self): fsm = _make_fsm() self._reach_charging(fsm) fsm.tick(_inp(undock_requested=True)) assert fsm.state == DockingState.UNDOCKING def test_charging_stays_charging_below_high_threshold(self): fsm = _make_fsm(battery_high_pct=80.0) self._reach_charging(fsm) fsm.tick(_inp(is_charging=True, battery_pct=70.0)) assert fsm.state == DockingState.CHARGING class TestDockingStateMachineUndocking: def _reach_undocking_via_charge(self, fsm): fsm.tick(_inp(dock_requested=True)) fsm.tick(_inp(dock_pose=_near_pose())) fsm.tick(_inp(dock_pose=_near_pose(), servo_aligned=True)) fsm.tick(_inp(is_charging=True)) fsm.tick(_inp(is_charging=True, battery_pct=85.0)) # → UNDOCKING assert fsm.state == DockingState.UNDOCKING def test_undocking_backs_away(self): fsm = _make_fsm(undock_ticks_max=5) self._reach_undocking_via_charge(fsm) out = fsm.tick(_inp()) assert out.cmd_linear < 0.0 def test_undocking_completes_back_to_idle(self): fsm = _make_fsm(undock_ticks_max=5) self._reach_undocking_via_charge(fsm) for _ in range(5): out = fsm.tick(_inp()) assert fsm.state == DockingState.IDLE def test_mission_resume_after_battery_auto_dock(self): """mission_resume must be True when auto-dock cycle completes.""" fsm = _make_fsm(undock_ticks_max=5) # Trigger via battery low fsm.tick(_inp(battery_pct=10.0)) # IDLE → DETECTING fsm.tick(_inp(dock_pose=_near_pose())) # → VISUAL_SERVO fsm.tick(_inp(dock_pose=_near_pose(), servo_aligned=True)) # → CONTACT fsm.tick(_inp(is_charging=True)) # → CHARGING fsm.tick(_inp(is_charging=True, battery_pct=85.0)) # → UNDOCKING out = None for _ in range(5): out = fsm.tick(_inp()) assert out is not None assert out.mission_resume is True assert fsm.state == DockingState.IDLE def test_no_mission_resume_on_explicit_undock(self): """Explicit undock should not trigger mission_resume.""" fsm = _make_fsm(undock_ticks_max=5) fsm.tick(_inp(dock_requested=True)) # explicit dock — no interrupt fsm.tick(_inp(dock_pose=_near_pose())) fsm.tick(_inp(dock_pose=_near_pose(), servo_aligned=True)) fsm.tick(_inp(is_charging=True)) fsm.tick(_inp(undock_requested=True)) # explicit undock last_out = None for _ in range(5): last_out = fsm.tick(_inp()) assert last_out is not None assert last_out.mission_resume is False class TestDockingStateMachineReset: def test_reset_returns_to_idle(self): fsm = _make_fsm() fsm.tick(_inp(dock_requested=True)) assert fsm.state == DockingState.DETECTING fsm.reset() assert fsm.state == DockingState.IDLE def test_state_changed_flag_on_transition(self): fsm = _make_fsm() out = fsm.tick(_inp(dock_requested=True)) assert out.state_changed is True def test_no_state_changed_when_same_state(self): fsm = _make_fsm() out = fsm.tick(_inp()) # stays IDLE assert out.state_changed is False