Inverse Kinematics

An arm gets Cartesian (end-effector) control by declaring six CartesianIKActuators and an ik_backend. Solarian then automatically wraps the module's joint bus so that Cartesian writes are solved to joint targets every control tick — no solve_ik method to write, no mixin to inherit.

IK lives in solarian/core/ik/ and works the same whether the module runs on hardware or in simulation: the same MJCF describes the kinematics either way.

Drag a joint slider and the hand follows. Joints to hand is forward kinematics. Inverse kinematics runs the other way: you name the hand pose, and the module finds the joints.

BaseRevolute jointLinkEnd-effectorPrismatic jointend effector Parallel gripper · cyclingbase fixed

Dragging a joint pauses the loop. The SO-106 also has three wrist joints, wrist_flex, wrist_yaw, and wrist_roll. This model leaves them off so the two joint types stay readable.

The recipe

Add three things to your Module:

from solarian.core.module import Module
from solarian_feetech.actuator import FeetechServo
from solarian.core.ik import CartesianIKActuator, MinkIK

class ArmModule(Module):
    # 1) joint-space servos (hardware or, in sim, the same servos)
    shoulder_pan = FeetechServo()
    shoulder_lift = FeetechServo()
    elbow_flex = FeetechServo()
    wrist_flex = FeetechServo()
    wrist_yaw = FeetechServo()
    wrist_roll = FeetechServo()

    # 2) six Cartesian pose components: x, y, z, rx, ry, rz
    x = CartesianIKActuator()
    y = CartesianIKActuator()
    z = CartesianIKActuator()
    rx = CartesianIKActuator()
    ry = CartesianIKActuator()
    rz = CartesianIKActuator()

    # 3) a declarative IK backend spec
    ik_backend = MinkIK(
        mjcf="assets/arm.xml",
        joints={
            "shoulder_pan": "shoulder_pan", "shoulder_lift": "shoulder_lift",
            "elbow_flex": "elbow_flex", "wrist_flex": "wrist_flex",
            "wrist_yaw": "wrist_yaw", "wrist_roll": "wrist_roll",
        },
        frame="tcp",
        position_cost=1.0,
        orientation_cost=1.0,
    )

The pose convention is a 6-vector (x, y, z, rx, ry, rz): position in metres, orientation as an axis-angle rotation vector in radians.

The CartesianIKActuators

Each of the six CartesianIKActuator declarations is one component of the end-effector pose. The pose index each drives comes from its cfg.id (0..5 == x, y, z, rx, ry, rz), and they use raw metric units (mode: raw), not the normalized space of joint servos:

# conf/modules/arm.yaml — Cartesian actuators alongside the joint actuators
actuators:
  # ... the six joint servos ...
  x:  { id: 0, model: ik_cartesian, type: ik_cartesian, port: ${actuator_port}, mode: raw }
  y:  { id: 1, model: ik_cartesian, type: ik_cartesian, port: ${actuator_port}, mode: raw }
  z:  { id: 2, model: ik_cartesian, type: ik_cartesian, port: ${actuator_port}, mode: raw }
  rx: { id: 3, model: ik_cartesian, type: ik_cartesian, port: ${actuator_port}, mode: raw }
  ry: { id: 4, model: ik_cartesian, type: ik_cartesian, port: ${actuator_port}, mode: raw }
  rz: { id: 5, model: ik_cartesian, type: ik_cartesian, port: ${actuator_port}, mode: raw }

The ik_backend spec

MinkIK is a declarative spec; the module resolves it (calling .build()) the first time it connects a port with Cartesian actuators, producing a MinkBackend — a velocity-level (differential) IK solver built on mink and MuJoCo.

  • Name
    mjcf
    Type
    str
    Description

    Robot-description name or path to an MJCF describing the kinematics (resolved like scene MJCF — see Resolving MJCF paths).

  • Name
    joints
    Type
    dict[str, str]
    Description

    Ordered mapping of joint-actuator name → MuJoCo joint name. The order defines the solver's joint vector.

  • Name
    frame
    Type
    str
    Description

    End-effector frame (site/body/geom) in the model.

  • Name
    frame_type
    Type
    str
    Description

    "site" (default), "body", or "geom".

  • Name
    position_cost / orientation_cost
    Type
    float
    Description

    Frame-task tracking weights.

  • Name
    posture_cost
    Type
    float
    Description

    Regularization toward the rest posture (helps an under-actuated arm resolve a 6D target gracefully).

  • Name
    lm_damping / damping / solver
    Type
    float / str
    Description

    QP damping and solver knobs (default solver "daqp").

ik_backend also accepts a ready DifferentialIKBackend instance or a zero-arg callable returning one — not just a MinkIK spec.

Automatic wiring

You don't build or call the solver. When a module connects a port that has Cartesian actuators, Module.wrap_port_bus wraps the joint bus in an IKActuatorBus:

Cartesian write (x, y, z, rx, ry, rz)
   │
   ▼
IKActuatorBus            set_q(current joints) → solve(target, dt) → integrate → q_next
   │                     (one differential IK step per control tick)
   ▼
joint bus (Feetech / BusClient / MuJoCo proxy)   ← writes joint targets

Cartesian reads run forward kinematics on the current joint positions. Joint reads/writes pass straight through to the inner bus. In simulation the inner bus is the MuJoCo BusClient proxy, so the exact same IK code drives the sim.

Smooth Cartesian motion (pose interpolation)

Sparse controller / policy waypoints feel jerky if you latch them directly onto the IK bus. With interpolator_type: pose on the module yaml, Module runs a PoseTrajectoryInterpolator (linear position + Slerp rotation, with max_speed limits) — the same pattern UMI uses before UR servoL. Controllers send SCHEDULE_L with wall-clock timestamps; the module upsamples at frequency and writes x..rz each tick. See Scheduling Waypoints for the pose + scalar contract, and Adding a new arm for an SO-106 walkthrough.

Extending: a custom backend

Implement the DifferentialIKBackend interface and point ik_backend at it:

import numpy as np
from solarian.core.ik.base import DifferentialIKBackend

class MyBackend(DifferentialIKBackend):
    def set_q(self, q): ...                 # set current joint config
    def solve(self, target_pose, dt): ...   # return joint velocities
    def integrate(self, v, dt): ...         # return next q (radians)
    def get_pose(self) -> np.ndarray: ...   # forward kinematics (x,y,z,rx,ry,rz)
    @property
    def joint_actuator_names(self) -> list[str]: ...
    @property
    def joint_limits(self) -> dict[str, tuple[float, float]]: ...

class MyArm(Module):
    ik_backend = MyBackend(...)   # or a spec with .build()

Validating a backend

solarian.core.ik.diagnostics provides backend-agnostic checks for bring-up — axis response, per-joint sensitivity (catches mis-mapped joints), and convergence to reachable poses:

from solarian.core.ik.diagnostics import diagnose_backend

report = diagnose_backend(MyArm.ik_backend.build())
print(report.format())

Making it track on real hardware

IK is exact in the model, but a real arm only follows a commanded +X/+Y/+Z correctly when its calibration and the model agree on what each joint angle means. Calibration is the guided process for that — a limit sweep, a home match at model q=0, a highlighted-joint sign check, and verify-calibration / derive-model-limits tools that keep the model's jnt_range equal to the real mechanical limits.

Adding a new arm

Solarian mirrors the UMI / UR5 control pattern for any MinkIK arm: controllers and skills send sparse, timestamped Cartesian waypoints; the arm module upsamples them with a PoseTrajectoryInterpolator at its control rate and the IKActuatorBus turns each pose into joint targets. The only arm-specific piece is the kinematics (MJCF + MinkIK + joint mapping).

Controller / skill  (~10–30 Hz)
        │  SCHEDULE_L  x,y,z,rx,ry,rz + wall-clock timestamp
        ▼
Arm Module  (frequency: 90, interpolator_type: pose)
        │  PoseTrajectoryInterpolator sample @ module rate
        │  write x..rz
        ▼
IKActuatorBus (Mink differential IK)
        │
        ▼
Joint bus (Feetech / sim BusClient)

On a UR5, UMI streams dense TCP poses with servoL and the robot controller does IK. Here there is no onboard Cartesian servo, so host IK replaces that step — the interpolator API stays the same.

A new arm reuses the core path when it provides:

  1. Joint servos — hardware actuators the IK bus will write.
  2. Six CartesianIKActuators — x, y, z, rx, ry, rz with cfg.id 0..5 and mode: raw.
  3. ik_backend = MinkIK(...) — MJCF, joint-actuator → MuJoCo joint map, TCP frame.
  4. Yaml — interpolator_type: pose, max_speed: [max_pos_m_s, max_rot_rad_s], and a module frequency higher than the controller (e.g. 90 Hz).
  5. Callers — emit ActionType.SCHEDULE_L with pose fields and wall-clock timestamp for policy chunks (absolute target times minus action latency). Teleop uses MOVE_L; leader follow uses SERVO_J.

No per-arm interpolator code. Optional extras: servo gains in initialize(), and default_joint_speed if 1.05 rad/s is the wrong moveJ limit for this arm.

Example: SO-106

The so106 project arm is the reference implementation.

Module class

The module is the recipe above with the SO-106 model. Pose scheduling / sampling is inherited from Module when yaml sets interpolator_type: pose:

IK_JOINT_ACTUATORS = [
    "shoulder_pan", "shoulder_lift", "elbow_flex",
    "wrist_flex", "wrist_yaw", "wrist_roll",
]

class ArmModule(Module):
    # ... the six FeetechServo joints and six CartesianIKActuators ...

    ik_backend = MinkIK(
        mjcf="assets/so106.xml",
        joints=dict(zip(IK_JOINT_ACTUATORS, IK_JOINT_ACTUATORS)),
        frame="tcp",
        position_cost=1.0,
        orientation_cost=1.0,
    )

When the TCP is a CAD Frame on the gripper, pass that tool's flange-relative pose into the arm (So106ArmModule(ik_tcp=UmiSlimFeetechModule.tcp_from_flange)). MinkIK tracks the arm flange and applies the offset. Named poses a skill can drive live in conf/skills/move.yaml.

Arm yaml

# conf/modules/right_arm.yaml
frequency: 90
interpolator_type: pose
max_speed: [0.25, 0.6]   # m/s, rad/s (UMI-like)

actuators:
  shoulder_pan: { id: 1, port: ${actuator_port}, sim_joint: "right/shoulder_pan", ... }
  # ... remaining joints ...
  # cartesian x..rz are omitted — id comes from the attribute name, mode is raw

frequency is the upsample / IK rate. Controllers stay at ~30 fps and only carry sparse setpoints.

Scheduling a waypoint (teleop)

Match UMI SpaceMouse teleop: advance the absolute target locally, then schedule one controller period ahead:

from solarian.core.action import Action, ActionType

target_time = time.time() + 1.0 / controller_fps
data = {
    axis: {"value": float(pose[i]), "timestamp": target_time}
    for i, axis in enumerate(("x", "y", "z", "rx", "ry", "rz"))
}
arm_action = {"action_type": ActionType.SCHEDULE_L, "data": data}
return Action(
    action_type=ActionType.MODULE_ACTION,
    data={"right_arm": arm_action},
)

The module converts wall time → monotonic, inserts the waypoint with speed limits, and each control_step writes the interpolated pose through IK.

Policy / skill path

Skills should not own a second arm PoseTrajectoryInterpolator (that double-smooths). Forward each predicted pose with its absolute target_time (minus the arm module's action_latency) as SCHEDULE_L on the arm module — same as UMI exec_actions → schedule_waypoint on the RTDE controller.

Robot + scene

Wire the module on the Robot, expose pose fields on Action / Observation, and attach the MJCF in the scene (sim). Real and sim share the same module yaml; only sim_joint / sim_range_* differ.

Checklist for a new arm

  • MJCF with correct joint names and end-effector site/body
  • Module: joint servos + six CartesianIKActuators + MinkIK
  • Yaml: interpolator_type: pose, max_speed, frequency; cartesian x..rz omitted from yaml
  • Action / observation schemas include x..rz
  • Controllers / skills use SCHEDULE_L with wall-clock timestamps
  • Calibration so real hardware tracks Cartesian moves

See also

  • Scheduling Waypoints — pose and scalar SCHEDULE_L module upsampling.
  • Motion actions — SCHEDULE_L, MOVE_L and SERVO_J.
  • Calibration — get Cartesian moves tracking correctly on the real robot.
  • Modules — where actuators and ik_backend are declared.
  • Simulation — IK drives sim unchanged; the arm's frame is drawn in the viewer.
  • Actions / Observations — commanding and reading the end-effector pose.

Was this page helpful?