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_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 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 sim.prepare()
95 joint_ids = robot.get_joint_ids(arm_name)
96 qpos_seed = torch.tensor(
97 [[np.pi / 6, 0.0, 0.0, -np.pi / 2, 0.0, 0.0, np.pi / 6]],
98 dtype=torch.float32,
99 device=sim.device,
100 )
101 reference_qpos = qpos_seed.clone()
102 robot.set_qpos(qpos_seed, joint_ids=joint_ids, target=False)
103 robot.set_qpos(qpos_seed, joint_ids=joint_ids, target=True)
104 sim.update(step=physics_steps)
105
106 start_pose = robot.compute_fk(qpos=qpos_seed, name=arm_name, to_matrix=True)
107 target_poses = start_pose.repeat(num_steps, 1, 1)
108 offset = torch.tensor(
109 line_offset, dtype=start_pose.dtype, device=start_pose.device
110 )
111 interpolation = torch.linspace(
112 0.0, 1.0, num_steps, dtype=start_pose.dtype, device=start_pose.device
113 )
114 target_poses[:, :3, 3] += interpolation.unsqueeze(1) * offset
115
116 # Warm up lazy FK compilation and the selected SRS backend outside timing.
117 robot.compute_ik(
118 pose=target_poses[:1],
119 joint_seed=qpos_seed,
120 name=arm_name,
121 return_all_solutions=True,
122 )
123 if device == "cuda":
124 torch.cuda.synchronize()
125 torch.cuda.reset_peak_memory_stats()
126
127 solve_times_ms: list[float] = []
128 candidate_counts: list[int] = []
129 translation_errors_mm: list[float] = []
130 waypoint_candidates: list[torch.Tensor] = []
131 continuity_weights = torch.tensor(
132 [2.0, 2.0, 2.0, 2.0, 1.0, 1.0, 1.0],
133 dtype=qpos_seed.dtype,
134 device=qpos_seed.device,
135 )
136 max_joint_step = torch.deg2rad(
137 torch.tensor(
138 max_joint_step_deg, dtype=qpos_seed.dtype, device=qpos_seed.device
139 )
140 )
141 for waypoint, target_pose in enumerate(target_poses):
142 start_time = time.perf_counter()
143 success, solution = robot.compute_ik(
144 pose=target_pose.unsqueeze(0),
145 # Use one fixed seed while enumerating candidates. Path continuity
146 # is selected globally below instead of greedily changing the
147 # candidate set after every waypoint.
148 joint_seed=reference_qpos,
149 name=arm_name,
150 return_all_solutions=True,
151 )
152 if device == "cuda":
153 torch.cuda.synchronize()
154 solve_times_ms.append((time.perf_counter() - start_time) * 1000.0)
155
156 if not bool(success[0]):
157 raise RuntimeError(
158 f"Trajectory planning failed at waypoint "
159 f"{waypoint + 1}/{num_steps}; nothing was executed."
160 )
161
162 candidates = solution[0]
163 candidate_counts.append(candidates.shape[0])
164 waypoint_candidates.append(candidates)
165 print(
166 f"Enumerated {waypoint + 1:03d}/{num_steps}: "
167 f"solve={solve_times_ms[-1]:.3f} ms, "
168 f"candidates={candidates.shape[0]}"
169 )
170
171 # Find a minimum-cost path through the layered IK candidate graph. This
172 # avoids the greedy failure mode where a locally attractive arm angle has
173 # no continuous successor at a later waypoint.
174 path_costs: list[torch.Tensor] = []
175 predecessors: list[torch.Tensor] = []
176 first_delta = torch.atan2(
177 torch.sin(waypoint_candidates[0] - reference_qpos),
178 torch.cos(waypoint_candidates[0] - reference_qpos),
179 )
180 first_allowed = first_delta.abs().amax(dim=1) <= max_joint_step
181 first_cost = (first_delta.square() * continuity_weights).sum(dim=1)
182 first_cost.masked_fill_(~first_allowed, float("inf"))
183 path_costs.append(first_cost)
184 predecessors.append(torch.full_like(first_cost, -1, dtype=torch.long))
185
186 for waypoint in range(1, num_steps):
187 previous_candidates = waypoint_candidates[waypoint - 1]
188 candidates = waypoint_candidates[waypoint]
189 edge_delta = torch.atan2(
190 torch.sin(candidates[:, None, :] - previous_candidates[None, :, :]),
191 torch.cos(candidates[:, None, :] - previous_candidates[None, :, :]),
192 )
193 allowed_edges = edge_delta.abs().amax(dim=2) <= max_joint_step
194 transition_cost = (edge_delta.square() * continuity_weights).sum(dim=2)
195 transition_cost.masked_fill_(~allowed_edges, float("inf"))
196 reference_delta = torch.atan2(
197 torch.sin(candidates - reference_qpos),
198 torch.cos(candidates - reference_qpos),
199 )
200 node_cost = 0.05 * (reference_delta.square() * continuity_weights).sum(
201 dim=1
202 )
203 total_cost = transition_cost + path_costs[-1].unsqueeze(0)
204 best_cost, best_predecessor = total_cost.min(dim=1)
205 best_cost += node_cost
206 if not bool(torch.isfinite(best_cost).any()):
207 reachable_previous = torch.isfinite(path_costs[-1])
208 reachable_edges = edge_delta[:, reachable_previous]
209 smallest_step = reachable_edges.abs().amax(dim=2).min()
210 raise RuntimeError(
211 f"No globally continuous IK path reaches waypoint "
212 f"{waypoint + 1}/{num_steps}: smallest available maximum "
213 f"joint step is {torch.rad2deg(smallest_step).item():.3f} deg, "
214 f"limit is {max_joint_step_deg:.3f} deg."
215 )
216 path_costs.append(best_cost)
217 predecessors.append(best_predecessor)
218
219 selected_indices = [int(path_costs[-1].argmin())]
220 for waypoint in range(num_steps - 1, 0, -1):
221 selected_indices.append(int(predecessors[waypoint][selected_indices[-1]]))
222 selected_indices.reverse()
223 planned_qpos = [
224 waypoint_candidates[i][selected_indices[i]].unsqueeze(0)
225 for i in range(num_steps)
226 ]
227
228 for waypoint, (target_pose, qpos_seed) in enumerate(
229 zip(target_poses, planned_qpos, strict=True)
230 ):
231 actual_pose = robot.compute_fk(
232 qpos=qpos_seed, name=arm_name, to_matrix=True
233 )
234 error_mm = float(
235 torch.linalg.vector_norm(
236 actual_pose[0, :3, 3] - target_pose[:3, 3]
237 ).item()
238 * 1000.0
239 )
240 translation_errors_mm.append(error_mm)
241 print(
242 f"Planned {waypoint + 1:03d}/{num_steps}: " f"error={error_mm:.4f} mm"
243 )
244
245 print(
246 f"SRS {device.upper()} planning summary: {num_steps}/{num_steps} solved, "
247 f"median/p95/mean solve={np.median(solve_times_ms):.3f}/"
248 f"{np.percentile(solve_times_ms, 95):.3f}/"
249 f"{np.mean(solve_times_ms):.3f} ms, "
250 f"max translation error="
251 f"{max(translation_errors_mm, default=float('nan')):.4f} mm"
252 )
253 print(
254 f"IK candidates per waypoint min/median/max: "
255 f"{min(candidate_counts)}/{int(np.median(candidate_counts))}/"
256 f"{max(candidate_counts)}"
257 )
258 if device == "cuda":
259 print(
260 f"CUDA peak allocated memory: "
261 f"{torch.cuda.max_memory_allocated() / 1024**2:.2f} MiB"
262 )
263 planned_qpos_tensor = torch.cat(planned_qpos)
264 wrapped_steps = torch.atan2(
265 torch.sin(planned_qpos_tensor[1:] - planned_qpos_tensor[:-1]),
266 torch.cos(planned_qpos_tensor[1:] - planned_qpos_tensor[:-1]),
267 )
268 max_step_flat_index = wrapped_steps.abs().argmax()
269 max_step_waypoint = int(max_step_flat_index // wrapped_steps.shape[1]) + 2
270 max_step_joint = int(max_step_flat_index % wrapped_steps.shape[1]) + 1
271 print(
272 f"Max adjacent joint step: "
273 f"{torch.rad2deg(wrapped_steps.abs().max()).item():.3f} deg "
274 f"at waypoint {max_step_waypoint}, joint {max_step_joint}"
275 )
276 if wrapped_steps.shape[0] > 1:
277 joint_step_changes = wrapped_steps[1:] - wrapped_steps[:-1]
278 print(
279 f"Max joint step change: "
280 f"{torch.rad2deg(joint_step_changes.abs().max()).item():.3f} deg"
281 )
282
283 sim.open_window()
284 marker_stride = max(1, num_steps // 25)
285 marker_indices = torch.arange(0, num_steps, marker_stride, device=sim.device)
286 if marker_indices[-1] != num_steps - 1:
287 marker_indices = torch.cat(
288 (marker_indices, marker_indices.new_tensor([num_steps - 1]))
289 )
290 sim.draw_marker(
291 MarkerCfg(
292 name="srs_target_path",
293 marker_type="axis",
294 axis_xpos=target_poses[marker_indices],
295 axis_size=0.0006,
296 axis_len=0.008,
297 arena_index=0,
298 )
299 )
300
301 print("Planning completed; executing the joint trajectory...")
302 execution_errors_mm: list[float] = []
303 previous_qpos = robot.get_qpos(name=arm_name).clone()
304 zero_qvel = torch.zeros_like(previous_qpos)
305 for waypoint, qpos in enumerate(planned_qpos):
306 wrapped_delta = torch.atan2(
307 torch.sin(qpos - previous_qpos), torch.cos(qpos - previous_qpos)
308 )
309 for substep in range(1, physics_steps + 1):
310 alpha = substep / physics_steps
311 interpolated_qpos = previous_qpos + alpha * wrapped_delta
312 robot.set_qpos(interpolated_qpos, joint_ids=joint_ids, target=False)
313 robot.set_qpos(interpolated_qpos, joint_ids=joint_ids, target=True)
314 robot.set_qvel(zero_qvel, joint_ids=joint_ids, target=False)
315 robot.set_qvel(zero_qvel, joint_ids=joint_ids, target=True)
316 sim.update(step=1)
317 previous_qpos = qpos
318 actual_qpos = robot.get_qpos(name=arm_name)
319 actual_pose = robot.compute_fk(
320 qpos=actual_qpos, name=arm_name, to_matrix=True
321 )
322 execution_errors_mm.append(
323 float(
324 torch.linalg.vector_norm(
325 actual_pose[0, :3, 3] - target_poses[waypoint, :3, 3]
326 ).item()
327 * 1000.0
328 )
329 )
330 if waypoint % marker_stride == 0 or waypoint == num_steps - 1:
331 sim.draw_marker(
332 MarkerCfg(
333 name=f"srs_executed_path_{waypoint:03d}",
334 marker_type="axis",
335 axis_xpos=actual_pose,
336 axis_size=0.0012,
337 axis_len=0.004,
338 arena_index=0,
339 )
340 )
341 print("Trajectory execution completed.")
342 print(
343 f"Max execution tracking error: {max(execution_errors_mm):.4f} mm. "
344 "Long axes show targets; short thick axes show executed samples."
345 )
346 sim.capture_visualization(force=True)
347 finally:
348 # Do not use the default os._exit(0) cleanup path: it suppresses Python
349 # tracebacks raised during planning and makes failures look like clean exits.
350 sim.destroy(exit_process=False)
351
352
353if __name__ == "__main__":
354 parser = argparse.ArgumentParser(description=__doc__)
355 parser.add_argument(
356 "--device", choices=("cpu", "cuda"), default="cpu", help="SRS backend."
357 )
358 parser.add_argument(
359 "--num-steps", type=int, default=50, help="Number of line waypoints."
360 )
361 parser.add_argument(
362 "--line-offset",
363 type=float,
364 nargs=3,
365 metavar=("X", "Y", "Z"),
366 default=(0.0, -0.30, 0.0),
367 help="TCP line displacement in meters (default: 0 -0.30 0).",
368 )
369 parser.add_argument(
370 "--physics-steps",
371 type=int,
372 default=20,
373 help="Simulation interpolation steps per IK waypoint (default: 20).",
374 )
375 parser.add_argument(
376 "--max-joint-step-deg",
377 type=float,
378 default=15.0,
379 help="Reject an IK branch changing any joint by more than this angle.",
380 )
381 add_viser_args_to_parser(parser)
382 args = parser.parse_args()
383 exit_code = 0
384 try:
385 main(
386 device=args.device,
387 num_steps=args.num_steps,
388 line_offset=tuple(args.line_offset),
389 physics_steps=args.physics_steps,
390 max_joint_step_deg=args.max_joint_step_deg,
391 visualization=visualization_cfg_from_args(args),
392 )
393 except BaseException:
394 traceback.print_exc()
395 exit_code = 1
396 finally:
397 # Deferred destruction is only safe after main() has unwound and no local
398 # Robot/solver wrappers remain live on its Python frame.
399 SimulationManager.flush_cleanup_queue()
400 sys.exit(exit_code)
Typical Usage#
Step 1: Configure solver
from embodichain.lab.sim.motion.solvers import SRSSolverCfg
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
import torch
robot_cfg.solver_cfg = srs_cfg
robot = Robot(cfg=robot_cfg, device=torch.device("cpu"))
Step 3: Use FK/IK/Jacobian
import numpy as np
import torch
qpos = torch.tensor([[0.0, -1.57, 1.57, 0.0, 1.57, 0.0]])
ee_pose = robot.compute_fk(qpos=qpos, name="arm", to_matrix=True)
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, name="arm"
)
J = robot.get_solver("arm").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_nearest_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