@@ -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
3979class 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
0 commit comments