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:
- Scenario config — set
enforce_limits = falsein the[simulator]table. This is the reproducible toggle:run_scenarioembeds the resolved value in the log's run metadata, sorerun_logand an exported config re-run with the same choice. See Control Configuration. run_scenario(..., enforce_limits=...)/simulate_controlled(..., enforce_limits=False)— the programmatic override;None(therun_scenariodefault) defers to the scenario'ssimulator.enforce_limits. The resolved value is what gets recorded.--no-joint-limitsCLI flag on the interactive tools (and theSkelarmSimulator/ recorder classes'enforce_limits=Falseargument), 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).
Related
- Robot Configuration — declaring
limitsin a config. - Trajectory Tracking Control and Reaching Control — the controllers run through the fixed-step loop.