From 98ea02cbd6c9e5b176e739a028320bb81e87ec5d Mon Sep 17 00:00:00 2001 From: zilch Date: Wed, 7 Oct 2026 22:21:19 +0800 Subject: [PATCH 1/3] feat(scene): unify body actuator configuration across model formats --- docs/source/en/user_guide/robots.md | 36 ++- .../tutorial/building_envs/scene.md | 42 ++++ docs/source/zh_CN/user_guide/robots.md | 35 ++- .../tutorial/building_envs/scene.md | 41 ++++ .../motrix_env_core/config/scene/__init__.py | 4 +- .../motrix_env_core/config/scene/actuator.py | 47 ++++ .../src/motrix_env_core/config/scene/base.py | 19 +- .../src/motrix_env_core/config/scene/urdf.py | 47 +--- motrix_env_core/tests/test_actuator_config.py | 48 ++++ .../src/motrix_env_motrixsim/actuators.py | 67 ++++++ .../src/motrix_env_motrixsim/compiler.py | 12 +- .../src/motrix_env_motrixsim/urdf.py | 57 ----- .../tests/test_motrixsim_body_actuators.py | 145 ++++++++++++ .../src/motrix_env_mujoco/actuators.py | 72 ++++++ .../src/motrix_env_mujoco/compiler.py | 2 + .../src/motrix_env_mujoco/urdf.py | 60 +---- .../tests/test_body_actuators.py | 224 ++++++++++++++++++ .../tests/test_mujoco_scene_compiler.py | 22 +- motrix_envs/src/motrix_envs/robot/dex_evt.py | 10 +- 19 files changed, 782 insertions(+), 208 deletions(-) create mode 100644 motrix_env_core/src/motrix_env_core/config/scene/actuator.py create mode 100644 motrix_env_core/tests/test_actuator_config.py create mode 100644 motrix_env_motrixsim/src/motrix_env_motrixsim/actuators.py create mode 100644 motrix_env_motrixsim/tests/test_motrixsim_body_actuators.py create mode 100644 motrix_env_mujoco/src/motrix_env_mujoco/actuators.py create mode 100644 motrix_env_mujoco/tests/test_body_actuators.py diff --git a/docs/source/en/user_guide/robots.md b/docs/source/en/user_guide/robots.md index f3bdddf9..bbddbe38 100644 --- a/docs/source/en/user_guide/robots.md +++ b/docs/source/en/user_guide/robots.md @@ -39,7 +39,11 @@ motrix_envs/src/motrix_envs/robot/ ``` An MJCF file should contain only the reusable robot model, without a floor, lights, or task-specific objects. For a -URDF robot, `UrdfFileCfg` can add simulation properties through its `geoms`, `sites`, `joints`, and `actuators` fields. +URDF robot, `UrdfFileCfg` can add simulation properties through its `geoms`, `sites`, and `joints` fields. +Declare MJCF and URDF actuators on the robot instance through its inherited `BodyCfg.actuators` dictionary. +When migrating an existing declaration, import `ActuatorCfg` and `PositionActuatorCfg` from +`motrix_env_core.config.scene.actuator` rather than `motrix_env_core.config.scene.urdf`, and move the actuator list out of +`UrdfFileCfg` into this dictionary. Dictionary keys replace the old `name` field and `actuator_name` property. ### 2. Declare the `RobotCfg` @@ -77,6 +81,7 @@ The public `RobotCfg` fields are: | `base_link_name` | Root link used to attach the model to a scene | | `translation` / `rotation` | Optional default instance pose; task configs can override it | | `prefix` / `suffix` | Optional element-name affixes for name isolation within a scene | +| `actuators` | `None` preserves imported actuators; `{}` removes them; a non-empty dictionary replaces them | | `key_pose` | Named poses that share one explicit joint order, such as `default` or `crouch` | `KeyPoseCfg.joint_names` must be unique and non-empty. Every pose must contain one finite value per joint. Keep only @@ -99,24 +104,37 @@ class MyHumanoid(HumanoidRobotCfg): Both foot-link names must be non-empty and distinct. `resolved_foot_link_names` automatically applies the robot instance's `prefix` and `suffix`. -A URDF robot uses the same config hierarchy and replaces `model` with `UrdfFileCfg`. Add position actuators or sole sites -explicitly when the source URDF does not provide them: +A URDF robot uses the same config hierarchy and replaces `model` with `UrdfFileCfg`. Sites remain on the model config; +actuators belong to `RobotCfg`. This minimal example assumes `base` and `left_foot` links and a limited `left_hip` joint: ```python -model: UrdfFileCfg = UrdfFileCfg( - file=MY_ROBOT_ASSET_DIR / "my_robot.urdf", - sites=[SiteCfg(name="left_sole", parent_link_name="left_foot")], - actuators=[ - PositionActuatorCfg( +from motrix_env_core.config.scene import RobotCfg +from motrix_env_core.config.scene.actuator import PositionActuatorCfg +from motrix_env_core.config.scene.urdf import SiteCfg, UrdfFileCfg + +robot = RobotCfg( + model=UrdfFileCfg( + file="my_robot.urdf", + sites=[SiteCfg(name="left_sole", parent_link_name="left_foot")], + ), + base_link_name="base", + actuators={ + "left_hip_position": PositionActuatorCfg( joint_name="left_hip", kp=100.0, kv=2.0, inherit_joint_range=True, ), - ], + }, ) ``` +Dictionary keys name actuators; `joint_name` uses imported names before instance `prefix` / `suffix` decoration. +Declare all controlled joints in the dictionary: it replaces rather than appends to the imported actuator set. +Omit `actuators` or set it to `None` to preserve imported actuators; use `{}` to remove them. See +[scene actuator configuration](tutorial/building_envs/scene.md#actuators-on-imported-bodies) for control ranges, +actuator ordering, and keyframe behavior. + ### 3. Register the robot Import and register a built-in robot in `motrix_envs/robot/__init__.py`: diff --git a/docs/source/en/user_guide/tutorial/building_envs/scene.md b/docs/source/en/user_guide/tutorial/building_envs/scene.md index 11f866a0..51ac5de0 100644 --- a/docs/source/en/user_guide/tutorial/building_envs/scene.md +++ b/docs/source/en/user_guide/tutorial/building_envs/scene.md @@ -37,6 +37,48 @@ class MyTaskEnvCfg(EnvCfg): reset_noise_scale: float = 0.01 ``` +### Actuators on imported bodies + +Declare actuators on `BodyCfg.actuators`, not on `UrdfFileCfg`. `RobotCfg` inherits the same field, and both MJCF and URDF +models use the format-neutral declarations in `motrix_env_core.config.scene.actuator`: + +```python +from motrix_env_core.config.scene import BodyCfg, MjcfFileCfg +from motrix_env_core.config.scene.actuator import PositionActuatorCfg + +body = BodyCfg( + model=MjcfFileCfg(file="my_robot.xml"), + base_link_name="base", + prefix="robot_", + actuators={ + "hip_position": PositionActuatorCfg( + joint_name="hip", + kp=100.0, + kv=2.0, + inherit_joint_range=True, + ), + }, +) +``` + +This example assumes the imported model contains a `base` link and a limited `hip` joint. The dictionary key names the +actuator; `joint_name` uses the imported joint name **before** `prefix` / `suffix` decoration. Here, the assembled actuator +and joint names are `robot_hip_position` and `robot_hip`. Dictionary insertion order defines actuator order. + +- `actuators=None` (the default) preserves the imported model's actuators. +- `actuators={}` removes all imported actuators. +- A non-empty dictionary replaces the entire imported actuator set, rather than appending to it. Declare every actuator + needed by the body. + +`PositionActuatorCfg` inherits `joint_name`, `ctrl_range`, and `force_range` from `ActuatorCfg`. `kp` is the position gain; +`kv` is the absolute damping coefficient. `inherit_joint_range=True` uses the imported joint limits as the control range +and cannot be combined with an explicit `ctrl_range`. For direct joint effort control, use `MotorActuatorCfg` from the +same module, for example `MotorActuatorCfg(joint_name="hip", ctrl_range=(-10.0, 10.0))`; it uses unit gear and gain. + +Replacement or removal resets actuator control (`ctrl`) and activation (`act`, where represented) values in imported +keyframes while preserving joint positions (`qpos`) and velocities (`qvel`). MuJoCo resets both `ctrl` and `act`; +MotrixSim resets `ctrl` because MSD keyframes do not expose activation state. + ### Recommended Directory Structure ``` diff --git a/docs/source/zh_CN/user_guide/robots.md b/docs/source/zh_CN/user_guide/robots.md index dffe9549..1654d1ad 100644 --- a/docs/source/zh_CN/user_guide/robots.md +++ b/docs/source/zh_CN/user_guide/robots.md @@ -38,7 +38,11 @@ motrix_envs/src/motrix_envs/robot/ ``` MJCF 文件应只包含可跨场景复用的机器人模型,不要把地面、灯光或任务专属物体放进机器人文件。若使用 URDF,可以通过 -`UrdfFileCfg` 的 `geoms`、`sites`、`joints` 和 `actuators` 补充 URDF 本身不包含的仿真属性。 +`UrdfFileCfg` 的 `geoms`、`sites` 和 `joints` 补充 URDF 本身不包含的仿真属性。 +MJCF 和 URDF 的执行器均通过机器人实例继承的 `BodyCfg.actuators` 字典声明。 +迁移现有声明时,改从 `motrix_env_core.config.scene.actuator` 导入 `ActuatorCfg` 和 `PositionActuatorCfg`, +不再从 `motrix_env_core.config.scene.urdf` 导入;将执行器列表从 `UrdfFileCfg` 移到这个字典中。 +字典键取代原来的 `name` 字段和 `actuator_name` 属性。 ### 2. 声明 `RobotCfg` @@ -76,6 +80,7 @@ class MyRobot(RobotCfg): | `base_link_name` | 模型连接到场景时使用的根链接名称 | | `translation` / `rotation` | 可选的默认实例位姿;任务配置可以在实例化时覆盖 | | `prefix` / `suffix` | 可选的模型元素名称前后缀,用于同一场景中的名称隔离 | +| `actuators` | `None` 保留导入执行器;`{}` 移除全部执行器;非空字典替换全部执行器 | | `key_pose` | 共享一套明确关节顺序的有名姿态,例如 `default`、`crouch` | `KeyPoseCfg.joint_names` 必须唯一且非空;每个姿态的数值数量必须与关节数量一致,所有值必须为有限数。这里只保存机器人 @@ -97,24 +102,36 @@ class MyHumanoid(HumanoidRobotCfg): 两个脚部名称必须非空且互不相同。`resolved_foot_link_names` 会自动应用机器人实例的 `prefix` 和 `suffix`。 -URDF 机器人沿用相同的配置层次,并将 `model` 换成 `UrdfFileCfg`。需要 position actuator 或脚底 site 时,可以 -在模型配置中显式补充: +URDF 机器人沿用相同的配置层次,并将 `model` 换成 `UrdfFileCfg`。site 仍属于模型配置,执行器属于 `RobotCfg`。 +下面的最小示例假定模型包含 `base` 和 `left_foot` link,以及具有限位的 `left_hip` joint: ```python -model: UrdfFileCfg = UrdfFileCfg( - file=MY_ROBOT_ASSET_DIR / "my_robot.urdf", - sites=[SiteCfg(name="left_sole", parent_link_name="left_foot")], - actuators=[ - PositionActuatorCfg( +from motrix_env_core.config.scene import RobotCfg +from motrix_env_core.config.scene.actuator import PositionActuatorCfg +from motrix_env_core.config.scene.urdf import SiteCfg, UrdfFileCfg + +robot = RobotCfg( + model=UrdfFileCfg( + file="my_robot.urdf", + sites=[SiteCfg(name="left_sole", parent_link_name="left_foot")], + ), + base_link_name="base", + actuators={ + "left_hip_position": PositionActuatorCfg( joint_name="left_hip", kp=100.0, kv=2.0, inherit_joint_range=True, ), - ], + }, ) ``` +字典键定义执行器名称;`joint_name` 使用应用实例 `prefix` / `suffix` 之前的导入名称。 +字典替换而非追加导入执行器集合,因此需声明全部受控关节。省略 `actuators` 或设为 `None` 可保留导入执行器, +`{}` 则移除全部执行器。控制范围、执行器顺序与关键帧行为见 +[场景执行器配置](tutorial/building_envs/scene.md#导入本体的执行器)。 + ### 3. 注册机器人 内置机器人在 `motrix_envs/robot/__init__.py` 中导入并注册: diff --git a/docs/source/zh_CN/user_guide/tutorial/building_envs/scene.md b/docs/source/zh_CN/user_guide/tutorial/building_envs/scene.md index 4db41c4b..afe584d2 100644 --- a/docs/source/zh_CN/user_guide/tutorial/building_envs/scene.md +++ b/docs/source/zh_CN/user_guide/tutorial/building_envs/scene.md @@ -32,6 +32,47 @@ class MyTaskEnvCfg(EnvCfg): ctrl_dt: float = 0.02 ``` +### 导入本体的执行器 + +执行器通过 `BodyCfg.actuators` 声明,不属于 `UrdfFileCfg`。`RobotCfg` 继承同一字段;MJCF 和 URDF 模型均使用 +`motrix_env_core.config.scene.actuator` 中与文件格式无关的声明: + +```python +from motrix_env_core.config.scene import BodyCfg, MjcfFileCfg +from motrix_env_core.config.scene.actuator import PositionActuatorCfg + +body = BodyCfg( + model=MjcfFileCfg(file="my_robot.xml"), + base_link_name="base", + prefix="robot_", + actuators={ + "hip_position": PositionActuatorCfg( + joint_name="hip", + kp=100.0, + kv=2.0, + inherit_joint_range=True, + ), + }, +) +``` + +示例假定导入模型包含 `base` link 和具有限位的 `hip` joint。字典键定义执行器名称;`joint_name` 使用应用 +`prefix` / `suffix` **之前**的导入关节名称。此处组装后的执行器和关节名称分别为 `robot_hip_position` 和 +`robot_hip`。字典插入顺序决定执行器顺序。 + +- `actuators=None`(默认值)保留导入模型的执行器。 +- `actuators={}` 移除全部导入执行器。 +- 非空字典替换整个导入执行器集合,而非追加;必须声明本体需要的全部执行器。 + +`PositionActuatorCfg` 从 `ActuatorCfg` 继承 `joint_name`、`ctrl_range` 和 `force_range`。`kp` 是位置增益, +`kv` 是绝对阻尼系数。`inherit_joint_range=True` 将导入关节限位用作控制范围,不能与显式 `ctrl_range` 同时设置。 +直接控制关节力或力矩时,使用同一模块中的 `MotorActuatorCfg`,例如 +`MotorActuatorCfg(joint_name="hip", ctrl_range=(-10.0, 10.0))`;其传动比与增益均为 1。 + +替换或移除执行器会重置导入关键帧中的执行器控制值(`ctrl`)与 activation 状态(后端表示该状态时的 `act`), +保留关节位置(`qpos`)和速度(`qvel`)。MuJoCo 重置 `ctrl` 和 `act`;MotrixSim 重置 `ctrl`,因为 MSD 关键帧 +不暴露 activation 状态。 + ### 推荐目录结构 ``` diff --git a/motrix_env_core/src/motrix_env_core/config/scene/__init__.py b/motrix_env_core/src/motrix_env_core/config/scene/__init__.py index 0723e649..013f8dad 100644 --- a/motrix_env_core/src/motrix_env_core/config/scene/__init__.py +++ b/motrix_env_core/src/motrix_env_core/config/scene/__init__.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 from motrix_env_core.config.scene._utils import Vec2, Vec3, Vec4 +from motrix_env_core.config.scene.actuator import ActuatorCfg, MotorActuatorCfg, PositionActuatorCfg from motrix_env_core.config.scene.asset import ( HFieldAssetCfg, MaterialCfg, @@ -50,9 +51,7 @@ grid_terrain, ) from motrix_env_core.config.scene.urdf import ( - ActuatorCfg, JointCfg, - PositionActuatorCfg, SiteCfg, UrdfFileCfg, UrdfGeomCfg, @@ -82,6 +81,7 @@ "MaterialCfg", "MjcfFileCfg", "ModelFileCfg", + "MotorActuatorCfg", "NoiseTerrainGeneratorCfg", "PositionActuatorCfg", "ProceduralHFieldAssetCfg", diff --git a/motrix_env_core/src/motrix_env_core/config/scene/actuator.py b/motrix_env_core/src/motrix_env_core/config/scene/actuator.py new file mode 100644 index 00000000..e1f90e06 --- /dev/null +++ b/motrix_env_core/src/motrix_env_core/config/scene/actuator.py @@ -0,0 +1,47 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +"""Format-neutral joint actuator declarations for model instances.""" + +import math + +from motrix_env_core.config import configclass +from motrix_env_core.config.scene._utils import Vec2, validate_range + + +@configclass(kw_only=True) +class ActuatorCfg: + """Base declaration using undecorated names in the imported model.""" + + joint_name: str + ctrl_range: Vec2 | None = None + force_range: Vec2 | None = None + + def validate(self) -> None: + if not self.joint_name: + raise ValueError("ActuatorCfg.joint_name must not be empty") + validate_range("ActuatorCfg.ctrl_range", self.ctrl_range) + validate_range("ActuatorCfg.force_range", self.force_range) + + +@configclass(kw_only=True) +class MotorActuatorCfg(ActuatorCfg): + """Direct joint effort motor with unit transmission and gain.""" + + +@configclass(kw_only=True) +class PositionActuatorCfg(ActuatorCfg): + """Joint position servo with absolute damping coefficient kv.""" + + kp: float + kv: float = 0.0 + inherit_joint_range: bool = False + + def validate(self) -> None: + super().validate() + if self.inherit_joint_range and self.ctrl_range is not None: + raise ValueError("PositionActuatorCfg.inherit_joint_range is mutually exclusive with ctrl_range") + if not math.isfinite(self.kp) or self.kp <= 0.0: + raise ValueError(f"PositionActuatorCfg.kp must be positive and finite, got {self.kp}") + if not math.isfinite(self.kv) or self.kv < 0.0: + raise ValueError(f"PositionActuatorCfg.kv must be non-negative and finite, got {self.kv}") diff --git a/motrix_env_core/src/motrix_env_core/config/scene/base.py b/motrix_env_core/src/motrix_env_core/config/scene/base.py index 57f5d20f..5621f514 100644 --- a/motrix_env_core/src/motrix_env_core/config/scene/base.py +++ b/motrix_env_core/src/motrix_env_core/config/scene/base.py @@ -12,6 +12,7 @@ from motrix_env_core.config import configclass from motrix_env_core.config.scene._utils import Vec3, Vec4, optional_vec, resolve_path +from motrix_env_core.config.scene.actuator import ActuatorCfg @configclass(kw_only=True) @@ -269,9 +270,12 @@ class BodyCfg(SceneObjCfg): Holds the backend-neutral contract for attaching an external MJCF/URDF model (a free-floating body tree): which file, which link is the attach - root, and optional placement/name decoration. Robots extend this with - key-pose and actuation semantics; prop objects (balls, boxes, ...) use - it directly. + root, placement/name decoration, and optional actuator declarations. + ``actuators=None`` preserves the imported model; an explicit dict replaces + its actuators, and an empty dict removes them. Dict keys own actuator names. Names refer to the imported + model before prefix/suffix decoration. Replacement resets actuator control + and activation keyframe state, while preserving joint pose/velocity state. + Robots extend this with key-pose semantics; props use it directly. """ model: ModelFileCfg @@ -280,6 +284,8 @@ class BodyCfg(SceneObjCfg): rotation: Vec4 | None = None prefix: str | None = None suffix: str | None = None + # None preserves imported actuators; a dict replaces them (empty removes all). + actuators: dict[str, ActuatorCfg] | None = None def validate(self, name: str) -> None: super().validate(name) @@ -290,6 +296,13 @@ def validate(self, name: str) -> None: raise ValueError("BodyCfg.base_link_name must not be empty") optional_vec("BodyCfg.translation", self.translation, 3) optional_vec("BodyCfg.rotation", self.rotation, 4) + if self.actuators is not None: + for actuator_name, actuator in self.actuators.items(): + if not isinstance(actuator_name, str) or not actuator_name: + raise ValueError("BodyCfg.actuators keys must be non-empty names") + if not isinstance(actuator, ActuatorCfg): + raise TypeError(f"BodyCfg.actuators must contain ActuatorCfg, got {type(actuator).__name__}") + actuator.validate() def resolve_name(self, name: str) -> str: return f"{self.prefix or ''}{name}{self.suffix or ''}" diff --git a/motrix_env_core/src/motrix_env_core/config/scene/urdf.py b/motrix_env_core/src/motrix_env_core/config/scene/urdf.py index b80f989e..7dc25b63 100644 --- a/motrix_env_core/src/motrix_env_core/config/scene/urdf.py +++ b/motrix_env_core/src/motrix_env_core/config/scene/urdf.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 from motrix_env_core.config import configclass -from motrix_env_core.config.scene._utils import Vec2, Vec3, Vec4, optional_vec, resolve_path, validate_range +from motrix_env_core.config.scene._utils import Vec3, Vec4, optional_vec, resolve_path from motrix_env_core.config.scene.base import ModelFileCfg @@ -62,54 +62,13 @@ def validate(self) -> None: optional_vec("SiteCfg.rotation", self.rotation, 4) -@configclass(kw_only=True) -class ActuatorCfg: - """An actuator added to an imported robot before scene composition.""" - - joint_name: str - name: str | None = None - ctrl_range: Vec2 | None = None - force_range: Vec2 | None = None - - @property - def actuator_name(self) -> str: - return self.joint_name if self.name is None else self.name - - def validate(self) -> None: - if not self.joint_name: - raise ValueError("ActuatorCfg.joint_name must not be empty") - if not self.actuator_name: - raise ValueError("ActuatorCfg.name must not be empty") - validate_range("ActuatorCfg.ctrl_range", self.ctrl_range) - validate_range("ActuatorCfg.force_range", self.force_range) - - -@configclass(kw_only=True) -class PositionActuatorCfg(ActuatorCfg): - """A joint position servo using absolute damping coefficient ``kv``.""" - - kp: float - kv: float = 0.0 - inherit_joint_range: bool = False - - def validate(self) -> None: - super().validate() - if self.inherit_joint_range and self.ctrl_range is not None: - raise ValueError("PositionActuatorCfg.inherit_joint_range is mutually exclusive with ctrl_range") - if self.kp <= 0.0: - raise ValueError(f"PositionActuatorCfg.kp must be positive, got {self.kp}") - if self.kv < 0.0: - raise ValueError(f"PositionActuatorCfg.kv must be non-negative, got {self.kv}") - - @configclass(kw_only=True) class UrdfFileCfg(ModelFileCfg): - """A URDF model augmented with simulation-only sites, joints, and actuators.""" + """A URDF model augmented with simulation-only sites and joint properties.""" geoms: list[UrdfGeomCfg] = [] sites: list[SiteCfg] = [] joints: list[JointCfg] = [] - actuators: list[ActuatorCfg] = [] def validate(self) -> None: super().validate() @@ -123,5 +82,3 @@ def validate(self) -> None: site.validate() for joint in self.joints: joint.validate() - for actuator in self.actuators: - actuator.validate() diff --git a/motrix_env_core/tests/test_actuator_config.py b/motrix_env_core/tests/test_actuator_config.py new file mode 100644 index 00000000..72f9f9c6 --- /dev/null +++ b/motrix_env_core/tests/test_actuator_config.py @@ -0,0 +1,48 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +"""Format-neutral actuator declarations belong to model instances.""" + +import pytest +from omegaconf import OmegaConf + +from motrix_env_core.config.scene.actuator import MotorActuatorCfg, PositionActuatorCfg +from motrix_env_core.config.scene.base import BodyCfg, RobotCfg +from motrix_env_core.config.scene.mjcf import MjcfFileCfg + + +def test_body_actuator_defaults_and_instance_isolation(): + model = MjcfFileCfg(file="unused.xml") + preserved = BodyCfg(model=model, base_link_name="base") + removed = BodyCfg(model=model, base_link_name="base", actuators={}) + assert preserved.actuators is None + assert removed.actuators == {} + removed.actuators["motor"] = MotorActuatorCfg(joint_name="hinge") + assert BodyCfg(model=model, base_link_name="base", actuators={}).actuators == {} + robot = RobotCfg(model=model, base_link_name="base", actuators={"motor": MotorActuatorCfg(joint_name="hinge")}) + assert robot.actuators["motor"].joint_name == "hinge" + + +@pytest.mark.parametrize("kp,kv", [(0.0, 0.0), (-1.0, 0.0), (float("nan"), 0.0), (1.0, -1.0), (1.0, float("inf"))]) +def test_position_actuator_validates_finite_gains(kp, kv): + with pytest.raises(ValueError): + PositionActuatorCfg(joint_name="hinge", kp=kp, kv=kv).validate() + + +def test_named_actuator_override_preserves_other_fields(): + body = BodyCfg( + model=MjcfFileCfg(file="unused.xml"), + base_link_name="base", + actuators={"servo": PositionActuatorCfg(joint_name="hinge", kp=10.0, kv=1.0)}, + ) + cfg = OmegaConf.merge(OmegaConf.structured(body), {"actuators": {"servo": {"kp": 20.0}}}) + overridden = OmegaConf.to_object(cfg) + assert overridden.actuators["servo"].kp == 20.0 + assert overridden.actuators["servo"].kv == 1.0 + assert overridden.actuators["servo"].joint_name == "hinge" + assert body.actuators["servo"].kp == 10.0 + + +def test_position_actuator_range_sources_are_exclusive(): + with pytest.raises(ValueError, match="mutually exclusive"): + PositionActuatorCfg(joint_name="hinge", kp=1.0, inherit_joint_range=True, ctrl_range=(-1.0, 1.0)).validate() diff --git a/motrix_env_motrixsim/src/motrix_env_motrixsim/actuators.py b/motrix_env_motrixsim/src/motrix_env_motrixsim/actuators.py new file mode 100644 index 00000000..225acd39 --- /dev/null +++ b/motrix_env_motrixsim/src/motrix_env_motrixsim/actuators.py @@ -0,0 +1,67 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +from typing import Any + +import motrixsim as mtx + +from motrix_env_core.config.scene.actuator import ActuatorCfg, MotorActuatorCfg, PositionActuatorCfg +from motrix_env_motrixsim.urdf import _iter_links + + +def _build_actuator(name: str, cfg: ActuatorCfg) -> Any: + actuator = mtx.msd.Actuator() + actuator.name = name + actuator.target = mtx.msd.ActuatorTarget.joint(cfg.joint_name) + actuator.gear = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0] + if isinstance(cfg, PositionActuatorCfg): + param = mtx.msd.PositionParam() + param.kp = cfg.kp + param.damping_value = cfg.kv + param.damping_type = mtx.msd.DampingType.Kv + actuator.actuator_type = mtx.msd.ActuatorType.position(param) + elif isinstance(cfg, MotorActuatorCfg): + actuator.actuator_type = mtx.msd.ActuatorType.motor() + else: + raise TypeError(f"MotrixSim does not support actuator config {type(cfg).__name__}") + if cfg.ctrl_range is not None: + actuator.ctrlrange = mtx.msd.Range(*cfg.ctrl_range) + if cfg.force_range is not None: + actuator.forcerange = mtx.msd.Range(*cfg.force_range) + return actuator + + +def apply_actuator_cfgs(world: mtx.msd.World, cfgs: dict[str, ActuatorCfg] | None) -> None: + """Preserve imported actuation for None, otherwise replace it before attachment. + + Replacement clears keyframe controls, since their old ordering no longer applies. + MotrixSim MSD keyframes do not expose actuator activation state. + """ + if cfgs is None: + return + target_names = {cfg.joint_name for cfg in cfgs.values()} + if len(target_names) != len(cfgs): + raise ValueError("Multiple actuator configs target the same joint") + if any(not name for name in cfgs): + raise ValueError("Configured actuator name must not be empty") + joints = { + joint.name: joint + for body in world.hierarchy.bodies + for link in _iter_links(body.link) + for joint in link.joints + if joint.name is not None + } + unknown_joints = sorted(target_names.difference(joints)) + if unknown_joints: + raise ValueError(f"Configured actuator joints do not exist in model: {unknown_joints}") + replacements = [] + for name, cfg in cfgs.items(): + cfg.validate() + actuator = _build_actuator(name, cfg) + if isinstance(cfg, PositionActuatorCfg) and cfg.inherit_joint_range: + actuator.ctrlrange = joints[cfg.joint_name].pos_limit + replacements.append(actuator) + world.actuators.clear() + world.actuators.extend(replacements) + for key in world.keyframes: + key.ctrl = [0.0] * len(replacements) diff --git a/motrix_env_motrixsim/src/motrix_env_motrixsim/compiler.py b/motrix_env_motrixsim/src/motrix_env_motrixsim/compiler.py index 951f1746..1b092632 100644 --- a/motrix_env_motrixsim/src/motrix_env_motrixsim/compiler.py +++ b/motrix_env_motrixsim/src/motrix_env_motrixsim/compiler.py @@ -37,6 +37,7 @@ from motrix_env_core.config.scene.urdf import UrdfFileCfg from motrix_env_core.config.scene.validation import validate_scene_cfg from motrix_env_core.config.sim import SimCfg +from motrix_env_motrixsim.actuators import apply_actuator_cfgs from motrix_env_motrixsim.urdf import apply_urdf_cfgs _CONTACT_REDUCE = { @@ -101,13 +102,6 @@ def _load_model_world(self, model: ModelFileCfg) -> mtx.msd.World: if isinstance(model, UrdfFileCfg): path = resolve_path(model.file) world = mtx.msd.from_str(path.read_text(), format="urdf", file_path=str(path)) - # urdf2msd (since 0.10.1.dev120408) synthesizes a default - # `_motor` actuator per joint. The declarative - # ``UrdfFileCfg.actuators`` are the sole actuation authority, so - # drop the synthesized defaults before applying the config. - # Track merging declarative configs onto the synthesized motors - # in https://gitlab.mp/motphys/morphos-lab/-/issues/226. - world.actuators.clear() apply_urdf_cfgs(world, model) return world raise TypeError(f"MotrixSim does not support model file config {type(model).__name__}") @@ -234,8 +228,10 @@ def _append_obj(self, world: mtx.msd.World, name: str, cfg: SceneObjCfg) -> None world.hierarchy.lights.append(light) return if isinstance(cfg, BodyCfg): + model_world = self._load_model_world(cfg.model) + apply_actuator_cfgs(model_world, cfg.actuators) world.attach( - self._load_model_world(cfg.model), + model_world, other_link_name=cfg.base_link_name, other_translation=cfg.translation, other_rotation=cfg.rotation, diff --git a/motrix_env_motrixsim/src/motrix_env_motrixsim/urdf.py b/motrix_env_motrixsim/src/motrix_env_motrixsim/urdf.py index 4e90fed4..8f0e854a 100644 --- a/motrix_env_motrixsim/src/motrix_env_motrixsim/urdf.py +++ b/motrix_env_motrixsim/src/motrix_env_motrixsim/urdf.py @@ -7,9 +7,7 @@ import motrixsim as mtx from motrix_env_core.config.scene.urdf import ( - ActuatorCfg, JointCfg, - PositionActuatorCfg, SiteCfg, UrdfFileCfg, UrdfGeomCfg, @@ -46,25 +44,6 @@ def _build_site(cfg: SiteCfg) -> mtx.msd.Site: return site -def _build_actuator(cfg: ActuatorCfg) -> Any: - actuator = mtx.msd.Actuator() - actuator.name = cfg.actuator_name - actuator.target = mtx.msd.ActuatorTarget.joint(cfg.joint_name) - if isinstance(cfg, PositionActuatorCfg): - param = mtx.msd.PositionParam() - param.kp = cfg.kp - param.damping_value = cfg.kv - param.damping_type = mtx.msd.DampingType.Kv - actuator.actuator_type = mtx.msd.ActuatorType.position(param) - else: - raise TypeError(f"MotrixSim does not support actuator config {type(cfg).__name__}") - if cfg.ctrl_range is not None: - actuator.ctrlrange = mtx.msd.Range(*cfg.ctrl_range) - if cfg.force_range is not None: - actuator.forcerange = mtx.msd.Range(*cfg.force_range) - return actuator - - def apply_urdf_cfgs(world: mtx.msd.World, cfg: UrdfFileCfg) -> None: """Apply declarative URDF augmentations to a MotrixSim MSD world.""" cfg_by_geom: dict[str, UrdfGeomCfg] = {} @@ -88,21 +67,9 @@ def apply_urdf_cfgs(world: mtx.msd.World, cfg: UrdfFileCfg) -> None: raise ValueError(f"Multiple joint configs target joint {joint_cfg.joint_name!r}") cfg_by_joint[joint_cfg.joint_name] = joint_cfg - target_joints: set[str] = set() - actuator_names: set[str] = set() - for actuator_cfg in cfg.actuators: - actuator_cfg.validate() - if actuator_cfg.joint_name in target_joints: - raise ValueError(f"Multiple actuator configs target joint {actuator_cfg.joint_name!r}") - if actuator_cfg.actuator_name in actuator_names: - raise ValueError(f"Duplicate configured actuator name {actuator_cfg.actuator_name!r}") - target_joints.add(actuator_cfg.joint_name) - actuator_names.add(actuator_cfg.actuator_name) - configured_geoms: set[str] = set() configured_joints: set[str] = set() links_by_name: dict[str, Any] = {} - joints_by_name: dict[str, Any] = {} existing_site_names = {site.name for site in world.hierarchy.sites if site.name is not None} def apply_geom(geom: Any) -> None: @@ -122,8 +89,6 @@ def apply_geom(geom: Any) -> None: for geom in link.geoms: apply_geom(geom) for joint in link.joints: - if joint.name is not None: - joints_by_name[joint.name] = joint if joint.name not in cfg_by_joint: continue _apply_joint_cfg(cfg_by_joint[joint.name], joint) @@ -146,27 +111,5 @@ def apply_geom(geom: Any) -> None: if unknown_site_links: raise ValueError(f"Configured site parent links do not exist in model: {unknown_site_links}") - unknown_actuator_joints = sorted(target_joints.difference(joints_by_name)) - if unknown_actuator_joints: - raise ValueError(f"Configured actuator joints do not exist in model: {unknown_actuator_joints}") - - existing_names = {actuator.name for actuator in world.actuators if actuator.name is not None} - duplicate_names = sorted(existing_names.intersection(actuator_names)) - if duplicate_names: - raise ValueError(f"Configured actuator names already exist in model: {duplicate_names}") - - existing_joint_targets = { - actuator.target.value for actuator in world.actuators if actuator.target.variant == "joint" - } - duplicate_targets = sorted(existing_joint_targets.intersection(target_joints)) - if duplicate_targets: - raise ValueError(f"Configured joints already have actuators in model: {duplicate_targets}") - for site_cfg in cfg.sites: links_by_name[site_cfg.parent_link_name].sites.append(_build_site(site_cfg)) - - for actuator_cfg in cfg.actuators: - actuator = _build_actuator(actuator_cfg) - if isinstance(actuator_cfg, PositionActuatorCfg) and actuator_cfg.inherit_joint_range: - actuator.ctrlrange = joints_by_name[actuator_cfg.joint_name].pos_limit - world.actuators.append(actuator) diff --git a/motrix_env_motrixsim/tests/test_motrixsim_body_actuators.py b/motrix_env_motrixsim/tests/test_motrixsim_body_actuators.py new file mode 100644 index 00000000..71a578ce --- /dev/null +++ b/motrix_env_motrixsim/tests/test_motrixsim_body_actuators.py @@ -0,0 +1,145 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +import numpy as np +import pytest + +from motrix_env_core.config import configclass +from motrix_env_core.config.scene.actuator import ActuatorCfg, MotorActuatorCfg, PositionActuatorCfg +from motrix_env_core.config.scene.base import BodyCfg, SceneCfg, SceneObjsCfg +from motrix_env_core.config.scene.mjcf import MjcfFileCfg +from motrix_env_core.config.scene.urdf import JointCfg, SiteCfg, UrdfFileCfg + +mtx = pytest.importorskip("motrixsim") + +from motrix_env_motrixsim.actuators import apply_actuator_cfgs # noqa: E402 +from motrix_env_motrixsim.compiler import build_scene_world # noqa: E402 + + +@pytest.fixture(params=["mjcf", "urdf"]) +def source(request, tmp_path): + if request.param == "mjcf": + path = tmp_path / "tiny.xml" + path.write_text(""" + + + + """) + return MjcfFileCfg(file=path) + path = tmp_path / "tiny.urdf" + path.write_text(""" + + + + + + """) + return UrdfFileCfg(file=path) + + +def _scene(*bodies): + @configclass + class Objects(SceneObjsCfg): + first: BodyCfg = bodies[0] + second: BodyCfg | None = bodies[1] if len(bodies) > 1 else None + + return SceneCfg(objs=Objects()) + + +def _body(source, actuators=None, prefix="", suffix=""): + return BodyCfg(model=source, base_link_name="base", actuators=actuators, prefix=prefix, suffix=suffix) + + +def test_preserve_imported_actuators(source): + world = build_scene_world(_scene(_body(source))) + assert len(world.actuators) == 1 + actuator = world.actuators[0] + assert actuator.target.value == "hinge" + if isinstance(source, MjcfFileCfg): + assert actuator.name == "source" + np.testing.assert_allclose(actuator.gear, [3, 0, 0, 0, 0, 0]) + # Native attachment does not transfer keyframes; direct imports preserve them. + imported = build_scene_world(SceneCfg(file=source.file)) + np.testing.assert_allclose(imported.keyframes[0].ctrl, [1]) + else: + assert actuator.name == "hinge_motor" + assert world.build().num_actuators == 1 + + +@pytest.mark.parametrize("actuators", [{}, {"source": MotorActuatorCfg(joint_name="hinge")}]) +def test_replace_remove_and_reset_keyframes(source, actuators): + world = build_scene_world(_scene(_body(source, actuators))) + assert len(world.actuators) == len(actuators) + if isinstance(source, MjcfFileCfg): + imported = build_scene_world(SceneCfg(file=source.file)) + apply_actuator_cfgs(imported, actuators) + assert len(imported.keyframes) == 1 + key = imported.keyframes[0] + np.testing.assert_allclose(key.ctrl, np.zeros(len(actuators))) + np.testing.assert_allclose(key.dof_pos, [0.2]) + np.testing.assert_allclose(key.dof_vel, [0.3]) + assert imported.build().num_actuators == len(actuators) + model = world.build() + assert model.num_actuators == len(actuators) + + +def test_position_and_motor_parameters(source): + cfg = PositionActuatorCfg(joint_name="hinge", kp=12, kv=3, inherit_joint_range=True, force_range=(-8, 9)) + world = build_scene_world(_scene(_body(source, {"servo": cfg}))) + actuator = world.actuators[0] + assert actuator.actuator_type.variant == "position" + assert actuator.actuator_type.value.kp == 12 + assert actuator.actuator_type.value.damping_value == 3 + np.testing.assert_allclose(actuator.gear, [1, 0, 0, 0, 0, 0]) + assert world.build().num_actuators == 1 + world = build_scene_world(_scene(_body(source, {"motor": MotorActuatorCfg(joint_name="hinge")}))) + assert world.actuators[0].actuator_type.variant == "motor" + assert world.actuators[0].ctrlrange is None + assert world.actuators[0].forcerange is None + np.testing.assert_allclose(world.actuators[0].gear, [1, 0, 0, 0, 0, 0]) + + +def test_two_instances_prefix_suffix_without_shared_mutation(source): + scene = _scene( + _body(source, {"drive": MotorActuatorCfg(joint_name="hinge")}, "left_", "_a"), + _body(source, {"drive": PositionActuatorCfg(joint_name="hinge", kp=7)}, "right_", "_b"), + ) + for _ in range(2): + world = build_scene_world(scene) + assert [a.name for a in world.actuators] == ["left_drive_a", "right_drive_b"] + assert [a.target.value for a in world.actuators] == ["left_hinge_a", "right_hinge_b"] + assert world.build().num_actuators == 2 + assert len(build_scene_world(_scene(_body(source))).actuators) == 1 + + +@pytest.mark.parametrize("actuators", [None, {}, {"servo": PositionActuatorCfg(joint_name="hinge", kp=5)}]) +def test_urdf_augmentation_is_independent(source, actuators): + if not isinstance(source, UrdfFileCfg): + pytest.skip("URDF augmentations only") + source.joints = [JointCfg(joint_name="hinge", armature=0.04)] + source.sites = [SiteCfg(name="marker", parent_link_name="tip")] + world = build_scene_world(_scene(_body(source, actuators, prefix="u_"))) + assert len(world.actuators) == (1 if actuators is None else len(actuators)) + model = world.build() + assert model.get_site("u_marker") is not None + + +@pytest.mark.parametrize( + ("actuators", "error", "message"), + [ + ( + {"first": MotorActuatorCfg(joint_name="hinge"), "second": MotorActuatorCfg(joint_name="hinge")}, + ValueError, + "same joint", + ), + ({"": MotorActuatorCfg(joint_name="hinge")}, ValueError, "must not be empty"), + ({"motor": MotorActuatorCfg(joint_name="missing")}, ValueError, "do not exist"), + ({"motor": ActuatorCfg(joint_name="hinge")}, TypeError, "does not support"), + ], +) +def test_invalid_replacement_does_not_mutate_world(source, actuators, error, message): + world = build_scene_world(_scene(_body(source))) + before = [(a.name, a.target.value) for a in world.actuators] + with pytest.raises(error, match=message): + apply_actuator_cfgs(world, actuators) + assert [(a.name, a.target.value) for a in world.actuators] == before diff --git a/motrix_env_mujoco/src/motrix_env_mujoco/actuators.py b/motrix_env_mujoco/src/motrix_env_mujoco/actuators.py new file mode 100644 index 00000000..cc12c541 --- /dev/null +++ b/motrix_env_mujoco/src/motrix_env_mujoco/actuators.py @@ -0,0 +1,72 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +import math + +import mujoco as mj + +from motrix_env_core.config.scene.actuator import ActuatorCfg, MotorActuatorCfg, PositionActuatorCfg + + +def apply_actuator_cfgs(spec: mj.MjSpec, cfgs: dict[str, ActuatorCfg] | None) -> None: + """Replace an imported model's actuators before attachment, or preserve them for ``None``. + + Replacement resets keyframe controls and activations: source actuator ordering and + dynamics no longer describe the new actuators. Other keyframe state is preserved. + """ + if cfgs is None: + return + + if any(not isinstance(name, str) or not name for name in cfgs): + raise ValueError("Configured actuator names must be non-empty strings") + target_names = {cfg.joint_name for cfg in cfgs.values()} + if len(target_names) != len(cfgs): + raise ValueError("Multiple actuator configs target the same joint") + joints = {joint.name: joint for joint in spec.joints} + unknown_joints = sorted(target_names.difference(joints)) + if unknown_joints: + raise ValueError(f"Configured actuator joints do not exist in model: {unknown_joints}") + for cfg in cfgs.values(): + if not isinstance(cfg, (PositionActuatorCfg, MotorActuatorCfg)): + raise TypeError(f"MuJoCo does not support actuator config {type(cfg).__name__}") + cfg.validate() + + # Materialize the collection before deleting: deletion invalidates the spec iterator. + for actuator in list(spec.actuators): + spec.delete(actuator) + for key in spec.keys: + key.ctrl = [] + key.act = [] + + for name, cfg in cfgs.items(): + ctrl_range = cfg.ctrl_range + gain = 1.0 + bias = [0.0] * 10 + bias_type = mj.mjtBias.mjBIAS_NONE + if isinstance(cfg, PositionActuatorCfg): + gain = cfg.kp + bias[:3] = [0.0, -cfg.kp, -cfg.kv] + bias_type = mj.mjtBias.mjBIAS_AFFINE + if cfg.inherit_joint_range: + joint = joints[cfg.joint_name] + ctrl_range = list(joint.range) + # Joint hinge limits use compiler angle units; actuator controls always use radians. + if spec.compiler.degree and joint.type == mj.mjtJoint.mjJNT_HINGE: + ctrl_range = [math.radians(value) for value in ctrl_range] + spec.add_actuator( + name=name, + trntype=mj.mjtTrn.mjTRN_JOINT, + target=cfg.joint_name, + gear=[1.0, 0.0, 0.0, 0.0, 0.0, 0.0], + gaintype=mj.mjtGain.mjGAIN_FIXED, + gainprm=[gain] + [0.0] * 9, + biastype=bias_type, + biasprm=bias, + dyntype=mj.mjtDyn.mjDYN_NONE, + actdim=0, + actlimited=mj.mjtLimited.mjLIMITED_FALSE, + ctrllimited=mj.mjtLimited.mjLIMITED_TRUE if ctrl_range is not None else mj.mjtLimited.mjLIMITED_FALSE, + ctrlrange=ctrl_range if ctrl_range is not None else [0.0, 0.0], + forcelimited=mj.mjtLimited.mjLIMITED_TRUE if cfg.force_range is not None else mj.mjtLimited.mjLIMITED_FALSE, + forcerange=cfg.force_range if cfg.force_range is not None else [0.0, 0.0], + ) diff --git a/motrix_env_mujoco/src/motrix_env_mujoco/compiler.py b/motrix_env_mujoco/src/motrix_env_mujoco/compiler.py index d30603ce..946bd983 100644 --- a/motrix_env_mujoco/src/motrix_env_mujoco/compiler.py +++ b/motrix_env_mujoco/src/motrix_env_mujoco/compiler.py @@ -44,6 +44,7 @@ from motrix_env_core.config.scene.urdf import UrdfFileCfg from motrix_env_core.config.scene.validation import validate_scene_cfg from motrix_env_core.config.sim import SimCfg +from motrix_env_mujoco.actuators import apply_actuator_cfgs from motrix_env_mujoco.urdf import apply_urdf_cfgs _CONTACT_DATA_BITS = { @@ -261,6 +262,7 @@ def _append_obj(self, spec: mj.MjSpec, name: str, cfg: SceneObjCfg) -> None: return if isinstance(cfg, BodyCfg): robot_spec = self._load_model_spec(cfg.model) + apply_actuator_cfgs(robot_spec, cfg.actuators) base_link = robot_spec.body(cfg.base_link_name) if base_link is None: raise ValueError(f"Attached body base link {cfg.base_link_name!r} does not exist in model") diff --git a/motrix_env_mujoco/src/motrix_env_mujoco/urdf.py b/motrix_env_mujoco/src/motrix_env_mujoco/urdf.py index ca289edd..843f11c6 100644 --- a/motrix_env_mujoco/src/motrix_env_mujoco/urdf.py +++ b/motrix_env_mujoco/src/motrix_env_mujoco/urdf.py @@ -3,11 +3,7 @@ import mujoco as mj -from motrix_env_core.config.scene.urdf import ( - ActuatorCfg, - PositionActuatorCfg, - UrdfFileCfg, -) +from motrix_env_core.config.scene.urdf import UrdfFileCfg def _mujoco_quat(rotation: tuple[float, float, float, float]) -> list[float]: @@ -16,30 +12,6 @@ def _mujoco_quat(rotation: tuple[float, float, float, float]) -> list[float]: return [w, x, y, z] -def _build_position_actuator(spec: mj.MjSpec, cfg: PositionActuatorCfg, joint: mj.MjsJoint) -> None: - ctrl_range = list(joint.range) if cfg.inherit_joint_range else cfg.ctrl_range - spec.add_actuator( - name=cfg.actuator_name, - gaintype=mj.mjtGain.mjGAIN_FIXED, - gainprm=[cfg.kp, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - biastype=mj.mjtBias.mjBIAS_AFFINE, - biasprm=[0.0, -cfg.kp, -cfg.kv, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - trntype=mj.mjtTrn.mjTRN_JOINT, - target=cfg.joint_name, - ctrllimited=mj.mjtLimited.mjLIMITED_TRUE if ctrl_range is not None else None, - ctrlrange=ctrl_range, - forcelimited=mj.mjtLimited.mjLIMITED_TRUE if cfg.force_range is not None else None, - forcerange=cfg.force_range, - ) - - -def _build_actuator(spec: mj.MjSpec, cfg: ActuatorCfg, joint: mj.MjsJoint) -> None: - if isinstance(cfg, PositionActuatorCfg): - _build_position_actuator(spec, cfg, joint) - return - raise TypeError(f"MuJoCo does not support actuator config {type(cfg).__name__}") - - def apply_urdf_cfgs(spec: mj.MjSpec, cfg: UrdfFileCfg) -> None: """Apply declarative URDF augmentations to a MuJoCo specification.""" cfg_by_geom = {geom.geom_name: geom for geom in cfg.geoms} @@ -54,13 +26,6 @@ def apply_urdf_cfgs(spec: mj.MjSpec, cfg: UrdfFileCfg) -> None: if len(cfg_by_site) != len(cfg.sites): raise ValueError("Duplicate configured site name") - target_joints = {actuator.joint_name for actuator in cfg.actuators} - if len(target_joints) != len(cfg.actuators): - raise ValueError("Multiple actuator configs target the same joint") - actuator_names = {actuator.actuator_name for actuator in cfg.actuators} - if len(actuator_names) != len(cfg.actuators): - raise ValueError("Duplicate configured actuator name") - existing_geom_names = {geom.name for geom in spec.geoms} unknown_geoms = sorted(set(cfg_by_geom).difference(existing_geom_names)) if unknown_geoms: @@ -71,10 +36,6 @@ def apply_urdf_cfgs(spec: mj.MjSpec, cfg: UrdfFileCfg) -> None: if unknown_joints: raise ValueError(f"Configured joints do not exist in model: {unknown_joints}") - unknown_actuator_joints = sorted(target_joints.difference(existing_joint_names)) - if unknown_actuator_joints: - raise ValueError(f"Configured actuator joints do not exist in model: {unknown_actuator_joints}") - existing_site_names = {site.name for site in spec.sites} duplicate_site_names = sorted(existing_site_names.intersection(cfg_by_site)) if duplicate_site_names: @@ -86,20 +47,6 @@ def apply_urdf_cfgs(spec: mj.MjSpec, cfg: UrdfFileCfg) -> None: if unknown_site_links: raise ValueError(f"Configured site parent links do not exist in model: {unknown_site_links}") - existing_actuator_names = {actuator.name for actuator in spec.actuators} - duplicate_actuator_names = sorted(existing_actuator_names.intersection(actuator_names)) - if duplicate_actuator_names: - raise ValueError(f"Configured actuator names already exist in model: {duplicate_actuator_names}") - - existing_actuator_joints = { - actuator.target - for actuator in spec.actuators - if actuator.trntype in (mj.mjtTrn.mjTRN_JOINT, mj.mjtTrn.mjTRN_JOINTINPARENT) - } - duplicate_targets = sorted(existing_actuator_joints.intersection(target_joints)) - if duplicate_targets: - raise ValueError(f"Configured joints already have actuators in model: {duplicate_targets}") - for name, geom_cfg in cfg_by_geom.items(): geom = spec.geom(name) assert geom is not None @@ -126,8 +73,3 @@ def apply_urdf_cfgs(spec: mj.MjSpec, cfg: UrdfFileCfg) -> None: pos=site_cfg.position, quat=_mujoco_quat(site_cfg.rotation), ) - - for actuator_cfg in cfg.actuators: - joint = spec.joint(actuator_cfg.joint_name) - assert joint is not None - _build_actuator(spec, actuator_cfg, joint) diff --git a/motrix_env_mujoco/tests/test_body_actuators.py b/motrix_env_mujoco/tests/test_body_actuators.py new file mode 100644 index 00000000..69e028c0 --- /dev/null +++ b/motrix_env_mujoco/tests/test_body_actuators.py @@ -0,0 +1,224 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +import numpy as np +import pytest + +from motrix_env_core.config import configclass +from motrix_env_core.config.scene.actuator import ActuatorCfg, MotorActuatorCfg, PositionActuatorCfg +from motrix_env_core.config.scene.base import BodyCfg, SceneCfg, SceneObjsCfg +from motrix_env_core.config.scene.mjcf import MjcfFileCfg +from motrix_env_core.config.scene.urdf import JointCfg, SiteCfg, UrdfFileCfg + +mj = pytest.importorskip("mujoco") + +from motrix_env_mujoco.actuators import apply_actuator_cfgs # noqa: E402 +from motrix_env_mujoco.compiler import build_mujoco_model # noqa: E402 + + +@pytest.fixture +def mjcf_file(tmp_path): + path = tmp_path / "tiny.xml" + path.write_text( + """ + + + + + + + + + + + + + + + + +""", + encoding="utf-8", + ) + return path + + +@pytest.fixture +def urdf_file(tmp_path): + path = tmp_path / "tiny.urdf" + path.write_text( + """ + + + + + + + + + + + + +""", + encoding="utf-8", + ) + return path + + +def _scene(*bodies): + @configclass + class Objects(SceneObjsCfg): + first: BodyCfg = bodies[0] + second: BodyCfg | None = bodies[1] if len(bodies) > 1 else None + + return SceneCfg(objs=Objects()) + + +def _body(model, actuators=None, prefix="", suffix=""): + return BodyCfg(model=model, base_link_name="base", actuators=actuators, prefix=prefix, suffix=suffix) + + +def test_mjcf_preserve_source_actuators_and_root_model(mjcf_file): + source = mj.MjSpec.from_file(str(mjcf_file)).compile() + for scene in (SceneCfg(file=mjcf_file), _scene(_body(MjcfFileCfg(file=mjcf_file)))): + model = build_mujoco_model(scene) + assert model.nu == 2 + np.testing.assert_array_equal(model.actuator_gear, source.actuator_gear) + np.testing.assert_array_equal(model.actuator_dyntype, source.actuator_dyntype) + np.testing.assert_allclose(model.key_ctrl, [[1, 2]]) + np.testing.assert_allclose(model.key_act, [[0.7]]) + + +@pytest.mark.parametrize("actuators", [{}, {"source": MotorActuatorCfg(joint_name="hinge")}]) +def test_mjcf_replacement_resets_keyframe_controls_and_activations(mjcf_file, actuators): + model = build_mujoco_model(_scene(_body(MjcfFileCfg(file=mjcf_file), actuators))) + assert model.nu == len(actuators) + assert model.na == 0 + assert model.nkey == 1 + np.testing.assert_allclose(model.key_ctrl, np.zeros((1, len(actuators)))) + assert model.key_act.shape == (1, 0) + np.testing.assert_allclose(model.key_qpos, [[0.2, 0.3]]) + np.testing.assert_allclose(model.key_qvel, [[0.4, 0.5]]) + + +def test_position_and_motor_parameters(mjcf_file): + actuators = { + "servo": PositionActuatorCfg(joint_name="hinge", kp=12, kv=3, inherit_joint_range=True, force_range=(-8, 9)), + "torque": MotorActuatorCfg(joint_name="other", ctrl_range=(-4, 5), force_range=(-6, 7)), + } + model = build_mujoco_model(_scene(_body(MjcfFileCfg(file=mjcf_file), actuators))) + assert [model.actuator(i).name for i in range(model.nu)] == ["servo", "torque"] + np.testing.assert_allclose(model.actuator_gainprm[:, 0], [12, 1]) + np.testing.assert_allclose(model.actuator_biasprm[:, :3], [[0, -12, -3], [0, 0, 0]]) + np.testing.assert_array_equal(model.actuator_biastype, [mj.mjtBias.mjBIAS_AFFINE, mj.mjtBias.mjBIAS_NONE]) + np.testing.assert_array_equal(model.actuator_dyntype, [mj.mjtDyn.mjDYN_NONE] * 2) + np.testing.assert_array_equal(model.actuator_gaintype, [mj.mjtGain.mjGAIN_FIXED] * 2) + np.testing.assert_array_equal(model.actuator_trntype, [mj.mjtTrn.mjTRN_JOINT] * 2) + np.testing.assert_allclose(model.actuator_gear, [[1, 0, 0, 0, 0, 0]] * 2) + np.testing.assert_allclose(model.actuator_ctrlrange, [[-1, 2], [-4, 5]]) + np.testing.assert_allclose(model.actuator_forcerange, [[-8, 9], [-6, 7]]) + assert np.all(model.actuator_ctrllimited) + assert np.all(model.actuator_forcelimited) + + +@pytest.mark.parametrize("file_kind", ["mjcf", "urdf"]) +def test_two_instances_prefix_suffix_without_shared_mutation(mjcf_file, urdf_file, file_kind): + source = MjcfFileCfg(file=mjcf_file) if file_kind == "mjcf" else UrdfFileCfg(file=urdf_file) + scene = _scene( + _body(source, {"drive": MotorActuatorCfg(joint_name="hinge")}, prefix="left_", suffix="_a"), + _body(source, {"drive": PositionActuatorCfg(joint_name="hinge", kp=7)}, prefix="right_", suffix="_b"), + ) + for _ in range(2): + model = build_mujoco_model(scene) + assert model.nu == 2 + for index, (prefix, suffix) in enumerate((("left_", "_a"), ("right_", "_b"))): + assert model.actuator(index).name == f"{prefix}drive{suffix}" + joint = model.joint(int(model.actuator_trnid[index, 0])) + assert joint.name == f"{prefix}hinge{suffix}" + np.testing.assert_allclose(model.actuator_gainprm[:, 0], [1, 7]) + original = mj.MjSpec.from_file(str(source.file)).compile() + assert original.nu == (2 if file_kind == "mjcf" else 0) + + +@pytest.mark.parametrize( + "actuators", [None, {}, {"servo": PositionActuatorCfg(joint_name="hinge", kp=5, ctrl_range=(-0.5, 0.8))}] +) +def test_urdf_overrides_follow_joint_and_site_augmentation(urdf_file, actuators): + source = UrdfFileCfg( + file=urdf_file, + joints=[JointCfg(joint_name="hinge", armature=0.04)], + sites=[SiteCfg(name="marker", parent_link_name="tip")], + ) + model = build_mujoco_model(_scene(_body(source, actuators, prefix="u_"))) + assert model.nu == (0 if not actuators else 1) + assert model.site("u_marker").id >= 0 + np.testing.assert_allclose(model.dof_armature, [0.04]) + if actuators: + np.testing.assert_allclose(model.actuator_ctrlrange, [[-0.5, 0.8]]) + + +@pytest.mark.parametrize( + ("actuators", "error", "message"), + [ + ( + {"first": MotorActuatorCfg(joint_name="hinge"), "second": MotorActuatorCfg(joint_name="hinge")}, + ValueError, + "same joint", + ), + ( + {"": MotorActuatorCfg(joint_name="hinge")}, + ValueError, + "non-empty", + ), + ({"motor": MotorActuatorCfg(joint_name="missing")}, ValueError, "do not exist"), + ({"unsupported": ActuatorCfg(joint_name="hinge")}, TypeError, "does not support"), + ], +) +def test_invalid_replacement_does_not_mutate_spec(mjcf_file, actuators, error, message): + spec = mj.MjSpec.from_file(str(mjcf_file)) + before = spec.to_xml() + with pytest.raises(error, match=message): + apply_actuator_cfgs(spec, actuators) + assert spec.to_xml() == before + assert spec.compile().nu == 2 + + +@pytest.mark.parametrize("compiler", ["", '', '']) +def test_inherited_joint_range_uses_compiled_units_without_mutating_source(compiler): + spec = mj.MjSpec.from_string( + f"""{compiler} + + + """ + ) + original_ranges = {joint.name: list(joint.range) for joint in spec.joints} + apply_actuator_cfgs( + spec, + { + name: PositionActuatorCfg(joint_name=name, kp=1.0, inherit_joint_range=True) + for name in original_ranges + }, + ) + model = spec.compile() + for index, name in enumerate(original_ranges): + np.testing.assert_allclose(model.actuator_ctrlrange[index], model.joint(name).range) + np.testing.assert_allclose(spec.joint(name).range, original_ranges[name]) + np.testing.assert_allclose(model.actuator_ctrlrange[1], [-2, 3]) + + +def test_source_defaults_do_not_leak_into_canonical_actuators(): + spec = mj.MjSpec.from_string( + """ + """ + ) + apply_actuator_cfgs(spec, {"motor": MotorActuatorCfg(joint_name="hinge")}) + model = spec.compile() + assert model.na == 0 + assert not model.actuator_ctrllimited[0] + assert not model.actuator_forcelimited[0] + np.testing.assert_allclose(model.actuator_gear[0], [1, 0, 0, 0, 0, 0]) + np.testing.assert_allclose(model.actuator_gainprm[0], [1] + [0] * 9) + np.testing.assert_allclose(model.actuator_biasprm[0], [0] * 10) diff --git a/motrix_env_mujoco/tests/test_mujoco_scene_compiler.py b/motrix_env_mujoco/tests/test_mujoco_scene_compiler.py index e6c60a8c..34a15dec 100644 --- a/motrix_env_mujoco/tests/test_mujoco_scene_compiler.py +++ b/motrix_env_mujoco/tests/test_mujoco_scene_compiler.py @@ -113,7 +113,7 @@ def import_without_mujoco(name, *args, **kwargs): raise AssertionError("MuJoCo backend module unexpectedly loaded") """ env = os.environ.copy() - env["PYTHONPATH"] = str(source_root) + env["PYTHONPATH"] = os.pathsep.join(filter(None, (str(source_root), env.get("PYTHONPATH")))) result = subprocess.run([sys.executable, "-c", script], env=env, capture_output=True, text=True) assert result.returncode == 0, result.stderr @@ -342,20 +342,20 @@ def test_mujoco_compiler_applies_urdf_augmentations(tmp_path): model_cfg = UrdfFileCfg( file=urdf_file, sites=[SiteCfg(name="probe", parent_link_name="tip", position=(0.0, 0.0, 0.1))], - actuators=[ - PositionActuatorCfg( - joint_name="hinge", - kp=20.0, - kv=2.0, - inherit_joint_range=True, - force_range=(-5.0, 5.0), - ) - ], ) + actuators = { + "hinge": PositionActuatorCfg( + joint_name="hinge", + kp=20.0, + kv=2.0, + inherit_joint_range=True, + force_range=(-5.0, 5.0), + ) + } @configclass class RobotObjsCfg(SceneObjsCfg): - robot: RobotCfg = RobotCfg(model=model_cfg, base_link_name="base") + robot: RobotCfg = RobotCfg(model=model_cfg, base_link_name="base", actuators=actuators) model = MuJoCoSceneCompiler().compile(SceneCfg(objs=RobotObjsCfg()), SimCfg()) actuator_id = mj.mj_name2id(model, mj.mjtObj.mjOBJ_ACTUATOR, "hinge") diff --git a/motrix_envs/src/motrix_envs/robot/dex_evt.py b/motrix_envs/src/motrix_envs/robot/dex_evt.py index ddafdc5d..8ef01b38 100644 --- a/motrix_envs/src/motrix_envs/robot/dex_evt.py +++ b/motrix_envs/src/motrix_envs/robot/dex_evt.py @@ -92,9 +92,9 @@ def _dex_evt_sites() -> list[SiteCfg]: ] -def _dex_evt_actuators() -> list[PositionActuatorCfg]: - return [ - PositionActuatorCfg( +def _dex_evt_actuators() -> dict[str, PositionActuatorCfg]: + return { + name: PositionActuatorCfg( joint_name=name, kp=kp, kv=kv, @@ -102,7 +102,7 @@ def _dex_evt_actuators() -> list[PositionActuatorCfg]: force_range=(-effort, effort), ) for name, (kp, kv, effort) in _DRIVE_PARAMS.items() - ] + } @configclass(kw_only=True) @@ -114,8 +114,8 @@ class DexEvt(HumanoidRobotCfg): geoms=_dex_evt_geoms(), sites=_dex_evt_sites(), joints=_dex_evt_joints(), - actuators=_dex_evt_actuators(), ) + actuators: dict[str, PositionActuatorCfg] = _dex_evt_actuators() base_link_name: str = "pelvis" left_foot_link_name: str = "ankle_roll_l_link" right_foot_link_name: str = "ankle_roll_r_link" From 41cbe72bc875ba13fa5981338b2bc6fc7efc9114 Mon Sep 17 00:00:00 2001 From: zilch Date: Wed, 7 Oct 2026 22:40:08 +0800 Subject: [PATCH 2/3] style: align actuator regression test with CI Ruff formatter --- motrix_env_mujoco/tests/test_body_actuators.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/motrix_env_mujoco/tests/test_body_actuators.py b/motrix_env_mujoco/tests/test_body_actuators.py index 69e028c0..e688a489 100644 --- a/motrix_env_mujoco/tests/test_body_actuators.py +++ b/motrix_env_mujoco/tests/test_body_actuators.py @@ -195,10 +195,7 @@ def test_inherited_joint_range_uses_compiled_units_without_mutating_source(compi original_ranges = {joint.name: list(joint.range) for joint in spec.joints} apply_actuator_cfgs( spec, - { - name: PositionActuatorCfg(joint_name=name, kp=1.0, inherit_joint_range=True) - for name in original_ranges - }, + {name: PositionActuatorCfg(joint_name=name, kp=1.0, inherit_joint_range=True) for name in original_ranges}, ) model = spec.compile() for index, name in enumerate(original_ranges): From 5dd9ec9ce7e995765a1e472c649b20f9f4471044 Mon Sep 17 00:00:00 2001 From: zilch Date: Wed, 7 Oct 2026 22:58:05 +0800 Subject: [PATCH 3/3] docs: simplify actuator guidance to current robot configuration --- docs/source/en/user_guide/robots.md | 13 +++--- .../tutorial/building_envs/scene.md | 42 ------------------- docs/source/zh_CN/user_guide/robots.md | 12 +++--- .../tutorial/building_envs/scene.md | 41 ------------------ 4 files changed, 14 insertions(+), 94 deletions(-) diff --git a/docs/source/en/user_guide/robots.md b/docs/source/en/user_guide/robots.md index bbddbe38..b6eea17b 100644 --- a/docs/source/en/user_guide/robots.md +++ b/docs/source/en/user_guide/robots.md @@ -41,9 +41,7 @@ motrix_envs/src/motrix_envs/robot/ An MJCF file should contain only the reusable robot model, without a floor, lights, or task-specific objects. For a URDF robot, `UrdfFileCfg` can add simulation properties through its `geoms`, `sites`, and `joints` fields. Declare MJCF and URDF actuators on the robot instance through its inherited `BodyCfg.actuators` dictionary. -When migrating an existing declaration, import `ActuatorCfg` and `PositionActuatorCfg` from -`motrix_env_core.config.scene.actuator` rather than `motrix_env_core.config.scene.urdf`, and move the actuator list out of -`UrdfFileCfg` into this dictionary. Dictionary keys replace the old `name` field and `actuator_name` property. +Import `ActuatorCfg`, `PositionActuatorCfg`, and `MotorActuatorCfg` from `motrix_env_core.config.scene.actuator`. ### 2. Declare the `RobotCfg` @@ -131,9 +129,12 @@ robot = RobotCfg( Dictionary keys name actuators; `joint_name` uses imported names before instance `prefix` / `suffix` decoration. Declare all controlled joints in the dictionary: it replaces rather than appends to the imported actuator set. -Omit `actuators` or set it to `None` to preserve imported actuators; use `{}` to remove them. See -[scene actuator configuration](tutorial/building_envs/scene.md#actuators-on-imported-bodies) for control ranges, -actuator ordering, and keyframe behavior. +Omit `actuators` or set it to `None` to preserve imported actuators; use `{}` to remove them. + +`PositionActuatorCfg` uses position gain `kp` and absolute damping coefficient `kv` (default `0.0`). +It inherits `joint_name`, `ctrl_range` (control input limits), and `force_range` (actuator output limits) from `ActuatorCfg`. +Set `inherit_joint_range=True` to use imported joint limits as the control range; this cannot be combined with an explicit +`ctrl_range`. For direct joint effort control with unit transmission and gain, use `MotorActuatorCfg`. ### 3. Register the robot diff --git a/docs/source/en/user_guide/tutorial/building_envs/scene.md b/docs/source/en/user_guide/tutorial/building_envs/scene.md index 51ac5de0..11f866a0 100644 --- a/docs/source/en/user_guide/tutorial/building_envs/scene.md +++ b/docs/source/en/user_guide/tutorial/building_envs/scene.md @@ -37,48 +37,6 @@ class MyTaskEnvCfg(EnvCfg): reset_noise_scale: float = 0.01 ``` -### Actuators on imported bodies - -Declare actuators on `BodyCfg.actuators`, not on `UrdfFileCfg`. `RobotCfg` inherits the same field, and both MJCF and URDF -models use the format-neutral declarations in `motrix_env_core.config.scene.actuator`: - -```python -from motrix_env_core.config.scene import BodyCfg, MjcfFileCfg -from motrix_env_core.config.scene.actuator import PositionActuatorCfg - -body = BodyCfg( - model=MjcfFileCfg(file="my_robot.xml"), - base_link_name="base", - prefix="robot_", - actuators={ - "hip_position": PositionActuatorCfg( - joint_name="hip", - kp=100.0, - kv=2.0, - inherit_joint_range=True, - ), - }, -) -``` - -This example assumes the imported model contains a `base` link and a limited `hip` joint. The dictionary key names the -actuator; `joint_name` uses the imported joint name **before** `prefix` / `suffix` decoration. Here, the assembled actuator -and joint names are `robot_hip_position` and `robot_hip`. Dictionary insertion order defines actuator order. - -- `actuators=None` (the default) preserves the imported model's actuators. -- `actuators={}` removes all imported actuators. -- A non-empty dictionary replaces the entire imported actuator set, rather than appending to it. Declare every actuator - needed by the body. - -`PositionActuatorCfg` inherits `joint_name`, `ctrl_range`, and `force_range` from `ActuatorCfg`. `kp` is the position gain; -`kv` is the absolute damping coefficient. `inherit_joint_range=True` uses the imported joint limits as the control range -and cannot be combined with an explicit `ctrl_range`. For direct joint effort control, use `MotorActuatorCfg` from the -same module, for example `MotorActuatorCfg(joint_name="hip", ctrl_range=(-10.0, 10.0))`; it uses unit gear and gain. - -Replacement or removal resets actuator control (`ctrl`) and activation (`act`, where represented) values in imported -keyframes while preserving joint positions (`qpos`) and velocities (`qvel`). MuJoCo resets both `ctrl` and `act`; -MotrixSim resets `ctrl` because MSD keyframes do not expose activation state. - ### Recommended Directory Structure ``` diff --git a/docs/source/zh_CN/user_guide/robots.md b/docs/source/zh_CN/user_guide/robots.md index 1654d1ad..e7867891 100644 --- a/docs/source/zh_CN/user_guide/robots.md +++ b/docs/source/zh_CN/user_guide/robots.md @@ -40,9 +40,7 @@ motrix_envs/src/motrix_envs/robot/ MJCF 文件应只包含可跨场景复用的机器人模型,不要把地面、灯光或任务专属物体放进机器人文件。若使用 URDF,可以通过 `UrdfFileCfg` 的 `geoms`、`sites` 和 `joints` 补充 URDF 本身不包含的仿真属性。 MJCF 和 URDF 的执行器均通过机器人实例继承的 `BodyCfg.actuators` 字典声明。 -迁移现有声明时,改从 `motrix_env_core.config.scene.actuator` 导入 `ActuatorCfg` 和 `PositionActuatorCfg`, -不再从 `motrix_env_core.config.scene.urdf` 导入;将执行器列表从 `UrdfFileCfg` 移到这个字典中。 -字典键取代原来的 `name` 字段和 `actuator_name` 属性。 +从 `motrix_env_core.config.scene.actuator` 导入 `ActuatorCfg`、`PositionActuatorCfg` 和 `MotorActuatorCfg`。 ### 2. 声明 `RobotCfg` @@ -129,8 +127,12 @@ robot = RobotCfg( 字典键定义执行器名称;`joint_name` 使用应用实例 `prefix` / `suffix` 之前的导入名称。 字典替换而非追加导入执行器集合,因此需声明全部受控关节。省略 `actuators` 或设为 `None` 可保留导入执行器, -`{}` 则移除全部执行器。控制范围、执行器顺序与关键帧行为见 -[场景执行器配置](tutorial/building_envs/scene.md#导入本体的执行器)。 +`{}` 则移除全部执行器。 + +`PositionActuatorCfg` 使用位置增益 `kp` 和绝对阻尼系数 `kv`(默认 `0.0`),并从 `ActuatorCfg` 继承 +`joint_name`、`ctrl_range`(控制输入限幅)和 `force_range`(执行器输出限幅)。 +设置 `inherit_joint_range=True` 可将导入关节限位用作控制范围,不能与显式 `ctrl_range` 同时设置。 +直接控制关节力或力矩时,使用传动比与增益均为 1 的 `MotorActuatorCfg`。 ### 3. 注册机器人 diff --git a/docs/source/zh_CN/user_guide/tutorial/building_envs/scene.md b/docs/source/zh_CN/user_guide/tutorial/building_envs/scene.md index afe584d2..4db41c4b 100644 --- a/docs/source/zh_CN/user_guide/tutorial/building_envs/scene.md +++ b/docs/source/zh_CN/user_guide/tutorial/building_envs/scene.md @@ -32,47 +32,6 @@ class MyTaskEnvCfg(EnvCfg): ctrl_dt: float = 0.02 ``` -### 导入本体的执行器 - -执行器通过 `BodyCfg.actuators` 声明,不属于 `UrdfFileCfg`。`RobotCfg` 继承同一字段;MJCF 和 URDF 模型均使用 -`motrix_env_core.config.scene.actuator` 中与文件格式无关的声明: - -```python -from motrix_env_core.config.scene import BodyCfg, MjcfFileCfg -from motrix_env_core.config.scene.actuator import PositionActuatorCfg - -body = BodyCfg( - model=MjcfFileCfg(file="my_robot.xml"), - base_link_name="base", - prefix="robot_", - actuators={ - "hip_position": PositionActuatorCfg( - joint_name="hip", - kp=100.0, - kv=2.0, - inherit_joint_range=True, - ), - }, -) -``` - -示例假定导入模型包含 `base` link 和具有限位的 `hip` joint。字典键定义执行器名称;`joint_name` 使用应用 -`prefix` / `suffix` **之前**的导入关节名称。此处组装后的执行器和关节名称分别为 `robot_hip_position` 和 -`robot_hip`。字典插入顺序决定执行器顺序。 - -- `actuators=None`(默认值)保留导入模型的执行器。 -- `actuators={}` 移除全部导入执行器。 -- 非空字典替换整个导入执行器集合,而非追加;必须声明本体需要的全部执行器。 - -`PositionActuatorCfg` 从 `ActuatorCfg` 继承 `joint_name`、`ctrl_range` 和 `force_range`。`kp` 是位置增益, -`kv` 是绝对阻尼系数。`inherit_joint_range=True` 将导入关节限位用作控制范围,不能与显式 `ctrl_range` 同时设置。 -直接控制关节力或力矩时,使用同一模块中的 `MotorActuatorCfg`,例如 -`MotorActuatorCfg(joint_name="hip", ctrl_range=(-10.0, 10.0))`;其传动比与增益均为 1。 - -替换或移除执行器会重置导入关键帧中的执行器控制值(`ctrl`)与 activation 状态(后端表示该状态时的 `act`), -保留关节位置(`qpos`)和速度(`qvel`)。MuJoCo 重置 `ctrl` 和 `act`;MotrixSim 重置 `ctrl`,因为 MSD 关键帧 -不暴露 activation 状态。 - ### 推荐目录结构 ```