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#