Skip to main content
Bimanual inverse kinematics using pyroki and JAX/jaxls. Creating a KinematicsSolver loads the bundled URDF, builds the collision model between the arms and the torso (self_collision, on by default), and JIT-compiles the solver. It compiles the solve both with and without elbow hints, so box mode’s first frame never waits for a compile. The first solver in a process takes a few seconds to create. Later calls and later solver instances are fast.
Solver startup is cached at two levels, both automatic:
  • Per machine. The XLA compile is saved to ~/.almond/jax-cache, so its cost (minutes on a Jetson with an empty cache) is paid once per machine. The cache key includes the solver graph and the jax/jaxlib versions and backend, so upgrading JAX or changing the solver compiles again. Set JAX_COMPILATION_CACHE_DIR to use another location.
  • Per process. The URDF, pyroki robot and collision model are built once per process and shared by every KinematicsSolver and AxolForwardKinematics instance. Extra instances don’t repeat the JAX trace or the jaxls analysis.
Every new process still spends several seconds on imports, URDF parsing and the jaxls tracing pass (the Building optimization problem and Vectorizing group log lines). axol teleop and axol serve do this while the IK worker starts.The disk cache covers compiles that happen after it is turned on, which KinematicsSolver and AxolForwardKinematics do in __init__. If your script runs its own JAX or jaxls work before creating a solver, call almond_axol.kinematics.enable_persistent_compilation_cache() first.
All poses are in the robot’s world frame: +x forward, +y left, +z up (FLU), with the origin at the base of the torso axis. Every joint vector the solver takes or returns is in the robot’s own order: the left arm’s 7 joints in ARM_JOINTS order (shoulder_1 to wrist_3), then the right arm’s. q[:7] and q[7:] are the per-arm arrays motion_control takes, without the gripper slot.

Robot models

The bundled URDFs are in almond_axol/kinematics/urdf/, one per version. All but axol.urdf are generated from the Onshape exports by scripts/build_urdfs.py. They share the body and arm meshes in meshes/jelly/, and keep the parts specific to each version in meshes/<version>/: One model for everything. Each generated file describes the full robot. IK, the sim viewer, the headset overlay, the control panel’s viewer and physics simulators all load it. With every body joint at zero, the torso root (root) is the world origin, so poses match axol.urdf. IK loads the file through almond_axol.kinematics.model.ik_urdf, which:
  • freezes every joint except the arm joints. The base, lift, fingers and wheels stay at zero, so the lifts are fully raised.
  • uses simpler collision shapes than each part’s hull. These are the custom <ik_collision> elements, which other importers ignore. They cover the shoulder bar, the head-camera mount, the lift column’s top plate and each section, the Jelly deck and the Owl Mount foot plate as strips, and each gripper’s jaws as one body over their whole motion.
Picking the version. With Jelly enabled, the model is Axol on Jelly. Otherwise it is the mount picked under Mount on the control panel’s Robot tab, saved as kinematics.mount: classic (Legacy, the default), standalone, ox_cart or owl. kinematics.robot_model forces a version. Each version tells IK which parts of the body to keep the arms away from:
  • Ox Cart: the lift column. IK keeps the cart’s lift fully raised, so its caster frame is out of reach. If the lift is lowered, the frame comes up toward the hands, and IK doesn’t account for it.
  • Owl Mount: the column and its foot plate, as six thin strips. The strips are left out of the hard upper-arm guard, since they sit under the arms’ path.
  • Jelly: the deck, as three strips, also left out of the hard upper-arm guard. The lift carries the arms down while the deck stays on the ground. Teleop passes the height the lift reports to IK, which moves the deck and the lower column up toward the arms by that amount. Until the lift first reports a homed height, IK treats it as fully raised, and if the readings stop, IK keeps the last height.
Parcel gripper. With axol.gripper set to parcel, Axol on Jelly uses axol_jelly_parcel.urdf everywhere a URDF is chosen: the IK solver (KinematicsConfig.gripper, which teleop sets from AxolConfig.gripper), the sim viewer, the headset overlay and the control panel’s pose viewer. Each gripper is a fixed jaw (*_gripper) and a plate hinged on it (*_plate). The hinge, *_plate_joint, turns through half a turn, with 0 alongside the jaw. The parcel gripper has no wrist cameras, so this model has only the head camera frames. The parcel gripper model exists for Jelly only. The other versions keep the parallel gripper whichever gripper is fitted. All versions share the same arm chain, so their forward kinematics match. Every generated model has camera optical frames in the ROS optical convention (+z along the optical axis, +x to the right of the image, +y down):
  • left_wrist_camera_optical and right_wrist_camera_optical (parallel gripper only)
  • head_camera_left_optical and head_camera_right_optical
In a physics simulator. Mesh paths are relative, so the files import without a ROS package:
  • Joints: Every moving part is a joint. The Jelly base moves through base_x, base_y and base_yaw. The x-drive’s omni rollers are not modelled, so plain wheels can’t strafe. Each finger pair is one joint (left_finger_1_joint, right_finger_1_joint), and the second finger mimics it. Importers that ignore <mimic> treat the mimicked joints as independent, so command them with the same value.
  • Joint friction and drives: Each arm joint carries AxolConfig’s measured friction for its arm, as <dynamics friction> (Coulomb) and damping (viscous). The motors’ impedance gains are on the same joint as <drive stiffness damping> (kp in N·m/rad, kd in N·m·s/rad). URDF has no drive-gain element, so importers ignore this custom one: read it and set each joint’s position-drive stiffness and damping after import. The controller-only terms j_eff and kd_host are left out.
  • Masses: The arm masses and centres of mass are AxolConfig’s tuned values, the same ones gravity compensation uses. Wrist 3’s mass is split across the gripper parts, keeping its total and centre of mass. The body, head, wheel and gripper-part masses are estimates, listed in each Body’s masses and each gripper’s wrist_parts in the generator. Replace them with measured values.
  • MuJoCo: Compile with fusestatic="false". Fusing fixed-jointed links misplaces their centres of mass.
  • Cameras: Attach Isaac Sim cameras to the *_optical links with ROS camera axes. Each wrist camera (ZED X One S fisheye) sits at the projection centre from Stereolabs’ camera model, rolled so the gripper is at the bottom of the image as in real frames. Render the wrist cameras with the factory fisheye (Kannala-Brandt) calibration, not a pinhole model. The head camera (ZED X Mini Wide) frames are at each lens’s projection centre from Stereolabs’ model, 7.2 mm behind its front cover and 50 mm apart.

KinematicsSolver interface

ElbowSwivel holds:
  • the shoulder centre, where the three shoulder axes meet
  • the elbow
  • the wrist centre, where the forearm roll axis meets wrist_2, in the gripper mount frame
  • the unit direction the elbow moves in as the arm swings with its mount held, from the null space of the arm’s Jacobian. The wrist isn’t spherical, so this swing isn’t a circle.
pose_weight_scale in ik() is a (position, orientation) pair, default (1, 1). It multiplies pos_weight and ori_weight for one solve. Box mode uses (2, 4).

Straight-line paths

almond_axol.kinematics.path.plan_linear_segment(solver, q_from, q_to, ...) returns joint vectors that move both gripper tips along a straight world-frame line between the poses of two joint configurations. Position is interpolated linearly and orientation with slerp. Each sample is solved with ik(), and the posture attractor moves between the endpoints so the elbow keeps the configuration you started from. fk() and ik() work in the gripper mount frame, where the URDF chain ends. The fingers close GRIPPER_TIP_OFFSET (145 mm along the link’s -Z) beyond it. The planner keeps the tip on the line, because a mount moved in a straight line swings the tip through an arc as the wrist turns. tip_poses(solver, q) returns the tip frames. The planner solves the whole segment before anything moves and returns one vector per control tick at rate. You can stream the result to motion_control as it is. Timing follows the minimum-jerk curve ease(), with zero velocity and zero acceleration at both ends. Its peak speed is 1.875 times the average. IK doesn’t run on every tick:
  • The planner solves at least plan_rate samples per second of motion (default 10, capped at rate).
  • It solves more often when the tip would otherwise move more than MAX_STEP_M (10 mm) or turn more than MAX_STEP_RAD (0.1 rad) between samples.
  • A clamped cubic spline fills in the ticks between solved samples.
Solving on every tick would make the motion rougher. Each solve lands slightly off its neighbours’ trend, and commanding every sample turns that error into acceleration noise. Because sample spacing follows distance, accuracy doesn’t drop as speed rises. A faster segment has higher acceleration instead, which grows with the square of the speed. These parameters control accuracy:
  • settle_fraction: how close each sample is driven to its target, by solving again, before the planner moves on.
  • pos_tolerance and ori_tolerance: the error at which a sample counts as unreachable. The planner then raises PathPlanningError before anything moves. It also checks the path between solved samples, where a chord strays furthest from the line.
  • min_travel and min_rotation: an arm whose tip moves less than min_travel and turns less than min_rotation stays at q_from for the whole segment. At that scale the difference between two taught configurations is drift, and an arm chasing it would swing its elbow a long way for a tiny tip motion.
The first vector returned reaches the same gripper pose as q_from, but usually with the elbow placed slightly differently. Commanding it directly puts that difference into a single tick. Plan each segment from the previous segment’s final vector, and blend in the rest over a few tenths of a second. axol waypoints does both. Planning uses a JAX trace that KinematicsSolver doesn’t compile at startup. Call path.warmup(solver) once, outside time-critical code, to compile it. For a shortest-path move in joint space instead, such as a return to rest, see almond_axol.teleop.trajectory.plan_collision_aware_trajectory.

KinematicsConfig fields

When the headset elbow hint is on (elbow_weight above 0), it is projected onto the elbow positions the robot can reach and fades out above shoulder height (see elbow_fade_band). In plain teleop with the hint off, the posture attractor sets the arm’s swivel. Box mode can pass its own “elbows out” hint with a per-call weight, through ik(elbow_weight=...) and with no fade. It is set by VRTeleopConfig.box_elbow_out and box_elbow_weight, and is off by default.

Self-collision

With self_collision on (the default):
  • the capsule model is built, and a soft self-collision cost is part of the IK solve
  • a hard guard between the upper arms and the base runs on every final step, so a pose target can’t push an arm into the torso
  • the return-to-rest planner avoids collisions
Plain teleop rarely needs it, because the operator’s own arms keep targets plausible. Box mode does need it: its parallel, closing gripper poses would otherwise fold the elbows into the base. With self_collision off, all of this is dropped, and the return-to-rest planner interpolates in joint space within the joint limits. Joint limits are clamped on every step either way. self_collision_margin and self_collision_weight tune the collision-aware solve.