Skip to main content
Bimanual inverse kinematics using pyroki and JAX/jaxls. Loads the bundled URDF, builds a collision model, and JIT-compiles the solver during __init__ — the first solver in a process takes a few seconds; subsequent calls (and subsequent solver instances) are fast.
Solver startup is cached at two levels, both automatic:
  • Per machine — the XLA compile (the expensive part, minutes cold on a Jetson) is cached to disk (~/.almond/jax-cache), so it’s paid once per machine, not on every process start. The cache key covers the solver graph plus the jax/jaxlib versions and backend, so upgrading JAX or changing the solver simply recompiles and refreshes the cache (stale entries are never reused). Set JAX_COMPILATION_CACHE_DIR to override the location.
  • Per process — the URDF, pyroki robot, and collision model are built once per process and shared by every KinematicsSolver / AxolForwardKinematics instance, so creating additional instances doesn’t repeat the JAX trace or jaxls problem analysis.
What remains on every fresh process start is the Python side: imports, URDF parsing, and the jaxls analysis/tracing pass (the Building optimization problem / Vectorizing group log lines) — several seconds that no disk cache can skip. Within a process it’s paid once, up front: additional solver instances reuse the shared model and the already-traced solve. (axol teleop / serve absorb it in the IK worker’s startup handshake, which is why it isn’t felt there.)The disk cache only covers compiles that happen after it’s enabled, which KinematicsSolver / AxolForwardKinematics do in __init__. A script that runs its own JAX/jaxls work before creating a solver can call almond_axol.kinematics.enable_persistent_compilation_cache() first so those compiles are disk-cached too.
All poses live in the robot’s world frame — +x forward, +y left, +z up (FLU), origin at the base of the torso axis. Every joint vector the solver takes or returns is ordered the way the robot itself is: the left arm’s 7 joints in ARM_JOINTS order (shoulder_1wrist_3) followed by the right arm’s. q[:7] and q[7:] are exactly the per-arm arrays motion_control takes (minus the gripper slot).

KinematicsSolver interface

Straight-line paths

almond_axol.kinematics.path.plan_linear_segment(solver, q_from, q_to, ...) returns the joint vectors that walk both gripper tips along a straight world-frame line between the poses of two joint configurations — position lerped, orientation slerped, every sample resolved with ik() and the posture attractor swept between the endpoints so the redundant elbow keeps the configuration you started from. fk() and ik() work in the gripper mount frame, which the URDF chain ends at; the fingers close GRIPPER_TIP_OFFSET (145 mm along the link’s -Z) beyond it. The planner holds the line at the tip and converts back to a mount target per solve, since a mount held straight swings the tip through an arc as the wrist reorients. tip_poses(solver, q) returns the tip frames directly. The whole segment is solved up front, at one vector per control tick, so the result can be streamed to motion_control verbatim. Timing follows the minimum-jerk curve ease() — zero velocity and zero acceleration at both ends, so a leg neither jolts as it departs nor as it arrives, at the cost of peaking at 1.875x the average speed rather than smoothstep’s 1.5x. IK does not run at every tick. It runs a couple of dozen times a second — plan_rate sets a floor, MAX_STEP_M and MAX_STEP_RAD a ceiling on how far the tip may travel between solves — and a clamped cubic spline fills in the rest. Solving more finely is not just wasted work but actively worse: each solve lands a fraction of a milliradian off its neighbours’ trend (the cost’s flat direction, not early stopping — more iterations do not change it), and commanding those samples directly turns that wobble into acceleration noise. Interpolating between sparse solves leaves it behind. Measured peak tip acceleration on a 35 cm leg was 1.7 m/s² solving every tick against 0.11 solving 20 a second, where the profile’s own optimum is 0.105. Because the ceiling is a distance and the floor is rarely the binding one, a leg is sampled the same way however fast it is run, and accuracy does not degrade with speed: the same 35 cm leg holds its line to 0.74 mm and lands 0.02 mm from its target at 0.08 m/s and at 1.0 m/s alike, planning in 0.15 s either way. Speed buys itself in acceleration instead, which rises with its square. Two separate thresholds govern accuracy. settle_fraction is how close a sample is driven before the planner moves on, by re-solving against the same target; pos_tolerance and ori_tolerance are where a sample is declared unreachable and PathPlanningError is raised, before anything has moved. Conflating them leaves every sample a different distance behind its target, which is itself a source of acceleration noise. The delivered trajectory is then checked between solved samples too, where a chord deviates most from the line. An arm whose tip travels less than min_travel (and turns less than min_rotation) is pinned at q_from for the whole segment rather than tracked. Below that scale the difference between two taught configurations is drift rather than intent, and a redundant arm chasing it wanders through its null space — degrees of elbow for a millimetre of tip. The first vector returned is the configuration IK settles into at the start of the line, which reaches the same gripper pose as q_from but generally holds the elbow slightly differently. Commanding it directly puts that difference into a single tick, so plan each segment from the previous one’s final vector and blend the remainder in over a few tenths of a second (axol waypoints does both). Planning calls ik() without elbow hints, which is a different JAX trace from the one KinematicsSolver warms up; call path.warmup(solver) once (off the hot path) to absorb that compile. This is what axol waypoints replays — for a shortest-path move in joint space instead (returning to rest, for example), see almond_axol.teleop.trajectory.plan_collision_aware_trajectory.

KinematicsConfig fields