diff --git a/docs/source/references/retargeting/dvrk.rst b/docs/source/references/retargeting/dvrk.rst new file mode 100644 index 000000000..998d7b961 --- /dev/null +++ b/docs/source/references/retargeting/dvrk.rst @@ -0,0 +1,184 @@ +.. SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +.. SPDX-License-Identifier: Apache-2.0 + +dVRK PSM retargeters +==================== + +The da Vinci Research Kit (dVRK) is a telerobotics research platform built from +first-generation da Vinci surgical-system components and an open control stack. +See the `dVRK Research Wiki `_ +and the `JHU dVRK software wiki +`_ for the hardware, +software, and research community. + +The dVRK Patient Side Manipulator (PSM) retargeters map XR controllers to one or +two simulated PSMs. They convert controller state into absolute tool-pose and +paired-jaw targets. A simulator integration can read those targets, use its +live articulation Jacobian and joint state, and solve differential IK. + +At a glance +----------- + +.. list-table:: + :header-rows: 1 + :widths: 30 26 44 + + * - Retargeter + - Output + - Contract + * - ``DVRKPSMClutchRetargeter`` + - ``ee_pose``: 7-D ``[x, y, z, qx, qy, qz, qw]`` + - Updates an absolute pose while squeeze is held. Release holds the last + target; the next valid squeezed sample re-bases without moving it. + * - ``DVRKPSMGripperRetargeter`` + - ``jaw_targets``: 2-D ``[jaw_1, jaw_2]`` [rad] + - Maps latched trigger movement to two mirrored PSM jaw targets. The + output is not a scalar closedness value, and releasing the arm clutch + does not change it. + +The clutch accepts a sample only when controller tracking is valid and every +pose value is finite. If a pose sample is missing or invalid, the retargeter +holds the last safe target and clears its controller origin. The next valid +sample with squeeze above threshold captures a fresh origin without moving the +tool. An inactive teleoperation session holds both pose and jaw targets. + +Controller mapping +------------------ + +OpenXR's *grip pose* is the tracked controller-handle transform. It is not the +side grip button, which is called ``squeeze`` in the input packet. The pose and +squeeze control tool motion; the index trigger controls the jaws. + +For each PSM: + +.. code-block:: text + + controller grip pose in shared reference frame + -> squeeze deadman + origin-rebased absolute tool pose + -> six-DOF DLS IK at psm_tool_tip_link + + controller trigger + -> [psm_tool_gripper1_joint, psm_tool_gripper2_joint] + +These retargeters do not consume face buttons, thumbsticks, thumbstick clicks, +or menu buttons. A task or hosting XR application may bind those inputs +separately. + +The squeeze threshold defaults to ``0.5``. The trigger value captured at +squeeze engagement is neutral. The first jaw movement requires at least +``0.05`` travel from that value. Positive travel past the threshold starts +closing immediately. Negative travel must first be observed at least ``0.05`` +below its reference, then remain there for ``0.1`` seconds before opening +begins. Once opening is active, further release opens the jaws continuously. +Releasing squeeze, losing tracking, or stopping the session cancels a pending +opening and holds the last target. Travel is capped at the configured physical +endpoints: + +.. code-block:: text + + open endpoint: [-0.50, +0.50] rad + closed endpoint: [-0.09, +0.09] rad + +Those are library defaults, not a claim about every PSM asset. Pass the +articulation's measured open and grip targets through +``DVRKPSMGripperConfig`` when the simulator uses different endpoints. + +``initial_closedness`` selects the reset target: ``0`` is open and ``1`` is +closed. It does not inspect contact or attach an object. + +Reference frames +^^^^^^^^^^^^^^^^ + +``DVRKPSMClutchConfig`` does not prescribe a reference frame, but +``home_reference_T_ee``, controller input, and workspace bounds must all use the +same one. This can be a PSM base frame for a single arm or a shared world frame +for a bimanual setup. The home pose must lie within the workspace bounds so +the first clutch engagement remains continuous with the reset pose instead of +clipping to another target. + +For a bimanual setup, express both controller streams and home poses in one +world frame. Convert each target to that PSM's base frame immediately before +its IK solve. Reusing one base transform for independently placed PSMs is +incorrect. + +Use the retargeters from Python +------------------------------- + +.. code-block:: python + + import numpy as np + + from isaacteleop.retargeting_engine.deviceio_source_nodes import ControllersSource + from isaacteleop.retargeting_engine.interface import OutputCombiner + from isaacteleop.retargeters import ( + DVRKPSMClutchConfig, + DVRKPSMClutchRetargeter, + DVRKPSMGripperConfig, + DVRKPSMGripperRetargeter, + ) + + def build_right_psm_pipeline() -> OutputCombiner: + controllers = ControllersSource(name="controllers") + + # The home transform, raw controller grip pose, and workspace are all + # expressed in this example's shared tracking reference frame. + home_reference_T_ee = np.eye(4, dtype=np.float64) + home_reference_T_ee[:3, 3] = (0.0, 0.0, 0.18) + + pose = DVRKPSMClutchRetargeter( + DVRKPSMClutchConfig( + input_device=ControllersSource.RIGHT, + home_reference_T_ee=home_reference_T_ee, + workspace_lower=(-0.16, -0.14, 0.06), + workspace_upper=(0.16, 0.14, 0.28), + clutch_threshold=0.5, + ), + name="right_psm_pose", + ) + jaws = DVRKPSMGripperRetargeter( + DVRKPSMGripperConfig(input_device=ControllersSource.RIGHT), + name="right_psm_jaws", + ) + + right_controller = controllers.output(ControllersSource.RIGHT) + connected_pose = pose.connect( + {ControllersSource.RIGHT: right_controller} + ) + connected_jaws = jaws.connect( + {ControllersSource.RIGHT: right_controller} + ) + + return OutputCombiner( + { + "ee_pose": connected_pose.output( + DVRKPSMClutchRetargeter.OUTPUT_POSE + ), + "jaw_targets": connected_jaws.output( + DVRKPSMGripperRetargeter.OUTPUT_JAW_TARGETS + ), + } + ) + + + pipeline = build_right_psm_pipeline() + +Create one pose retargeter and one jaw retargeter for each PSM. Each instance +publishes an independent named output; the downstream consumer chooses any +packing and ordering required by its own action contract. Use +``DVRKPSMClutchRetargeter.OUTPUT_POSE`` and +``DVRKPSMGripperRetargeter.OUTPUT_JAW_TARGETS`` when wiring those output ports. +These names are part of the public retargeter API, so callers do not need to +import implementation modules. + +Validate +-------- + +Tests that do not require Isaac Sim cover jaw endpoints, time-based opening +intent, jaw hold across clutch release and re-engagement, Cartesian clutch +behaviour, continuous motion, workspace bounds, invalid tracking, and malformed +samples. Fixed-trace tests also compare the public retargeter wrappers with the +shared NumPy kernels: + +.. code-block:: console + + $ ctest --test-dir build -R retargeting_test_dvrk_psm_retargeters --output-on-failure diff --git a/docs/source/references/retargeting/index.rst b/docs/source/references/retargeting/index.rst index d67113c80..6c78e87ed 100644 --- a/docs/source/references/retargeting/index.rst +++ b/docs/source/references/retargeting/index.rst @@ -55,6 +55,14 @@ Available Retargeters ``SO101GripperRetargeter`` maps the trigger to a proportional jaw closedness in ``[0, 1]``. See :doc:`so101` for the full setup. +.. dropdown:: DVRKPSMClutchRetargeter / DVRKPSMGripperRetargeter + + Simulated dVRK Patient Side Manipulator teleoperation primitives. The clutch + maps controller grip pose to a squeeze-deadman, re-clutched 7-D tool target; + the trigger maps to two mirrored PSM jaw targets. The simulator integration + owns the per-arm IK solve. See :doc:`dvrk` for the controller, frame, and + safety contracts. + .. dropdown:: JointStateRetargeter Maps a name-keyed joint-state input (from ``JointStateSource``) to an action for a generic @@ -338,4 +346,5 @@ and :doc:`Contributing Guide <../../getting_started/contributing>` for details. sharpa so101 + dvrk joint_space diff --git a/src/core/python/pyproject.toml.in b/src/core/python/pyproject.toml.in index 1946d2808..0f3d261ec 100644 --- a/src/core/python/pyproject.toml.in +++ b/src/core/python/pyproject.toml.in @@ -51,6 +51,7 @@ packages = [ "isaacteleop.retargeters", "isaacteleop.retargeters.G1", "isaacteleop.retargeters.SO101", + "isaacteleop.retargeters.DVRK", "isaacteleop.retargeters.joint_space", "isaacteleop.retargeting_engine_ui", "isaacteleop.teleop_session_manager", diff --git a/src/core/retargeting_engine_tests/python/CMakeLists.txt b/src/core/retargeting_engine_tests/python/CMakeLists.txt index 5feb90623..8f85db74d 100644 --- a/src/core/retargeting_engine_tests/python/CMakeLists.txt +++ b/src/core/retargeting_engine_tests/python/CMakeLists.txt @@ -56,6 +56,7 @@ add_test( COMMAND uv run --python ${ISAAC_TELEOP_PYTHON_VERSION} --extra dev mypy --config-file pyproject.toml "${CMAKE_SOURCE_DIR}/src/core/retargeting_engine/python" + "${CMAKE_SOURCE_DIR}/src/retargeters/DVRK" WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" ) diff --git a/src/core/retargeting_engine_tests/python/test_dvrk_psm_retargeters.py b/src/core/retargeting_engine_tests/python/test_dvrk_psm_retargeters.py new file mode 100644 index 000000000..bd8f7e7da --- /dev/null +++ b/src/core/retargeting_engine_tests/python/test_dvrk_psm_retargeters.py @@ -0,0 +1,1206 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Sim-free tests for the dVRK PSM clutch and paired-jaw retargeters.""" + +import math + +import numpy as np +import pytest +import isaacteleop.retargeters as retargeters + +from isaacteleop.retargeting_engine.deviceio_source_nodes import ControllersSource +from isaacteleop.retargeting_engine.interface import ( + ComputeContext, + ExecutionEvents, + ExecutionState, + OptionalTensorGroup, + TensorGroup, +) +from isaacteleop.retargeting_engine.interface.retargeter_core_types import GraphTime +from isaacteleop.retargeting_engine.interface.tensor_group_type import ( + OptionalTensorGroupType, +) +from isaacteleop.retargeting_engine.tensor_types import ( + ControllerInput, + ControllerInputIndex, +) +from isaacteleop.retargeters import ( + DVRKPSMClutchConfig, + DVRKPSMClutchRetargeter, + DVRKPSMGripperConfig, + DVRKPSMGripperRetargeter, +) +from isaacteleop.retargeters.DVRK.control import ( + DVRKPSMCartesianClutchConfig, + DVRKPSMCartesianClutchStateMachine, + DVRKPSMJawIntentConfig, + DVRKPSMJawIntentStateMachine, + closedness_to_jaw_targets, + normalise_quaternion_xyzw, + quat_mul_xyzw, + rebased_position, +) + +_IDENTITY_QUAT = np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float32) +_JAW_OPEN = np.array([-0.50, 0.50], dtype=np.float64) +_JAW_CLOSED = np.array([-0.09, 0.09], dtype=np.float64) +_POSE_OUTPUT = DVRKPSMClutchRetargeter.OUTPUT_POSE +_JAW_OUTPUT = DVRKPSMGripperRetargeter.OUTPUT_JAW_TARGETS + + +def test_public_package_surface_contains_only_supported_dvrk_types() -> None: + supported = { + "DVRKPSMClutchConfig", + "DVRKPSMClutchRetargeter", + "DVRKPSMGripperConfig", + "DVRKPSMGripperRetargeter", + } + internal = { + "DVRKPSMCartesianClutchConfig", + "DVRKPSMCartesianClutchStateMachine", + "DVRKPSMJawIntentConfig", + "DVRKPSMJawIntentStateMachine", + } + + assert supported <= set(retargeters.__all__) + assert internal.isdisjoint(retargeters.__all__) + assert all(hasattr(retargeters, name) for name in supported) + assert all(not hasattr(retargeters, name) for name in internal) + assert DVRKPSMClutchRetargeter.OUTPUT_POSE == "ee_pose" + assert DVRKPSMGripperRetargeter.OUTPUT_JAW_TARGETS == "jaw_targets" + + +def test_clutch_config_requires_an_explicit_home_transform() -> None: + with pytest.raises(TypeError, match="home_reference_T_ee"): + DVRKPSMClutchConfig() # type: ignore[call-arg] + + +def _make_context( + *, + reset: bool = False, + state: ExecutionState = ExecutionState.RUNNING, + real_time_s: float = 0.0, +) -> ComputeContext: + """Build one retargeter compute context.""" + time_ns = round(real_time_s * 1_000_000_000) + return ComputeContext( + graph_time=GraphTime(sim_time_ns=time_ns, real_time_ns=time_ns), + execution_events=ExecutionEvents(reset=reset, execution_state=state), + ) + + +def _build_io(retargeter): + """Construct empty input/output tensor groups from a retargeter contract.""" + inputs = {} + for key, value in retargeter.input_spec().items(): + inputs[key] = ( + OptionalTensorGroup(value) + if isinstance(value, OptionalTensorGroupType) + else TensorGroup(value) + ) + outputs = {} + for key, value in retargeter.output_spec().items(): + outputs[key] = ( + OptionalTensorGroup(value) + if isinstance(value, OptionalTensorGroupType) + else TensorGroup(value) + ) + return inputs, outputs + + +def _make_controller( + *, + grip_pos=(0.0, 0.0, 0.0), + grip_ori=_IDENTITY_QUAT, + trigger: float = 0.0, + squeeze: float = 1.0, + grip_is_valid: bool = True, +) -> TensorGroup: + """Build a present XR controller sample for a retargeter frame.""" + controller = TensorGroup(ControllerInput()) + controller[ControllerInputIndex.GRIP_POSITION] = np.asarray( + grip_pos, dtype=np.float32 + ) + controller[ControllerInputIndex.GRIP_ORIENTATION] = np.asarray( + grip_ori, dtype=np.float32 + ) + controller[ControllerInputIndex.GRIP_IS_VALID] = grip_is_valid + controller[ControllerInputIndex.AIM_ORIENTATION] = _IDENTITY_QUAT + controller[ControllerInputIndex.AIM_IS_VALID] = True + controller[ControllerInputIndex.SQUEEZE_VALUE] = float(squeeze) + controller[ControllerInputIndex.TRIGGER_VALUE] = float(trigger) + return controller + + +def _make_home_transform(position, rotation: np.ndarray | None = None) -> np.ndarray: + """Build a PSM base-to-tool home transform with identity orientation.""" + transform = np.eye(4, dtype=np.float64) + if rotation is not None: + transform[:3, :3] = rotation + transform[:3, 3] = np.asarray(position, dtype=np.float64) + return transform + + +def _quat_xyzw_z(angle: float) -> np.ndarray: + """Build a scalar-last quaternion for a rotation about the Z axis.""" + return np.array((0.0, 0.0, math.sin(angle / 2.0), math.cos(angle / 2.0))) + + +def _quat_xyzw_x(angle: float) -> np.ndarray: + """Build a scalar-last quaternion for a rotation about the X axis.""" + return np.array((math.sin(angle / 2.0), 0.0, 0.0, math.cos(angle / 2.0))) + + +def _rotation_matrix_z(angle: float) -> np.ndarray: + """Build a 3-D Z-axis rotation matrix for a configured tool home.""" + return np.array( + ( + (math.cos(angle), -math.sin(angle), 0.0), + (math.sin(angle), math.cos(angle), 0.0), + (0.0, 0.0, 1.0), + ) + ) + + +def _read_vector(outputs, key: str) -> np.ndarray: + """Read an emitted DLPack vector into a float64 array for comparisons.""" + return np.asarray(np.from_dlpack(outputs[key][0]), dtype=np.float64) + + +class TestDVRKPSMGripperMath: + """Pure trigger-to-paired-jaw mapping checks.""" + + def test_trigger_endpoints_match_i4h_psm_joint_targets(self): + """Released/open and pressed/closed commands land on I4H jaw endpoints.""" + np.testing.assert_allclose( + closedness_to_jaw_targets(0.0, _JAW_OPEN, _JAW_CLOSED), + _JAW_OPEN, + ) + np.testing.assert_allclose( + closedness_to_jaw_targets(1.0, _JAW_OPEN, _JAW_CLOSED), + _JAW_CLOSED, + ) + + def test_mapping_is_continuous_and_mirror_coupled(self): + """A half trigger produces a bounded pair that moves symmetrically toward zero.""" + targets = closedness_to_jaw_targets(0.5, _JAW_OPEN, _JAW_CLOSED) + assert _JAW_OPEN[0] < targets[0] < _JAW_CLOSED[0] + assert _JAW_CLOSED[1] < targets[1] < _JAW_OPEN[1] + assert targets[0] == pytest.approx(-targets[1], abs=1e-8) + + +class TestDVRKPSMGripperRetargeter: + """End-to-end paired-jaw commands through BaseRetargeter.compute.""" + + @staticmethod + def _compute(retargeter, *, time_s: float, **controller_kwargs) -> np.ndarray: + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(**controller_kwargs) + retargeter.compute(inputs, outputs, _make_context(real_time_s=time_s)) + return _read_vector(outputs, _JAW_OUTPUT) + + def test_engagement_captures_trigger_then_full_travel_closes_both_jaws(self): + """Engagement is no-op; deliberate press emits bounded articulation-order targets.""" + retargeter = DVRKPSMGripperRetargeter(DVRKPSMGripperConfig(), name="jaws") + engaged = self._compute(retargeter, time_s=0.00, trigger=0.0, squeeze=1.0) + np.testing.assert_allclose(engaged, _JAW_OPEN, atol=1e-6) + + closed = self._compute(retargeter, time_s=0.02, trigger=1.0, squeeze=1.0) + np.testing.assert_allclose(closed, _JAW_CLOSED, atol=1e-6) + + def test_configured_initial_hold_can_use_a_validated_tighter_endpoint(self): + """A task can start at its physical grasp cap without an initial trigger jump.""" + validated_closed = (-0.032, 0.032) + retargeter = DVRKPSMGripperRetargeter( + DVRKPSMGripperConfig( + jaw_closed=validated_closed, + initial_closedness=1.0, + ), + name="jaws", + ) + initial = self._compute(retargeter, time_s=0.00, trigger=0.63, squeeze=1.0) + np.testing.assert_allclose(initial, validated_closed, atol=1e-7) + + # A different trigger position after re-clutch remains a no-op until + # deliberate post-clutch travel occurs. + self._compute(retargeter, time_s=0.02, trigger=0.10, squeeze=0.0) + reclutched = self._compute(retargeter, time_s=0.04, trigger=0.10, squeeze=1.0) + np.testing.assert_array_equal(reclutched, initial) + + def test_explicit_target_reset_restores_configured_hold_before_reengagement(self): + """An environment reset restores the configured initial jaw target.""" + configured_hold = (-0.030, 0.030) + retargeter = DVRKPSMGripperRetargeter( + DVRKPSMGripperConfig( + jaw_open=(-0.25, 0.25), + jaw_closed=configured_hold, + initial_closedness=1.0, + ), + name="left_jaws", + ) + initial = self._compute(retargeter, time_s=0.00, trigger=1.0) + np.testing.assert_allclose(initial, configured_hold, atol=1e-8) + np.testing.assert_array_equal( + self._compute(retargeter, time_s=0.05, trigger=0.0), initial + ) + np.testing.assert_array_equal( + self._compute(retargeter, time_s=0.10, trigger=0.0), initial + ) + opened = self._compute(retargeter, time_s=0.151, trigger=0.0) + assert not np.array_equal(opened, initial) + + retargeter.reset_target_state() + reset_engagement = self._compute(retargeter, time_s=0.16, trigger=0.0) + np.testing.assert_allclose(reset_engagement, configured_hold, atol=1e-8) + + def test_wrapper_times_deliberate_opening_from_graph_time(self): + """Opening guard uses elapsed graph time rather than an assumed frame rate.""" + retargeter = DVRKPSMGripperRetargeter( + DVRKPSMGripperConfig(initial_closedness=1.0), name="jaws" + ) + closed = self._compute(retargeter, time_s=0.000, trigger=1.0) + np.testing.assert_array_equal( + self._compute(retargeter, time_s=0.020, trigger=0.4), closed + ) + np.testing.assert_array_equal( + self._compute(retargeter, time_s=0.099, trigger=0.4), closed + ) + np.testing.assert_array_equal( + self._compute(retargeter, time_s=0.100, trigger=0.4), closed + ) + opened = self._compute(retargeter, time_s=0.121, trigger=0.4) + assert not np.array_equal(opened, closed) + + def test_regressed_graph_time_cannot_bypass_opening_duration(self): + """A clock regression contributes zero time and does not move the high-water mark.""" + retargeter = DVRKPSMGripperRetargeter( + DVRKPSMGripperConfig(initial_closedness=1.0), name="jaws" + ) + closed = self._compute(retargeter, time_s=1.00, trigger=1.0) + np.testing.assert_array_equal( + self._compute(retargeter, time_s=0.50, trigger=0.4), closed + ) + np.testing.assert_array_equal( + self._compute(retargeter, time_s=1.05, trigger=0.4), closed + ) + opened = self._compute(retargeter, time_s=1.10, trigger=0.4) + assert not np.array_equal(opened, closed) + + def test_dropped_frame_holds_and_reset_restores_configured_initial_target(self): + """Tracking loss holds exactly; reset restores the configured initial target.""" + retargeter = DVRKPSMGripperRetargeter(DVRKPSMGripperConfig(), name="jaws") + self._compute(retargeter, time_s=0.00, trigger=0.0) + closed = self._compute(retargeter, time_s=0.02, trigger=1.0) + + dropped_inputs, dropped_outputs = _build_io(retargeter) + retargeter.compute( + dropped_inputs, dropped_outputs, _make_context(real_time_s=0.04) + ) + np.testing.assert_allclose( + _read_vector(dropped_outputs, _JAW_OUTPUT), + closed, + atol=1e-6, + ) + + reset_inputs, reset_outputs = _build_io(retargeter) + retargeter.compute( + reset_inputs, + reset_outputs, + _make_context(reset=True, real_time_s=0.06), + ) + np.testing.assert_allclose( + _read_vector(reset_outputs, _JAW_OUTPUT), _JAW_OPEN, atol=1e-6 + ) + + def test_nonfinite_trigger_holds_last_safe_jaw_target(self): + """A malformed analog sample cannot emit NaN joint targets.""" + retargeter = DVRKPSMGripperRetargeter(DVRKPSMGripperConfig(), name="jaws") + self._compute(retargeter, time_s=0.00, trigger=0.0) + closed = self._compute(retargeter, time_s=0.02, trigger=1.0) + + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(trigger=math.nan) + retargeter.compute(inputs, outputs, _make_context(real_time_s=0.04)) + emitted = _read_vector(outputs, _JAW_OUTPUT) + assert np.all(np.isfinite(emitted)) + np.testing.assert_allclose(emitted, closed, atol=1e-6) + + def test_stopped_session_holds_the_last_jaw_target(self): + """A session stop freezes jaws as well as the Cartesian PSM target.""" + retargeter = DVRKPSMGripperRetargeter(DVRKPSMGripperConfig(), name="jaws") + self._compute(retargeter, time_s=0.00, trigger=0.0) + closed = self._compute(retargeter, time_s=0.02, trigger=1.0) + + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(trigger=0.0) + retargeter.compute( + inputs, + outputs, + _make_context(state=ExecutionState.STOPPED, real_time_s=0.04), + ) + np.testing.assert_allclose( + _read_vector(outputs, _JAW_OUTPUT), closed, atol=1e-6 + ) + + +class TestDVRKPSMJawIntentStateMachine: + """Pure jaw-intent behaviour shared by device adapters.""" + + @staticmethod + def _kernel(*, initial_closedness: float = 0.65): + return DVRKPSMJawIntentStateMachine( + DVRKPSMJawIntentConfig( + jaw_open=tuple(_JAW_OPEN), + jaw_closed=tuple(_JAW_CLOSED), + initial_closedness=initial_closedness, + trigger_deadband=0.05, + opening_intent_duration_s=0.08, + ) + ) + + @staticmethod + def _step( + kernel, + *, + trigger: float | None = 0.8, + squeeze: float | None = 1.0, + valid: bool = True, + active: bool = True, + dt: float = 0.02, + ) -> np.ndarray: + return kernel.step( + trigger=trigger, + squeeze=squeeze, + tracking_valid=valid, + session_active=active, + dt_seconds=dt, + ) + + def test_initial_target_is_exact_and_first_engagement_is_no_op(self): + kernel = self._kernel(initial_closedness=0.65) + expected = closedness_to_jaw_targets(0.65, _JAW_OPEN, _JAW_CLOSED) + np.testing.assert_allclose(kernel.targets, expected, atol=1e-6) + np.testing.assert_allclose( + self._step(kernel, trigger=0.73), expected, atol=1e-6 + ) + + @pytest.mark.parametrize( + ("jaw_open", "jaw_closed"), + ( + ((-0.50, 0.50), (-0.60, 0.09)), + ((-0.50, 0.50), (-0.09, 0.60)), + ((0.10, 0.50), (0.05, 0.09)), + ((-0.50, -0.10), (-0.09, -0.05)), + ), + ) + def test_invalid_dvrk_jaw_endpoint_direction_is_rejected( + self, jaw_open, jaw_closed + ): + with pytest.raises(ValueError, match="must move inward toward zero"): + DVRKPSMJawIntentStateMachine( + DVRKPSMJawIntentConfig(jaw_open=jaw_open, jaw_closed=jaw_closed) + ) + + @pytest.mark.parametrize("field", ("trigger", "squeeze")) + def test_malformed_analog_sample_holds_and_rearms(self, field): + kernel = self._kernel(initial_closedness=0.8) + held = self._step(kernel, trigger=0.4) + sample = {field: "not-a-number"} + np.testing.assert_array_equal(self._step(kernel, **sample), held) + np.testing.assert_array_equal(self._step(kernel, trigger=0.9), held) + + @pytest.mark.parametrize( + "transition", + ( + {"squeeze": 0.0}, + {"valid": False}, + {"active": False}, + {"trigger": None}, + ), + ) + def test_unclutch_tracking_loss_and_inactive_session_hold_exact_target( + self, transition + ): + kernel = self._kernel(initial_closedness=0.2) + self._step(kernel, trigger=0.2) + moved = self._step(kernel, trigger=0.7) + sample = {"trigger": 0.0, **transition} + held = self._step(kernel, **sample) + np.testing.assert_array_equal(held, moved) + + def test_reclutch_captures_new_reference_and_requires_fresh_interaction(self): + kernel = self._kernel(initial_closedness=0.7) + initial = self._step(kernel, trigger=0.9) + self._step(kernel, trigger=0.2, squeeze=0.0) + + # Re-clutching at a very different index-trigger position is a no-op. + np.testing.assert_array_equal(self._step(kernel, trigger=0.2), initial) + np.testing.assert_array_equal(self._step(kernel, trigger=0.23), initial) + + # Deliberate travel beyond the deadband resumes relative control. + moved = self._step(kernel, trigger=0.4) + assert kernel.closedness == pytest.approx(0.9) + assert not np.array_equal(moved, initial) + + def test_short_trigger_release_then_squeeze_release_never_opens(self): + kernel = self._kernel(initial_closedness=1.0) + closed = self._step(kernel, trigger=1.0) + + # Index release starts first, as it commonly does in a natural + # unclutch, but remains shorter than the deliberate-opening duration. + np.testing.assert_array_equal( + self._step(kernel, trigger=0.2, squeeze=1.0, dt=0.02), closed + ) + np.testing.assert_array_equal( + self._step(kernel, trigger=0.2, squeeze=0.0, dt=0.02), closed + ) + + # Re-clutch at the relaxed trigger position also preserves the hold. + np.testing.assert_array_equal( + self._step(kernel, trigger=0.2, squeeze=1.0, dt=0.50), closed + ) + + def test_deliberate_opening_uses_observed_time_not_frame_count(self): + for time_steps in ((0.02, 0.02, 0.04), (0.079, 0.001)): + kernel = self._kernel(initial_closedness=1.0) + closed = self._step(kernel, trigger=1.0, dt=0.0) + + # A long gap before opening intent is first observed contributes + # no duration: the trigger state during that gap is unknown. + np.testing.assert_array_equal( + self._step(kernel, trigger=0.4, dt=10.0), closed + ) + for dt in time_steps[:-1]: + np.testing.assert_array_equal( + self._step(kernel, trigger=0.4, dt=dt), closed + ) + opened = self._step(kernel, trigger=0.4, dt=time_steps[-1]) + assert kernel.closedness == pytest.approx(0.4) + assert not np.array_equal(opened, closed) + + def test_reversing_after_closing_starts_a_fresh_observed_opening_timer(self): + kernel = self._kernel(initial_closedness=0.5) + self._step(kernel, trigger=0.4, dt=0.0) + closed = self._step(kernel, trigger=0.8, dt=0.02) + assert kernel.closedness == pytest.approx(0.9) + + # This exercises the post-interaction direction-reversal path. The + # first lower sample starts, but cannot satisfy, the time gate. + np.testing.assert_array_equal(self._step(kernel, trigger=0.6, dt=10.0), closed) + opened = self._step(kernel, trigger=0.6, dt=0.08) + assert kernel.closedness == pytest.approx(0.7) + assert not np.array_equal(opened, closed) + + def test_returning_to_anchor_cancels_pending_opening_after_closing(self): + kernel = self._kernel(initial_closedness=0.5) + self._step(kernel, trigger=0.4, dt=0.0) + closed = self._step(kernel, trigger=0.8, dt=0.02) + self._step(kernel, trigger=0.6, dt=0.0) + + # Returning to the pre-opening anchor cannot mature a zero-motion + # intent, regardless of how long it remains there. + np.testing.assert_array_equal(self._step(kernel, trigger=0.8, dt=1.0), closed) + np.testing.assert_array_equal(self._step(kernel, trigger=0.6, dt=0.0), closed) + + opened = self._step(kernel, trigger=0.6, dt=0.08) + assert kernel.closedness == pytest.approx(0.7) + assert not np.array_equal(opened, closed) + + def test_closing_is_immediate_but_capped_at_configured_endpoint(self): + kernel = self._kernel(initial_closedness=0.4) + self._step(kernel, trigger=0.0) + kernel.step( + trigger=4.0, + squeeze=1.0, + tracking_valid=True, + session_active=True, + dt_seconds=0.01, + ) + assert kernel.closedness == 1.0 + np.testing.assert_allclose(kernel.targets, _JAW_CLOSED, atol=1e-6) + + def test_reset_restores_initial_target_and_rearms_reference_capture(self): + kernel = self._kernel(initial_closedness=0.35) + self._step(kernel, trigger=0.0) + self._step(kernel, trigger=0.6) + reset = kernel.reset() + expected = closedness_to_jaw_targets(0.35, _JAW_OPEN, _JAW_CLOSED) + np.testing.assert_allclose(reset, expected, atol=1e-6) + np.testing.assert_allclose(self._step(kernel, trigger=0.9), expected, atol=1e-6) + + @pytest.mark.parametrize( + ("config", "field"), + ( + ( + DVRKPSMJawIntentConfig( + jaw_open=(-1e40, 1e40), + jaw_closed=(-0.09, 0.09), + ), + "jaw_open", + ), + ( + DVRKPSMJawIntentConfig( + jaw_open=(-0.50, 0.50), + jaw_closed=(-1e40, 1e40), + ), + "jaw_closed", + ), + ), + ) + def test_finite_jaw_endpoints_outside_float32_range_are_rejected( + self, config, field + ): + with pytest.raises( + ValueError, match=f"{field} must lie within the finite float32 range" + ): + DVRKPSMJawIntentStateMachine(config) + + +class TestDVRKPSMClutchMath: + """Pure absolute-pose clutch calculations.""" + + def test_large_finite_quaternion_normalises_without_overflow(self): + quaternion = normalise_quaternion_xyzw((1e308, 1e308, 1e308, 1e308)) + assert quaternion is not None + np.testing.assert_allclose(quaternion, (0.5, 0.5, 0.5, 0.5), atol=1e-12) + + def test_rebased_position_applies_scaled_controller_delta(self): + """The latching frame is home and later controller motion is scaled from it.""" + home = np.array([0.02, 0.00, 0.18], dtype=np.float64) + origin = np.array([0.31, -0.12, 0.44], dtype=np.float64) + delta = np.array([0.04, -0.01, 0.03], dtype=np.float64) + np.testing.assert_allclose(rebased_position(origin, origin, home, 1.5), home) + np.testing.assert_allclose( + rebased_position(origin + delta, origin, home, 1.5), home + 1.5 * delta + ) + + +class TestDVRKPSMCartesianClutchStateMachine: + """Pure Cartesian clutch behaviour shared by every simulator consumer.""" + + @staticmethod + def _kernel( + *, + home_position=(0.02, 0.00, 0.18), + home_orientation=(0.0, 0.0, 0.0, 1.0), + ) -> DVRKPSMCartesianClutchStateMachine: + return DVRKPSMCartesianClutchStateMachine( + DVRKPSMCartesianClutchConfig( + home_position=home_position, + home_orientation=home_orientation, + workspace_lower=(-0.10, -0.10, 0.08), + workspace_upper=(0.10, 0.10, 0.24), + translation_scale=1.0, + ) + ) + + @staticmethod + def _step( + kernel, + *, + position=(0.30, -0.20, 0.50), + orientation=_IDENTITY_QUAT, + squeeze: float | None = 1.0, + valid: bool = True, + active: bool = True, + ) -> np.ndarray: + return kernel.step( + controller_position=position, + controller_orientation=orientation, + squeeze=squeeze, + tracking_valid=valid, + session_active=active, + ) + + def test_engagement_and_reclutch_are_no_jump_absolute_deltas(self): + kernel = self._kernel() + origin = np.array((0.30, -0.20, 0.50), dtype=np.float64) + home = kernel.pose + + np.testing.assert_array_equal(self._step(kernel, position=origin), home) + moved = self._step( + kernel, + position=origin + (0.03, 0.01, -0.02), + orientation=_quat_xyzw_z(0.30), + ) + np.testing.assert_allclose(moved[:3], home[:3] + (0.03, 0.01, -0.02), atol=1e-6) + np.testing.assert_allclose(moved[3:], _quat_xyzw_z(0.30), atol=1e-6) + + # Repositioning while unclutched cannot move either pose component. + np.testing.assert_array_equal( + self._step( + kernel, + position=(9.0, 9.0, 9.0), + orientation=_quat_xyzw_z(-1.0), + squeeze=0.0, + ), + moved, + ) + new_origin = np.array((-0.8, 0.4, 1.2), dtype=np.float64) + np.testing.assert_array_equal( + self._step( + kernel, + position=new_origin, + orientation=_quat_xyzw_z(-0.6), + ), + moved, + ) + resumed = self._step( + kernel, + position=new_origin + (0.01, -0.02, 0.01), + orientation=_quat_xyzw_z(-0.4), + ) + np.testing.assert_allclose( + resumed[:3], moved[:3] + (0.01, -0.02, 0.01), atol=1e-6 + ) + expected_orientation = quat_mul_xyzw( + moved[3:].astype(np.float64), _quat_xyzw_z(0.2) + ) + np.testing.assert_allclose(resumed[3:], expected_orientation, atol=1e-6) + + def test_non_commuting_orientation_offset_maps_z_rotation_to_negative_y(self): + quarter_turn_about_x = _quat_xyzw_x(math.pi / 2.0) + kernel = DVRKPSMCartesianClutchStateMachine( + DVRKPSMCartesianClutchConfig( + home_position=(0.02, 0.00, 0.18), + workspace_lower=(-0.10, -0.10, 0.08), + workspace_upper=(0.10, 0.10, 0.24), + orientation_offset=tuple(quarter_turn_about_x), + ) + ) + origin = np.array((0.30, -0.20, 0.50), dtype=np.float64) + self._step(kernel, position=origin, orientation=_IDENTITY_QUAT) + + angle = 0.4 + pose = self._step( + kernel, + position=origin, + orientation=_quat_xyzw_z(angle), + ) + + # Rx(pi/2) Rz(angle) Rx(-pi/2) is a rotation of -angle about Y. + expected = np.array( + (0.0, -math.sin(angle / 2.0), 0.0, math.cos(angle / 2.0)), + dtype=np.float64, + ) + np.testing.assert_allclose(pose[3:], expected, atol=1e-6) + + @pytest.mark.parametrize( + ("config", "field"), + ( + ( + DVRKPSMCartesianClutchConfig( + home_position=(1e40, 0.0, 0.18), + ), + "home_position", + ), + ( + DVRKPSMCartesianClutchConfig( + workspace_lower=(-1e40, -0.14, 0.06), + ), + "workspace_lower", + ), + ( + DVRKPSMCartesianClutchConfig( + workspace_upper=(1e40, 0.14, 0.28), + ), + "workspace_upper", + ), + ), + ) + def test_finite_pose_configuration_outside_float32_range_is_rejected( + self, config, field + ): + with pytest.raises( + ValueError, match=f"{field} must lie within the finite float32 range" + ): + DVRKPSMCartesianClutchStateMachine(config) + + @pytest.mark.parametrize( + ("bad_position", "bad_orientation"), + ( + ((math.nan, 0.0, 0.0), _IDENTITY_QUAT), + ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0, 0.0)), + ((0.0, 0.0, 0.0), (math.nan, 0.0, 0.0, 1.0)), + ), + ) + def test_invalid_pose_holds_exactly_and_requires_fresh_origin( + self, bad_position, bad_orientation + ): + kernel = self._kernel() + origin = np.array((0.30, -0.20, 0.50), dtype=np.float64) + self._step(kernel, position=origin) + moved = self._step(kernel, position=origin + (0.02, 0.0, 0.0)) + + invalid = self._step(kernel, position=bad_position, orientation=bad_orientation) + np.testing.assert_array_equal(invalid, moved) + assert not kernel.engaged + + # Recovery only captures a new origin; stale deltas cannot leak across + # the invalid interval. + np.testing.assert_array_equal( + self._step(kernel, position=(1.0, 1.0, 1.0)), moved + ) + + @pytest.mark.parametrize( + "transition", + ( + {"squeeze": 0.0}, + {"valid": False}, + {"active": False}, + ), + ) + def test_unclutch_tracking_loss_and_inactive_session_hold_exact_pose( + self, transition + ): + kernel = self._kernel() + origin = np.array((0.30, -0.20, 0.50), dtype=np.float64) + self._step(kernel, position=origin) + moved = self._step( + kernel, + position=origin + (0.02, -0.01, 0.01), + orientation=_quat_xyzw_z(0.2), + ) + held = self._step(kernel, position=(8.0, 8.0, 8.0), **transition) + np.testing.assert_array_equal(held, moved) + assert not kernel.engaged + + def test_reset_is_deterministic_and_rearms_origin_capture(self): + home_orientation = _quat_xyzw_z(0.35) + kernel = self._kernel(home_orientation=tuple(home_orientation)) + home = kernel.pose + origin = np.array((0.30, -0.20, 0.50), dtype=np.float64) + self._step(kernel, position=origin) + self._step(kernel, position=origin + (0.03, 0.0, 0.0)) + np.testing.assert_array_equal(kernel.reset(), home) + assert not kernel.engaged + np.testing.assert_array_equal( + self._step(kernel, position=(1.0, 1.0, 1.0)), home + ) + + def test_left_and_right_instances_are_isolated(self): + left = self._kernel(home_position=(-0.04, 0.00, 0.18)) + right = self._kernel(home_position=(0.04, 0.00, 0.18)) + left_origin = np.array((-0.30, 0.10, 0.40), dtype=np.float64) + right_origin = np.array((0.30, 0.10, 0.40), dtype=np.float64) + self._step(left, position=left_origin) + self._step(right, position=right_origin) + right_home = right.pose + + left_moved = self._step(left, position=left_origin + (0.03, 0.0, 0.0)) + np.testing.assert_allclose(left_moved[:3], (-0.01, 0.00, 0.18), atol=1e-6) + np.testing.assert_array_equal(right.pose, right_home) + + right_moved = self._step( + right, + position=right_origin + (0.0, -0.02, 0.01), + orientation=_quat_xyzw_z(-0.2), + ) + np.testing.assert_allclose(right_moved[:3], (0.04, -0.02, 0.19), atol=1e-6) + np.testing.assert_array_equal(left.pose, left_moved) + + +class TestDVRKPSMClutchRetargeter: + """End-to-end PSM clutch output and resilience checks.""" + + @staticmethod + def _retargeter() -> DVRKPSMClutchRetargeter: + config = DVRKPSMClutchConfig( + home_reference_T_ee=_make_home_transform((0.02, 0.00, 0.18)), + workspace_lower=(-0.10, -0.10, 0.08), + workspace_upper=(0.10, 0.10, 0.24), + orientation_offset=(0.0, 0.0, 0.0, 1.0), + ) + return DVRKPSMClutchRetargeter(config, name="ee_pose") + + def test_output_contract_is_absolute_7d_pose(self): + """The DLS integration receives one position-plus-xyzw target vector.""" + pose_type = self._retargeter().output_spec()[_POSE_OUTPUT].types[0] + assert pose_type.shape == (7,) + + def test_wrapper_matches_shared_kernel_on_fixed_controller_trace(self): + """Retargeting-engine adaptation cannot diverge from the reusable kernel.""" + home_position = (0.02, 0.00, 0.18) + workspace_lower = (-0.10, -0.10, 0.08) + workspace_upper = (0.10, 0.10, 0.24) + orientation_offset = tuple(_quat_xyzw_x(0.5)) + wrapper = DVRKPSMClutchRetargeter( + DVRKPSMClutchConfig( + home_reference_T_ee=_make_home_transform(home_position), + workspace_lower=workspace_lower, + workspace_upper=workspace_upper, + translation_scale=1.25, + orientation_offset=orientation_offset, + ), + name="ee_pose", + ) + kernel = DVRKPSMCartesianClutchStateMachine( + DVRKPSMCartesianClutchConfig( + home_position=home_position, + workspace_lower=workspace_lower, + workspace_upper=workspace_upper, + translation_scale=1.25, + orientation_offset=orientation_offset, + ) + ) + + origin_orientation = _quat_xyzw_z(-0.4) + moved_orientation = quat_mul_xyzw(origin_orientation, _quat_xyzw_z(0.3)) + recovered_orientation = _quat_xyzw_z(0.7) + trace = ( + { + "position": (0.30, -0.20, 0.50), + "orientation": origin_orientation, + }, + { + "position": (0.33, -0.19, 0.48), + "orientation": moved_orientation, + }, + { + "position": (math.nan, 0.0, 0.0), + "orientation": _IDENTITY_QUAT, + }, + { + "position": (-0.6, 0.4, 1.0), + "orientation": recovered_orientation, + }, + { + "position": (-0.59, 0.38, 1.01), + "orientation": quat_mul_xyzw(recovered_orientation, _quat_xyzw_z(-0.2)), + }, + { + "position": (8.0, 8.0, 8.0), + "orientation": _quat_xyzw_z(1.0), + "squeeze": 0.0, + }, + { + "position": (0.2, 0.3, 0.4), + "orientation": _quat_xyzw_z(-0.2), + "active": False, + }, + { + "position": (0.9, 0.8, 0.7), + "orientation": _quat_xyzw_z(0.6), + "reset": True, + }, + ) + + for index, sample in enumerate(trace): + position = np.asarray(sample["position"], dtype=np.float32) + orientation = np.asarray(sample["orientation"], dtype=np.float32) + squeeze = float(sample.get("squeeze", 1.0)) + active = bool(sample.get("active", True)) + reset = bool(sample.get("reset", False)) + + inputs, outputs = _build_io(wrapper) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=position, + grip_ori=orientation, + squeeze=squeeze, + ) + wrapper.compute( + inputs, + outputs, + _make_context( + reset=reset, + state=( + ExecutionState.RUNNING if active else ExecutionState.STOPPED + ), + ), + ) + if reset: + kernel.reset() + expected = kernel.step( + controller_position=position, + controller_orientation=orientation, + squeeze=squeeze, + tracking_valid=True, + session_active=active, + ) + np.testing.assert_array_equal( + _read_vector(outputs, _POSE_OUTPUT), + expected.astype(np.float64), + err_msg=f"trace sample {index}", + ) + + def test_out_of_workspace_configured_home_is_rejected(self): + """A reset pose outside the target guard would violate no-jump engagement.""" + with pytest.raises(ValueError, match="must lie within"): + DVRKPSMClutchRetargeter( + DVRKPSMClutchConfig( + home_reference_T_ee=_make_home_transform((0.20, 0.0, 0.18)), + workspace_lower=(-0.10, -0.10, 0.08), + workspace_upper=(0.10, 0.10, 0.24), + ), + name="ee_pose", + ) + + def test_non_homogeneous_configured_home_is_rejected(self): + """A finite 4x4 array is not a pose unless its projective row is canonical.""" + transform = _make_home_transform((0.02, 0.0, 0.18)) + transform[3] = (0.0, 0.0, 0.25, 1.0) + with pytest.raises(ValueError, match="homogeneous transform"): + DVRKPSMClutchRetargeter( + DVRKPSMClutchConfig(home_reference_T_ee=transform), + name="ee_pose", + ) + + def test_engage_motion_workspace_clamp_and_reclutch_are_no_jump(self): + """Engage, clamp, stop, and re-engage preserve a bounded continuous target.""" + retargeter = self._retargeter() + controller_origin = np.array([0.30, -0.20, 0.50], dtype=np.float64) + + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(grip_pos=controller_origin) + retargeter.compute(inputs, outputs, _make_context()) + np.testing.assert_allclose( + _read_vector(outputs, _POSE_OUTPUT)[:3], + (0.02, 0.00, 0.18), + atol=1e-6, + ) + + # This position would exceed every configured bound without the Cartesian guard. + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=controller_origin + np.array((1.0, -1.0, 1.0)) + ) + retargeter.compute(inputs, outputs, _make_context()) + held = _read_vector(outputs, _POSE_OUTPUT) + np.testing.assert_allclose(held[:3], (0.10, -0.10, 0.24), atol=1e-6) + + # Repositioning the hand while stopped cannot move the virtual tool. + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(grip_pos=(9.0, 9.0, 9.0)) + retargeter.compute(inputs, outputs, _make_context(state=ExecutionState.STOPPED)) + np.testing.assert_allclose(_read_vector(outputs, _POSE_OUTPUT), held, atol=1e-6) + + # The next running frame merely latches its new controller origin; it does not jump. + next_origin = np.array((1.0, 1.0, 1.0), dtype=np.float64) + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(grip_pos=next_origin) + retargeter.compute(inputs, outputs, _make_context()) + np.testing.assert_allclose(_read_vector(outputs, _POSE_OUTPUT), held, atol=1e-6) + + # A small fresh delta now starts from the previous target rather than reset home. + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=next_origin + np.array((-0.02, 0.01, -0.01)) + ) + retargeter.compute(inputs, outputs, _make_context()) + np.testing.assert_allclose( + _read_vector(outputs, _POSE_OUTPUT)[:3], + (0.08, -0.09, 0.23), + atol=1e-6, + ) + + def test_continuous_squeeze_uses_controller_delta_not_integrated_delta(self): + """Two frames while held follow absolute displacement from one clutch origin.""" + retargeter = self._retargeter() + origin = np.array((0.30, -0.20, 0.50), dtype=np.float64) + home = np.array((0.02, 0.00, 0.18), dtype=np.float64) + + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(grip_pos=origin, squeeze=1.0) + retargeter.compute(inputs, outputs, _make_context()) + + first_delta = np.array((0.03, 0.01, -0.01), dtype=np.float64) + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=origin + first_delta, squeeze=1.0 + ) + retargeter.compute(inputs, outputs, _make_context()) + np.testing.assert_allclose( + _read_vector(outputs, _POSE_OUTPUT)[:3], + home + first_delta, + atol=1e-6, + ) + + # This is an absolute controller location relative to ``origin``; + # the first delta must not be applied a second time. + second_delta = np.array((0.04, -0.02, 0.02), dtype=np.float64) + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=origin + second_delta, squeeze=1.0 + ) + retargeter.compute(inputs, outputs, _make_context()) + np.testing.assert_allclose( + _read_vector(outputs, _POSE_OUTPUT)[:3], + home + second_delta, + atol=1e-6, + ) + + def test_squeeze_is_deadman_clutch_and_reengagement_is_no_jump(self): + """Release freezes the PSM; squeeze again latches a fresh origin at the held pose.""" + retargeter = self._retargeter() + origin = np.array((0.30, -0.20, 0.50), dtype=np.float64) + + # Merely tracking a controller does not command motion before squeeze. + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(grip_pos=origin, squeeze=0.0) + retargeter.compute(inputs, outputs, _make_context()) + np.testing.assert_allclose( + _read_vector(outputs, _POSE_OUTPUT)[:3], + (0.02, 0.00, 0.18), + atol=1e-6, + ) + assert not retargeter._clutch_state.engaged + + # Squeeze latches, then a hand displacement moves the virtual tool. + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(grip_pos=origin, squeeze=1.0) + retargeter.compute(inputs, outputs, _make_context()) + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=origin + (0.03, 0.0, 0.0), squeeze=1.0 + ) + retargeter.compute(inputs, outputs, _make_context()) + held = _read_vector(outputs, _POSE_OUTPUT) + np.testing.assert_allclose(held[:3], (0.05, 0.00, 0.18), atol=1e-6) + + # Releasing while moving cannot change the target. + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=(9.0, 9.0, 9.0), squeeze=0.0 + ) + retargeter.compute(inputs, outputs, _make_context()) + np.testing.assert_allclose(_read_vector(outputs, _POSE_OUTPUT), held, atol=1e-6) + + # Squeezing at the new hand pose only latches it. Motion resumes from + # the held PSM target, not from the configured reset home. + new_origin = np.array((1.0, 1.0, 1.0), dtype=np.float64) + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=new_origin, squeeze=1.0 + ) + retargeter.compute(inputs, outputs, _make_context()) + np.testing.assert_allclose(_read_vector(outputs, _POSE_OUTPUT), held, atol=1e-6) + + def test_orientation_is_relative_to_squeeze_latch(self): + """Squeeze holds the configured orientation, then follows controller-relative rotation.""" + home_angle = 0.4 + controller_angle = -0.8 + home_rotation = _rotation_matrix_z(home_angle) + retargeter = DVRKPSMClutchRetargeter( + DVRKPSMClutchConfig( + home_reference_T_ee=_make_home_transform( + (0.02, 0.00, 0.18), home_rotation + ), + workspace_lower=(-0.10, -0.10, 0.08), + workspace_upper=(0.10, 0.10, 0.24), + ), + name="ee_pose", + ) + origin_orientation = _quat_xyzw_z(controller_angle) + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=(0.30, -0.20, 0.50), + grip_ori=origin_orientation, + squeeze=1.0, + ) + retargeter.compute(inputs, outputs, _make_context()) + home_quaternion = _quat_xyzw_z(home_angle) + np.testing.assert_allclose( + _read_vector(outputs, _POSE_OUTPUT)[3:], home_quaternion, atol=1e-6 + ) + + relative_rotation = _quat_xyzw_z(0.3) + current_orientation = quat_mul_xyzw(origin_orientation, relative_rotation) + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=(0.30, -0.20, 0.50), + grip_ori=current_orientation, + squeeze=1.0, + ) + retargeter.compute(inputs, outputs, _make_context()) + expected = quat_mul_xyzw(home_quaternion, relative_rotation) + np.testing.assert_allclose( + _read_vector(outputs, _POSE_OUTPUT)[3:], expected, atol=1e-6 + ) + + def test_invalid_or_dropped_tracking_holds_a_finite_last_pose(self): + """A bad validity flag, NaN position, or absent sample never poisons downstream IK.""" + retargeter = self._retargeter() + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(grip_pos=(0.3, 0.2, 0.5)) + retargeter.compute(inputs, outputs, _make_context()) + initial = _read_vector(outputs, _POSE_OUTPUT) + + for controller in ( + _make_controller(grip_pos=(9.0, 9.0, 9.0), grip_is_valid=False), + _make_controller(grip_pos=(math.nan, 0.0, 0.0)), + ): + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = controller + retargeter.compute(inputs, outputs, _make_context()) + emitted = _read_vector(outputs, _POSE_OUTPUT) + assert np.all(np.isfinite(emitted)) + np.testing.assert_allclose(emitted, initial, atol=1e-6) + + inputs, outputs = _build_io(retargeter) + retargeter.compute(inputs, outputs, _make_context()) + np.testing.assert_allclose( + _read_vector(outputs, _POSE_OUTPUT), initial, atol=1e-6 + ) + + def test_malformed_pose_rearms_clutch_before_the_next_valid_frame(self): + """A malformed valid-flagged sample cannot leave a stale clutch origin armed.""" + retargeter = self._retargeter() + origin = np.array((0.30, -0.20, 0.50), dtype=np.float64) + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(grip_pos=origin) + retargeter.compute(inputs, outputs, _make_context()) + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=origin + (0.03, 0.0, 0.0) + ) + retargeter.compute(inputs, outputs, _make_context()) + held = _read_vector(outputs, _POSE_OUTPUT) + + # The source claims tracking is valid but sends malformed coordinates. + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=(math.nan, 0.0, 0.0) + ) + retargeter.compute(inputs, outputs, _make_context()) + np.testing.assert_allclose(_read_vector(outputs, _POSE_OUTPUT), held, atol=1e-6) + + # A distant recovered controller pose only establishes a fresh origin. + recovered_origin = np.array((1.0, 1.0, 1.0), dtype=np.float64) + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(grip_pos=recovered_origin) + retargeter.compute(inputs, outputs, _make_context()) + np.testing.assert_allclose(_read_vector(outputs, _POSE_OUTPUT), held, atol=1e-6) + + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=recovered_origin + (0.01, 0.0, 0.0) + ) + retargeter.compute(inputs, outputs, _make_context()) + np.testing.assert_allclose( + _read_vector(outputs, _POSE_OUTPUT)[:3], + held[:3] + (0.01, 0.0, 0.0), + atol=1e-6, + ) + + def test_reset_restores_configured_home(self): + """An episode reset discards a prior re-clutch target and starts from configured home.""" + retargeter = self._retargeter() + origin = np.array((0.3, 0.2, 0.5), dtype=np.float64) + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(grip_pos=origin) + retargeter.compute(inputs, outputs, _make_context()) + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller( + grip_pos=origin + (0.03, 0.0, 0.0) + ) + retargeter.compute(inputs, outputs, _make_context()) + + inputs, outputs = _build_io(retargeter) + inputs[ControllersSource.RIGHT] = _make_controller(grip_pos=(1.0, 1.0, 1.0)) + retargeter.compute(inputs, outputs, _make_context(reset=True)) + np.testing.assert_allclose( + _read_vector(outputs, _POSE_OUTPUT)[:3], + (0.02, 0.00, 0.18), + atol=1e-6, + ) diff --git a/src/retargeters/CMakeLists.txt b/src/retargeters/CMakeLists.txt index ba1c06784..80ffa2c81 100644 --- a/src/retargeters/CMakeLists.txt +++ b/src/retargeters/CMakeLists.txt @@ -12,6 +12,8 @@ if(BUILD_PYTHON_BINDINGS) "${CMAKE_BINARY_DIR}/python_package/$/isaacteleop/retargeters/G1/__pycache__" COMMAND ${CMAKE_COMMAND} -E rm -rf "${CMAKE_BINARY_DIR}/python_package/$/isaacteleop/retargeters/SO101/__pycache__" + COMMAND ${CMAKE_COMMAND} -E rm -rf + "${CMAKE_BINARY_DIR}/python_package/$/isaacteleop/retargeters/DVRK/__pycache__" COMMAND ${CMAKE_COMMAND} -E rm -rf "${CMAKE_BINARY_DIR}/python_package/$/isaacteleop/retargeters/joint_space/__pycache__" COMMAND ${CMAKE_COMMAND} -E rm -f diff --git a/src/retargeters/DVRK/__init__.py b/src/retargeters/DVRK/__init__.py new file mode 100644 index 000000000..1152b6fbf --- /dev/null +++ b/src/retargeters/DVRK/__init__.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""dVRK Patient Side Manipulator retargeters.""" + +import importlib as _importlib + +_RETARGETER_EXPORTS = { + "DVRKPSMClutchConfig", + "DVRKPSMClutchRetargeter", + "DVRKPSMGripperConfig", + "DVRKPSMGripperRetargeter", +} + + +def __getattr__(name: str): + """Load engine-dependent retargeter wrappers only when requested.""" + if name not in _RETARGETER_EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(_importlib.import_module(".psm_retargeter", __package__), name) + globals()[name] = value + return value + + +__all__ = [ + "DVRKPSMClutchConfig", + "DVRKPSMClutchRetargeter", + "DVRKPSMGripperConfig", + "DVRKPSMGripperRetargeter", +] diff --git a/src/retargeters/DVRK/control.py b/src/retargeters/DVRK/control.py new file mode 100644 index 000000000..ad685d7f9 --- /dev/null +++ b/src/retargeters/DVRK/control.py @@ -0,0 +1,625 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Simulator-independent control state for a dVRK PSM. + +This module intentionally depends only on NumPy. Isaac Teleop retargeters and +lightweight simulator consumers can therefore share the exact same Cartesian +clutch and jaw-intent semantics without importing one another's runtime. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +DEFAULT_JAW_OPEN = (-0.50, 0.50) +DEFAULT_JAW_CLOSED = (-0.09, 0.09) +DEFAULT_CLUTCH_THRESHOLD = 0.50 +DEFAULT_TRIGGER_DEADZONE = 0.05 +DEFAULT_OPENING_INTENT_DURATION_S = 0.10 + +# Conservative I4H PSM-base-frame defaults. They guard a target stream; they +# do not replace consumer-side articulation limits or collision checking. +DEFAULT_HOME_POSITION = (0.0, 0.0, 0.18) +DEFAULT_WORKSPACE_LOWER = (-0.16, -0.14, 0.06) +DEFAULT_WORKSPACE_UPPER = (0.16, 0.14, 0.28) +IDENTITY_QUATERNION_XYZW = (0.0, 0.0, 0.0, 1.0) +MIN_QUATERNION_NORM = 1e-6 +FLOAT32_MAX = float(np.finfo(np.float32).max) + + +def _as_vector(values: object, *, length: int, name: str) -> np.ndarray: + """Return a finite vector, accepting NumPy and DLPack producers.""" + if hasattr(values, "__dlpack__"): + values = np.from_dlpack(values) + vector = np.asarray(values, dtype=np.float64) + if vector.shape != (length,) or not np.all(np.isfinite(vector)): + raise ValueError(f"{name} must be a finite vector with shape ({length},)") + return vector.copy() + + +def _as_float32_output_vector(values: object, *, length: int, name: str) -> np.ndarray: + """Return a finite vector whose values lie within the finite float32 range.""" + vector = _as_vector(values, length=length, name=name) + if np.any(np.abs(vector) > FLOAT32_MAX): + raise ValueError(f"{name} must lie within the finite float32 range") + return vector + + +def normalise_quaternion_xyzw(quaternion: object) -> np.ndarray | None: + """Return a finite unit scalar-last quaternion, or ``None`` if unsafe.""" + try: + vector = _as_vector(quaternion, length=4, name="quaternion") + except (TypeError, ValueError): + return None + scale = float(np.max(np.abs(vector))) + if scale == 0.0: + return None + scaled = vector / scale + scaled_norm = float(np.linalg.norm(scaled)) + if not np.isfinite(scaled_norm) or scale < MIN_QUATERNION_NORM / scaled_norm: + return None + return scaled / scaled_norm + + +def quat_mul_xyzw(left: np.ndarray, right: np.ndarray) -> np.ndarray: + """Return the Hamilton product of two scalar-last quaternions.""" + lx, ly, lz, lw = left + rx, ry, rz, rw = right + return np.array( + [ + lw * rx + lx * rw + ly * rz - lz * ry, + lw * ry - lx * rz + ly * rw + lz * rx, + lw * rz + lx * ry - ly * rx + lz * rw, + lw * rw - lx * rx - ly * ry - lz * rz, + ], + dtype=np.float64, + ) + + +def quat_conjugate_xyzw(quaternion: np.ndarray) -> np.ndarray: + """Return the conjugate of a scalar-last unit quaternion.""" + return np.array( + [-quaternion[0], -quaternion[1], -quaternion[2], quaternion[3]], + dtype=np.float64, + ) + + +def rotation_matrix_to_quat_xyzw(rotation: object) -> np.ndarray | None: + """Convert a proper 3-D rotation matrix to a unit scalar-last quaternion.""" + rotation = np.asarray(rotation, dtype=np.float64) + if rotation.shape != (3, 3) or not np.all(np.isfinite(rotation)): + return None + if ( + not np.allclose(rotation.T @ rotation, np.eye(3), atol=1e-5) + or np.linalg.det(rotation) <= 0.0 + ): + return None + trace = float(np.trace(rotation)) + if trace > 0.0: + scale = 2.0 * np.sqrt(trace + 1.0) + quaternion = np.array( + [ + (rotation[2, 1] - rotation[1, 2]) / scale, + (rotation[0, 2] - rotation[2, 0]) / scale, + (rotation[1, 0] - rotation[0, 1]) / scale, + 0.25 * scale, + ], + dtype=np.float64, + ) + else: + axis = int(np.argmax(np.diag(rotation))) + if axis == 0: + scale = 2.0 * np.sqrt( + 1.0 + rotation[0, 0] - rotation[1, 1] - rotation[2, 2] + ) + quaternion = np.array( + [ + 0.25 * scale, + (rotation[0, 1] + rotation[1, 0]) / scale, + (rotation[0, 2] + rotation[2, 0]) / scale, + (rotation[2, 1] - rotation[1, 2]) / scale, + ], + dtype=np.float64, + ) + elif axis == 1: + scale = 2.0 * np.sqrt( + 1.0 + rotation[1, 1] - rotation[0, 0] - rotation[2, 2] + ) + quaternion = np.array( + [ + (rotation[0, 1] + rotation[1, 0]) / scale, + 0.25 * scale, + (rotation[1, 2] + rotation[2, 1]) / scale, + (rotation[0, 2] - rotation[2, 0]) / scale, + ], + dtype=np.float64, + ) + else: + scale = 2.0 * np.sqrt( + 1.0 + rotation[2, 2] - rotation[0, 0] - rotation[1, 1] + ) + quaternion = np.array( + [ + (rotation[0, 2] + rotation[2, 0]) / scale, + (rotation[1, 2] + rotation[2, 1]) / scale, + 0.25 * scale, + (rotation[1, 0] - rotation[0, 1]) / scale, + ], + dtype=np.float64, + ) + return normalise_quaternion_xyzw(quaternion) + + +def rebased_position( + controller_position: np.ndarray, + controller_origin: np.ndarray, + home_position: np.ndarray, + translation_scale: float, +) -> np.ndarray: + """Apply an absolute controller delta to a pose captured at engagement.""" + return np.asarray( + home_position + translation_scale * (controller_position - controller_origin), + dtype=np.float64, + ) + + +def clip_workspace( + position: np.ndarray, workspace_lower: np.ndarray, workspace_upper: np.ndarray +) -> np.ndarray: + """Clip a Cartesian target to configured reference-frame bounds.""" + return np.asarray( + np.minimum(np.maximum(position, workspace_lower), workspace_upper), + dtype=np.float64, + ) + + +@dataclass(frozen=True) +class DVRKPSMCartesianClutchConfig: + """Configuration for simulator-independent clutch-rebased Cartesian pose. + + Home, controller samples, and workspace bounds must share one reference + frame. Orientations are scalar-last ``xyzw`` quaternions. The calibration + ``orientation_offset`` is conjugated around controller-relative rotation; + it does not change the configured home orientation at engagement. + """ + + home_position: tuple[float, float, float] = DEFAULT_HOME_POSITION + home_orientation: tuple[float, float, float, float] = IDENTITY_QUATERNION_XYZW + workspace_lower: tuple[float, float, float] = DEFAULT_WORKSPACE_LOWER + workspace_upper: tuple[float, float, float] = DEFAULT_WORKSPACE_UPPER + translation_scale: float = 1.0 + orientation_offset: tuple[float, float, float, float] = IDENTITY_QUATERNION_XYZW + clutch_threshold: float = DEFAULT_CLUTCH_THRESHOLD + + +class DVRKPSMCartesianClutchStateMachine: + """Produce a bounded absolute tool pose from one controller clutch stream. + + The first valid squeezed sample captures a controller origin and emits the + exact held pose. Subsequent samples apply absolute translation and + orientation deltas from that origin. Unclutch, tracking loss, invalid + samples, and inactive sessions hold the exact last pose and require a fresh + origin on the next engagement. Differential IK remains a consumer concern. + """ + + def __init__(self, config: DVRKPSMCartesianClutchConfig | None = None) -> None: + self._config = config or DVRKPSMCartesianClutchConfig() + self._configured_home = _as_float32_output_vector( + self._config.home_position, length=3, name="home_position" + ) + home_orientation = normalise_quaternion_xyzw(self._config.home_orientation) + if home_orientation is None: + raise ValueError( + "home_orientation must be a non-zero finite xyzw quaternion" + ) + self._configured_home_orientation = home_orientation + self._workspace_lower = _as_float32_output_vector( + self._config.workspace_lower, length=3, name="workspace_lower" + ) + self._workspace_upper = _as_float32_output_vector( + self._config.workspace_upper, length=3, name="workspace_upper" + ) + if np.any(self._workspace_lower > self._workspace_upper): + raise ValueError("workspace_lower must not exceed workspace_upper") + if not np.array_equal( + self._configured_home, + clip_workspace( + self._configured_home, + self._workspace_lower, + self._workspace_upper, + ), + ): + raise ValueError("home_position must lie within the configured workspace") + if ( + not np.isfinite(self._config.translation_scale) + or self._config.translation_scale <= 0.0 + ): + raise ValueError("translation_scale must be finite and positive") + self._translation_scale = float(self._config.translation_scale) + orientation_offset = normalise_quaternion_xyzw(self._config.orientation_offset) + if orientation_offset is None: + raise ValueError( + "orientation_offset must be a non-zero finite xyzw quaternion" + ) + self._orientation_offset = orientation_offset + if not np.isfinite(self._config.clutch_threshold) or not ( + 0.0 <= self._config.clutch_threshold <= 1.0 + ): + raise ValueError("clutch_threshold must be finite and in [0, 1]") + + self._last_pose = np.concatenate( + [self._configured_home, self._configured_home_orientation] + ).astype(np.float32) + self._engaged = False + self._controller_origin: np.ndarray | None = None + self._controller_orientation_origin: np.ndarray | None = None + self._pose_at_engagement: np.ndarray | None = None + + @property + def pose(self) -> np.ndarray: + """Return a copy of the exact currently held absolute pose.""" + return self._last_pose.copy() + + @property + def engaged(self) -> bool: + """Whether a valid controller origin is currently captured.""" + return self._engaged + + def reset(self) -> np.ndarray: + """Restore configured home and require a fresh controller origin.""" + self._last_pose = np.concatenate( + [self._configured_home, self._configured_home_orientation] + ).astype(np.float32) + self._disengage() + return self.pose + + def _disengage(self) -> None: + self._engaged = False + self._controller_origin = None + self._controller_orientation_origin = None + self._pose_at_engagement = None + + def step( + self, + *, + controller_position: object | None, + controller_orientation: object | None, + squeeze: float | None, + tracking_valid: bool, + session_active: bool, + ) -> np.ndarray: + """Advance one controller sample and return the absolute held pose.""" + try: + squeeze_value = float(squeeze) if squeeze is not None else None + except (TypeError, ValueError): + squeeze_value = None + if ( + not session_active + or not tracking_valid + or squeeze_value is None + or not np.isfinite(squeeze_value) + or squeeze_value < self._config.clutch_threshold + ): + self._disengage() + return self.pose + + try: + position = _as_vector( + controller_position, length=3, name="controller_position" + ) + except (TypeError, ValueError): + self._disengage() + return self.pose + orientation = normalise_quaternion_xyzw(controller_orientation) + if orientation is None: + self._disengage() + return self.pose + + if not self._engaged: + self._engaged = True + self._controller_origin = position + self._controller_orientation_origin = orientation + self._pose_at_engagement = self._last_pose.astype(np.float64) + return self.pose + + assert self._controller_origin is not None + assert self._controller_orientation_origin is not None + assert self._pose_at_engagement is not None + target_position = clip_workspace( + rebased_position( + position, + self._controller_origin, + self._pose_at_engagement[:3], + self._translation_scale, + ), + self._workspace_lower, + self._workspace_upper, + ) + controller_relative_orientation = quat_mul_xyzw( + quat_conjugate_xyzw(self._controller_orientation_origin), orientation + ) + calibrated_relative_orientation = quat_mul_xyzw( + quat_mul_xyzw(self._orientation_offset, controller_relative_orientation), + quat_conjugate_xyzw(self._orientation_offset), + ) + target_orientation = normalise_quaternion_xyzw( + quat_mul_xyzw( + self._pose_at_engagement[3:7], calibrated_relative_orientation + ) + ) + if target_orientation is None: + self._disengage() + return self.pose + + self._last_pose = np.concatenate([target_position, target_orientation]).astype( + np.float32 + ) + return self.pose + + +def closedness_to_jaw_targets( + closedness: float, jaw_open: np.ndarray, jaw_closed: np.ndarray +) -> np.ndarray: + """Interpolate paired jaw targets between configured physical endpoints.""" + return np.asarray( + jaw_open + float(np.clip(closedness, 0.0, 1.0)) * (jaw_closed - jaw_open), + dtype=np.float64, + ) + + +def _as_jaw_vector(values: object, *, name: str) -> np.ndarray: + """Return a finite two-element jaw vector within the float32 range.""" + return _as_float32_output_vector(values, length=2, name=name) + + +@dataclass(frozen=True) +class DVRKPSMJawIntentConfig: + """Configuration for :class:`DVRKPSMJawIntentStateMachine`. + + ``jaw_open`` and ``jaw_closed`` are the physical articulation endpoints; + every emitted target is interpolated between them. ``initial_closedness`` + selects the exact reset target on that segment (zero is open, one is + closed). The squeeze threshold is intentionally shared with the arm + clutch so releasing the arm deadman never changes the jaw command. + + Trigger motion is relative to the value captured on each squeeze + engagement. This avoids a jump when the operator re-clutches with the + index trigger at a different position. Positive trigger travel closes + immediately, but negative travel must exceed ``trigger_deadband`` and + remain present for ``opening_intent_duration_s`` before it opens the jaws. + That short time gate rejects the common natural-unclutch sequence in which + the index trigger relaxes one or two samples before squeeze. The pending + opening is cancelled if squeeze or tracking disappears. + """ + + jaw_open: tuple[float, float] = DEFAULT_JAW_OPEN + jaw_closed: tuple[float, float] = DEFAULT_JAW_CLOSED + initial_closedness: float = 0.0 + clutch_threshold: float = DEFAULT_CLUTCH_THRESHOLD + trigger_deadband: float = DEFAULT_TRIGGER_DEADZONE + opening_intent_duration_s: float = DEFAULT_OPENING_INTENT_DURATION_S + + +class DVRKPSMJawIntentStateMachine: + """Turn trigger/clutch samples into bounded, latched PSM jaw targets. + + Call :meth:`step` with elapsed seconds to keep opening-intent timing + independent of render or physics frame rate. Missing/invalid tracking, an + inactive session, or a released squeeze freezes the exact last target and + re-arms trigger-reference capture for the next engagement. + + The state machine never inspects contacts and never creates an attachment; + it only represents operator jaw intent. + """ + + _DIRECTION_EPSILON = 1e-9 + + def __init__(self, config: DVRKPSMJawIntentConfig | None = None) -> None: + self._config = config or DVRKPSMJawIntentConfig() + self._jaw_open = _as_jaw_vector(self._config.jaw_open, name="jaw_open") + self._jaw_closed = _as_jaw_vector(self._config.jaw_closed, name="jaw_closed") + if not ( + self._jaw_open[0] < self._jaw_closed[0] <= 0.0 + and self._jaw_open[1] > self._jaw_closed[1] >= 0.0 + ): + raise ValueError( + "dVRK jaw endpoints must move inward toward zero in " + "[gripper1, gripper2] order" + ) + if not np.isfinite(self._config.initial_closedness) or not ( + 0.0 <= self._config.initial_closedness <= 1.0 + ): + raise ValueError("initial_closedness must be finite and in [0, 1]") + if not np.isfinite(self._config.clutch_threshold) or not ( + 0.0 <= self._config.clutch_threshold <= 1.0 + ): + raise ValueError("clutch_threshold must be finite and in [0, 1]") + if not np.isfinite(self._config.trigger_deadband) or not ( + 0.0 <= self._config.trigger_deadband < 1.0 + ): + raise ValueError("trigger_deadband must be finite and in [0, 1)") + if not np.isfinite(self._config.opening_intent_duration_s) or ( + self._config.opening_intent_duration_s < 0.0 + ): + raise ValueError( + "opening_intent_duration_s must be finite and non-negative" + ) + + self._initial_closedness = float(self._config.initial_closedness) + self._closedness = self._initial_closedness + self._targets = closedness_to_jaw_targets( + self._closedness, self._jaw_open, self._jaw_closed + ).astype(np.float32) + self._engaged = False + self._interaction_started = False + self._last_trigger: float | None = None + self._opening_anchor_trigger: float | None = None + self._opening_elapsed_s = 0.0 + self._opening_active = False + + @property + def targets(self) -> np.ndarray: + """Return a copy of the exact currently latched jaw targets.""" + return self._targets.copy() + + @property + def closedness(self) -> float: + """Return current scalar jaw closedness in ``[0, 1]``.""" + return self._closedness + + def reset(self) -> np.ndarray: + """Restore the configured initial target and clear engagement state.""" + self._closedness = self._initial_closedness + self._targets = closedness_to_jaw_targets( + self._closedness, self._jaw_open, self._jaw_closed + ).astype(np.float32) + self._disengage() + return self.targets + + def _disengage(self) -> None: + """Hold target while requiring fresh trigger-reference capture.""" + self._engaged = False + self._interaction_started = False + self._last_trigger = None + self._cancel_opening() + + def _cancel_opening(self) -> None: + self._opening_anchor_trigger = None + self._opening_elapsed_s = 0.0 + self._opening_active = False + + def _set_closedness(self, value: float) -> None: + self._closedness = float(np.clip(value, 0.0, 1.0)) + self._targets = closedness_to_jaw_targets( + self._closedness, self._jaw_open, self._jaw_closed + ).astype(np.float32) + + def step( + self, + *, + trigger: float | None, + squeeze: float | None, + tracking_valid: bool, + session_active: bool, + dt_seconds: float, + ) -> np.ndarray: + """Advance one input sample and return the exact jaw target to emit. + + ``dt_seconds`` is elapsed wall/graph time since the previous sample. + A non-finite or negative duration is rejected because silently counting + it could bypass the deliberate-opening guard. + """ + try: + elapsed_seconds = float(dt_seconds) + except (TypeError, ValueError) as error: + raise ValueError("dt_seconds must be finite and non-negative") from error + if not np.isfinite(elapsed_seconds) or elapsed_seconds < 0.0: + raise ValueError("dt_seconds must be finite and non-negative") + + try: + trigger_value = float(trigger) if trigger is not None else None + squeeze_value = float(squeeze) if squeeze is not None else None + except (TypeError, ValueError): + trigger_value = None + squeeze_value = None + if ( + not session_active + or not tracking_valid + or trigger_value is None + or squeeze_value is None + or not np.isfinite(trigger_value) + or not np.isfinite(squeeze_value) + or squeeze_value < self._config.clutch_threshold + ): + # This also cancels a not-yet-committed opening, which is the key + # protection for trigger-up immediately followed by squeeze-up. + self._disengage() + return self.targets + + trigger_value = float(np.clip(trigger_value, 0.0, 1.0)) + if not self._engaged: + self._engaged = True + self._interaction_started = False + self._last_trigger = trigger_value + self._cancel_opening() + return self.targets + + assert self._last_trigger is not None + trigger_delta = trigger_value - self._last_trigger + + if not self._interaction_started: + # Reference capture makes re-clutch a no-op until the operator + # moves the index trigger far enough to be unambiguous. + if trigger_delta >= self._config.trigger_deadband: + self._interaction_started = True + self._cancel_opening() + self._set_closedness(self._closedness + trigger_delta) + self._last_trigger = trigger_value + return self.targets + if trigger_delta <= -self._config.trigger_deadband: + if self._opening_anchor_trigger is None: + self._opening_anchor_trigger = self._last_trigger + else: + # The interval preceding the first qualifying sample is + # not evidence that opening intent was already present. + self._opening_elapsed_s += elapsed_seconds + if self._opening_elapsed_s >= self._config.opening_intent_duration_s: + opening_delta = trigger_value - self._opening_anchor_trigger + self._interaction_started = True + self._opening_active = True + self._set_closedness(self._closedness + opening_delta) + self._last_trigger = trigger_value + return self.targets + + self._cancel_opening() + return self.targets + + if trigger_delta > self._DIRECTION_EPSILON: + # Closing is immediately safe but remains physically bounded by + # the configured endpoint interpolation. + self._cancel_opening() + self._set_closedness(self._closedness + trigger_delta) + self._last_trigger = trigger_value + return self.targets + + if trigger_delta < -self._DIRECTION_EPSILON: + if self._opening_active: + self._set_closedness(self._closedness + trigger_delta) + self._last_trigger = trigger_value + return self.targets + if self._opening_anchor_trigger is None: + self._opening_anchor_trigger = self._last_trigger + else: + # Start timing at the first observed threshold crossing. A + # delayed frame must not count the unobserved preceding gap. + self._opening_elapsed_s += elapsed_seconds + if ( + trigger_value + > self._opening_anchor_trigger - self._config.trigger_deadband + ): + self._cancel_opening() + return self.targets + if self._opening_elapsed_s >= self._config.opening_intent_duration_s: + opening_delta = trigger_value - self._opening_anchor_trigger + self._opening_active = True + self._set_closedness(self._closedness + opening_delta) + self._last_trigger = trigger_value + return self.targets + + # While opening is pending, _last_trigger remains the pre-opening + # anchor. A zero delta therefore means the trigger returned to that + # anchor, rather than remaining held lower, so pending intent must end. + if self._opening_anchor_trigger is not None and not self._opening_active: + self._cancel_opening() + return self.targets + + +__all__ = [ + "DVRKPSMCartesianClutchConfig", + "DVRKPSMCartesianClutchStateMachine", + "DVRKPSMJawIntentConfig", + "DVRKPSMJawIntentStateMachine", +] diff --git a/src/retargeters/DVRK/psm_retargeter.py b/src/retargeters/DVRK/psm_retargeter.py new file mode 100644 index 000000000..2838cc8cf --- /dev/null +++ b/src/retargeters/DVRK/psm_retargeter.py @@ -0,0 +1,300 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""XR-controller retargeters for a dVRK Patient Side Manipulator (PSM). + +The PSM has six task-space arm joints and two mechanically coupled jaw joints. +This module owns the XR-facing contracts only: + +* :class:`DVRKPSMClutchRetargeter` emits a clipped, absolute 7-D pose in a + caller-selected reference frame only while the XR squeeze (deadman) is + held. It captures the controller origin at engagement, so the target does + not jump when the operator re-clutches. It wraps the simulator-independent + :class:`DVRKPSMCartesianClutchStateMachine` shared with other consumers. +* :class:`DVRKPSMGripperRetargeter` maps the analog trigger to the paired PSM + jaw targets in radians. It wraps :class:`DVRKPSMJawIntentStateMachine`, a + simulator-independent input-state kernel that preserves jaw intent across + arm clutch and tracking transitions. + +Differential IK deliberately remains in the simulator integration, where the +live articulation Jacobian, current joint state, and robot-specific limits are +available. A simulator consumer can use this module's outputs with a six-DOF +DLS controller targeting its PSM tool-tip link. + +Frame contract +-------------- +The controller stream, home pose, and workspace must use one shared reference +frame. For one PSM a caller may choose its base frame. A bimanual integration +can instead keep both streams in a shared world frame, then transform each +target into that arm's base frame immediately before DLS IK. This keeps one XR +stream valid for two independently placed PSM bases. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from isaacteleop.retargeting_engine.deviceio_source_nodes import ControllersSource +from isaacteleop.retargeting_engine.interface import BaseRetargeter, RetargeterIOType +from isaacteleop.retargeting_engine.interface.execution_events import ExecutionState +from isaacteleop.retargeting_engine.interface.retargeter_core_types import RetargeterIO +from isaacteleop.retargeting_engine.interface.tensor_group_type import ( + OptionalType, + TensorGroupType, +) +from isaacteleop.retargeting_engine.tensor_types import ( + ControllerInput, + ControllerInputIndex, + DLDataType, + NDArrayType, +) + +from .control import ( + DEFAULT_CLUTCH_THRESHOLD as _DEFAULT_CLUTCH_THRESHOLD, + DEFAULT_JAW_CLOSED as _DEFAULT_JAW_CLOSED, + DEFAULT_JAW_OPEN as _DEFAULT_JAW_OPEN, + DEFAULT_OPENING_INTENT_DURATION_S as _DEFAULT_OPENING_INTENT_DURATION_S, + DEFAULT_TRIGGER_DEADZONE as _TRIGGER_DEADZONE, + DEFAULT_WORKSPACE_LOWER as _DEFAULT_WORKSPACE_LOWER, + DEFAULT_WORKSPACE_UPPER as _DEFAULT_WORKSPACE_UPPER, + DVRKPSMCartesianClutchConfig, + DVRKPSMCartesianClutchStateMachine, + DVRKPSMJawIntentConfig, + DVRKPSMJawIntentStateMachine, + rotation_matrix_to_quat_xyzw, +) + + +@dataclass(frozen=True) +class DVRKPSMClutchConfig: + """Configuration for an absolute-pose PSM clutch. + + ``home_reference_T_ee`` and all workspace coordinates are expressed in one + shared command reference frame. The owning simulator must reset the PSM + to the same physical home pose before the first engagement. The + ``orientation_offset`` is a scalar-last calibration rotation conjugated + around the controller's relative rotation; this preserves the configured + tool orientation on the squeeze-latching frame. + """ + + home_reference_T_ee: np.ndarray + input_device: str = ControllersSource.RIGHT + workspace_lower: tuple[float, float, float] = _DEFAULT_WORKSPACE_LOWER + workspace_upper: tuple[float, float, float] = _DEFAULT_WORKSPACE_UPPER + translation_scale: float = 1.0 + orientation_offset: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0) + clutch_threshold: float = _DEFAULT_CLUTCH_THRESHOLD + + +class DVRKPSMClutchRetargeter(BaseRetargeter): + """Emit a workspace-bounded absolute PSM tool-tip target with clutch rebasing. + + The Quest controller's analog squeeze is the deadman clutch. The first + valid squeezed frame after entering ``RUNNING`` latches the controller + origin. The tool target then follows controller displacement from that + origin. Releasing squeeze preserves the last target and re-arms the + origin, so a later squeeze re-clutches at the current PSM target without + teleporting. The trigger is deliberately not read here: the paired-jaw + retargeter owns that relative, intent-latched gripper command. + """ + + OUTPUT_POSE = "ee_pose" + """Output group carrying the 7-D PSM tool-tip pose command.""" + + def __init__(self, config: DVRKPSMClutchConfig, name: str) -> None: + self._input_device = config.input_device + transform = np.asarray(config.home_reference_T_ee, dtype=np.float64) + if transform.shape != (4, 4) or not np.all(np.isfinite(transform)): + raise ValueError("home_reference_T_ee must be a finite (4, 4) matrix") + if not np.allclose(transform[3], (0.0, 0.0, 0.0, 1.0), atol=1e-8): + raise ValueError( + "home_reference_T_ee must be a homogeneous transform with " + "bottom row [0, 0, 0, 1]" + ) + home_position = transform[:3, 3].copy() + home_orientation = rotation_matrix_to_quat_xyzw(transform[:3, :3]) + if home_orientation is None: + raise ValueError( + "home_reference_T_ee must contain a proper rotation matrix" + ) + self._clutch_state = DVRKPSMCartesianClutchStateMachine( + DVRKPSMCartesianClutchConfig( + home_position=tuple(home_position), + home_orientation=tuple(home_orientation), + workspace_lower=config.workspace_lower, + workspace_upper=config.workspace_upper, + translation_scale=config.translation_scale, + orientation_offset=config.orientation_offset, + clutch_threshold=config.clutch_threshold, + ) + ) + super().__init__(name=name) + + def input_spec(self) -> RetargeterIOType: + """Require the optional controller input for the selected PSM side.""" + return {self._input_device: OptionalType(ControllerInput())} + + def output_spec(self) -> RetargeterIOType: + """Emit one 7-D ``[x, y, z, qx, qy, qz, qw]`` absolute pose.""" + return { + self.OUTPUT_POSE: TensorGroupType( + self.OUTPUT_POSE, + [ + NDArrayType( + "pose", shape=(7,), dtype=DLDataType.FLOAT, dtype_bits=32 + ) + ], + ) + } + + def reset_target_state(self) -> None: + """Restore the configured home and require a fresh clutch origin. + + Simulator integrations should call this after an environment reset or + while discarding inactive-session outputs. The next valid squeezed + sample then captures its current controller pose and emits the exact + configured home, preventing pre-start controller motion from becoming + a first-frame target jump. + """ + self._clutch_state.reset() + + def _compute_fn(self, inputs: RetargeterIO, outputs: RetargeterIO, context) -> None: + """Adapt one controller tensor sample to the shared clutch kernel.""" + if context.execution_events.reset: + self.reset_target_state() + + output = outputs[self.OUTPUT_POSE] + controller = inputs[self._input_device] + if controller.is_none: + output[0] = self._clutch_state.step( + controller_position=None, + controller_orientation=None, + squeeze=None, + tracking_valid=False, + session_active=( + context.execution_events.execution_state == ExecutionState.RUNNING + ), + ) + return + output[0] = self._clutch_state.step( + controller_position=controller[ControllerInputIndex.GRIP_POSITION], + controller_orientation=controller[ControllerInputIndex.GRIP_ORIENTATION], + squeeze=controller[ControllerInputIndex.SQUEEZE_VALUE], + tracking_valid=bool(controller[ControllerInputIndex.GRIP_IS_VALID]), + session_active=( + context.execution_events.execution_state == ExecutionState.RUNNING + ), + ) + + +@dataclass(frozen=True) +class DVRKPSMGripperConfig: + """Configuration for one intent-latched pair of native PSM jaws.""" + + input_device: str = ControllersSource.RIGHT + jaw_open: tuple[float, float] = _DEFAULT_JAW_OPEN + jaw_closed: tuple[float, float] = _DEFAULT_JAW_CLOSED + initial_closedness: float = 0.0 + clutch_threshold: float = _DEFAULT_CLUTCH_THRESHOLD + trigger_deadband: float = _TRIGGER_DEADZONE + opening_intent_duration_s: float = _DEFAULT_OPENING_INTENT_DURATION_S + + +class DVRKPSMGripperRetargeter(BaseRetargeter): + """Map an analog trigger to the two coupled dVRK PSM jaw targets. + + The I4H PSM model uses ``psm_tool_gripper1_joint`` and + ``psm_tool_gripper2_joint``. The emitted vector is ordered exactly as that + pair: trigger travel toward pressed closes the jaws and travel toward + released opens them, bounded by ``[-0.50, +0.50]`` and + ``[-0.09, +0.09]`` rad by default. The trigger reference is captured at + squeeze engagement, so arm unclutch, tracking loss, and re-clutch hold the + exact last safe target. See :class:`DVRKPSMJawIntentStateMachine` for the + deliberate-opening guard. + """ + + OUTPUT_JAW_TARGETS = "jaw_targets" + """Output group carrying the two PSM gripper-joint targets in radians.""" + + def __init__(self, config: DVRKPSMGripperConfig, name: str) -> None: + self._input_device = config.input_device + self._jaw_intent = DVRKPSMJawIntentStateMachine( + DVRKPSMJawIntentConfig( + jaw_open=config.jaw_open, + jaw_closed=config.jaw_closed, + initial_closedness=config.initial_closedness, + clutch_threshold=config.clutch_threshold, + trigger_deadband=config.trigger_deadband, + opening_intent_duration_s=config.opening_intent_duration_s, + ) + ) + self._last_graph_time_ns: int | None = None + super().__init__(name=name) + + def input_spec(self) -> RetargeterIOType: + """Require the optional selected controller with an analog trigger.""" + return {self._input_device: OptionalType(ControllerInput())} + + def output_spec(self) -> RetargeterIOType: + """Emit the two ordered PSM gripper-joint targets in radians.""" + return { + self.OUTPUT_JAW_TARGETS: TensorGroupType( + self.OUTPUT_JAW_TARGETS, + [ + NDArrayType( + "jaws", shape=(2,), dtype=DLDataType.FLOAT, dtype_bits=32 + ) + ], + ) + } + + def reset_target_state(self) -> None: + """Restore configured initial jaw intent and re-arm trigger capture.""" + self._jaw_intent.reset() + self._last_graph_time_ns = None + + def _compute_fn(self, inputs: RetargeterIO, outputs: RetargeterIO, context) -> None: + """Advance the shared jaw-intent kernel from one graph-time sample.""" + if context.execution_events.reset: + self.reset_target_state() + + graph_time_ns = int(context.graph_time.real_time_ns) + if self._last_graph_time_ns is None: + dt_seconds = 0.0 + self._last_graph_time_ns = graph_time_ns + elif graph_time_ns <= self._last_graph_time_ns: + # A repeated or regressed clock sample contributes no intent time. + # Keep the prior high-water mark so the next normal sample cannot + # count the regressed interval a second time. + dt_seconds = 0.0 + else: + dt_seconds = (graph_time_ns - self._last_graph_time_ns) / 1_000_000_000.0 + self._last_graph_time_ns = graph_time_ns + + output = outputs[self.OUTPUT_JAW_TARGETS] + controller = inputs[self._input_device] + if controller.is_none: + output[0] = self._jaw_intent.step( + trigger=None, + squeeze=None, + tracking_valid=False, + session_active=( + context.execution_events.execution_state == ExecutionState.RUNNING + ), + dt_seconds=dt_seconds, + ) + return + + trigger = float(controller[ControllerInputIndex.TRIGGER_VALUE]) + squeeze = float(controller[ControllerInputIndex.SQUEEZE_VALUE]) + output[0] = self._jaw_intent.step( + trigger=trigger, + squeeze=squeeze, + tracking_valid=bool(controller[ControllerInputIndex.GRIP_IS_VALID]), + session_active=( + context.execution_events.execution_state == ExecutionState.RUNNING + ), + dt_seconds=dt_seconds, + ) diff --git a/src/retargeters/__init__.py b/src/retargeters/__init__.py index ae3a9de19..530154411 100644 --- a/src/retargeters/__init__.py +++ b/src/retargeters/__init__.py @@ -18,6 +18,8 @@ - GripperRetargeter: Pinch-based gripper control - SO101ClutchRetargeter: Clutch-rebased absolute EE pose for the SO-101 5-DOF arm - SO101GripperRetargeter: Proportional (analog) jaw closedness for the SO-101 gripper + - DVRKPSMClutchRetargeter: No-jump absolute tool-pose clutch for a dVRK PSM + - DVRKPSMGripperRetargeter: Intent-latched paired dVRK PSM jaw targets - JointStateRetargeter: Generic joint-space device (leader arm, exoskeleton) -> joint or EE action - SharpaHandRetargeter: Pinocchio/Pink IK-based retargeting for Sharpa hand - SharpaBiManualRetargeter: Bimanual version of SharpaHandRetargeter @@ -112,6 +114,27 @@ "SO101GripperRetargeter", None, ), + # .DVRK (dVRK PSM: Cartesian clutch and paired jaw targets) + "DVRKPSMClutchConfig": ( + ".DVRK.psm_retargeter", + "DVRKPSMClutchConfig", + None, + ), + "DVRKPSMClutchRetargeter": ( + ".DVRK.psm_retargeter", + "DVRKPSMClutchRetargeter", + None, + ), + "DVRKPSMGripperConfig": ( + ".DVRK.psm_retargeter", + "DVRKPSMGripperConfig", + None, + ), + "DVRKPSMGripperRetargeter": ( + ".DVRK.psm_retargeter", + "DVRKPSMGripperRetargeter", + None, + ), # .joint_space (generic joint-space devices: leader arms, exoskeletons, ...) "JointStateRetargeter": ( ".joint_space.joint_state_retargeter", @@ -217,6 +240,11 @@ def __getattr__(name: str): # SO-101 5-DOF arm retargeters "SO101ClutchRetargeter", "SO101GripperRetargeter", + # dVRK PSM retargeters + "DVRKPSMClutchConfig", + "DVRKPSMClutchRetargeter", + "DVRKPSMGripperConfig", + "DVRKPSMGripperRetargeter", # Generic joint-space device retargeters (leader arms, exoskeletons, ...) "JointStateRetargeter", "JointStateRetargeterConfig",