Skip to content

Commit 535aa69

Browse files
committed
feat(so101): keep position+pitch IK well-conditioned near singularities
The reduced-task position+pitch IK stretched the arm flat, pinned shoulder_lift at its joint limit, and ran away near singularities (in-sim diagnostics showed the smallest task singular value collapsing, a ~4 rad one-step joint command, and all actuators saturated). Add three controller levers so the solve stays well-conditioned: - Soft-weighted pitch: scale the pitch task row and error by ``pitch_task_weight`` so position is prioritized and pitch is best-effort (w=1 keeps the hard 4x4 task; w=0 recovers position-only). - Manipulability-aware damped least squares: ramp the DLS damping from ``lambda_min`` to ``lambda_max`` as the smallest task singular value falls below ``sigma_thresh``, replacing the fixed-lambda solve that amplified the singular-direction error into the runaway. - Null-space joint-limit avoidance: project a center-seeking bias (active within ``jla_margin`` of a limit) into the position task's null space so shoulder_lift no longer pins, without perturbing the commanded end-effector position; the action term injects the limits. Wire tuned starting values (all TODO(tune-in-sim)) into the IK-Abs env and add sim-free unit tests for each lever plus an integrated compute check. Validate the new damping/JLA cfg ranges in __post_init__.
1 parent c887369 commit 535aa69

5 files changed

Lines changed: 327 additions & 9 deletions

File tree

source/isaaclab_tasks/changelog.d/jiwenc-so-101.minor.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ Added
55
environments for the SO-101 5-DOF arm. The IK-Abs variant uses absolute task-space
66
differential inverse kinematics over a reduced 4x4 ``[x, y, z, pitch]`` task (the 5-DOF arm
77
cannot track a full 6-DOF pose target, so yaw stays coupled to the reach direction) and is
8-
seated on the table.
8+
seated on the table. The IK uses a soft-weighted pitch row, manipulability-aware damped least
9+
squares, and null-space joint-limit avoidance to stay well-conditioned near singularities.
910
* Generalized the cube-stack MDP gripper observations and terminations to support single-jaw
1011
grippers in addition to two-finger parallel grippers.
1112
* Added XR teleoperation for ``Isaac-Stack-Cube-SO101-IK-Abs-v0`` via an IsaacTeleop retargeting

source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/position_pitch_ik_action.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ def __init__(self, cfg: SO101PositionPitchIKActionCfg, env: ManagerBasedEnv):
6363
"clip is not supported for SO101PositionPitchIKAction (task-space "
6464
"[x, y, z, pitch] action does not map to joint-name clip keys)."
6565
)
66+
# Joint limits are injected lazily on the first apply (asset data is populated by then)
67+
# so the controller can do null-space joint-limit avoidance.
68+
self._limits_injected = False
69+
70+
def apply_actions(self) -> None:
71+
if not self._limits_injected:
72+
# Limits are uniform across envs for the SO-101; env 0 is representative.
73+
limits = self._asset.data.soft_joint_pos_limits.torch[0, self._joint_ids, :]
74+
self._ik_controller.set_joint_pos_limits(limits[:, 0].clone(), limits[:, 1].clone())
75+
self._limits_injected = True
76+
super().apply_actions()
6677

6778

6879
@configclass

source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/position_pitch_ik_controller.py

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,56 @@ class SO101PositionPitchIKControllerCfg(DifferentialIKControllerCfg):
3535
"""Minimum horizontal EE reach [m] below which the pitch axis ``n`` is treated as
3636
degenerate (EE ~directly above the base); the pitch row/error are zeroed that step."""
3737

38+
pitch_task_weight: float = 0.5
39+
"""Weight ``w`` in ``[0, 1]`` on the pitch task row. The pitch Jacobian row and pitch error
40+
are scaled by ``w`` before the solve, so position is prioritized and pitch is best-effort.
41+
``w = 1`` is the fully-constrained 4x4 task; ``w = 0`` zeros the pitch row entirely,
42+
recovering position-only IK."""
43+
44+
lambda_min: float = 0.05
45+
"""Baseline damped-least-squares damping coefficient [matched to the task Jacobian
46+
singular-value scale, roughly m/rad for this position-dominant task] used away from
47+
singularities."""
48+
49+
lambda_max: float = 0.2
50+
"""Maximum DLS damping coefficient [same scale as :attr:`lambda_min`, roughly m/rad],
51+
reached as the smallest task singular value -> 0."""
52+
53+
sigma_thresh: float = 0.02
54+
"""Smallest-singular-value threshold [same scale as the task Jacobian singular values,
55+
roughly m/rad] below which damping ramps from ``lambda_min`` toward ``lambda_max``
56+
(Maciejewski-Klein style)."""
57+
58+
jla_gain: float = 0.0
59+
"""Gain for the null-space joint-limit-avoidance bias. ``0`` disables it (default); the env
60+
cfg enables it with a tuned value. Active only once joint limits are injected via
61+
:meth:`SO101PositionPitchIKController.set_joint_pos_limits`."""
62+
63+
jla_margin: float = 0.3
64+
"""Joint-range margin [rad] within which the avoidance bias activates (1 at the limit,
65+
ramping to 0 at ``jla_margin`` away from it)."""
66+
67+
def __post_init__(self):
68+
super().__post_init__()
69+
if self.sigma_thresh <= 0.0:
70+
raise ValueError(f"sigma_thresh must be > 0, got {self.sigma_thresh}.")
71+
if self.lambda_min > self.lambda_max:
72+
raise ValueError(f"lambda_min ({self.lambda_min}) must be <= lambda_max ({self.lambda_max}).")
73+
if self.jla_gain < 0.0:
74+
raise ValueError(f"jla_gain must be >= 0, got {self.jla_gain}.")
75+
if self.jla_margin <= 0.0:
76+
raise ValueError(f"jla_margin must be > 0, got {self.jla_margin}.")
77+
3878

3979
class SO101PositionPitchIKController(DifferentialIKController):
4080
"""Differential IK over a 4-row task: 3 linear rows + 1 in-plane pitch row.
4181
42-
Reuses the base-class DLS solver (:meth:`_compute_delta_joint_pos`), which sizes its
43-
damping from the task-row count, so the only additions are the 4D command split
44-
(:meth:`set_command`) and the task assembly (:meth:`_assemble_task`).
82+
Uses a manipulability-aware DLS solver (:meth:`_damped_least_squares`) that ramps damping
83+
from :attr:`SO101PositionPitchIKControllerCfg.lambda_min` to
84+
:attr:`SO101PositionPitchIKControllerCfg.lambda_max` as the smallest task singular value
85+
falls below :attr:`SO101PositionPitchIKControllerCfg.sigma_thresh` (Maciejewski-Klein
86+
style). Task assembly is :meth:`_assemble_task`; the 4D command split is
87+
:meth:`set_command`.
4588
4689
.. note::
4790
The quaternion convention throughout this class is **xyzw** (scalar-last), matching
@@ -55,6 +98,9 @@ def __init__(self, cfg: SO101PositionPitchIKControllerCfg, num_envs: int, device
5598
super().__init__(cfg, num_envs, device)
5699
self.pitch_des = torch.zeros(self.num_envs, device=self._device)
57100
self._approach_axis = torch.tensor(_APPROACH_AXIS_B, device=self._device).repeat(self.num_envs, 1)
101+
# Joint position limits for null-space limit avoidance; injected by the action term.
102+
self._joint_pos_lower: torch.Tensor | None = None
103+
self._joint_pos_upper: torch.Tensor | None = None
58104

59105
@property
60106
def action_dim(self) -> int:
@@ -121,10 +167,91 @@ def _assemble_task(
121167
J_ang = jacobian[:, 3:6, :]
122168
J_pitch = torch.bmm(n.unsqueeze(1), J_ang) # (N,1,num_joints)
123169
J_pitch = torch.where(degenerate.view(-1, 1, 1), torch.zeros_like(J_pitch), J_pitch)
170+
# Soft-weight the pitch task: scaling J and e by the same w de-emphasises pitch in the
171+
# DLS cost without changing the task structure or dimensionality.
172+
w = self.cfg.pitch_task_weight
173+
J_pitch = J_pitch * w
174+
pitch_err = pitch_err * w
124175
J_task = torch.cat([J_pos, J_pitch], dim=1) # (N,4,num_joints)
125176
err = torch.cat([pos_err, pitch_err.unsqueeze(1)], dim=1) # (N,4)
126177
return J_task, err, degenerate
127178

179+
def _adaptive_lambda_sq(self, sigma_min: torch.Tensor) -> torch.Tensor:
180+
"""Manipulability-aware squared DLS damping per environment.
181+
182+
Returns ``lambda_min**2`` where ``sigma_min >= sigma_thresh`` and ramps quadratically to
183+
``lambda_max**2`` as ``sigma_min -> 0``.
184+
185+
Args:
186+
sigma_min: Smallest singular value of the task Jacobian, shape (N,).
187+
188+
Returns:
189+
Squared damping coefficient per environment, shape (N,).
190+
"""
191+
lo2 = self.cfg.lambda_min**2
192+
hi2 = self.cfg.lambda_max**2
193+
ratio = (sigma_min / self.cfg.sigma_thresh).clamp(max=1.0)
194+
return lo2 + (1.0 - ratio**2) * (hi2 - lo2)
195+
196+
def _damped_least_squares(self, J_task: torch.Tensor, err: torch.Tensor) -> torch.Tensor:
197+
"""Solve the damped least-squares step with manipulability-aware damping.
198+
199+
Computes ``dq = J^T (J J^T + lambda^2 I)^-1 e`` where ``lambda`` follows
200+
:meth:`_adaptive_lambda_sq` from the smallest singular value of ``J_task``.
201+
202+
Args:
203+
J_task: Task Jacobian, shape (N, T, M) with T task rows over M joints.
204+
err: Task-space error, shape (N, T).
205+
206+
Returns:
207+
Joint-space delta, shape (N, M).
208+
"""
209+
# NOTE: sigma_min is taken from the (pitch-weighted) J_task, so the damping ramp couples
210+
# with pitch_task_weight -- sigma_thresh must be re-tuned if pitch_task_weight changes.
211+
sigma_min = torch.linalg.svdvals(J_task)[:, -1] # (N,)
212+
lam2 = self._adaptive_lambda_sq(sigma_min) # (N,)
213+
jt = J_task.transpose(1, 2) # (N, M, T)
214+
n_task = J_task.shape[1]
215+
a = torch.bmm(J_task, jt) + lam2.view(-1, 1, 1) * torch.eye(n_task, device=self._device) # (N, T, T)
216+
return torch.bmm(jt, torch.linalg.solve(a, err.unsqueeze(-1))).squeeze(-1) # (N, M)
217+
218+
def set_joint_pos_limits(self, lower: torch.Tensor, upper: torch.Tensor) -> None:
219+
"""Provide the IK joints' position limits [rad] for null-space limit avoidance.
220+
221+
Args:
222+
lower: Lower joint limits [rad], shape (M,).
223+
upper: Upper joint limits [rad], shape (M,).
224+
"""
225+
self._joint_pos_lower = lower.to(self._device)
226+
self._joint_pos_upper = upper.to(self._device)
227+
228+
def _joint_limit_avoidance(self, joint_pos: torch.Tensor, J_task: torch.Tensor) -> torch.Tensor:
229+
"""Null-space joint-centering bias that keeps joints off their limits.
230+
231+
Projects a center-seeking velocity (active only within :attr:`jla_margin` of a limit)
232+
into the null space of the position task rows, so it never perturbs the commanded EE
233+
position. Returns zeros when disabled or before limits are injected.
234+
235+
Args:
236+
joint_pos: Current joint positions [rad], shape (N, M).
237+
J_task: Task Jacobian, shape (N, T, M); rows 0-2 are the position rows.
238+
239+
Returns:
240+
Joint-space correction [rad], shape (N, M).
241+
"""
242+
if self.cfg.jla_gain <= 0.0 or self._joint_pos_lower is None:
243+
return torch.zeros_like(joint_pos)
244+
lower, upper = self._joint_pos_lower, self._joint_pos_upper
245+
q_mid = 0.5 * (lower + upper)
246+
dist = torch.minimum(joint_pos - lower, upper - joint_pos) # (N,M) margin to nearest limit
247+
activation = 1.0 - (dist / self.cfg.jla_margin).clamp(0.0, 1.0) # 1 at limit, 0 mid-range
248+
dq_center = -self.cfg.jla_gain * activation * (joint_pos - q_mid) # (N,M) toward center
249+
j_pos = J_task[:, :3, :] # (N,3,M)
250+
j_pos_pinv = torch.linalg.pinv(j_pos) # (N,M,3)
251+
m = J_task.shape[2]
252+
null_proj = torch.eye(m, device=self._device) - torch.bmm(j_pos_pinv, j_pos) # (N,M,M)
253+
return torch.bmm(null_proj, dq_center.unsqueeze(-1)).squeeze(-1)
254+
128255
def compute(
129256
self,
130257
ee_pos: torch.Tensor,
@@ -144,5 +271,9 @@ def compute(
144271
Target joint positions in shape (N, num_joints).
145272
"""
146273
J_task, err, _ = self._assemble_task(ee_pos, ee_quat, jacobian)
147-
delta_joint_pos = self._compute_delta_joint_pos(delta_pose=err, jacobian=J_task)
148-
return joint_pos + delta_joint_pos
274+
delta = self._damped_least_squares(J_task, err)
275+
delta = delta + self._joint_limit_avoidance(joint_pos, J_task)
276+
# When the pitch row is zeroed (degenerate EE-over-base frame) the task loses rank and
277+
# adaptive damping saturates to lambda_max -- this damps the whole solve, which is the
278+
# intended graceful behavior inside radial_eps of the base axis.
279+
return joint_pos + delta

source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_ik_abs_env_cfg.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,9 @@ class SO101CubeStackEnvCfg(stack_joint_pos_env_cfg.SO101CubeStackEnvCfg):
211211
(``shoulder_pan``, ``shoulder_lift``, ``elbow_flex``, ``wrist_flex``), where pitch is the
212212
absolute in-plane gripper tilt [rad], and drive ``wrist_roll`` directly from the
213213
controller's roll (engage-relative swing-twist about the controller's local Z axis). The resulting action is
214-
``[pos_x, pos_y, pos_z, pitch, roll, gripper]``.
214+
``[pos_x, pos_y, pos_z, pitch, roll, gripper]``. The IK is kept well-conditioned near
215+
singularities via a soft-weighted pitch row, manipulability-aware damped least squares
216+
(adaptive lambda), and null-space joint-limit avoidance.
215217
"""
216218

217219
def __post_init__(self):
@@ -242,6 +244,12 @@ def __post_init__(self):
242244
command_type="position",
243245
use_relative_mode=False,
244246
ik_method="dls",
247+
pitch_task_weight=0.5, # TODO(tune-in-sim)
248+
lambda_min=0.05, # TODO(tune-in-sim)
249+
lambda_max=0.2, # TODO(tune-in-sim)
250+
sigma_thresh=0.02, # TODO(tune-in-sim)
251+
jla_gain=2.0, # TODO(tune-in-sim); 0 disables joint-limit avoidance
252+
jla_margin=0.3, # TODO(tune-in-sim)
245253
),
246254
),
247255
# Direct wrist-roll passthrough from the retargeted controller roll [rad].

0 commit comments

Comments
 (0)