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 simple per-joint projection: a joint that reaches its bound is pinned
there and its velocity is set to zero — no bounce and no restoring spring. It does
not model the impact: the other joints keep their velocities, and because the
joints' inertias are coupled, zeroing one joint's velocity does not necessarily
remove energy — the arm's total kinetic energy can even rise at the stop. Treat
it as a way to keep the angles in range, not as an energy-dissipating contact
model. 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.run_scenariorecords the resolved value in the log's run metadata; a directsimulate_controlledcall applies it without embedding any reproduction metadata.--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 scenario simulators the flag overrides [simulator].enforce_limits off,
and the resolved value is recorded in the run metadata. (A re-run of an interactive
recording reproduces the unperturbed scenario — mouse-drag forces are not replayed;
see Record, Replay, and Re-simulate.)
The generic dynamics simulator and the trajectory recorder apply the flag the same
way but write plain state logs with no reproduction metadata, so the choice is not
embedded for re-simulation there.
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.