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#