Create a solver#
Overview#
The solver module in EmbodiChain provides a unified and extensible interface for robot kinematics computation, including forward kinematics (FK), inverse kinematics (IK), and Jacobian calculation. It supports multiple solver backends (e.g., Pinocchio, OPW, SRS, PINK, PyTorch) and is designed for both simulation and real-robot applications.
Key Features#
Unified API: Abstract base class (BaseSolver) defines a common interface for all solvers.
Multiple Backends: Supports Pinocchio, OPW, SRS, PINK, PyTorch, and differential solvers.
Flexible Configuration: Easily switch solver type and parameters via configuration.
Batch and Single Query: Supports both batch and single FK/IK/Jacobian queries.
Extensible: New solvers can be added by subclassing BaseSolver and implementing required methods.
The Code#
The tutorial corresponds to the srs_solver.py script in the scripts/tutorials/sim directory.
Code for srs_solver.py
1# ----------------------------------------------------------------------------
2# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15# ----------------------------------------------------------------------------
16
17"""Move a W1 end effector along a Cartesian straight line with SRS IK."""
18
19from __future__ import annotations
20
21import argparse
22import sys
23import time
24import traceback
25
26import numpy as np
27import torch
28
29from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
30from embodichain.lab.sim.cfg import MarkerCfg
31from embodichain.lab.sim.objects import Robot
32from embodichain.lab.sim.robots import DexforceW1Cfg
33from embodichain.lab.visualization import (
34 VisualizationCfg,
35 add_viser_args_to_parser,
36 visualization_cfg_from_args,
37)
38
39
40def main(
41 device: str,
42 num_steps: int,
43 line_offset: tuple[float, float, float],
44 physics_steps: int,
45 max_joint_step_deg: float,
46 visualization: VisualizationCfg | None = None,
47) -> None:
48 """Run sequential SRS IK targets along a fixed-orientation straight line.
49
50 Args:
51 device: Simulation and SRS solver device, either ``cpu`` or ``cuda``.
52 num_steps: Number of Cartesian waypoints, including both endpoints.
53 line_offset: End-point translation relative to the initial TCP pose, in meters.
54 physics_steps: Number of simulation steps displayed per waypoint.
55 max_joint_step_deg: Maximum allowed change of any joint per IK waypoint.
56 visualization: Optional visualization configuration.
57 """
58 if num_steps < 2:
59 raise ValueError("num_steps must be at least 2")
60 if physics_steps < 1:
61 raise ValueError("physics_steps must be at least 1")
62 if max_joint_step_deg <= 0.0:
63 raise ValueError("max_joint_step_deg must be positive")
64 if device == "cuda" and not torch.cuda.is_available():
65 raise RuntimeError(
66 "CUDA was requested, but PyTorch cannot access a CUDA device"
67 )
68
69 np.set_printoptions(precision=5, suppress=True)
70 torch.set_printoptions(precision=5, sci_mode=False)
71 sim = SimulationManager(
72 SimulationManagerCfg(
73 # Keep the native window closed while planning so renderer/window
74 # lifecycle events cannot terminate or perturb timed CUDA IK calls.
75 headless=True,
76 sim_device=device,
77 width=2200,
78 height=1200,
79 visualization=visualization or VisualizationCfg(),
80 )
81 )
82 # Keep physics paused during pose conversion, IK, and FK error measurement.
83
84 try:
85 arm_name = "left_arm"
86 robot_cfg = DexforceW1Cfg.from_dict({"uid": "dexforce_w1"})
87 # The robot default intentionally ignores the elbow joint when ranking IK
88 # candidates. For a Cartesian-path tutorial, weight every joint to prevent
89 # visually disruptive branch changes between adjacent waypoints.
90 robot_cfg.solver_cfg[arm_name].ik_nearest_weight = np.array(
91 [2.0, 2.0, 2.0, 2.0, 1.0, 1.0, 1.0]
92 )
93 robot: Robot = sim.add_robot(cfg=robot_cfg)
94 joint_ids = robot.get_joint_ids(arm_name)
95 qpos_seed = torch.tensor(
96 [[np.pi / 6, 0.0, 0.0, -np.pi / 2, 0.0, 0.0, np.pi / 6]],
97 dtype=torch.float32,
98 device=sim.device,
99 )
100 reference_qpos = qpos_seed.clone()
101 robot.set_qpos(qpos_seed, joint_ids=joint_ids, target=False)
102 robot.set_qpos(qpos_seed, joint_ids=joint_ids, target=True)
103 sim.update(step=physics_steps)
104
105 start_pose = robot.compute_fk(qpos=qpos_seed, name=arm_name, to_matrix=True)
106 target_poses = start_pose.repeat(num_steps, 1, 1)
107 offset = torch.tensor(
108 line_offset, dtype=start_pose.dtype, device=start_pose.device
109 )
110 interpolation = torch.linspace(
111 0.0, 1.0, num_steps, dtype=start_pose.dtype, device=start_pose.device
112 )
113 target_poses[:, :3, 3] += interpolation.unsqueeze(1) * offset
114
115 # Warm up lazy FK compilation and the selected SRS backend outside timing.
116 robot.compute_ik(
117 pose=target_poses[:1],
118 joint_seed=qpos_seed,
119 name=arm_name,
120 return_all_solutions=True,
121 )
122 if device == "cuda":
123 torch.cuda.synchronize()
124 torch.cuda.reset_peak_memory_stats()
125
126 solve_times_ms: list[float] = []
127 candidate_counts: list[int] = []
128 translation_errors_mm: list[float] = []
129 waypoint_candidates: list[torch.Tensor] = []
130 continuity_weights = torch.tensor(
131 [2.0, 2.0, 2.0, 2.0, 1.0, 1.0, 1.0],
132 dtype=qpos_seed.dtype,
133 device=qpos_seed.device,
134 )
135 max_joint_step = torch.deg2rad(
136 torch.tensor(
137 max_joint_step_deg, dtype=qpos_seed.dtype, device=qpos_seed.device
138 )
139 )
140 for waypoint, target_pose in enumerate(target_poses):
141 start_time = time.perf_counter()
142 success, solution = robot.compute_ik(
143 pose=target_pose.unsqueeze(0),
144 # Use one fixed seed while enumerating candidates. Path continuity
145 # is selected globally below instead of greedily changing the
146 # candidate set after every waypoint.
147 joint_seed=reference_qpos,
148 name=arm_name,
149 return_all_solutions=True,
150 )
151 if device == "cuda":
152 torch.cuda.synchronize()
153 solve_times_ms.append((time.perf_counter() - start_time) * 1000.0)
154
155 if not bool(success[0]):
156 raise RuntimeError(
157 f"Trajectory planning failed at waypoint "
158 f"{waypoint + 1}/{num_steps}; nothing was executed."
159 )
160
161 candidates = solution[0]
162 candidate_counts.append(candidates.shape[0])
163 waypoint_candidates.append(candidates)
164 print(
165 f"Enumerated {waypoint + 1:03d}/{num_steps}: "
166 f"solve={solve_times_ms[-1]:.3f} ms, "
167 f"candidates={candidates.shape[0]}"
168 )
169
170 # Find a minimum-cost path through the layered IK candidate graph. This
171 # avoids the greedy failure mode where a locally attractive arm angle has
172 # no continuous successor at a later waypoint.
173 path_costs: list[torch.Tensor] = []
174 predecessors: list[torch.Tensor] = []
175 first_delta = torch.atan2(
176 torch.sin(waypoint_candidates[0] - reference_qpos),
177 torch.cos(waypoint_candidates[0] - reference_qpos),
178 )
179 first_allowed = first_delta.abs().amax(dim=1) <= max_joint_step
180 first_cost = (first_delta.square() * continuity_weights).sum(dim=1)
181 first_cost.masked_fill_(~first_allowed, float("inf"))
182 path_costs.append(first_cost)
183 predecessors.append(torch.full_like(first_cost, -1, dtype=torch.long))
184
185 for waypoint in range(1, num_steps):
186 previous_candidates = waypoint_candidates[waypoint - 1]
187 candidates = waypoint_candidates[waypoint]
188 edge_delta = torch.atan2(
189 torch.sin(candidates[:, None, :] - previous_candidates[None, :, :]),
190 torch.cos(candidates[:, None, :] - previous_candidates[None, :, :]),
191 )
192 allowed_edges = edge_delta.abs().amax(dim=2) <= max_joint_step
193 transition_cost = (edge_delta.square() * continuity_weights).sum(dim=2)
194 transition_cost.masked_fill_(~allowed_edges, float("inf"))
195 reference_delta = torch.atan2(
196 torch.sin(candidates - reference_qpos),
197 torch.cos(candidates - reference_qpos),
198 )
199 node_cost = 0.05 * (reference_delta.square() * continuity_weights).sum(
200 dim=1
201 )
202 total_cost = transition_cost + path_costs[-1].unsqueeze(0)
203 best_cost, best_predecessor = total_cost.min(dim=1)
204 best_cost += node_cost
205 if not bool(torch.isfinite(best_cost).any()):
206 reachable_previous = torch.isfinite(path_costs[-1])
207 reachable_edges = edge_delta[:, reachable_previous]
208 smallest_step = reachable_edges.abs().amax(dim=2).min()
209 raise RuntimeError(
210 f"No globally continuous IK path reaches waypoint "
211 f"{waypoint + 1}/{num_steps}: smallest available maximum "
212 f"joint step is {torch.rad2deg(smallest_step).item():.3f} deg, "
213 f"limit is {max_joint_step_deg:.3f} deg."
214 )
215 path_costs.append(best_cost)
216 predecessors.append(best_predecessor)
217
218 selected_indices = [int(path_costs[-1].argmin())]
219 for waypoint in range(num_steps - 1, 0, -1):
220 selected_indices.append(int(predecessors[waypoint][selected_indices[-1]]))
221 selected_indices.reverse()
222 planned_qpos = [
223 waypoint_candidates[i][selected_indices[i]].unsqueeze(0)
224 for i in range(num_steps)
225 ]
226
227 for waypoint, (target_pose, qpos_seed) in enumerate(
228 zip(target_poses, planned_qpos, strict=True)
229 ):
230 actual_pose = robot.compute_fk(
231 qpos=qpos_seed, name=arm_name, to_matrix=True
232 )
233 error_mm = float(
234 torch.linalg.vector_norm(
235 actual_pose[0, :3, 3] - target_pose[:3, 3]
236 ).item()
237 * 1000.0
238 )
239 translation_errors_mm.append(error_mm)
240 print(
241 f"Planned {waypoint + 1:03d}/{num_steps}: " f"error={error_mm:.4f} mm"
242 )
243
244 print(
245 f"SRS {device.upper()} planning summary: {num_steps}/{num_steps} solved, "
246 f"median/p95/mean solve={np.median(solve_times_ms):.3f}/"
247 f"{np.percentile(solve_times_ms, 95):.3f}/"
248 f"{np.mean(solve_times_ms):.3f} ms, "
249 f"max translation error="
250 f"{max(translation_errors_mm, default=float('nan')):.4f} mm"
251 )
252 print(
253 f"IK candidates per waypoint min/median/max: "
254 f"{min(candidate_counts)}/{int(np.median(candidate_counts))}/"
255 f"{max(candidate_counts)}"
256 )
257 if device == "cuda":
258 print(
259 f"CUDA peak allocated memory: "
260 f"{torch.cuda.max_memory_allocated() / 1024**2:.2f} MiB"
261 )
262 planned_qpos_tensor = torch.cat(planned_qpos)
263 wrapped_steps = torch.atan2(
264 torch.sin(planned_qpos_tensor[1:] - planned_qpos_tensor[:-1]),
265 torch.cos(planned_qpos_tensor[1:] - planned_qpos_tensor[:-1]),
266 )
267 max_step_flat_index = wrapped_steps.abs().argmax()
268 max_step_waypoint = int(max_step_flat_index // wrapped_steps.shape[1]) + 2
269 max_step_joint = int(max_step_flat_index % wrapped_steps.shape[1]) + 1
270 print(
271 f"Max adjacent joint step: "
272 f"{torch.rad2deg(wrapped_steps.abs().max()).item():.3f} deg "
273 f"at waypoint {max_step_waypoint}, joint {max_step_joint}"
274 )
275 if wrapped_steps.shape[0] > 1:
276 joint_step_changes = wrapped_steps[1:] - wrapped_steps[:-1]
277 print(
278 f"Max joint step change: "
279 f"{torch.rad2deg(joint_step_changes.abs().max()).item():.3f} deg"
280 )
281
282 sim.open_window()
283 marker_stride = max(1, num_steps // 25)
284 marker_indices = torch.arange(0, num_steps, marker_stride, device=sim.device)
285 if marker_indices[-1] != num_steps - 1:
286 marker_indices = torch.cat(
287 (marker_indices, marker_indices.new_tensor([num_steps - 1]))
288 )
289 sim.draw_marker(
290 MarkerCfg(
291 name="srs_target_path",
292 marker_type="axis",
293 axis_xpos=target_poses[marker_indices],
294 axis_size=0.0006,
295 axis_len=0.008,
296 arena_index=0,
297 )
298 )
299
300 print("Planning completed; executing the joint trajectory...")
301 execution_errors_mm: list[float] = []
302 previous_qpos = robot.get_qpos(name=arm_name).clone()
303 zero_qvel = torch.zeros_like(previous_qpos)
304 for waypoint, qpos in enumerate(planned_qpos):
305 wrapped_delta = torch.atan2(
306 torch.sin(qpos - previous_qpos), torch.cos(qpos - previous_qpos)
307 )
308 for substep in range(1, physics_steps + 1):
309 alpha = substep / physics_steps
310 interpolated_qpos = previous_qpos + alpha * wrapped_delta
311 robot.set_qpos(interpolated_qpos, joint_ids=joint_ids, target=False)
312 robot.set_qpos(interpolated_qpos, joint_ids=joint_ids, target=True)
313 robot.set_qvel(zero_qvel, joint_ids=joint_ids, target=False)
314 robot.set_qvel(zero_qvel, joint_ids=joint_ids, target=True)
315 sim.update(step=1)
316 previous_qpos = qpos
317 actual_qpos = robot.get_qpos(name=arm_name)
318 actual_pose = robot.compute_fk(
319 qpos=actual_qpos, name=arm_name, to_matrix=True
320 )
321 execution_errors_mm.append(
322 float(
323 torch.linalg.vector_norm(
324 actual_pose[0, :3, 3] - target_poses[waypoint, :3, 3]
325 ).item()
326 * 1000.0
327 )
328 )
329 if waypoint % marker_stride == 0 or waypoint == num_steps - 1:
330 sim.draw_marker(
331 MarkerCfg(
332 name=f"srs_executed_path_{waypoint:03d}",
333 marker_type="axis",
334 axis_xpos=actual_pose,
335 axis_size=0.0012,
336 axis_len=0.004,
337 arena_index=0,
338 )
339 )
340 print("Trajectory execution completed.")
341 print(
342 f"Max execution tracking error: {max(execution_errors_mm):.4f} mm. "
343 "Long axes show targets; short thick axes show executed samples."
344 )
345 sim.capture_visualization(force=True)
346 finally:
347 # Do not use the default os._exit(0) cleanup path: it suppresses Python
348 # tracebacks raised during planning and makes failures look like clean exits.
349 sim.destroy(exit_process=False)
350
351
352if __name__ == "__main__":
353 parser = argparse.ArgumentParser(description=__doc__)
354 parser.add_argument(
355 "--device", choices=("cpu", "cuda"), default="cpu", help="SRS backend."
356 )
357 parser.add_argument(
358 "--num-steps", type=int, default=50, help="Number of line waypoints."
359 )
360 parser.add_argument(
361 "--line-offset",
362 type=float,
363 nargs=3,
364 metavar=("X", "Y", "Z"),
365 default=(0.0, -0.30, 0.0),
366 help="TCP line displacement in meters (default: 0 -0.30 0).",
367 )
368 parser.add_argument(
369 "--physics-steps",
370 type=int,
371 default=20,
372 help="Simulation interpolation steps per IK waypoint (default: 20).",
373 )
374 parser.add_argument(
375 "--max-joint-step-deg",
376 type=float,
377 default=15.0,
378 help="Reject an IK branch changing any joint by more than this angle.",
379 )
380 add_viser_args_to_parser(parser)
381 args = parser.parse_args()
382 exit_code = 0
383 try:
384 main(
385 device=args.device,
386 num_steps=args.num_steps,
387 line_offset=tuple(args.line_offset),
388 physics_steps=args.physics_steps,
389 max_joint_step_deg=args.max_joint_step_deg,
390 visualization=visualization_cfg_from_args(args),
391 )
392 except BaseException:
393 traceback.print_exc()
394 exit_code = 1
395 finally:
396 # Deferred destruction is only safe after main() has unwound and no local
397 # Robot/solver wrappers remain live on its Python frame.
398 SimulationManager.flush_cleanup_queue()
399 sys.exit(exit_code)
Typical Usage#
Step 1: Configure solver
srs_cfg = SrsSolverCfg(
urdf_path="/path/to/robot.urdf",
joint_names=[
"shoulder_pan_joint", "shoulder_lift_joint", "elbow_joint",
"wrist_1_joint", "wrist_2_joint", "wrist_3_joint"
],
end_link_name="ee_link",
root_link_name="base_link"
)
Step 2: Instantiate the robot with solver
robot_cfg.solver_cfg = srs_cfg
robot = Robot(cfg=robot_cfg, entities=[], device="cpu")
Step 3: Use FK/IK/Jacobian
qpos = [0.0, -1.57, 1.57, 0.0, 1.57, 0.0]
ee_pose = robot.compute_fk(qpos)
target_pose = np.array([
[0, -1, 0, 0.5],
[1, 0, 0, 0.2],
[0, 0, 1, 0.3],
[0, 0, 0, 1.0]
])
success, qpos_sol = robot.compute_ik(target_pose, joint_seed=qpos)
J = robot.get_solver().get_jacobian(qpos)
Note
robot.compute_fk(qpos) internally calls the bound solver’s get_fk method.
robot.compute_ik(target_pose, joint_seed) internally calls the solver’s get_ik method.
API Reference#
BaseSolver
class BaseSolver:
def get_fk(self, qpos, **kwargs) -> torch.Tensor:
"""Compute forward kinematics for the end-effector."""
def get_ik(self, target_pose, joint_seed=None, num_samples=None, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]:
"""Compute inverse kinematics for a given pose."""
def get_jacobian(self, qpos, locations=None, jac_type="full") -> torch.Tensor:
"""Compute the Jacobian matrix for the given joint positions."""
set_ik_nearst_weight: Set weights for IK nearest neighbor search.
set_qpos_limits / get_qpos_limits: Set or get joint position limits.
set_tcp / get_tcp: Set or get the tool center point (TCP) transformation.
Configuration#
All solvers are configured via a SolverCfg or its subclass (e.g., PinkSolverCfg).
Key config fields: urdf_path, joint_names, end_link_name, root_link_name, tcp, and solver-specific parameters.
Use cfg.init_solver() to instantiate the solver, or assign to robot_cfg.solver_cfg for automatic integration.
Notes & Best Practices#
Always ensure URDF and joint/link names match your robot model.
For IK, providing a good qpos_seed improves convergence and solution quality.
Use set_iteration_params (if available) to tune solver performance for your application.
For custom robots or new algorithms, subclass BaseSolver and register your solver.
See Also#
Motion Generator — Motion Generator
Creating a Basic Environment — Basic Environment Setup