Skip to content

Robotics and Inverse Kinematics

NovaPhy's robotics path uses the same immutable-model and caller-owned-buffer contract as the rest of the engine:

asset → ModelBuilder → Model → State / Control → Solver
                              IK target generation

Inverse kinematics is a CPU/NumPy utility that produces generalized coordinates. Forward dynamics remains an explicit solver step; IK does not apply motor targets or advance simulation by itself.

Import a robot

For the usual URDF workflow, append directly to a builder:

import novaphy

builder = novaphy.ModelBuilder(up_axis=novaphy.Axis.Z)
builder.add_urdf(
    "robot.urdf",
    floating=False,
    enable_self_collisions=False,
)
model = builder.finalize()

Use floating=True for a floating base. A fixed-base serial manipulator is the simplest supported IK input because its revolute/prismatic joints have joint_coord_count == joint_dof_count.

The other current import entry points are:

Asset Entry point Result
URDF ModelBuilder.add_urdf(path, ...) Appends directly to a mutable builder.
MJCF ModelBuilder.add_mjcf(path) or add_mjcf_string(xml) Appends MuJoCo XML bodies and joints.
Parsed URDF UrdfParser + SceneBuilderEngine.build_from_urdf(...) Finalized model plus scene metadata.
PhysX USDA articulation ModelBuilder.add_usd(path, ...) Appends bodies, joints, and collision shapes directly to a mutable builder.
USDA text snapshot OpenUsdImporter + SceneBuilderEngine.build_from_openusd(...) Parses a smaller line-oriented subset into a finalized model; current metadata remains empty.

Use the parsed scene-builder path when link/joint/shape metadata and asset auditing are part of the application. See the I/O API for format-specific coverage and limitations.

Configure and send controls

Joint drive mode, gains, and default targets belong to the builder/model. Configure them before finalize():

import numpy as np

n = len(builder.joint_target_pos)
builder.joint_target_mode[:n] = [novaphy.JointTargetMode.POSITION] * n
builder.joint_target_pos[:n] = np.zeros(n, dtype=np.float32)
builder.joint_target_ke[:n] = np.full(n, 100.0, dtype=np.float32)
builder.joint_target_kd[:n] = np.full(n, 10.0, dtype=np.float32)

model = builder.finalize()
control = model.control()  # initialized from the Model defaults

Runtime commands live in Control:

  • control.joint_target_pos — position target per joint DOF.
  • control.joint_target_vel — velocity target per joint DOF.
  • control.joint_f — feed-forward force/torque per joint DOF.
  • control.joint_act — optional solver-specific activation input.

The buffers are device-aware arrays. Replace their contents with assign() rather than assuming a NumPy slice is a writable view:

control.joint_target_pos.assign(q_target)
control.joint_target_vel.fill_zero()
control.joint_f.assign(feed_forward_effort)

novaphy.actuators.ControllerPD and Actuator provide a small application-side PD layer that accumulates effort into control.joint_f. They are not a replacement for the model's built-in drive modes. See the actuator API.

Then drive the selected solver with the canonical five-argument call:

solver = novaphy.solvers.SolverFeatherstone(model)
state_in = model.state()
state_out = model.state()
collision_pipeline = novaphy.CollisionPipeline(model)
contacts = collision_pipeline.contacts()

collision_pipeline.collide(state_in, contacts)
solver.step(state_in, state_out, control, contacts, 1.0 / 120.0)

SolverMuJoCo is the native MuJoCo-style articulated/contact solver; SolverFeatherstone is the articulated-body path. Both remain explicit solver objects—there is no World orchestrator. See Articulated Bodies and SolverMuJoCo.

Class-style IK API

The current public names match the Newton-style class surface:

Symbol Purpose
IKSolver Sampling, optimization, best-seed selection, and cost reporting.
IKObjective Base class for composable residual blocks.
IKObjectivePosition Three-row end-effector position objective.
IKObjectiveRotation Three-row quaternion/axis-angle rotation objective.
IKObjectiveJointLimit Soft per-coordinate joint-limit penalty.
IKOptimizer Enum: LM or LBFGS.
IKOptimizerLM Levenberg-Marquardt implementation.
IKOptimizerLBFGS L-BFGS implementation with Wolfe line search.
IKJacobianType Enum: ANALYTIC or FINITE_DIFF.
IKSampler Enum: NONE, GAUSS, UNIFORM, or ROBERTS.

The objective class names are IKObjectivePosition, IKObjectiveRotation, and IKObjectiveJointLimit—not the older planned IKPositionObjective, IKRotationObjective, or IKJointLimitObjective names.

Position IK from a finalized model

This example assumes a fixed-base model containing only fixed, revolute, and prismatic joints:

import numpy as np
from novaphy import ik

target = np.array([[0.45, 0.10, 0.35]], dtype=np.float32)
position_objective = ik.IKObjectivePosition(
    link_index=end_effector_body,
    link_offset=np.zeros(3, dtype=np.float32),
    target_positions=target,
)
limit_objective = ik.IKObjectiveJointLimit(
    model.joint_limit_lower,
    model.joint_limit_upper,
    weight=0.1,
)

ik_solver = ik.IKSolver(
    model=model,
    n_problems=1,
    objectives=[position_objective, limit_objective],
    optimizer=ik.IKOptimizer.LM,
    jacobian_mode=ik.IKJacobianType.FINITE_DIFF,
)

q_in = np.asarray(model.joint_q, dtype=np.float32).reshape(1, -1).copy()
q_out = np.empty_like(q_in)
ik_solver.step(q_in, q_out, iterations=50)

control.joint_target_pos.assign(q_out[0])

IKSolver.step() writes into q_out and returns None. The input/output shape is (n_problems, model.joint_coord_count). The arrays may alias for in-place tracking:

position_objective.set_target_position(0, next_target)
ik_solver.step(q_in, q_in, iterations=20)
cost = float(ik_solver.costs[0])

The class solver runs a fixed number of iterations; it has no tolerance-based early exit or convergence Boolean. Evaluate the task-space error or ik_solver.costs in the application when a success decision is required.

Pose targets and multiple seeds

Rotation targets and offsets use [x, y, z, w] quaternions:

rotation_objective = ik.IKObjectiveRotation(
    link_index=end_effector_body,
    link_offset_rotation=np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float32),
    target_rotations=np.array([[0.0, 0.0, 0.0, 1.0]], dtype=np.float32),
    canonicalize_quat_err=True,
    weight=0.5,
)

ik_solver = ik.IKSolver(
    model=model,
    n_problems=1,
    objectives=[position_objective, rotation_objective, limit_objective],
    optimizer=ik.IKOptimizer.LM,
    jacobian_mode=ik.IKJacobianType.FINITE_DIFF,
    sampler=ik.IKSampler.GAUSS,
    n_seeds=8,
    noise_std=0.15,
    rng_seed=12345,
)

n_seeds expands each problem internally and gathers the lowest-cost row. IKSampler.NONE requires n_seeds == 1. GAUSS keeps the first input seed; UNIFORM and ROBERTS sample bounded coordinates from the model limits.

Jacobian and model limitations

The following boundaries are part of the current implementation:

  • NovaPhy has no autodiff IK backend. IKJacobianType contains only ANALYTIC and FINITE_DIFF; there is no AUTODIFF or MIXED member.
  • IK is CPU/NumPy today. n_problems > 1 and seed expansion are processed serially, not as a CUDA batch.
  • The optimizer currently adds an n_dofs update directly to an n_q coordinate vector. In practice, high-level IK requires joint_coord_count == joint_dof_count.
  • Ball, Free, and D6 joints need a quaternion/manifold retraction and are not supported by the optimizer. Do not include free-jointed props in an IK model. Build a robot-only kinematic model and keep the full scene for simulation, as python/demos/mujoco/demo_robot_panda_hydro.py does.
  • A raw finalized Model does not retain the Python Joint descriptors needed by the hand-written spatial Jacobian. Asking for ANALYTIC with a raw model currently falls back to finite differences.
  • The true analytic path is available with the Python-only JointBodyBundle, and only Revolute/Prismatic columns are implemented. Ball/Free/D6 columns remain zero in both bundle Jacobian modes.
  • IKObjectiveJointLimit is a soft penalty. The class solver does not hard clamp every accepted optimizer step.

These constraints are why the raw-model examples select FINITE_DIFF explicitly, even though IKSolver defaults to ANALYTIC.

JointBodyBundle and analytic IK

JointBodyBundle(joints, bodies) is a backwards-compatible Python bridge for list-based robot descriptions. It builds and caches a temporary NovaPhy Model, while preserving the Python joint axis/parent metadata used by the analytic Jacobian:

bundle = ik.JointBodyBundle(joints, bodies)
ik_solver = ik.IKSolver(
    model=bundle,
    n_problems=1,
    objectives=[position_objective],
    jacobian_mode=ik.IKJacobianType.ANALYTIC,
)

Use it only when joints and bodies contain one parent-before-child entry per link and the moving joints are Revolute or Prismatic. It is not a C++ articulation container, a GPU batch representation, or a way to make multi-DOF quaternion joints optimizer-compatible.

Legacy function API

The earlier functions remain exported directly from novaphy.ik:

q_solution, converged = ik.solve_ik(bundle, q0, target_position)
q_pose, converged = ik.solve_ik_pose(
    bundle,
    q0,
    target_position,
    target_rotation_matrix,
)

solve_ik, solve_ik_pose, and solve_ik_pose_best_of_seeds are now compatibility wrappers around IKSolver with LM. They run the requested fixed iteration count and compute the returned convergence flag afterward. When explicit limits are supplied, the wrappers add the soft limit objective and apply a final hard clip. New tracking and batched code should construct IKSolver directly.

The low-level compatibility helpers compute_jacobian_position(), compute_jacobian_angular_velocity(), get_ee_position(), get_ee_rotation(), get_link_transforms(), and rotation_error_axis_angle() also remain public.

Coordinate conventions

Python-facing flat buffers are linear-first:

  • free-joint q = [px, py, pz, qx, qy, qz, qw]
  • free-joint qd = [vx, vy, vz, wx, wy, wz]
  • free-joint joint_f = [fx, fy, fz, tx, ty, tz]
  • state.body_qd = [linear; angular]
  • state.body_f = [force; torque]

C++ novaphy::SpatialVector and the public spatial-algebra helpers use the same linear-first layout, so no component reordering is required at that boundary.

Demos

  • python/demos/mujoco/demo_robot_panda_hydro.py combines the class-style IK solver, runtime joint targets, native dynamics, and unified viewers.
  • python/demos/ik_arm_demo/benchmark_compare.py compares LM finite differences, LM analytic Jacobians, and L-BFGS on the same arm.
  • python/demos/demo_ik_arm.py uses ViewerGL.log_gizmo() for a draggable position target, renders the URDF DAE visuals in a Z-up viewer model, and writes name-mapped IK FK results into its standard SimState. Use --headless for a one-shot solve or --viewer null --num-frames 120 to exercise the interactive lifecycle without a window.

See also