Skip to content

Joint Limits

Each movable joint has an angle range [qmin, qmax]. This page explains how that range is enforced — which is not in the dynamics equations, but in the layer that drives them. The behavior therefore depends on which simulation path you use.

For how to declare limits in a config file (they are given in degrees and default to [-180, 180] when omitted), see Robot Configuration.

Where limits are defined

Limits live on each link as LinkProp.qmin / qmax, stored in radians (converted from the degrees in TOML). The fixed base link has no range.

The dynamics equations are limit-agnostic

compute_forward_dynamics and compute_inverse_dynamics (and the mass-matrix and Coriolis helpers) solve the equations of motion with no knowledge of qmin / qmax. Any limit handling is added by the integration layer on top of them.

Enforcement depends on the simulation path

Path Limit handling
Skeleton.q / set_state (kinematics) Clamp into range with a warning
simulate_controlled (fixed step) Hard stop: clip angle, zero velocity (toggleable via enforce_limits)
SkelarmSimulator GUI (fixed step) Hard stop: clip angle, zero velocity (toggleable via enforce_limits)
simulate_robot (adaptive solve_ivp) None — unconstrained integration
JointSpaceMPC Soft penalty in the rollout (+ plant clamp via the driving loop)

Fixed-step integrators: hard stops

simulate_controlled and the interactive SkelarmSimulator use semi-implicit (symplectic) Euler and apply limits as hard stops each step:

dq = dq + ddq * dt
q  = q  + dq * dt
q_clamped = np.clip(q, lower, upper)          # pin the angle at the bound
dq = np.where(q_clamped != q, 0.0, dq)        # zero the velocity of any joint that hit a limit

This is a fully inelastic, per-joint stop: a joint that reaches its bound is pinned there and its velocity is set to zero — no bounce, no restoring spring, and the kinetic energy in that joint is removed at contact. These loops write link.q / link.dq directly, bypassing the warning-emitting setter, so a run that rides a limit does not flood the log with warnings.

The shared one-step integrator integrate_with_limits(skeleton, tau, dt, lower, upper) takes the bounds as arguments; passing lower=upper=None (the default) skips the clamp/zero block entirely, integrating the unconstrained step.

Disabling the hard stop

Both fixed-step paths can drop the hard stop so the limits constrain only the kinematics (posing and IK) — not the dynamics. There are three ways in, ordered from most to least reproducible:

  1. Scenario config — set enforce_limits = false in the [simulator] table. This is the reproducible toggle: run_scenario embeds the resolved value in the log's run metadata, so rerun_log and an exported config re-run with the same choice. See Control Configuration.
  2. run_scenario(..., enforce_limits=...) / simulate_controlled(..., enforce_limits=False) — the programmatic override; None (the run_scenario default) defers to the scenario's simulator.enforce_limits. The resolved value is what gets recorded.
  3. --no-joint-limits CLI flag on the interactive tools (and the SkelarmSimulator / recorder classes' enforce_limits=False argument), which omits the bounds from the integrator for that run:
uv run python tools/dynamics_simulator.py examples/four_dof_robot.toml --no-joint-limits
uv run python tools/reaching_simulator.py examples/reach.toml --no-joint-limits   # also the --save path
uv run python tools/trajectory_recorder.py examples/four_dof_robot.toml --mode dynamics --no-joint-limits

For the interactive simulators the flag overrides [simulator].enforce_limits off, and the resolved value is recorded for a reproducible re-run.

The default keeps the hard stop on. In the recorder this only affects dynamics mode; ik mode always poses through the clamping kinematic setter regardless.

Adaptive solve_ivp: no enforcement

simulate_robot does not enforce joint limits

simulate_robot integrates the unconstrained ODE with scipy's adaptive RK45, so joints can pass straight through their limits. If you need limits respected during a dynamic simulation, use simulate_controlled (or the GUI simulator), which is also why the stateful controllers are run through it.

MPC: a soft penalty

JointSpaceMPC keeps limits in its prediction rollout as a soft penalty (limit_weight · overshoot²) and its prediction model is deliberately unclamped. Hard limits are not imposed in the optimizer itself — L-BFGS-B only bounds the torque variables (tau_max). The real clamping still happens in the simulate_controlled loop that drives the controller.

Kinematic setters (posing and IK)

Outside the integrators, assigning Skeleton.q or calling set_state clamps the requested angles into [qmin, qmax] and emits a UserWarning for any value that was out of range. This is the posing / forward-kinematics path; the integration loops above deliberately avoid it. Numerical inverse kinematics likewise clamps each proposed step to the limits and reports whether any were hit (IKResult.joint_limits_hit).