1+ #!/usr/bin/env python3
2+ """Custom Jacobian-based IK solver for OpenArm."""
3+
4+ import numpy as np
5+ from typing import Optional , Tuple
6+
7+
8+ class OpenArmJacobianIK :
9+ """Robust IK solver using numerical Jacobian and damped least squares."""
10+
11+ def __init__ (self , urdf_path : Optional [str ] = None ):
12+ """Initialize the IK solver.
13+
14+ Args:
15+ urdf_path: Path to URDF file (optional, uses default if None)
16+ """
17+ if urdf_path is None :
18+ from openarm .kinematics .models import OPENARM_URDF_PATH
19+ urdf_path = OPENARM_URDF_PATH
20+
21+ # Use ikpy only for FK (which works fine)
22+ import ikpy .chain
23+
24+ # Right arm chain
25+ active_links_mask = [False ] + [True ]* 7 + [False ]* 4
26+ self ._right_chain = ikpy .chain .Chain .from_urdf_file (
27+ urdf_path ,
28+ base_elements = ["openarm_right_link0" ],
29+ last_link_vector = [0 , 0 , 0.08 ],
30+ name = "openarm_right_arm" ,
31+ active_links_mask = active_links_mask ,
32+ )
33+
34+ # Left arm chain
35+ self ._left_chain = ikpy .chain .Chain .from_urdf_file (
36+ urdf_path ,
37+ base_elements = ["openarm_left_link0" ],
38+ last_link_vector = [0 , 0 , 0.08 ],
39+ name = "openarm_left_arm" ,
40+ active_links_mask = active_links_mask ,
41+ )
42+
43+ # Joint limits (radians)
44+ self .joint_limits = np .array ([
45+ [- 3.490659 , 1.396263 ], # J1
46+ [- 1.745329 , 1.745329 ], # J2
47+ [- 1.570796 , 1.570796 ], # J3
48+ [0.0 , 2.443461 ], # J4
49+ [- 1.570796 , 1.570796 ], # J5
50+ [- 0.785398 , 0.785398 ], # J6
51+ [- 1.570796 , 1.570796 ], # J7
52+ ])
53+
54+ def _forward_kinematics (self , chain , joint_angles : np .ndarray ) -> Tuple [np .ndarray , np .ndarray ]:
55+ """Compute forward kinematics.
56+
57+ Args:
58+ chain: IKPy chain
59+ joint_angles: 7 joint angles in radians
60+
61+ Returns:
62+ (position, rotation_matrix)
63+ """
64+ config_full = np .zeros (12 )
65+ config_full [1 :8 ] = joint_angles
66+
67+ fk_matrix = chain .forward_kinematics (config_full )
68+ position = fk_matrix [:3 , 3 ]
69+ rotation = fk_matrix [:3 , :3 ]
70+
71+ return position , rotation
72+
73+ def _compute_jacobian (self , chain , joint_angles : np .ndarray , epsilon : float = 1e-5 ) -> np .ndarray :
74+ """Compute numerical Jacobian (position only, 3x7).
75+
76+ Args:
77+ chain: IKPy chain
78+ joint_angles: Current 7 joint angles
79+ epsilon: Small perturbation for numerical derivative
80+
81+ Returns:
82+ 3x7 Jacobian matrix (dx/dq, dy/dq, dz/dq)
83+ """
84+ jacobian = np .zeros ((3 , 7 ))
85+
86+ # Current position
87+ pos_current , _ = self ._forward_kinematics (chain , joint_angles )
88+
89+ # Numerical derivative for each joint
90+ for i in range (7 ):
91+ # Perturb joint i
92+ joints_perturbed = joint_angles .copy ()
93+ joints_perturbed [i ] += epsilon
94+
95+ # Compute perturbed position
96+ pos_perturbed , _ = self ._forward_kinematics (chain , joints_perturbed )
97+
98+ # Derivative: (pos_perturbed - pos_current) / epsilon
99+ jacobian [:, i ] = (pos_perturbed - pos_current ) / epsilon
100+
101+ return jacobian
102+
103+ def _clamp_joints (self , joint_angles : np .ndarray ) -> np .ndarray :
104+ """Clamp joint angles to limits.
105+
106+ Args:
107+ joint_angles: 7 joint angles
108+
109+ Returns:
110+ Clamped joint angles
111+ """
112+ clamped = joint_angles .copy ()
113+ for i in range (7 ):
114+ clamped [i ] = np .clip (
115+ clamped [i ],
116+ self .joint_limits [i , 0 ],
117+ self .joint_limits [i , 1 ]
118+ )
119+ return clamped
120+
121+ def solve (
122+ self ,
123+ chain ,
124+ target_position : np .ndarray ,
125+ initial_joints : np .ndarray ,
126+ target_orientation : Optional [np .ndarray ] = None ,
127+ max_iterations : int = 200 , # Increased from 100
128+ position_tolerance : float = 0.001 , # 1mm
129+ step_size : float = 0.3 , # Reduced from 0.5 for stability
130+ damping : float = 0.05 , # Increased damping for near-singularities
131+ ) -> Tuple [np .ndarray , bool , float ]:
132+ """Solve IK using damped least squares.
133+
134+ Args:
135+ chain: IKPy chain (left or right)
136+ target_position: Target [x, y, z] position
137+ initial_joints: Initial 7 joint angles (starting guess)
138+ target_orientation: Optional target rotation matrix (ignored for now)
139+ max_iterations: Maximum iterations
140+ position_tolerance: Success threshold in meters
141+ step_size: Step size multiplier (0-1, smaller = more stable)
142+ damping: Damping factor for numerical stability
143+
144+ Returns:
145+ (solution_joints, converged, final_error)
146+ """
147+ current_joints = initial_joints .copy ()
148+ previous_error = float ('inf' )
149+
150+ for iteration in range (max_iterations ):
151+ # Forward kinematics
152+ current_pos , _ = self ._forward_kinematics (chain , current_joints )
153+
154+ # Position error
155+ error = target_position - current_pos
156+ error_norm = np .linalg .norm (error )
157+
158+ # Check convergence
159+ if error_norm < position_tolerance :
160+ return current_joints , True , error_norm
161+
162+ # Adaptive step size: increase if improving, decrease if stuck
163+ if error_norm < previous_error :
164+ adaptive_step = min (step_size * 1.2 , 1.0 ) # Increase up to 1.0
165+ else :
166+ adaptive_step = step_size * 0.5 # Decrease if not improving
167+
168+ previous_error = error_norm
169+
170+ # Compute Jacobian
171+ J = self ._compute_jacobian (chain , current_joints )
172+
173+ # Damped least squares: delta_q = J^T * (J*J^T + damping*I)^-1 * error
174+ # Simplified: delta_q = J^T * error (Jacobian transpose method)
175+ # More stable: Use pseudo-inverse with damping
176+ JJT = J @ J .T
177+ JJT_damped = JJT + damping * np .eye (3 )
178+
179+ try :
180+ delta_q = J .T @ np .linalg .solve (JJT_damped , error )
181+ except np .linalg .LinAlgError :
182+ # Fallback to simple Jacobian transpose
183+ delta_q = J .T @ error
184+
185+ # Update joints with adaptive step size
186+ current_joints = current_joints + adaptive_step * delta_q
187+
188+ # Clamp to joint limits
189+ current_joints = self ._clamp_joints (current_joints )
190+
191+ # Optional: Print progress every 10 iterations
192+ if iteration % 10 == 0 and iteration > 0 :
193+ print (f" Iteration { iteration } : error = { error_norm * 1000 :.2f} mm, step = { adaptive_step :.3f} " )
194+
195+ # Did not converge
196+ current_pos , _ = self ._forward_kinematics (chain , current_joints )
197+ final_error = np .linalg .norm (target_position - current_pos )
198+
199+ return current_joints , False , final_error
200+
201+ def solve_right_arm (
202+ self ,
203+ target_position : np .ndarray ,
204+ initial_joints : Optional [np .ndarray ] = None ,
205+ ** kwargs
206+ ) -> np .ndarray :
207+ """Solve IK for right arm.
208+
209+ Args:
210+ target_position: Target [x, y, z] position
211+ initial_joints: Initial joint guess (uses zeros if None)
212+ **kwargs: Additional arguments for solve()
213+
214+ Returns:
215+ 7 joint angles in radians
216+ """
217+ if initial_joints is None :
218+ # Default: arms extended sideways (J2=0°)
219+ initial_joints = np .zeros (7 )
220+
221+ solution , converged , error = self .solve (
222+ self ._right_chain ,
223+ target_position ,
224+ initial_joints ,
225+ ** kwargs
226+ )
227+
228+ if not converged :
229+ print (f"Warning: IK did not converge (error: { error * 1000 :.2f} mm)" )
230+
231+ return solution
232+
233+ def solve_left_arm (
234+ self ,
235+ target_position : np .ndarray ,
236+ initial_joints : Optional [np .ndarray ] = None ,
237+ ** kwargs
238+ ) -> np .ndarray :
239+ """Solve IK for left arm.
240+
241+ Args:
242+ target_position: Target [x, y, z] position
243+ initial_joints: Initial joint guess (uses zeros if None)
244+ **kwargs: Additional arguments for solve()
245+
246+ Returns:
247+ 7 joint angles in radians
248+ """
249+ if initial_joints is None :
250+ # Default: arms extended sideways (J2=0°)
251+ initial_joints = np .zeros (7 )
252+
253+ solution , converged , error = self .solve (
254+ self ._left_chain ,
255+ target_position ,
256+ initial_joints ,
257+ ** kwargs
258+ )
259+
260+ if not converged :
261+ print (f"Warning: IK did not converge (error: { error * 1000 :.2f} mm)" )
262+
263+ return solution
264+
265+
266+ # Convenience wrapper with frame translation
267+ class OpenArmIKWrapper :
268+ """Wrapper that handles physical<->URDF frame translation."""
269+
270+ def __init__ (self ):
271+ self .ik_solver = OpenArmJacobianIK ()
272+
273+ def physical_to_urdf_right (self , physical_joints_deg : np .ndarray ) -> np .ndarray :
274+ """Convert right arm physical joints to URDF frame (radians)."""
275+ urdf_joints_deg = physical_joints_deg .copy ()
276+ urdf_joints_deg [1 ] -= 90 # J2: Physical 0° → URDF -90°
277+ return np .deg2rad (urdf_joints_deg )
278+
279+ def urdf_to_physical_right (self , urdf_joints_rad : np .ndarray ) -> np .ndarray :
280+ """Convert right arm URDF joints to physical frame (degrees)."""
281+ urdf_joints_deg = np .rad2deg (urdf_joints_rad )
282+ physical_joints_deg = urdf_joints_deg .copy ()
283+ physical_joints_deg [1 ] += 90 # J2: URDF -90° → Physical 0°
284+ return physical_joints_deg
285+
286+ def physical_to_urdf_left (self , physical_joints_deg : np .ndarray ) -> np .ndarray :
287+ """Convert left arm physical joints to URDF frame (radians)."""
288+ urdf_joints_deg = physical_joints_deg .copy ()
289+ urdf_joints_deg [1 ] += 90 # J2: Physical 0° → URDF +90°
290+ return np .deg2rad (urdf_joints_deg )
291+
292+ def urdf_to_physical_left (self , urdf_joints_rad : np .ndarray ) -> np .ndarray :
293+ """Convert left arm URDF joints to physical frame (degrees)."""
294+ urdf_joints_deg = np .rad2deg (urdf_joints_rad )
295+ physical_joints_deg = urdf_joints_deg .copy ()
296+ physical_joints_deg [1 ] -= 90 # J2: URDF +90° → Physical 0°
297+ return physical_joints_deg
298+
299+ def solve_right_arm_physical (
300+ self ,
301+ target_position : np .ndarray ,
302+ current_physical_joints_deg : np .ndarray ,
303+ ** kwargs
304+ ) -> np .ndarray :
305+ """Solve IK for right arm, handling frame translation.
306+
307+ Args:
308+ target_position: Target [x, y, z] in meters
309+ current_physical_joints_deg: Current physical joint angles in degrees
310+
311+ Returns:
312+ Physical joint angles in degrees
313+ """
314+ # Convert to URDF frame
315+ urdf_initial = self .physical_to_urdf_right (current_physical_joints_deg )
316+
317+ # Solve IK in URDF frame
318+ urdf_solution = self .ik_solver .solve_right_arm (
319+ target_position ,
320+ initial_joints = urdf_initial ,
321+ ** kwargs
322+ )
323+
324+ # Convert back to physical frame
325+ physical_solution = self .urdf_to_physical_right (urdf_solution )
326+
327+ return physical_solution
328+
329+ def solve_left_arm_physical (
330+ self ,
331+ target_position : np .ndarray ,
332+ current_physical_joints_deg : np .ndarray ,
333+ ** kwargs
334+ ) -> np .ndarray :
335+ """Solve IK for left arm, handling frame translation.
336+
337+ Args:
338+ target_position: Target [x, y, z] in meters
339+ current_physical_joints_deg: Current physical joint angles in degrees
340+
341+ Returns:
342+ Physical joint angles in degrees
343+ """
344+ # Convert to URDF frame
345+ urdf_initial = self .physical_to_urdf_left (current_physical_joints_deg )
346+
347+ # Solve IK in URDF frame
348+ urdf_solution = self .ik_solver .solve_left_arm (
349+ target_position ,
350+ initial_joints = urdf_initial ,
351+ ** kwargs
352+ )
353+
354+ # Convert back to physical frame
355+ physical_solution = self .urdf_to_physical_left (urdf_solution )
356+
357+ return physical_solution
0 commit comments