yam-common 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: yam-common
3
+ Version: 0.1.0
4
+ Summary: Shared utilities for YAM arm LeRobot plugins
5
+ Author: Praveen Selvaraj
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: numpy
13
+ Requires-Dist: mujoco
14
+ Requires-Dist: lerobot>=0.4
15
+
16
+ # yam-common
17
+
18
+ Shared utilities for the YAM arm LeRobot plugins.
19
+
20
+ Includes the motor chain controller, MuJoCo gravity compensation, and gripper
21
+ utilities used by both the follower robot and leader teleoperator packages.
22
+
@@ -0,0 +1,7 @@
1
+ # yam-common
2
+
3
+ Shared utilities for the YAM arm LeRobot plugins.
4
+
5
+ Includes the motor chain controller, MuJoCo gravity compensation, and gripper
6
+ utilities used by both the follower robot and leader teleoperator packages.
7
+
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "yam-common"
7
+ version = "0.1.0"
8
+ description = "Shared utilities for YAM arm LeRobot plugins"
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "Praveen Selvaraj" },
12
+ ]
13
+ license = { text = "MIT" }
14
+ requires-python = ">=3.10"
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+ dependencies = [
21
+ "numpy",
22
+ "mujoco",
23
+ "lerobot>=0.4",
24
+ ]
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["."]
28
+ include = ["yam_common*"]
29
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,13 @@
1
+ from .mujoco_kdl import MuJoCoKDL, get_yam_mujoco_kdl
2
+ from .motor_chain_robot import MotorChainRobot
3
+ from .utils import GripperForceLimiter, GripperType, JointMapper
4
+
5
+ __all__ = [
6
+ "MuJoCoKDL",
7
+ "get_yam_mujoco_kdl",
8
+ "MotorChainRobot",
9
+ "GripperForceLimiter",
10
+ "GripperType",
11
+ "JointMapper",
12
+ ]
13
+
@@ -0,0 +1,404 @@
1
+ """i2rt-authentic MotorChainRobot port for YAM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import logging
7
+ import os
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass
11
+ from typing import Any, Dict, List, Optional, Union
12
+
13
+ import numpy as np
14
+
15
+ from lerobot.motors.dm.dm_driver import MotorChain, MotorInfo
16
+ from yam_common.mujoco_kdl import MuJoCoKDL
17
+ from yam_common.utils import GripperForceLimiter, GripperType, JointMapper
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class JointStates:
24
+ names: List[str]
25
+ pos: np.ndarray
26
+ vel: np.ndarray
27
+ eff: np.ndarray
28
+ temp_mos: np.ndarray
29
+ temp_rotor: np.ndarray
30
+
31
+ def asdict(self) -> Dict[str, Any]:
32
+ return {
33
+ "names": self.names,
34
+ "pos": self.pos.flatten().tolist(),
35
+ "vel": self.vel.flatten().tolist(),
36
+ "eff": self.eff.flatten().tolist(),
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class JointCommands:
42
+ torques: np.ndarray
43
+ pos: np.ndarray
44
+ vel: np.ndarray
45
+ kp: np.ndarray
46
+ kd: np.ndarray
47
+ indices: Optional[List[int]] = None
48
+
49
+ @classmethod
50
+ def init_all_zero(cls, n_joints: int) -> "JointCommands":
51
+ return cls(
52
+ torques=np.zeros(n_joints),
53
+ pos=np.zeros(n_joints),
54
+ vel=np.zeros(n_joints),
55
+ kp=np.zeros(n_joints),
56
+ kd=np.zeros(n_joints),
57
+ )
58
+
59
+
60
+ class MotorChainRobot:
61
+ """i2rt-authentic motor-chain robot controller."""
62
+
63
+ def __init__(
64
+ self,
65
+ motor_chain: MotorChain,
66
+ xml_path: Optional[str] = None,
67
+ use_gravity_comp: bool = True,
68
+ gravity: Optional[np.ndarray] = None,
69
+ gravity_comp_factor: float = 1.0,
70
+ gripper_index: Optional[int] = None,
71
+ kp: Union[float, List[float]] = 10.0,
72
+ kd: Union[float, List[float]] = 1.0,
73
+ joint_limits: Optional[np.ndarray] = None,
74
+ gripper_limits: Optional[np.ndarray] = None,
75
+ limit_gripper_force: float = -1,
76
+ clip_motor_torque: float = np.inf,
77
+ gripper_type: GripperType = GripperType.CRANK_4310,
78
+ temp_record_flag: bool = False,
79
+ enable_gripper_calibration: bool = False,
80
+ zero_gravity_mode: bool = True,
81
+ test_torque: float = 0.5,
82
+ test_duration: float = 2.0,
83
+ position_threshold: float = 0.01,
84
+ check_interval: float = 0.05,
85
+ ) -> None:
86
+ self.temp_record_flag = temp_record_flag
87
+ if gripper_index is not None:
88
+ assert gripper_index == len(motor_chain) - 1, (
89
+ "Gripper index should be the last one, but got {gripper_index}"
90
+ )
91
+ if gripper_limits is None and enable_gripper_calibration:
92
+ from lerobot.robots.yam_follower.utils import detect_gripper_limits
93
+
94
+ logger.info("Auto-detecting gripper limits...")
95
+ detected_limits = detect_gripper_limits(
96
+ motor_chain=motor_chain,
97
+ gripper_index=gripper_index,
98
+ test_torque=test_torque,
99
+ max_duration=test_duration,
100
+ position_threshold=position_threshold,
101
+ check_interval=check_interval,
102
+ )
103
+ gripper_limits = np.array(detected_limits)
104
+ logger.info(f"Gripper limits auto-detected: {gripper_limits}")
105
+ elif gripper_limits is None:
106
+ raise ValueError(
107
+ f"{self}: Gripper limits are required if gripper index is provided and auto-calibration is disabled."
108
+ )
109
+ else:
110
+ logger.info(f"Using provided gripper limits: {gripper_limits}")
111
+
112
+ self._last_gripper_command_qpos = 1
113
+ assert clip_motor_torque >= 0.0
114
+ self._clip_motor_torque = clip_motor_torque
115
+ self.motor_chain = motor_chain
116
+ self.use_gravity_comp = use_gravity_comp
117
+ self.gravity_comp_factor = gravity_comp_factor
118
+
119
+ self._gripper_index = gripper_index
120
+ self.remapper = JointMapper({}, len(motor_chain))
121
+ self._gripper_limits = gripper_limits
122
+
123
+ if self._gripper_index is not None:
124
+ self._gripper_force_limiter = GripperForceLimiter(
125
+ max_force=limit_gripper_force, gripper_type=gripper_type, kp=kp[gripper_index]
126
+ )
127
+ self._limit_gripper_force = limit_gripper_force
128
+ self.remapper = JointMapper(
129
+ index_range_map={gripper_index: gripper_limits},
130
+ total_dofs=len(motor_chain),
131
+ )
132
+
133
+ self._kp = (
134
+ np.array([kp] * len(motor_chain)) if isinstance(kp, float) else np.array(kp)
135
+ )
136
+ self._kd = (
137
+ np.array([kd] * len(motor_chain)) if isinstance(kd, float) else np.array(kd)
138
+ )
139
+
140
+ self._joint_limits: Optional[np.ndarray] = None
141
+ if xml_path is not None:
142
+ self.xml_path = os.path.expanduser(xml_path)
143
+ self.kdl = MuJoCoKDL(self.xml_path)
144
+ if gravity is not None:
145
+ self.kdl.set_gravity(gravity)
146
+ self._joint_limits = self.kdl.joint_limits
147
+ else:
148
+ assert use_gravity_comp is False, "Gravity compensation requires a valid XML path."
149
+
150
+ if joint_limits is not None:
151
+ joint_limits = np.array(joint_limits)
152
+ assert np.all(joint_limits[:, 0] < joint_limits[:, 1]), (
153
+ "Lower joint limits must be smaller than upper limits"
154
+ )
155
+ self._joint_limits = joint_limits
156
+
157
+ self._command_lock = threading.Lock()
158
+ self._state_lock = threading.Lock()
159
+ self._joint_state: Optional[JointStates] = None
160
+ while self._joint_state is None:
161
+ time.sleep(0.05)
162
+ self._joint_state = self._motor_state_to_joint_state(self.motor_chain.read_states())
163
+ self._commands = JointCommands.init_all_zero(len(motor_chain))
164
+ self._check_current_qpos_in_joint_limits()
165
+
166
+ self._stop_event = threading.Event()
167
+ self._server_thread = threading.Thread(target=self.start_server, name="robot_server")
168
+ self._server_thread.start()
169
+
170
+ if not zero_gravity_mode:
171
+ self.command_joint_pos(self._joint_state.pos)
172
+
173
+ def __repr__(self) -> str:
174
+ return f"MotorChainRobot(motor_chain={self.motor_chain})"
175
+
176
+ def _check_current_qpos_in_joint_limits(self, buffer_rad: float = 0.1) -> None:
177
+ if self._joint_state is None or self._joint_limits is None:
178
+ raise RuntimeError(f"{self}: Joint limits:{self._joint_limits} or joint state:{self._joint_state} are not set.")
179
+
180
+ current_pos = self._joint_state.pos
181
+ if self._gripper_index is not None:
182
+ arm_pos = current_pos[: self._gripper_index]
183
+ arm_limits = self._joint_limits
184
+ else:
185
+ arm_pos = current_pos
186
+ arm_limits = self._joint_limits
187
+
188
+ lower_limits = arm_limits[:, 0] - buffer_rad
189
+ upper_limits = arm_limits[:, 1] + buffer_rad
190
+ lower_violations = arm_pos < lower_limits
191
+ upper_violations = arm_pos > upper_limits
192
+
193
+ if np.any(lower_violations) or np.any(upper_violations):
194
+ violation_details = []
195
+ for i, (pos, lower, upper) in enumerate(zip(arm_pos, lower_limits, upper_limits)):
196
+ if pos < lower:
197
+ violation_details.append(f"Joint {i}: {pos:.4f} < {lower:.4f} (lower limit)")
198
+ elif pos > upper:
199
+ violation_details.append(f"Joint {i}: {pos:.4f} > {upper:.4f} (upper limit)")
200
+ violation_msg = "; ".join(violation_details)
201
+ self.motor_chain.running = False
202
+ raise RuntimeError(
203
+ f"{self}: Joint limit violation detected: {violation_msg}, the root reason should be zero position "
204
+ "offset. possible solution: 1. move the arm to zero position and power cycle the robot. "
205
+ "2. Recalibrate the motor zero position."
206
+ )
207
+
208
+ def start_server(self) -> None:
209
+ last_time = time.time()
210
+ iteration_count = 0
211
+ try:
212
+ self.update()
213
+ logging.info("initializing, ....")
214
+ while not self._stop_event.is_set():
215
+ current_time = time.time()
216
+ elapsed_time = current_time - last_time
217
+ self.update()
218
+ if not self.motor_chain.running:
219
+ raise RuntimeError(
220
+ f"{self}: motor_chain_robot's motor chain is not running, exiting the robot server"
221
+ )
222
+ time.sleep(0.004)
223
+ iteration_count += 1
224
+ if elapsed_time >= 10.0:
225
+ control_frequency = iteration_count / elapsed_time
226
+ logging.info(f"{self}: Grav Comp Control Frequency: {control_frequency:.2f} Hz")
227
+ if control_frequency < 100:
228
+ logging.warning(
229
+ f"{self}: Gravity compensation control loop is slow, current frequency: {control_frequency:.2f} Hz"
230
+ )
231
+ last_time = current_time
232
+ iteration_count = 0
233
+ except Exception as exc:
234
+ logging.error(f"{self}: robot server error, entering zero-torque mode: {exc}")
235
+ try:
236
+ self.zero_torque_mode()
237
+ except Exception:
238
+ pass
239
+ self._stop_event.set()
240
+ raise
241
+
242
+ def update(self) -> None:
243
+ with self._command_lock:
244
+ joint_commands = copy.deepcopy(self._commands)
245
+ with self._state_lock:
246
+ g = self._compute_gravity_compensation(self._joint_state)
247
+ motor_torques = joint_commands.torques + g * self.gravity_comp_factor
248
+ motor_torques = np.clip(motor_torques, -self._clip_motor_torque, self._clip_motor_torque)
249
+
250
+ if self._gripper_index is not None:
251
+ if self._limit_gripper_force > 0 and self._joint_state is not None:
252
+ gripper_state = {
253
+ "target_qpos": joint_commands.pos[self._gripper_index],
254
+ "current_qpos": self.remapper.to_robot_joint_pos_space(self._joint_state.pos)[
255
+ self._gripper_index
256
+ ],
257
+ "current_qvel": self._joint_state.vel[self._gripper_index],
258
+ "current_eff": self._joint_state.eff[self._gripper_index],
259
+ "current_normalized_qpos": self._joint_state.pos[self._gripper_index],
260
+ "target_normalized_qpos": self.remapper.to_command_joint_pos_space(joint_commands.pos)[
261
+ self._gripper_index
262
+ ],
263
+ "last_command_qpos": self._last_gripper_command_qpos,
264
+ }
265
+ joint_commands.pos[self._gripper_index] = self._gripper_force_limiter.update(gripper_state)
266
+
267
+ joint_commands.pos[self._gripper_index] = np.clip(
268
+ joint_commands.pos[self._gripper_index],
269
+ min(self._gripper_limits),
270
+ max(self._gripper_limits),
271
+ )
272
+ self._last_gripper_command_qpos = joint_commands.pos[self._gripper_index]
273
+
274
+ if not self.motor_chain.start_thread_flag:
275
+ self.motor_chain.set_commands(
276
+ motor_torques,
277
+ pos=joint_commands.pos,
278
+ vel=joint_commands.vel,
279
+ kp=joint_commands.kp,
280
+ kd=joint_commands.kd,
281
+ )
282
+ self.motor_chain.start_thread()
283
+ self.motor_chain.start_thread_flag = True
284
+
285
+ motor_state = self.motor_chain.set_commands(
286
+ motor_torques,
287
+ pos=joint_commands.pos,
288
+ vel=joint_commands.vel,
289
+ kp=joint_commands.kp,
290
+ kd=joint_commands.kd,
291
+ )
292
+ self._joint_state = self._motor_state_to_joint_state(motor_state)
293
+ self._check_current_qpos_in_joint_limits()
294
+
295
+ def _motor_state_to_joint_state(self, motor_state: List[MotorInfo]) -> JointStates:
296
+ names = [str(i) for i in range(len(motor_state))]
297
+ pos = np.array([motor.pos for motor in motor_state])
298
+ pos = self.remapper.to_command_joint_pos_space(pos)
299
+ vel = np.array([motor.vel for motor in motor_state])
300
+ vel = self.remapper.to_command_joint_vel_space(vel)
301
+ eff = np.array([motor.eff for motor in motor_state])
302
+ temp_mos = np.array([motor.temp_mos for motor in motor_state])
303
+ temp_rotor = np.array([motor.temp_rotor for motor in motor_state])
304
+ return JointStates(
305
+ names=names,
306
+ pos=pos,
307
+ vel=vel,
308
+ eff=eff,
309
+ temp_mos=temp_mos,
310
+ temp_rotor=temp_rotor,
311
+ )
312
+
313
+ def _compute_gravity_compensation(self, joint_state: Optional[JointStates]) -> np.ndarray:
314
+ if joint_state is None or not self.use_gravity_comp:
315
+ return np.zeros(len(self.motor_chain))
316
+ q = joint_state.pos[: self._gripper_index] if self._gripper_index is not None else joint_state.pos
317
+ t = self.kdl.compute_inverse_dynamics(q, np.zeros(q.shape), np.zeros(q.shape))
318
+ if np.max(np.abs(t)) > 20.0:
319
+ print([f"{s:.2f}" for s in t])
320
+ raise RuntimeError(f"{self}: too large torques")
321
+ if self._gripper_index is None:
322
+ return t
323
+ return np.append(t, 0.0)
324
+
325
+ def num_dofs(self) -> int:
326
+ return len(self.motor_chain)
327
+
328
+ def get_joint_pos(self) -> np.ndarray:
329
+ with self._state_lock:
330
+ return self._joint_state.pos
331
+
332
+ def _clip_robot_joint_pos_command(self, pos: np.ndarray) -> np.ndarray:
333
+ if self._joint_limits is not None:
334
+ if self._gripper_index is not None:
335
+ pos[: self._gripper_index] = np.clip(
336
+ pos[: self._gripper_index],
337
+ self._joint_limits[:, 0],
338
+ self._joint_limits[:, 1],
339
+ )
340
+ else:
341
+ pos = np.clip(pos, self._joint_limits[:, 0], self._joint_limits[:, 1])
342
+ return pos
343
+
344
+ def command_joint_pos(self, joint_pos: np.ndarray) -> None:
345
+ pos = self._clip_robot_joint_pos_command(joint_pos)
346
+ with self._command_lock:
347
+ self._commands = JointCommands.init_all_zero(len(self.motor_chain))
348
+ self._commands.pos = self.remapper.to_robot_joint_pos_space(pos)
349
+ self._commands.kp = self._kp
350
+ self._commands.kd = self._kd
351
+
352
+ def command_joint_state(self, joint_state: Dict[str, np.ndarray]) -> None:
353
+ pos = self._clip_robot_joint_pos_command(joint_state["pos"])
354
+ vel = joint_state["vel"]
355
+ self._commands = JointCommands.init_all_zero(len(self.motor_chain))
356
+ kp = joint_state.get("kp", self._kp)
357
+ kd = joint_state.get("kd", self._kd)
358
+ with self._command_lock:
359
+ self._commands.pos = self.remapper.to_robot_joint_pos_space(pos)
360
+ self._commands.vel = self.remapper.to_robot_joint_vel_space(vel)
361
+ self._commands.kp = kp
362
+ self._commands.kd = kd
363
+
364
+ def zero_torque_mode(self) -> None:
365
+ logging.info(f"Entering zero_torque_mode for {self}")
366
+ with self._command_lock:
367
+ self._commands = JointCommands.init_all_zero(len(self.motor_chain))
368
+ self._kp = np.zeros(len(self.motor_chain))
369
+ self._kd = np.zeros(len(self.motor_chain))
370
+
371
+ def get_observations(self) -> Dict[str, np.ndarray]:
372
+ with self._state_lock:
373
+ if self._gripper_index is None:
374
+ result = {
375
+ "joint_pos": self._joint_state.pos,
376
+ "joint_vel": self._joint_state.vel,
377
+ "joint_eff": self._joint_state.eff,
378
+ }
379
+ else:
380
+ result = {
381
+ "joint_pos": self._joint_state.pos[: self._gripper_index],
382
+ "gripper_pos": np.array([self._joint_state.pos[self._gripper_index]]),
383
+ "joint_vel": self._joint_state.vel,
384
+ "joint_eff": self._joint_state.eff,
385
+ }
386
+ if self.temp_record_flag:
387
+ result["temp_mos"] = self._joint_state.temp_mos
388
+ result["temp_rotor"] = self._joint_state.temp_rotor
389
+ return result
390
+
391
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
392
+ self.close()
393
+
394
+ def close(self) -> None:
395
+ self._stop_event.set()
396
+ self._server_thread.join()
397
+ self.motor_chain.close()
398
+ print("Robot closed with all torques set to zero.")
399
+
400
+ def update_kp_kd(self, kp: np.ndarray, kd: np.ndarray) -> None:
401
+ assert kp.shape == self._kp.shape == kd.shape
402
+ self._kp = kp
403
+ self._kd = kd
404
+
@@ -0,0 +1,206 @@
1
+ """
2
+ MuJoCo-based gravity compensation for YAM robot.
3
+
4
+ Faithfully ported from i2rt/utils/mujoco_utils.py.
5
+
6
+ This module uses MuJoCo's inverse dynamics to compute the torques needed
7
+ to compensate for gravity at any given joint configuration.
8
+ """
9
+
10
+ import os
11
+ from typing import Optional
12
+
13
+ import numpy as np
14
+
15
+ try:
16
+ import mujoco
17
+ except ImportError:
18
+ mujoco = None
19
+
20
+
21
+ class MuJoCoKDL:
22
+ """
23
+ A class for computing inverse dynamics (gravity compensation) using MuJoCo.
24
+
25
+ Source: i2rt/utils/mujoco_utils.py:MuJoCoKDL
26
+
27
+ This class loads a MuJoCo XML model and uses mujoco.mj_inverse() to compute
28
+ the torques needed to maintain a given configuration against gravity.
29
+
30
+ For gravity compensation specifically:
31
+ - Set qpos to current joint positions
32
+ - Set qvel to zero (no velocity)
33
+ - Set qacc to zero (no acceleration)
34
+ - Call mj_inverse() to get the torques needed to maintain this configuration
35
+
36
+ Usage:
37
+ kdl = MuJoCoKDL("/path/to/yam.xml")
38
+ gravity_torques = kdl.compute_gravity_compensation(current_joint_positions)
39
+ """
40
+
41
+ def __init__(self, xml_path: str, gravity: Optional[np.ndarray] = None):
42
+ """
43
+ Initialize MuJoCoKDL with a robot model.
44
+
45
+ Args:
46
+ xml_path: Path to MuJoCo XML model file
47
+ gravity: Optional gravity vector (default: [0, 0, -9.81])
48
+ """
49
+ if mujoco is None:
50
+ raise ImportError(
51
+ "mujoco is required for MuJoCoKDL gravity compensation. "
52
+ "Install with: pip install mujoco"
53
+ )
54
+
55
+ # Load model - expand user path as in i2rt
56
+ self.xml_path = os.path.expanduser(xml_path)
57
+ self.model = mujoco.MjModel.from_xml_path(self.xml_path)
58
+ self.data = mujoco.MjData(self.model)
59
+
60
+ # Set gravity (default: Earth gravity pointing down)
61
+ if gravity is None:
62
+ gravity = np.array([0.0, 0.0, -9.81])
63
+ self.set_gravity(gravity)
64
+
65
+ # Disable all collisions - we only care about dynamics, not contacts
66
+ # Source: i2rt/utils/mujoco_utils.py lines 16-17
67
+ self.model.geom_contype[:] = 0
68
+ self.model.geom_conaffinity[:] = 0
69
+
70
+ # Disable all joint limits - we handle limits separately
71
+ # Source: i2rt/utils/mujoco_utils.py line 19
72
+ self.model.jnt_limited[:] = 0
73
+
74
+ @property
75
+ def num_joints(self) -> int:
76
+ """Number of joints in the model."""
77
+ return self.model.nq
78
+
79
+ @property
80
+ def joint_limits(self) -> np.ndarray:
81
+ """
82
+ Get joint limits from model.
83
+
84
+ Returns:
85
+ Array of shape (num_joints, 2) with [min, max] for each joint
86
+ """
87
+ return self.model.jnt_range.copy()
88
+
89
+ def set_gravity(self, gravity: np.ndarray) -> None:
90
+ """
91
+ Set the gravity vector for the robot.
92
+
93
+ Source: i2rt/utils/mujoco_utils.py:MuJoCoKDL.set_gravity()
94
+
95
+ Args:
96
+ gravity: Gravity vector as a 3D NumPy array (e.g., [0, 0, -9.81])
97
+ """
98
+ assert gravity.shape == (3,), f"Gravity must be 3D vector, got shape {gravity.shape}"
99
+ self.model.opt.gravity = gravity
100
+
101
+ def compute_inverse_dynamics(
102
+ self,
103
+ q: np.ndarray,
104
+ qdot: np.ndarray,
105
+ qdotdot: np.ndarray,
106
+ ) -> np.ndarray:
107
+ """
108
+ Compute inverse dynamics to get required joint torques.
109
+
110
+ Source: i2rt/utils/mujoco_utils.py:MuJoCoKDL.compute_inverse_dynamics()
111
+
112
+ This computes the torques needed to achieve the given acceleration
113
+ from the given position and velocity, accounting for gravity,
114
+ Coriolis forces, etc.
115
+
116
+ Args:
117
+ q: Joint positions (radians)
118
+ qdot: Joint velocities (rad/s)
119
+ qdotdot: Joint accelerations (rad/s^2)
120
+
121
+ Returns:
122
+ Joint torques (Nm) needed to achieve the given motion
123
+ """
124
+ assert len(q) == len(qdot) == len(qdotdot), (
125
+ f"Input dimensions must match: q={len(q)}, qdot={len(qdot)}, qdotdot={len(qdotdot)}"
126
+ )
127
+
128
+ length = len(q)
129
+
130
+ # Set state
131
+ self.data.qpos[:length] = q
132
+ self.data.qvel[:length] = qdot
133
+ self.data.qacc[:length] = qdotdot
134
+
135
+ # Compute inverse dynamics
136
+ mujoco.mj_inverse(self.model, self.data)
137
+
138
+ # Return the computed torques
139
+ return self.data.qfrc_inverse[:length].copy()
140
+
141
+ def compute_gravity_compensation(self, q: np.ndarray) -> np.ndarray:
142
+ """
143
+ Compute gravity compensation torques for a given joint configuration.
144
+
145
+ This is a convenience method that calls compute_inverse_dynamics with
146
+ zero velocity and zero acceleration, which gives the torques needed
147
+ to hold the arm stationary against gravity.
148
+
149
+ Args:
150
+ q: Joint positions (radians)
151
+
152
+ Returns:
153
+ Gravity compensation torques (Nm)
154
+ """
155
+ zeros = np.zeros_like(q)
156
+ return self.compute_inverse_dynamics(q, zeros, zeros)
157
+
158
+
159
+ def get_yam_mujoco_kdl(gripper_type: str = "crank_4310") -> MuJoCoKDL:
160
+ """
161
+ Get MuJoCoKDL instance for YAM robot with appropriate XML model.
162
+
163
+ Args:
164
+ gripper_type: Type of gripper ("crank_4310", "linear_3507", "linear_4310",
165
+ "yam_teaching_handle", "no_gripper")
166
+
167
+ Returns:
168
+ MuJoCoKDL instance configured for YAM robot
169
+ """
170
+ # Map gripper type to XML file
171
+ # Source: i2rt/robots/utils.py:GripperType.get_xml_path()
172
+ xml_paths = {
173
+ "crank_4310": "yam.xml",
174
+ "linear_3507": "yam_lw_gripper.xml",
175
+ "linear_4310": "yam_4310_linear.xml",
176
+ "yam_teaching_handle": "yam_teaching_handle.xml",
177
+ "no_gripper": "yam_no_gripper.xml",
178
+ }
179
+
180
+ if gripper_type not in xml_paths:
181
+ raise ValueError(f"Unknown gripper type: {gripper_type}. Valid types: {list(xml_paths.keys())}")
182
+
183
+ # Try to find the XML file in several locations
184
+ xml_filename = xml_paths[gripper_type]
185
+
186
+ # Search paths (in order of preference):
187
+ # 1. i2rt installation (if available)
188
+ # 2. Local lerobot models directory
189
+ search_paths = [
190
+ # i2rt installation
191
+ os.path.expanduser("~/Desktop/code/i2rt/i2rt/robot_models/yam"),
192
+ # Relative to this file
193
+ os.path.join(os.path.dirname(__file__), "robot_models"),
194
+ # Package data
195
+ os.path.join(os.path.dirname(__file__), "..", "..", "data", "robot_models", "yam"),
196
+ ]
197
+
198
+ for base_path in search_paths:
199
+ xml_path = os.path.join(base_path, xml_filename)
200
+ if os.path.exists(xml_path):
201
+ return MuJoCoKDL(xml_path)
202
+
203
+ raise FileNotFoundError(
204
+ f"Could not find MuJoCo XML file '{xml_filename}' for YAM robot. "
205
+ f"Searched paths: {search_paths}"
206
+ )