From 5643459e1031fec6dc3a98fc502377cdad7f1da9 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Thu, 13 Aug 2026 11:02:22 -0700 Subject: [PATCH] Decouple implicit-actuator effort limit from solver clamp Treat effort_limit as the actuator-facing rated force or torque and effort_limit_sim as the physics-solver clamp. Preserve existing one-field fallbacks while allowing both fields to carry distinct values, mirroring the velocity-limit semantics introduced in #6481. --- ...actuators-effort-limit-semantics.minor.rst | 9 +++ .../isaaclab/actuators/actuator_base.py | 19 ++--- .../isaaclab/actuators/actuator_base_cfg.py | 29 +++----- .../isaaclab/actuators/actuator_pd.py | 15 ---- .../test/actuators/test_implicit_actuator.py | 74 +++++++------------ ...gyuz-actuators-effort-limit-semantics.skip | 0 .../test/assets/test_articulation.py | 29 +++----- ...gyuz-actuators-effort-limit-semantics.skip | 0 .../test/assets/test_articulation.py | 29 +++----- ...gyuz-actuators-effort-limit-semantics.skip | 0 .../test/assets/test_articulation.py | 29 +++----- 11 files changed, 80 insertions(+), 153 deletions(-) create mode 100644 source/isaaclab/changelog.d/zhengyuz-actuators-effort-limit-semantics.minor.rst create mode 100644 source/isaaclab_newton/changelog.d/zhengyuz-actuators-effort-limit-semantics.skip create mode 100644 source/isaaclab_ov/changelog.d/zhengyuz-actuators-effort-limit-semantics.skip create mode 100644 source/isaaclab_physx/changelog.d/zhengyuz-actuators-effort-limit-semantics.skip diff --git a/source/isaaclab/changelog.d/zhengyuz-actuators-effort-limit-semantics.minor.rst b/source/isaaclab/changelog.d/zhengyuz-actuators-effort-limit-semantics.minor.rst new file mode 100644 index 000000000000..9c15d2e5eeec --- /dev/null +++ b/source/isaaclab/changelog.d/zhengyuz-actuators-effort-limit-semantics.minor.rst @@ -0,0 +1,9 @@ +Changed +^^^^^^^ + +* **Breaking:** Changed the effort-limit semantics of implicit actuators. + :attr:`~isaaclab.actuators.ActuatorBaseCfg.effort_limit` now describes the actuator's + rated force or torque reflected at the joint, while + :attr:`~isaaclab.actuators.ActuatorBaseCfg.effort_limit_sim` remains the solver-level + clamp. Setting both to different values is now valid instead of raising ``ValueError``. + Configurations that set only one field, set equal values, or set neither behave as before. diff --git a/source/isaaclab/isaaclab/actuators/actuator_base.py b/source/isaaclab/isaaclab/actuators/actuator_base.py index 8b2686d0a80f..965b759694b9 100644 --- a/source/isaaclab/isaaclab/actuators/actuator_base.py +++ b/source/isaaclab/isaaclab/actuators/actuator_base.py @@ -54,23 +54,18 @@ class ActuatorBase(ABC): """ effort_limit: torch.Tensor - """The effort limit for the actuator group. Shape is (num_envs, num_joints). + """The joint effort limit for the actuator group [N or N·m]. Shape is (num_envs, num_joints). - This limit is used differently depending on the actuator type: - - - **Explicit actuators**: Used for internal torque clipping within the actuator model - (e.g., motor torque limits in DC motor models). - - **Implicit actuators**: Same as :attr:`effort_limit_sim` (aliased for consistency). + The actuator's rated force/torque reflected at the joint. It clips explicit-model output and remains + available as the model-facing limit for implicit actuators. When configured separately, it is not + pushed to the physics solver; that is :attr:`effort_limit_sim`. """ effort_limit_sim: torch.Tensor - """The effort limit for the actuator group in the simulation. Shape is (num_envs, num_joints). - - For implicit actuators, the :attr:`effort_limit` and :attr:`effort_limit_sim` are the same. + """The solver-level effort clamp for the actuator group [N or N·m]. Shape is (num_envs, num_joints). - - **Explicit actuators**: Typically set to a large value (1.0e9) to avoid double-clipping, - since the actuator model already clips efforts using :attr:`effort_limit`. - - **Implicit actuators**: Same as :attr:`effort_limit` (both values are synchronized). + Written to the simulation physics solver and resolved independently of :attr:`effort_limit` when both + fields are configured. """ velocity_limit: torch.Tensor diff --git a/source/isaaclab/isaaclab/actuators/actuator_base_cfg.py b/source/isaaclab/isaaclab/actuators/actuator_base_cfg.py index 316918dbaf68..24b3de5d4d26 100644 --- a/source/isaaclab/isaaclab/actuators/actuator_base_cfg.py +++ b/source/isaaclab/isaaclab/actuators/actuator_base_cfg.py @@ -30,23 +30,16 @@ class ActuatorBaseCfg: effort_limit: dict[str, float] | float | None = None """Force/Torque limit of the joints in the group. Defaults to None. - This limit is used to clip the computed torque sent to the simulation. If None, the - limit is set to the value specified in the USD joint prim. + This is the actuator's rated force/torque reflected at the joint. It clips the output of explicit + actuator models and remains available as the model-facing limit for implicit actuators. If None, it + uses the value specified in the USD joint prim. An implicit actuator configured with only + :attr:`effort_limit_sim` also uses that solver clamp as its model-facing limit. .. attention:: - The :attr:`effort_limit_sim` attribute should be used to set the effort limit for - the simulation physics solver. - - The :attr:`effort_limit` attribute is used for clipping the effort output of the - actuator model **only** in the case of explicit actuators, such as the - :class:`~isaaclab.actuators.IdealPDActuator`. - - .. note:: - - For implicit actuators, the attributes :attr:`effort_limit` and :attr:`effort_limit_sim` - are equivalent. However, we suggest using the :attr:`effort_limit_sim` attribute because - it is more intuitive. + Use :attr:`effort_limit_sim` for the solver-level clamp. Implicit actuators resolve the two + fields independently when both are configured. When only one is configured, it fills both fields + for backwards compatibility. """ @@ -72,10 +65,11 @@ class ActuatorBaseCfg: """ effort_limit_sim: dict[str, float] | float | None = None - """Effort limit of the joints in the group applied to the simulation physics solver. Defaults to None. + """Solver-level effort clamp of the joints in the group. Defaults to None. The effort limit is used to constrain the computed joint efforts in the physics engine. If the - computed effort exceeds this limit, the physics engine will clip the effort to this value. + computed effort exceeds this limit, the physics engine will clip the effort to this value. It is + resolved independently of :attr:`effort_limit` when both fields are configured. Since explicit actuators (e.g. DC motor), compute and clip the effort in the actuator model, this limit is by default set to a large value to prevent the physics engine from any additional clipping. @@ -83,7 +77,8 @@ class ActuatorBaseCfg: If None, the limit is resolved based on the type of actuator model: - * For implicit actuators, the limit is set to the value specified in the USD joint prim. + * For implicit actuators, the limit is set to :attr:`effort_limit` when it is configured, otherwise + to the value specified in the USD joint prim. * For explicit actuators, the limit is set to 1.0e9. """ diff --git a/source/isaaclab/isaaclab/actuators/actuator_pd.py b/source/isaaclab/isaaclab/actuators/actuator_pd.py index 3d190d178053..fda0add21b95 100644 --- a/source/isaaclab/isaaclab/actuators/actuator_pd.py +++ b/source/isaaclab/isaaclab/actuators/actuator_pd.py @@ -58,24 +58,9 @@ class ImplicitActuator(ActuatorBase): def __init__(self, cfg: ImplicitActuatorCfg, *args, **kwargs): # effort limits if cfg.effort_limit_sim is None and cfg.effort_limit is not None: - # throw a warning that we have a replacement for the deprecated parameter - logger.warning( - "The object has a value for 'effort_limit'." - " This parameter will be removed in the future." - " To set the effort limit, please use 'effort_limit_sim' instead." - ) cfg.effort_limit_sim = cfg.effort_limit elif cfg.effort_limit_sim is not None and cfg.effort_limit is None: - # TODO: Eventually we want to get rid of 'effort_limit' for implicit actuators. - # We should do this once all parameters have an "_sim" suffix. cfg.effort_limit = cfg.effort_limit_sim - elif cfg.effort_limit_sim is not None and cfg.effort_limit is not None: - if cfg.effort_limit_sim != cfg.effort_limit: - raise ValueError( - "The object has set both 'effort_limit_sim' and 'effort_limit'" - f" and they have different values {cfg.effort_limit_sim} != {cfg.effort_limit}." - " Please only set 'effort_limit_sim' for implicit actuators." - ) # velocity limits # 'velocity_limit' is the joint's peak velocity (the actuator's rated speed diff --git a/source/isaaclab/test/actuators/test_implicit_actuator.py b/source/isaaclab/test/actuators/test_implicit_actuator.py index bbdcea63a7cb..39018d694a1e 100644 --- a/source/isaaclab/test/actuators/test_implicit_actuator.py +++ b/source/isaaclab/test/actuators/test_implicit_actuator.py @@ -107,7 +107,7 @@ def test_implicit_actuator_init_minimum(sim, num_envs, num_joints, device, usd_d @pytest.mark.parametrize("effort_lim", [None, 300, 200]) @pytest.mark.parametrize("effort_lim_sim", [None, 400, 200]) def test_implicit_actuator_init_effort_limits(sim, num_envs, num_joints, device, effort_lim, effort_lim_sim): - """Test initialization of implicit actuator with effort limits.""" + """Test independent resolution of the model-facing effort limit and solver clamp.""" effort_limit_default = 5000 joint_names = [f"joint_{d}" for d in range(num_joints)] @@ -121,56 +121,32 @@ def test_implicit_actuator_init_effort_limits(sim, num_envs, num_joints, device, effort_limit_sim=effort_lim_sim, ) - if effort_lim is not None and effort_lim_sim is not None and effort_lim != effort_lim_sim: - with pytest.raises(ValueError): - actuator = actuator_cfg.class_type( - actuator_cfg, - joint_names=joint_names, - joint_ids=joint_ids, - num_envs=num_envs, - device=device, - stiffness=actuator_cfg.stiffness, - damping=actuator_cfg.damping, - effort_limit=effort_limit_default, - ) + actuator = actuator_cfg.class_type( + actuator_cfg, + joint_names=joint_names, + joint_ids=joint_ids, + num_envs=num_envs, + device=device, + stiffness=actuator_cfg.stiffness, + damping=actuator_cfg.damping, + effort_limit=effort_limit_default, + ) + effort_lim_sim_expected = effort_lim_sim + if effort_lim_sim_expected is None: + effort_lim_sim_expected = effort_lim if effort_lim is not None else effort_limit_default + if effort_lim is None: + assert actuator.cfg.effort_limit == actuator.cfg.effort_limit_sim + effort_lim_expected = effort_lim_sim_expected else: - actuator = actuator_cfg.class_type( - actuator_cfg, - joint_names=joint_names, - joint_ids=joint_ids, - num_envs=num_envs, - device=device, - stiffness=actuator_cfg.stiffness, - damping=actuator_cfg.damping, - effort_limit=effort_limit_default, - ) - if effort_lim is not None and effort_lim_sim is None: - assert actuator.cfg.effort_limit_sim == actuator.cfg.effort_limit - effort_lim_expected = effort_lim - effort_lim_sim_expected = effort_lim - - elif effort_lim is None and effort_lim_sim is not None: - assert actuator.cfg.effort_limit_sim == actuator.cfg.effort_limit - effort_lim_expected = effort_lim_sim - effort_lim_sim_expected = effort_lim_sim - - elif effort_lim is None and effort_lim_sim is None: - assert actuator.cfg.effort_limit_sim is None - assert actuator.cfg.effort_limit is None - effort_lim_expected = effort_limit_default - effort_lim_sim_expected = effort_limit_default - - elif effort_lim is not None and effort_lim_sim is not None: - assert actuator.cfg.effort_limit_sim == actuator.cfg.effort_limit - effort_lim_expected = effort_lim - effort_lim_sim_expected = effort_lim_sim + assert actuator.cfg.effort_limit == effort_lim + effort_lim_expected = effort_lim - torch.testing.assert_close( - actuator.effort_limit, effort_lim_expected * torch.ones(num_envs, num_joints, device=device) - ) - torch.testing.assert_close( - actuator.effort_limit_sim, effort_lim_sim_expected * torch.ones(num_envs, num_joints, device=device) - ) + torch.testing.assert_close( + actuator.effort_limit, effort_lim_expected * torch.ones(num_envs, num_joints, device=device) + ) + torch.testing.assert_close( + actuator.effort_limit_sim, effort_lim_sim_expected * torch.ones(num_envs, num_joints, device=device) + ) @pytest.mark.parametrize("num_envs", [1, 2]) diff --git a/source/isaaclab_newton/changelog.d/zhengyuz-actuators-effort-limit-semantics.skip b/source/isaaclab_newton/changelog.d/zhengyuz-actuators-effort-limit-semantics.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index dc705e3fbd5a..3423821cb052 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -2875,10 +2875,6 @@ def test_setting_effort_limit_implicit( device=device, ) # Play sim - if effort_limit_sim is not None and effort_limit is not None: - with pytest.raises(ValueError): - sim.reset() - return sim.reset() # obtain the physx effort limits @@ -2886,24 +2882,17 @@ def test_setting_effort_limit_implicit( articulation.root_view.get_attribute("joint_effort_limit", SimulationManager.get_model()) ).to(device)[:, 0, :] - # check that the two are equivalent - torch.testing.assert_close( - articulation.actuators["joint"].effort_limit_sim, - articulation.actuators["joint"].effort_limit, - ) + # The solver clamp reaches the physics engine; the rated limit remains on the actuator. torch.testing.assert_close(articulation.actuators["joint"].effort_limit_sim, newton_effort_limit) - # decide the limit based on what is set - if effort_limit_sim is None and effort_limit is None: - limit = articulation_cfg.spawn.joint_drive_props.max_force - elif effort_limit_sim is not None and effort_limit is None: - limit = effort_limit_sim - elif effort_limit_sim is None and effort_limit is not None: - limit = effort_limit - - # check that the max force is what we set - expected_effort_limit = torch.full_like(newton_effort_limit, limit) - torch.testing.assert_close(newton_effort_limit, expected_effort_limit) + solver_limit = effort_limit_sim if effort_limit_sim is not None else effort_limit + if solver_limit is None: + solver_limit = articulation_cfg.spawn.joint_drive_props.max_force + rated_limit = effort_limit if effort_limit is not None else solver_limit + torch.testing.assert_close(newton_effort_limit, torch.full_like(newton_effort_limit, solver_limit)) + torch.testing.assert_close( + articulation.actuators["joint"].effort_limit, torch.full_like(newton_effort_limit, rated_limit) + ) @pytest.mark.parametrize("num_articulations", [1, 2]) diff --git a/source/isaaclab_ov/changelog.d/zhengyuz-actuators-effort-limit-semantics.skip b/source/isaaclab_ov/changelog.d/zhengyuz-actuators-effort-limit-semantics.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_ov/test/assets/test_articulation.py b/source/isaaclab_ov/test/assets/test_articulation.py index 0c05580084ef..76614c3d7786 100644 --- a/source/isaaclab_ov/test/assets/test_articulation.py +++ b/source/isaaclab_ov/test/assets/test_articulation.py @@ -2429,33 +2429,22 @@ def test_setting_effort_limit_implicit(sim, num_articulations, device, effort_li device=device, ) # Play sim - if effort_limit_sim is not None and effort_limit is not None: - with pytest.raises(ValueError): - sim.reset() - return sim.reset() # obtain the physx effort limits physx_effort_limit = _read_binding_to_torch(articulation, TT.DOF_MAX_FORCE, device) - # check that the two are equivalent - torch.testing.assert_close( - articulation.actuators["joint"].effort_limit_sim, - articulation.actuators["joint"].effort_limit, - ) + # The solver clamp reaches the physics engine; the rated limit remains on the actuator. torch.testing.assert_close(articulation.actuators["joint"].effort_limit_sim, physx_effort_limit) - # decide the limit based on what is set - if effort_limit_sim is None and effort_limit is None: - limit = articulation_cfg.spawn.joint_drive_props.max_force - elif effort_limit_sim is not None and effort_limit is None: - limit = effort_limit_sim - elif effort_limit_sim is None and effort_limit is not None: - limit = effort_limit - - # check that the max force is what we set - expected_effort_limit = torch.full_like(physx_effort_limit, limit) - torch.testing.assert_close(physx_effort_limit, expected_effort_limit) + solver_limit = effort_limit_sim if effort_limit_sim is not None else effort_limit + if solver_limit is None: + solver_limit = articulation_cfg.spawn.joint_drive_props.max_force + rated_limit = effort_limit if effort_limit is not None else solver_limit + torch.testing.assert_close(physx_effort_limit, torch.full_like(physx_effort_limit, solver_limit)) + torch.testing.assert_close( + articulation.actuators["joint"].effort_limit, torch.full_like(physx_effort_limit, rated_limit) + ) @pytest.mark.parametrize("num_articulations", [1, 2]) diff --git a/source/isaaclab_physx/changelog.d/zhengyuz-actuators-effort-limit-semantics.skip b/source/isaaclab_physx/changelog.d/zhengyuz-actuators-effort-limit-semantics.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index d1a3847a5c35..2d1db57ac431 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -1895,33 +1895,22 @@ def test_setting_effort_limit_implicit(sim, num_articulations, device, effort_li device=device, ) # Play sim - if effort_limit_sim is not None and effort_limit is not None: - with pytest.raises(ValueError): - sim.reset() - return sim.reset() # obtain the physx effort limits physx_effort_limit = wp.to_torch(articulation.root_view.get_dof_max_forces()).to(device=device) - # check that the two are equivalent - torch.testing.assert_close( - articulation.actuators["joint"].effort_limit_sim, - articulation.actuators["joint"].effort_limit, - ) + # The solver clamp reaches the physics engine; the rated limit remains on the actuator. torch.testing.assert_close(articulation.actuators["joint"].effort_limit_sim, physx_effort_limit) - # decide the limit based on what is set - if effort_limit_sim is None and effort_limit is None: - limit = articulation_cfg.spawn.joint_drive_props.max_force - elif effort_limit_sim is not None and effort_limit is None: - limit = effort_limit_sim - elif effort_limit_sim is None and effort_limit is not None: - limit = effort_limit - - # check that the max force is what we set - expected_effort_limit = torch.full_like(physx_effort_limit, limit) - torch.testing.assert_close(physx_effort_limit, expected_effort_limit) + solver_limit = effort_limit_sim if effort_limit_sim is not None else effort_limit + if solver_limit is None: + solver_limit = articulation_cfg.spawn.joint_drive_props.max_force + rated_limit = effort_limit if effort_limit is not None else solver_limit + torch.testing.assert_close(physx_effort_limit, torch.full_like(physx_effort_limit, solver_limit)) + torch.testing.assert_close( + articulation.actuators["joint"].effort_limit, torch.full_like(physx_effort_limit, rated_limit) + ) @pytest.mark.parametrize("num_articulations", [1, 2])