From de6e8e13a4f8223a51ad6e27b39e79686d9d0bec Mon Sep 17 00:00:00 2001 From: Eric Heiden Date: Mon, 17 Aug 2026 09:58:41 -0700 Subject: [PATCH 1/5] Add MuJoCo DC motor actuator import Preserve high-level MJCF DC motor parameters and rebuild them through MuJoCo's native MjSpec shortcut. Support compiled USD actuator rows and retain their stateful parameters in MuJoCo Warp. --- CHANGELOG.md | 4 + newton/_src/solvers/mujoco/enums.py | 11 +- newton/_src/solvers/mujoco/kernels.py | 8 + newton/_src/solvers/mujoco/solver_mujoco.py | 213 +++++++++++++++++- newton/_src/utils/import_mjcf.py | 9 + newton/tests/test_mujoco_general_actuators.py | 134 +++++++++++ 6 files changed, 370 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7410ed6f01..d6db9c4686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ +### Added + +- Import MuJoCo DC-motor actuators from MJCF and compiled `MjcActuator` USD for `SolverMuJoCo`. (#3950) + ## [1.5.0] - 2026-08-11 ### Added diff --git a/newton/_src/solvers/mujoco/enums.py b/newton/_src/solvers/mujoco/enums.py index 9eb50a1624..413f64db7c 100644 --- a/newton/_src/solvers/mujoco/enums.py +++ b/newton/_src/solvers/mujoco/enums.py @@ -23,7 +23,9 @@ class _ActuatorBiasType(IntEnum): NONE = 0 AFFINE = 1 MUSCLE = 2 - USER = 3 + DCMOTOR = 3 + SO3 = 4 + USER = 5 class _ActuatorDynamicsType(IntEnum): @@ -32,14 +34,17 @@ class _ActuatorDynamicsType(IntEnum): FILTER = 2 FILTER_EXACT = 3 MUSCLE = 4 - USER = 5 + DCMOTOR = 5 + USER = 6 class _ActuatorGainType(IntEnum): FIXED = 0 AFFINE = 1 MUSCLE = 2 - USER = 3 + DCMOTOR = 3 + SO3 = 4 + USER = 5 __all__ = ["EqType"] diff --git a/newton/_src/solvers/mujoco/kernels.py b/newton/_src/solvers/mujoco/kernels.py index 43edbe6836..2e2ace046a 100644 --- a/newton/_src/solvers/mujoco/kernels.py +++ b/newton/_src/solvers/mujoco/kernels.py @@ -1543,6 +1543,7 @@ def convert_mjw_contacts_to_newton_kernel( CTRL_SOURCE_JOINT_TARGET = wp.constant(0) CTRL_SOURCE_CTRL_DIRECT = wp.constant(1) +CTRL_TYPE_DCMOTOR = wp.constant(3) @wp.func @@ -2117,6 +2118,7 @@ def update_axis_properties_kernel( def update_ctrl_direct_actuator_properties_kernel( mjc_actuator_ctrl_source: wp.array[wp.int32], mjc_actuator_to_newton_idx: wp.array[wp.int32], + newton_actuator_ctrl_type: wp.array[wp.int32], newton_actuator_gainprm: wp.array[vec10], newton_actuator_biasprm: wp.array[vec10], newton_actuator_dynprm: wp.array[vec10], @@ -2145,6 +2147,7 @@ def update_ctrl_direct_actuator_properties_kernel( Args: mjc_actuator_ctrl_source: 0=JOINT_TARGET, 1=CTRL_DIRECT mjc_actuator_to_newton_idx: Index into Newton's mujoco:actuator arrays + newton_actuator_ctrl_type: Intrinsic actuator shortcut type newton_actuator_gainprm: Newton's model.mujoco.actuator_gainprm newton_actuator_biasprm: Newton's model.mujoco.actuator_biasprm newton_actuator_dynprm: Newton's model.mujoco.actuator_dynprm @@ -2166,6 +2169,11 @@ def update_ctrl_direct_actuator_properties_kernel( return world_newton_idx = world * actuators_per_world + newton_idx + # High-level MJCF DC-motor rows keep placeholder general-actuator arrays; + # preserve the parameters compiled by MjsActuator.set_to_dcmotor(). + if newton_actuator_ctrl_type[world_newton_idx] == CTRL_TYPE_DCMOTOR: + return + actuator_gain[world, actuator] = newton_actuator_gainprm[world_newton_idx] actuator_bias[world, actuator] = newton_actuator_biasprm[world_newton_idx] actuator_dynprm[world, actuator] = newton_actuator_dynprm[world_newton_idx] diff --git a/newton/_src/solvers/mujoco/solver_mujoco.py b/newton/_src/solvers/mujoco/solver_mujoco.py index 775473a0b1..a401e04da2 100644 --- a/newton/_src/solvers/mujoco/solver_mujoco.py +++ b/newton/_src/solvers/mujoco/solver_mujoco.py @@ -465,11 +465,13 @@ class CtrlType(IntEnum): :attr:`~newton.JointTargetMode.POSITION_VELOCITY` mode, kd is handled by the separate velocity actuator. - :attr:`VELOCITY`: Maps from :attr:`~newton.Control.joint_target_qd`, syncs gains from :attr:`~newton.Model.joint_target_kd` - :attr:`GENERAL`: Used with :attr:`~newton.solvers.SolverMuJoCo.CtrlSource.CTRL_DIRECT` mode for motor/general actuators + - :attr:`DCMOTOR`: Recreates the MuJoCo ``dcmotor`` shortcut from its high-level MJCF parameters """ POSITION = 0 VELOCITY = 1 GENERAL = 2 + DCMOTOR = 3 class TrnType(IntEnum): """Transmission type values for MuJoCo actuators.""" @@ -1478,18 +1480,23 @@ def parse_actuator_enum(value: Any, mapping: dict[str, int]) -> int: "filter": _ActuatorDynamicsType.FILTER, "filterexact": _ActuatorDynamicsType.FILTER_EXACT, "muscle": _ActuatorDynamicsType.MUSCLE, + "dcmotor": _ActuatorDynamicsType.DCMOTOR, "user": _ActuatorDynamicsType.USER, } actuator_gain_types = { "fixed": _ActuatorGainType.FIXED, "affine": _ActuatorGainType.AFFINE, "muscle": _ActuatorGainType.MUSCLE, + "dcmotor": _ActuatorGainType.DCMOTOR, + "so3": _ActuatorGainType.SO3, "user": _ActuatorGainType.USER, } actuator_bias_types = { "none": _ActuatorBiasType.NONE, "affine": _ActuatorBiasType.AFFINE, "muscle": _ActuatorBiasType.MUSCLE, + "dcmotor": _ActuatorBiasType.DCMOTOR, + "so3": _ActuatorBiasType.SO3, "user": _ActuatorBiasType.USER, } @@ -1505,6 +1512,9 @@ def parse_gaintype(s: str, _context: dict[str, Any] | None = None) -> int: def parse_biastype(s: str, _context: dict[str, Any] | None = None) -> int: return parse_actuator_enum(s, actuator_bias_types) + def parse_dcmotor_input(s: str, _context: dict[str, Any] | None = None) -> int: + return parse_actuator_enum(s, {"voltage": 0, "position": 1, "velocity": 2}) + def parse_bool(value: Any, context: dict[str, Any] | None = None) -> bool: """Parse MJCF/USD boolean values to bool.""" if isinstance(value, bool): @@ -2011,6 +2021,145 @@ def parse_presence(_value: str, _context: dict[str, Any] | None = None) -> int: usd_attribute_name="mjc:gear", ) ) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_damping", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.float32, + default=0.0, + namespace="mujoco", + mjcf_attribute_name="damping", + usd_attribute_name="mjc:damping", + ) + ) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_armature", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.float32, + default=0.0, + namespace="mujoco", + mjcf_attribute_name="armature", + usd_attribute_name="mjc:armature", + ) + ) + + # Preserve high-level MJCF DC-motor parameters so MjSpec can apply the + # native shortcut when SolverMuJoCo rebuilds its MuJoCo model. + dcmotor_vec6 = wp.types.vector(length=6, dtype=wp.float32) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_motorconst", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.vec2, + default=wp.vec2(0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="motorconst", + ) + ) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_resistance", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.float32, + default=0.0, + namespace="mujoco", + mjcf_attribute_name="resistance", + ) + ) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_nominal", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.vec3, + default=wp.vec3(0.0, 0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="nominal", + ) + ) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_saturation", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.vec3, + default=wp.vec3(0.0, 0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="saturation", + ) + ) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_inductance", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.vec2, + default=wp.vec2(0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="inductance", + ) + ) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_cogging", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.vec3, + default=wp.vec3(0.0, 0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="cogging", + ) + ) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_controller", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=dcmotor_vec6, + default=dcmotor_vec6(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="controller", + ) + ) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_thermal", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=dcmotor_vec6, + default=dcmotor_vec6(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="thermal", + ) + ) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_lugre", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=vec5, + default=vec5(0.0, 0.0, 0.0, 0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="lugre", + ) + ) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_input", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.int32, + default=0, + namespace="mujoco", + mjcf_attribute_name="input", + mjcf_value_transformer=parse_dcmotor_input, + ) + ) builder.add_custom_attribute( ModelBuilder.CustomAttribute( name="actuator_cranklength", @@ -3087,6 +3236,7 @@ def _init_actuators( actuator_trnid = mujoco_attrs.actuator_trnid.numpy() trntype_arr = mujoco_attrs.actuator_trntype.numpy() if hasattr(mujoco_attrs, "actuator_trntype") else None ctrl_source_arr = mujoco_attrs.ctrl_source.numpy() if hasattr(mujoco_attrs, "ctrl_source") else None + ctrl_type_arr = mujoco_attrs.ctrl_type.numpy() if hasattr(mujoco_attrs, "ctrl_type") else None actuator_world_arr = mujoco_attrs.actuator_world.numpy() if hasattr(mujoco_attrs, "actuator_world") else None actuator_target_label_arr = getattr(mujoco_attrs, "actuator_target_label", None) joint_dof_label_arr = getattr(mujoco_attrs, "joint_dof_label", None) @@ -3141,6 +3291,30 @@ def resolve_target_from_label(target_label: str) -> tuple[int, int]: actlimited_arr = ( mujoco_attrs.actuator_actlimited.numpy() if hasattr(mujoco_attrs, "actuator_actlimited") else None ) + damping_arr = mujoco_attrs.actuator_damping.numpy() if hasattr(mujoco_attrs, "actuator_damping") else None + armature_arr = mujoco_attrs.actuator_armature.numpy() if hasattr(mujoco_attrs, "actuator_armature") else None + dcmotor_parameter_names = ( + "actuator_dcmotor_motorconst", + "actuator_dcmotor_resistance", + "actuator_dcmotor_nominal", + "actuator_dcmotor_saturation", + "actuator_dcmotor_inductance", + "actuator_dcmotor_cogging", + "actuator_dcmotor_controller", + "actuator_dcmotor_thermal", + "actuator_dcmotor_lugre", + "actuator_dcmotor_input", + ) + has_dcmotor_shortcut = ctrl_type_arr is not None and np.any(ctrl_type_arr == int(SolverMuJoCo.CtrlType.DCMOTOR)) + dcmotor_parameter_arrays = ( + { + name: getattr(mujoco_attrs, name).numpy() + for name in dcmotor_parameter_names + if hasattr(mujoco_attrs, name) + } + if has_dcmotor_shortcut + else {} + ) for mujoco_act_idx in range(mujoco_actuator_count): # Skip JOINT_TARGET actuators - they're already added via joint_target_mode path if ctrl_source_arr is not None: @@ -3285,6 +3459,10 @@ def resolve_target_from_label(target_label: str) -> tuple[int, int]: if hasattr(mujoco_attrs, "actuator_cranklength"): cranklength = float(mujoco_attrs.actuator_cranklength.numpy()[mujoco_act_idx]) general_args["cranklength"] = cranklength + if damping_arr is not None: + general_args["damping"] = float(damping_arr[mujoco_act_idx]) + if armature_arr is not None: + general_args["armature"] = float(armature_arr[mujoco_act_idx]) # Only pass range to MuJoCo when explicitly set in MJCF (has_*range flags), # so MuJoCo can correctly resolve auto-limited flags via spec.compiler.autolimits. if has_ctrlrange_arr is not None and has_ctrlrange_arr[mujoco_act_idx]: @@ -3315,12 +3493,30 @@ def resolve_target_from_label(target_label: str) -> tuple[int, int]: if hasattr(mujoco_attrs, "actuator_biastype"): biastype = int(mujoco_attrs.actuator_biastype.numpy()[mujoco_act_idx]) general_args["biastype"] = biastype - # Detect position/velocity actuator shortcuts. Use set_to_position/ - # set_to_velocity after add_actuator so MuJoCo's compiler computes kd - # from dampratio via mj_setConst (kd = dampratio * 2 * sqrt(kp * acc0)). - shortcut = None # "position" or "velocity" if detected - shortcut_args: dict[str, float] = {} - if general_args.get("biastype") == mujoco.mjtBias.mjBIAS_AFFINE and general_args.get("gainprm", [0])[0] > 0: + # Apply shortcut helpers after add_actuator so MuJoCo derives all + # compiled parameters exactly as it does for native MJCF. + shortcut = None + shortcut_args: dict[str, Any] = {} + ctrl_type = int(ctrl_type_arr[mujoco_act_idx]) if ctrl_type_arr is not None else -1 + if ctrl_type == int(SolverMuJoCo.CtrlType.DCMOTOR): + shortcut = "dcmotor" + shortcut_args = { + "motorconst": list(dcmotor_parameter_arrays["actuator_dcmotor_motorconst"][mujoco_act_idx]), + "resistance": float(dcmotor_parameter_arrays["actuator_dcmotor_resistance"][mujoco_act_idx]), + "nominal": list(dcmotor_parameter_arrays["actuator_dcmotor_nominal"][mujoco_act_idx]), + "saturation": list(dcmotor_parameter_arrays["actuator_dcmotor_saturation"][mujoco_act_idx]), + "inductance": list(dcmotor_parameter_arrays["actuator_dcmotor_inductance"][mujoco_act_idx]), + "cogging": list(dcmotor_parameter_arrays["actuator_dcmotor_cogging"][mujoco_act_idx]), + "controller": list(dcmotor_parameter_arrays["actuator_dcmotor_controller"][mujoco_act_idx]), + "thermal": list(dcmotor_parameter_arrays["actuator_dcmotor_thermal"][mujoco_act_idx]), + "lugre": list(dcmotor_parameter_arrays["actuator_dcmotor_lugre"][mujoco_act_idx]), + "input_mode": int(dcmotor_parameter_arrays["actuator_dcmotor_input"][mujoco_act_idx]), + } + for key in ("dynprm", "gainprm", "biasprm", "dyntype", "gaintype", "biastype", "actdim"): + general_args.pop(key, None) + elif ( + general_args.get("biastype") == mujoco.mjtBias.mjBIAS_AFFINE and general_args.get("gainprm", [0])[0] > 0 + ): kp = general_args["gainprm"][0] bp = general_args.get("biasprm", [0, 0, 0]) # Position shortcut: biasprm = [0, -kp, -kv] @@ -3360,6 +3556,8 @@ def resolve_target_from_label(target_label: str) -> tuple[int, int]: act.set_to_position(**shortcut_args) elif shortcut == "velocity": act.set_to_velocity(**shortcut_args) + elif shortcut == "dcmotor": + act.set_to_dcmotor(**shortcut_args) # CTRL_DIRECT actuators - store MJCF-order index into control.mujoco.ctrl # mujoco_act_idx is the index in Newton's mujoco:actuator frequency (MJCF order) mjc_actuator_ctrl_source_list.append(1) # CTRL_DIRECT @@ -8939,6 +9137,7 @@ def _update_actuator_properties(self): actuator_gainprm = getattr(mujoco_attrs, "actuator_gainprm", None) actuator_biasprm = getattr(mujoco_attrs, "actuator_biasprm", None) actuator_dynprm = getattr(mujoco_attrs, "actuator_dynprm", None) + actuator_ctrl_type = getattr(mujoco_attrs, "ctrl_type", None) actuator_ctrlrange = getattr(mujoco_attrs, "actuator_ctrlrange", None) actuator_forcerange = getattr(mujoco_attrs, "actuator_forcerange", None) actuator_actrange = getattr(mujoco_attrs, "actuator_actrange", None) @@ -8948,6 +9147,7 @@ def _update_actuator_properties(self): actuator_gainprm is None or actuator_biasprm is None or actuator_dynprm is None + or actuator_ctrl_type is None or actuator_ctrlrange is None or actuator_forcerange is None or actuator_actrange is None @@ -8965,6 +9165,7 @@ def _update_actuator_properties(self): inputs=[ self.mjc_actuator_ctrl_source, self.mjc_actuator_to_newton_idx, + actuator_ctrl_type, actuator_gainprm, actuator_biasprm, actuator_dynprm, diff --git a/newton/_src/utils/import_mjcf.py b/newton/_src/utils/import_mjcf.py index feec509519..16c274a8a5 100644 --- a/newton/_src/utils/import_mjcf.py +++ b/newton/_src/utils/import_mjcf.py @@ -3307,6 +3307,13 @@ def parse_actuators(actuator_section): biasprm = vec10(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) ctrl_source_val = SolverMuJoCo.CtrlSource.CTRL_DIRECT + elif actuator_type == "dcmotor": + # SolverMuJoCo applies MuJoCo's native DC-motor shortcut from + # the high-level parameters preserved by custom attributes. + gainprm = vec10(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + biasprm = vec10(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + ctrl_source_val = SolverMuJoCo.CtrlSource.CTRL_DIRECT + elif actuator_type == "general": gainprm_str = merged_attrib.get("gainprm", "1 0 0 0 0 0 0 0 0 0") biasprm_str = merged_attrib.get("biasprm", "0 0 0 0 0 0 0 0 0 0") @@ -3347,6 +3354,8 @@ def parse_actuators(actuator_section): ctrl_type_val = int(SolverMuJoCo.CtrlType.POSITION) elif actuator_type == "velocity": ctrl_type_val = int(SolverMuJoCo.CtrlType.VELOCITY) + elif actuator_type == "dcmotor": + ctrl_type_val = int(SolverMuJoCo.CtrlType.DCMOTOR) else: ctrl_type_val = int(SolverMuJoCo.CtrlType.GENERAL) diff --git a/newton/tests/test_mujoco_general_actuators.py b/newton/tests/test_mujoco_general_actuators.py index c4eb7e033d..9f3218fb13 100644 --- a/newton/tests/test_mujoco_general_actuators.py +++ b/newton/tests/test_mujoco_general_actuators.py @@ -65,6 +65,26 @@ """ +MJCF_DCMOTOR_ACTUATOR = """ + + +""" + USD_MJC_ACTUATOR_TEMPLATE = """#usda 1.0 ( defaultPrim = "Root" @@ -154,6 +174,26 @@ def Scope "Physics" } """ +USD_MJC_DCMOTOR_ACTUATOR = """ def MjcActuator "HingeDCMotor" + { + uniform token mjc:dynType = "dcmotor" + uniform token mjc:gainType = "dcmotor" + uniform token mjc:biasType = "dcmotor" + uniform int mjc:actDim = 5 + uniform bool mjc:actEarly = true + uniform double[] mjc:dynPrm = [0.005, 7, 0.004, 10, 90, 0.3, 0.4, 10, 2, 0] + uniform double[] mjc:gainPrm = [2, 0.0547722558, 0.001, 0.4, 5, 1, 0.2, 3, 1, 0] + uniform double[] mjc:biasPrm = [0.1, 6, 0.2, 0.5, 12, 0.02, 0, 0, 0, 0] + uniform double[] mjc:gear = [3, 0, 0, 0, 0, 0] + uniform double mjc:forceRange:min = -2 + uniform double mjc:forceRange:max = 2 + uniform token mjc:forceLimited = "true" + uniform double mjc:damping = 0.7 + uniform double mjc:armature = 0.02 + rel mjc:target = + } +""" + def make_usd_mjc_actuator_stage(*actuator_defs: str) -> str: return USD_MJC_ACTUATOR_TEMPLATE.replace("__ACTUATORS__", "\n\n".join(actuator_defs)) @@ -285,6 +325,100 @@ def test_usd_mjc_direct_actuator_stays_ctrl_direct(self): np.testing.assert_array_equal(solver.mjc_actuator_ctrl_source.numpy(), [SolverMuJoCo.CtrlSource.CTRL_DIRECT]) np.testing.assert_array_equal(solver.mjc_actuator_to_newton_idx.numpy(), [0]) + @unittest.skipUnless(USD_AVAILABLE, "Requires usd-core") + def test_usd_mjc_dcmotor_actuator_preserves_compiled_parameters(self): + """Preserve a compiled USD DC-motor actuator through SolverMuJoCo.""" + mujoco = SolverMuJoCo.import_mujoco()[0] + builder = load_usd_mjc_actuator_builder(USD_MJC_DCMOTOR_ACTUATOR) + model = builder.finalize() + + np.testing.assert_array_equal(model.mujoco.ctrl_source.numpy(), [SolverMuJoCo.CtrlSource.CTRL_DIRECT]) + + solver = SolverMuJoCo(model, iterations=1, disable_contacts=True) + mj_model = solver.mj_model + self.assertEqual(mj_model.nu, 1) + self.assertEqual(mj_model.na, 5) + self.assertEqual(mj_model.actuator_dyntype[0], mujoco.mjtDyn.mjDYN_DCMOTOR) + self.assertEqual(mj_model.actuator_gaintype[0], mujoco.mjtGain.mjGAIN_DCMOTOR) + self.assertEqual(mj_model.actuator_biastype[0], mujoco.mjtBias.mjBIAS_DCMOTOR) + np.testing.assert_allclose(mj_model.actuator_dynprm[0], [0.005, 7, 0.004, 10, 90, 0.3, 0.4, 10, 2, 0]) + np.testing.assert_allclose( + mj_model.actuator_gainprm[0], + [2, 0.0547722558, 0.001, 0.4, 5, 1, 0.2, 3, 1, 0], + ) + np.testing.assert_allclose(mj_model.actuator_biasprm[0], [0.1, 6, 0.2, 0.5, 12, 0.02, 0, 0, 0, 0]) + np.testing.assert_allclose(mj_model.actuator_forcerange[0], [-2, 2]) + np.testing.assert_allclose(mj_model.actuator_gear[0], [3, 0, 0, 0, 0, 0]) + np.testing.assert_allclose(mj_model.actuator_damping[0], 0.7) + np.testing.assert_allclose(mj_model.actuator_armature[0], 0.02) + for attribute in ( + "actuator_dynprm", + "actuator_gainprm", + "actuator_biasprm", + "actuator_forcerange", + "actuator_gear", + ): + np.testing.assert_allclose( + getattr(solver.mjw_model, attribute).numpy()[0], + getattr(mj_model, attribute), + ) + + def test_mjcf_dcmotor_actuator_matches_native_mujoco(self): + """Recreate a stateful MJCF DC-motor actuator with native parameters.""" + mujoco = SolverMuJoCo.import_mujoco()[0] + native_model = mujoco.MjModel.from_xml_string(MJCF_DCMOTOR_ACTUATOR) + + builder = ModelBuilder() + builder.add_mjcf(MJCF_DCMOTOR_ACTUATOR) + model = builder.finalize() + + self.assertEqual(model.custom_frequency_counts.get("mujoco:actuator", 0), 1) + np.testing.assert_array_equal(model.mujoco.ctrl_source.numpy(), [SolverMuJoCo.CtrlSource.CTRL_DIRECT]) + + solver = SolverMuJoCo(model, iterations=1, disable_contacts=True) + generated_model = solver.mj_model + self.assertEqual(generated_model.nu, native_model.nu) + self.assertEqual(generated_model.na, native_model.na) + for attribute in ( + "actuator_dyntype", + "actuator_gaintype", + "actuator_biastype", + "actuator_actearly", + "actuator_actnum", + "actuator_dynprm", + "actuator_gainprm", + "actuator_biasprm", + "actuator_forcerange", + "actuator_gear", + "actuator_damping", + "actuator_armature", + ): + np.testing.assert_allclose(getattr(generated_model, attribute), getattr(native_model, attribute)) + + self.assertEqual(solver.mjw_model.actuator_dyntype.numpy()[0], mujoco.mjtDyn.mjDYN_DCMOTOR) + self.assertEqual(solver.mjw_model.actuator_gaintype.numpy()[0], mujoco.mjtGain.mjGAIN_DCMOTOR) + self.assertEqual(solver.mjw_model.actuator_biastype.numpy()[0], mujoco.mjtBias.mjBIAS_DCMOTOR) + for attribute in ( + "actuator_dynprm", + "actuator_gainprm", + "actuator_biasprm", + "actuator_forcerange", + "actuator_gear", + ): + np.testing.assert_allclose( + getattr(solver.mjw_model, attribute).numpy()[0], + getattr(native_model, attribute), + ) + + state_0 = model.state() + state_1 = model.state() + control = model.control() + control.mujoco.ctrl.assign([0.25]) + solver.step(state_0, state_1, control, None, dt=0.001) + activation = solver.mjw_data.act.numpy() + self.assertTrue(np.all(np.isfinite(activation))) + self.assertGreater(float(np.max(np.abs(activation))), 0.0) + def test_parsing_ctrl_direct_false(self): """Test parsing with ctrl_direct=False.""" builder = ModelBuilder() From bb321d42e6f4616add97f584ae432d69ef2e0bfb Mon Sep 17 00:00:00 2001 From: Eric Heiden Date: Mon, 17 Aug 2026 14:06:10 -0700 Subject: [PATCH 2/5] Use Towncrier for DC motor release note Move the user-facing DC motor entry out of CHANGELOG.md and into the issue-linked fragment required by the current release workflow. --- CHANGELOG.md | 4 ---- changelog/3950.added.md | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) create mode 100644 changelog/3950.added.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d6db9c4686..7410ed6f01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,6 @@ -### Added - -- Import MuJoCo DC-motor actuators from MJCF and compiled `MjcActuator` USD for `SolverMuJoCo`. (#3950) - ## [1.5.0] - 2026-08-11 ### Added diff --git a/changelog/3950.added.md b/changelog/3950.added.md new file mode 100644 index 0000000000..937cdb6dd4 --- /dev/null +++ b/changelog/3950.added.md @@ -0,0 +1 @@ +Import MuJoCo DC-motor actuators from MJCF and compiled `MjcActuator` USD for `SolverMuJoCo`. From d89484fe30fcf642f392d974ab4a7225dd78102a Mon Sep 17 00:00:00 2001 From: Eric Heiden Date: Mon, 17 Aug 2026 14:53:57 -0700 Subject: [PATCH 3/5] Document MuJoCo actuator enum alignment Record the MuJoCo 3.11 USER, DC-motor, and SO3 enum correction as a separate Towncrier fixed entry. --- changelog/3950.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3950.fixed.md diff --git a/changelog/3950.fixed.md b/changelog/3950.fixed.md new file mode 100644 index 0000000000..5f9b873fa0 --- /dev/null +++ b/changelog/3950.fixed.md @@ -0,0 +1 @@ +Align MuJoCo actuator dynamics, gain, and bias enum values with MuJoCo 3.11, including the DC-motor and SO3 slots, so user-defined actuators are not misclassified. From d12010db78e33bb12faef394da72b94c80c6440e Mon Sep 17 00:00:00 2001 From: Eric Heiden Date: Mon, 17 Aug 2026 18:06:42 -0700 Subject: [PATCH 4/5] Avoid unused DC motor attribute arrays Register high-level DC motor parameter attributes only when an MJCF source contains a dcmotor element. This avoids replicating and allocating ten unused arrays for ordinary MuJoCo actuators. --- changelog/3950.fixed.1.md | 1 + newton/_src/solvers/mujoco/solver_mujoco.py | 227 +++++++++--------- newton/_src/utils/import_mjcf.py | 4 + newton/tests/test_mujoco_general_actuators.py | 9 + 4 files changed, 124 insertions(+), 117 deletions(-) create mode 100644 changelog/3950.fixed.1.md diff --git a/changelog/3950.fixed.1.md b/changelog/3950.fixed.1.md new file mode 100644 index 0000000000..8d8fc2bdc3 --- /dev/null +++ b/changelog/3950.fixed.1.md @@ -0,0 +1 @@ +Avoid allocating high-level DC-motor parameter arrays for MuJoCo models without DC-motor actuators. diff --git a/newton/_src/solvers/mujoco/solver_mujoco.py b/newton/_src/solvers/mujoco/solver_mujoco.py index a401e04da2..8891008ddd 100644 --- a/newton/_src/solvers/mujoco/solver_mujoco.py +++ b/newton/_src/solvers/mujoco/solver_mujoco.py @@ -741,6 +741,116 @@ def _expand_mjc_tendon_joint_rows(prim, context: dict[str, Any]) -> Iterable[dic for joint_idx, coef in joint_entries ] + @classmethod + def _register_dcmotor_custom_attributes(cls, builder: ModelBuilder) -> None: + """Declare high-level MJCF DC-motor parameters when a source uses them.""" + + def parse_dcmotor_input(value: Any, _context: dict[str, Any] | None = None) -> int: + return int( + cls._parse_named_int( + value, + {"voltage": 0, "position": 1, "velocity": 2}, + fallback_on_unknown=0, + ) + ) + + dcmotor_vec6 = wp.types.vector(length=6, dtype=wp.float32) + attributes = ( + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_motorconst", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.vec2, + default=wp.vec2(0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="motorconst", + ), + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_resistance", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.float32, + default=0.0, + namespace="mujoco", + mjcf_attribute_name="resistance", + ), + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_nominal", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.vec3, + default=wp.vec3(0.0, 0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="nominal", + ), + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_saturation", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.vec3, + default=wp.vec3(0.0, 0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="saturation", + ), + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_inductance", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.vec2, + default=wp.vec2(0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="inductance", + ), + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_cogging", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.vec3, + default=wp.vec3(0.0, 0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="cogging", + ), + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_controller", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=dcmotor_vec6, + default=dcmotor_vec6(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="controller", + ), + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_thermal", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=dcmotor_vec6, + default=dcmotor_vec6(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="thermal", + ), + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_lugre", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=vec5, + default=vec5(0.0, 0.0, 0.0, 0.0, 0.0), + namespace="mujoco", + mjcf_attribute_name="lugre", + ), + ModelBuilder.CustomAttribute( + name="actuator_dcmotor_input", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.int32, + default=0, + namespace="mujoco", + mjcf_attribute_name="input", + mjcf_value_transformer=parse_dcmotor_input, + ), + ) + for attribute in attributes: + builder.add_custom_attribute(attribute) + @override @classmethod def register_custom_attributes(cls, builder: ModelBuilder) -> None: @@ -1512,9 +1622,6 @@ def parse_gaintype(s: str, _context: dict[str, Any] | None = None) -> int: def parse_biastype(s: str, _context: dict[str, Any] | None = None) -> int: return parse_actuator_enum(s, actuator_bias_types) - def parse_dcmotor_input(s: str, _context: dict[str, Any] | None = None) -> int: - return parse_actuator_enum(s, {"voltage": 0, "position": 1, "velocity": 2}) - def parse_bool(value: Any, context: dict[str, Any] | None = None) -> bool: """Parse MJCF/USD boolean values to bool.""" if isinstance(value, bool): @@ -2046,120 +2153,6 @@ def parse_presence(_value: str, _context: dict[str, Any] | None = None) -> int: ) ) - # Preserve high-level MJCF DC-motor parameters so MjSpec can apply the - # native shortcut when SolverMuJoCo rebuilds its MuJoCo model. - dcmotor_vec6 = wp.types.vector(length=6, dtype=wp.float32) - builder.add_custom_attribute( - ModelBuilder.CustomAttribute( - name="actuator_dcmotor_motorconst", - frequency="mujoco:actuator", - assignment=AttributeAssignment.MODEL, - dtype=wp.vec2, - default=wp.vec2(0.0, 0.0), - namespace="mujoco", - mjcf_attribute_name="motorconst", - ) - ) - builder.add_custom_attribute( - ModelBuilder.CustomAttribute( - name="actuator_dcmotor_resistance", - frequency="mujoco:actuator", - assignment=AttributeAssignment.MODEL, - dtype=wp.float32, - default=0.0, - namespace="mujoco", - mjcf_attribute_name="resistance", - ) - ) - builder.add_custom_attribute( - ModelBuilder.CustomAttribute( - name="actuator_dcmotor_nominal", - frequency="mujoco:actuator", - assignment=AttributeAssignment.MODEL, - dtype=wp.vec3, - default=wp.vec3(0.0, 0.0, 0.0), - namespace="mujoco", - mjcf_attribute_name="nominal", - ) - ) - builder.add_custom_attribute( - ModelBuilder.CustomAttribute( - name="actuator_dcmotor_saturation", - frequency="mujoco:actuator", - assignment=AttributeAssignment.MODEL, - dtype=wp.vec3, - default=wp.vec3(0.0, 0.0, 0.0), - namespace="mujoco", - mjcf_attribute_name="saturation", - ) - ) - builder.add_custom_attribute( - ModelBuilder.CustomAttribute( - name="actuator_dcmotor_inductance", - frequency="mujoco:actuator", - assignment=AttributeAssignment.MODEL, - dtype=wp.vec2, - default=wp.vec2(0.0, 0.0), - namespace="mujoco", - mjcf_attribute_name="inductance", - ) - ) - builder.add_custom_attribute( - ModelBuilder.CustomAttribute( - name="actuator_dcmotor_cogging", - frequency="mujoco:actuator", - assignment=AttributeAssignment.MODEL, - dtype=wp.vec3, - default=wp.vec3(0.0, 0.0, 0.0), - namespace="mujoco", - mjcf_attribute_name="cogging", - ) - ) - builder.add_custom_attribute( - ModelBuilder.CustomAttribute( - name="actuator_dcmotor_controller", - frequency="mujoco:actuator", - assignment=AttributeAssignment.MODEL, - dtype=dcmotor_vec6, - default=dcmotor_vec6(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), - namespace="mujoco", - mjcf_attribute_name="controller", - ) - ) - builder.add_custom_attribute( - ModelBuilder.CustomAttribute( - name="actuator_dcmotor_thermal", - frequency="mujoco:actuator", - assignment=AttributeAssignment.MODEL, - dtype=dcmotor_vec6, - default=dcmotor_vec6(0.0, 0.0, 0.0, 0.0, 0.0, 0.0), - namespace="mujoco", - mjcf_attribute_name="thermal", - ) - ) - builder.add_custom_attribute( - ModelBuilder.CustomAttribute( - name="actuator_dcmotor_lugre", - frequency="mujoco:actuator", - assignment=AttributeAssignment.MODEL, - dtype=vec5, - default=vec5(0.0, 0.0, 0.0, 0.0, 0.0), - namespace="mujoco", - mjcf_attribute_name="lugre", - ) - ) - builder.add_custom_attribute( - ModelBuilder.CustomAttribute( - name="actuator_dcmotor_input", - frequency="mujoco:actuator", - assignment=AttributeAssignment.MODEL, - dtype=wp.int32, - default=0, - namespace="mujoco", - mjcf_attribute_name="input", - mjcf_value_transformer=parse_dcmotor_input, - ) - ) builder.add_custom_attribute( ModelBuilder.CustomAttribute( name="actuator_cranklength", diff --git a/newton/_src/utils/import_mjcf.py b/newton/_src/utils/import_mjcf.py index 16c274a8a5..da5a5a5c8b 100644 --- a/newton/_src/utils/import_mjcf.py +++ b/newton/_src/utils/import_mjcf.py @@ -375,6 +375,10 @@ def parse_mjcf( # Register the MuJoCo custom attributes needed to preserve imported model # properties. The operation is idempotent. SolverMuJoCo.register_custom_attributes(builder) + # Avoid replicating and allocating high-level DC-motor arrays for the + # overwhelmingly common case where the MJCF contains no DC motors. + if any(True for _ in root.iter("dcmotor")): + SolverMuJoCo._register_dcmotor_custom_attributes(builder) # Bit 1 in one MJCF file may describe different shapes than bit 1 in # another. Give every add_mjcf() call a domain so those equal numbers are # not mistaken for one shared collision rule. The domain is only a source diff --git a/newton/tests/test_mujoco_general_actuators.py b/newton/tests/test_mujoco_general_actuators.py index 9f3218fb13..ebaf234c3e 100644 --- a/newton/tests/test_mujoco_general_actuators.py +++ b/newton/tests/test_mujoco_general_actuators.py @@ -419,6 +419,15 @@ def test_mjcf_dcmotor_actuator_matches_native_mujoco(self): self.assertTrue(np.all(np.isfinite(activation))) self.assertGreater(float(np.max(np.abs(activation))), 0.0) + def test_mjcf_without_dcmotor_omits_high_level_parameters(self): + """Avoid allocating high-level DC-motor parameters for ordinary actuators.""" + builder = ModelBuilder() + builder.add_mjcf(MJCF_ACTUATORS) + + self.assertNotIn("mujoco:actuator_dcmotor_motorconst", builder.custom_attributes) + model = builder.finalize() + self.assertFalse(hasattr(model.mujoco, "actuator_dcmotor_motorconst")) + def test_parsing_ctrl_direct_false(self): """Test parsing with ctrl_direct=False.""" builder = ModelBuilder() From 3b6b2b0067f255e6a7c8d784d9ebc5791ea1328d Mon Sep 17 00:00:00 2001 From: Eric Heiden Date: Fri, 4 Sep 2026 15:30:58 -0700 Subject: [PATCH 5/5] Fix DC motor actuator integration Preserve independently authored actuator properties when refreshing compiled DC motor rows and synchronize updates on both backends. Adapt control metadata to MuJoCo 3.12, bridge its representation for MuJoCo Warp, validate replicated worlds, and expand regression coverage. --- changelog/3950.fixed.md | 2 +- docs/solvers/mujoco.rst | 20 ++ newton/_src/solvers/mujoco/kernels.py | 37 ++- newton/_src/solvers/mujoco/solver_mujoco.py | 143 +++++++++- newton/_src/utils/import_mjcf.py | 9 + newton/tests/test_mujoco_general_actuators.py | 256 ++++++++++++++++-- newton/tests/test_mujoco_solver.py | 14 +- 7 files changed, 444 insertions(+), 37 deletions(-) diff --git a/changelog/3950.fixed.md b/changelog/3950.fixed.md index 5f9b873fa0..170d5c2f8d 100644 --- a/changelog/3950.fixed.md +++ b/changelog/3950.fixed.md @@ -1 +1 @@ -Align MuJoCo actuator dynamics, gain, and bias enum values with MuJoCo 3.11, including the DC-motor and SO3 slots, so user-defined actuators are not misclassified. +Recognize MuJoCo DC-motor and SO3 actuator dynamics, gain, and bias types when importing compiled actuator parameters. diff --git a/docs/solvers/mujoco.rst b/docs/solvers/mujoco.rst index 3c50efb3dc..1eed5b6cb8 100644 --- a/docs/solvers/mujoco.rst +++ b/docs/solvers/mujoco.rst @@ -348,6 +348,26 @@ only reachable through the ``mujoco`` :ref:`custom-attribute namespace `` actuators follow this MuJoCo-specific path. The importer +preserves their high-level electrical, controller, thermal, cogging, and LuGre +parameters, and :class:`~newton.solvers.SolverMuJoCo` reconstructs them through +MuJoCo's native DC-motor compiler for both the CPU and MuJoCo-Warp backends. +Commands use the MJCF actuator order in ``control.mujoco.ctrl``. The supported +``input`` signatures each consume one control value: ``voltage``, ``pos``, or +``vel``; ``position`` and ``velocity`` are accepted as compatibility aliases. +MuJoCo's ``ff``, ``none``, and combined multi-input signatures are not yet +supported because they require control handling that MuJoCo-Warp does not yet +provide. Compiled USD ``MjcActuator`` rows can preserve the low-level parameter +arrays and a supported ``mjc:ctrlSpec`` value instead. +When using ``separate_worlds=True``, corresponding high-level ```` +actuators must have identical parameters because MuJoCo-Warp replicates one +compiled template model across worlds. + +This native, stateful MuJoCo actuator is distinct from +:class:`~newton.actuators.ClampingDCMotor`, which is a stateless Newton +clamping stage and does not model winding current, thermal effects, cogging, or +LuGre friction. + .. _mujoco-equality-constraints: diff --git a/newton/_src/solvers/mujoco/kernels.py b/newton/_src/solvers/mujoco/kernels.py index 768c37b2a8..70af2594f9 100644 --- a/newton/_src/solvers/mujoco/kernels.py +++ b/newton/_src/solvers/mujoco/kernels.py @@ -1541,6 +1541,7 @@ def convert_mjw_contacts_to_newton_kernel( CTRL_SOURCE_JOINT_TARGET = wp.constant(0) CTRL_SOURCE_CTRL_DIRECT = wp.constant(1) CTRL_TYPE_DCMOTOR = wp.constant(3) +ACTUATOR_GAIN_TYPE_DCMOTOR = wp.constant(3) @wp.func @@ -2116,6 +2117,8 @@ def update_ctrl_direct_actuator_properties_kernel( mjc_actuator_ctrl_source: wp.array[wp.int32], mjc_actuator_to_newton_idx: wp.array[wp.int32], newton_actuator_ctrl_type: wp.array[wp.int32], + newton_actuator_gain_type: wp.array[wp.int32], + newton_actuator_ctrlspec: wp.array[wp.int32], newton_actuator_gainprm: wp.array[vec10], newton_actuator_biasprm: wp.array[vec10], newton_actuator_dynprm: wp.array[vec10], @@ -2145,6 +2148,8 @@ def update_ctrl_direct_actuator_properties_kernel( mjc_actuator_ctrl_source: 0=JOINT_TARGET, 1=CTRL_DIRECT mjc_actuator_to_newton_idx: Index into Newton's mujoco:actuator arrays newton_actuator_ctrl_type: Intrinsic actuator shortcut type + newton_actuator_gain_type: MuJoCo actuator gain type + newton_actuator_ctrlspec: MuJoCo 3.12 DC-motor control-input mask newton_actuator_gainprm: Newton's model.mujoco.actuator_gainprm newton_actuator_biasprm: Newton's model.mujoco.actuator_biasprm newton_actuator_dynprm: Newton's model.mujoco.actuator_dynprm @@ -2167,15 +2172,33 @@ def update_ctrl_direct_actuator_properties_kernel( world_newton_idx = world * actuators_per_world + newton_idx # High-level MJCF DC-motor rows keep placeholder general-actuator arrays; - # preserve the parameters compiled by MjsActuator.set_to_dcmotor(). - if newton_actuator_ctrl_type[world_newton_idx] == CTRL_TYPE_DCMOTOR: - return + # preserve the parameters and force range compiled by set_to_dcmotor(). + if newton_actuator_ctrl_type[world_newton_idx] != CTRL_TYPE_DCMOTOR: + actuator_gain[world, actuator] = newton_actuator_gainprm[world_newton_idx] + actuator_bias[world, actuator] = newton_actuator_biasprm[world_newton_idx] + actuator_dynprm[world, actuator] = newton_actuator_dynprm[world_newton_idx] + actuator_forcerange[world, actuator] = newton_actuator_forcerange[world_newton_idx] + + # MuJoCo 3.12 stores the DC-motor input signature in actuator_ctrlspec, + # while MuJoCo-Warp currently reads its legacy value from gainprm[8]. + # Patch only that compatibility slot after preserving or copying the + # compiled parameters above. A zero ctrlspec denotes legacy compiled data. + ctrlspec = newton_actuator_ctrlspec[world_newton_idx] + is_dcmotor = ( + newton_actuator_ctrl_type[world_newton_idx] == CTRL_TYPE_DCMOTOR + or newton_actuator_gain_type[world_newton_idx] == ACTUATOR_GAIN_TYPE_DCMOTOR + ) + if is_dcmotor and ctrlspec > 0: + gain = actuator_gain[world, actuator] + if ctrlspec == 1: # position + gain[8] = 1.0 + elif ctrlspec == 2: # velocity + gain[8] = 2.0 + else: # voltage + gain[8] = 0.0 + actuator_gain[world, actuator] = gain - actuator_gain[world, actuator] = newton_actuator_gainprm[world_newton_idx] - actuator_bias[world, actuator] = newton_actuator_biasprm[world_newton_idx] - actuator_dynprm[world, actuator] = newton_actuator_dynprm[world_newton_idx] actuator_ctrlrange[world, actuator] = newton_actuator_ctrlrange[world_newton_idx] - actuator_forcerange[world, actuator] = newton_actuator_forcerange[world_newton_idx] actuator_actrange[world, actuator] = newton_actuator_actrange[world_newton_idx] actuator_gear[world, actuator] = newton_actuator_gear[world_newton_idx] actuator_cranklength[world, actuator] = newton_actuator_cranklength[world_newton_idx] diff --git a/newton/_src/solvers/mujoco/solver_mujoco.py b/newton/_src/solvers/mujoco/solver_mujoco.py index 2cb474f64b..f6a1c4e336 100644 --- a/newton/_src/solvers/mujoco/solver_mujoco.py +++ b/newton/_src/solvers/mujoco/solver_mujoco.py @@ -755,13 +755,24 @@ def _register_dcmotor_custom_attributes(cls, builder: ModelBuilder) -> None: """Declare high-level MJCF DC-motor parameters when a source uses them.""" def parse_dcmotor_input(value: Any, _context: dict[str, Any] | None = None) -> int: - return int( - cls._parse_named_int( - value, - {"voltage": 0, "position": 1, "velocity": 2}, - fallback_on_unknown=0, + control_signatures = { + "pos": 1, + "position": 1, + "vel": 2, + "velocity": 2, + "voltage": 8, + } + try: + control_signature = cls._parse_named_int(value, control_signatures) + except ValueError: + control_signature = -1 + if control_signature not in control_signatures.values(): + raise NotImplementedError( + "SolverMuJoCo supports one DC-motor control input per actuator row: " + "'voltage', 'pos', or 'vel'. MuJoCo 'ff', 'none', and combined input " + "signatures require control handling that MuJoCo-Warp does not yet provide." ) - ) + return control_signature dcmotor_vec6 = wp.types.vector(length=6, dtype=wp.float32) attributes = ( @@ -851,7 +862,7 @@ def parse_dcmotor_input(value: Any, _context: dict[str, Any] | None = None) -> i frequency="mujoco:actuator", assignment=AttributeAssignment.MODEL, dtype=wp.int32, - default=0, + default=8, namespace="mujoco", mjcf_attribute_name="input", mjcf_value_transformer=parse_dcmotor_input, @@ -2542,6 +2553,18 @@ def parse_presence(_value: str, _context: dict[str, Any] | None = None) -> int: usd_attribute_name="mjc:actDim", ) ) + builder.add_custom_attribute( + ModelBuilder.CustomAttribute( + name="actuator_ctrlspec", + frequency="mujoco:actuator", + assignment=AttributeAssignment.MODEL, + dtype=wp.int32, + default=0, + namespace="mujoco", + mjcf_attribute_name="ctrlspec", + usd_attribute_name="mjc:ctrlSpec", + ) + ) builder.add_custom_attribute( ModelBuilder.CustomAttribute( @@ -3598,6 +3621,7 @@ def resolve_target_from_label(target_label: str) -> tuple[int, int]: actlimited_arr = ( mujoco_attrs.actuator_actlimited.numpy() if hasattr(mujoco_attrs, "actuator_actlimited") else None ) + ctrlspec_arr = mujoco_attrs.actuator_ctrlspec.numpy() if hasattr(mujoco_attrs, "actuator_ctrlspec") else None damping_arr = mujoco_attrs.actuator_damping.numpy() if hasattr(mujoco_attrs, "actuator_damping") else None armature_arr = mujoco_attrs.actuator_armature.numpy() if hasattr(mujoco_attrs, "actuator_armature") else None dcmotor_parameter_names = ( @@ -3613,6 +3637,16 @@ def resolve_target_from_label(target_label: str) -> tuple[int, int]: "actuator_dcmotor_input", ) has_dcmotor_shortcut = ctrl_type_arr is not None and np.any(ctrl_type_arr == int(SolverMuJoCo.CtrlType.DCMOTOR)) + missing_dcmotor_parameters = ( + [name for name in dcmotor_parameter_names if not hasattr(mujoco_attrs, name)] + if has_dcmotor_shortcut + else [] + ) + if missing_dcmotor_parameters: + raise ValueError( + "High-level DC-motor actuator rows require all importer-managed parameters. " + f"Missing: {', '.join(missing_dcmotor_parameters)}." + ) dcmotor_parameter_arrays = ( { name: getattr(mujoco_attrs, name).numpy() @@ -3794,6 +3828,8 @@ def resolve_target_from_label(target_label: str) -> tuple[int, int]: actdim = mujoco_attrs.actuator_actdim.numpy()[mujoco_act_idx] if actdim >= 0: # -1 means auto general_args["actdim"] = int(actdim) + if ctrlspec_arr is not None and ctrlspec_arr[mujoco_act_idx] != 0: + general_args["ctrlspec"] = int(ctrlspec_arr[mujoco_act_idx]) if hasattr(mujoco_attrs, "actuator_dyntype"): dyntype = int(mujoco_attrs.actuator_dyntype.numpy()[mujoco_act_idx]) general_args["dyntype"] = dyntype @@ -3803,11 +3839,21 @@ def resolve_target_from_label(target_label: str) -> tuple[int, int]: if hasattr(mujoco_attrs, "actuator_biastype"): biastype = int(mujoco_attrs.actuator_biastype.numpy()[mujoco_act_idx]) general_args["biastype"] = biastype + ctrl_type = int(ctrl_type_arr[mujoco_act_idx]) if ctrl_type_arr is not None else -1 + ctrlspec = int(ctrlspec_arr[mujoco_act_idx]) if ctrlspec_arr is not None else 0 + is_dcmotor = ctrl_type == int(SolverMuJoCo.CtrlType.DCMOTOR) or general_args.get("gaintype") == int( + mujoco.mjtGain.mjGAIN_DCMOTOR + ) + if is_dcmotor and ctrlspec not in (0, 1, 2, 8): + raise NotImplementedError( + "SolverMuJoCo supports one DC-motor control input per actuator row: " + "'voltage', 'pos', or 'vel'. MuJoCo 'ff', 'none', and combined input " + "signatures require control handling that MuJoCo-Warp does not yet provide." + ) # Apply shortcut helpers after add_actuator so MuJoCo derives all # compiled parameters exactly as it does for native MJCF. shortcut = None shortcut_args: dict[str, Any] = {} - ctrl_type = int(ctrl_type_arr[mujoco_act_idx]) if ctrl_type_arr is not None else -1 if ctrl_type == int(SolverMuJoCo.CtrlType.DCMOTOR): shortcut = "dcmotor" shortcut_args = { @@ -3820,7 +3866,7 @@ def resolve_target_from_label(target_label: str) -> tuple[int, int]: "controller": list(dcmotor_parameter_arrays["actuator_dcmotor_controller"][mujoco_act_idx]), "thermal": list(dcmotor_parameter_arrays["actuator_dcmotor_thermal"][mujoco_act_idx]), "lugre": list(dcmotor_parameter_arrays["actuator_dcmotor_lugre"][mujoco_act_idx]), - "input_mode": int(dcmotor_parameter_arrays["actuator_dcmotor_input"][mujoco_act_idx]), + "ctrlspec": int(dcmotor_parameter_arrays["actuator_dcmotor_input"][mujoco_act_idx]), } for key in ("dynprm", "gainprm", "biasprm", "dyntype", "gaintype", "biastype", "actdim"): general_args.pop(key, None) @@ -5037,6 +5083,26 @@ def _notify_model_changed(self, flags: ModelFlags | int) -> None: self.mj_model.jnt_margin[:] = self.mjw_model.jnt_margin.numpy()[0] self.mj_model.jnt_range[:] = self.mjw_model.jnt_range.numpy()[0] self.mj_model.jnt_actfrcrange[:] = self.mjw_model.jnt_actfrcrange.numpy()[0] + if flags & ModelFlags.ACTUATOR_PROPERTIES: + gainprm = self.mjw_model.actuator_gainprm.numpy()[0].copy() + # MuJoCo-Warp's compatibility bridge uses gainprm[8] for + # MuJoCo 3.12 DC motors, while MuJoCo-C owns the input mode in + # actuator_ctrlspec. Keep the native compiled slot unchanged. + modern_dcmotor = (self.mj_model.actuator_gaintype == self._mujoco.mjtGain.mjGAIN_DCMOTOR) & ( + self.mj_model.actuator_ctrlspec > 0 + ) + gainprm[modern_dcmotor, 8] = self.mj_model.actuator_gainprm[modern_dcmotor, 8] + self.mj_model.actuator_gainprm[:] = gainprm + for name in ( + "actuator_biasprm", + "actuator_dynprm", + "actuator_ctrlrange", + "actuator_forcerange", + "actuator_actrange", + "actuator_gear", + "actuator_cranklength", + ): + getattr(self.mj_model, name)[:] = getattr(self.mjw_model, name).numpy()[0] if need_length_range or need_const_fixed or need_const_0: self._set_const_0_with_physical_meaninertia() if need_solref_update: @@ -9585,6 +9651,8 @@ def _update_actuator_properties(self): actuator_biasprm = getattr(mujoco_attrs, "actuator_biasprm", None) actuator_dynprm = getattr(mujoco_attrs, "actuator_dynprm", None) actuator_ctrl_type = getattr(mujoco_attrs, "ctrl_type", None) + actuator_gain_type = getattr(mujoco_attrs, "actuator_gaintype", None) + actuator_ctrlspec = getattr(mujoco_attrs, "actuator_ctrlspec", None) actuator_ctrlrange = getattr(mujoco_attrs, "actuator_ctrlrange", None) actuator_forcerange = getattr(mujoco_attrs, "actuator_forcerange", None) actuator_actrange = getattr(mujoco_attrs, "actuator_actrange", None) @@ -9595,6 +9663,8 @@ def _update_actuator_properties(self): or actuator_biasprm is None or actuator_dynprm is None or actuator_ctrl_type is None + or actuator_gain_type is None + or actuator_ctrlspec is None or actuator_ctrlrange is None or actuator_forcerange is None or actuator_actrange is None @@ -9613,6 +9683,8 @@ def _update_actuator_properties(self): self.mjc_actuator_ctrl_source, self.mjc_actuator_to_newton_idx, actuator_ctrl_type, + actuator_gain_type, + actuator_ctrlspec, actuator_gainprm, actuator_biasprm, actuator_dynprm, @@ -9645,6 +9717,7 @@ def _validate_model_for_separate_worlds(self, model: Model) -> None: 2. Entity types match across corresponding entities in each world 3. Corresponding joints have the same linear/angular DOF counts in each world 4. Global world (-1) only contains static shapes (no bodies, joints, or constraints) + 5. High-level DC-motor actuator layouts and parameters match across worlds Args: model: The Newton model to validate. @@ -9708,6 +9781,58 @@ def _validate_model_for_separate_worlds(self, model: Model) -> None: if world_count <= 1: return + # DC-motor shortcut parameters are compiled into one template MuJoCo + # actuator before its model arrays are replicated across worlds. Reject + # per-world differences instead of silently simulating every world with + # world 0's electrical and controller parameters. Low-level compiled + # actuator arrays remain independently updateable per world. + mujoco_attrs = getattr(model, "mujoco", None) + actuator_world_attr = getattr(mujoco_attrs, "actuator_world", None) if mujoco_attrs is not None else None + ctrl_type_attr = getattr(mujoco_attrs, "ctrl_type", None) if mujoco_attrs is not None else None + if actuator_world_attr is not None and ctrl_type_attr is not None: + actuator_world = actuator_world_attr.numpy() + ctrl_type = ctrl_type_attr.numpy() + dcmotor_type = int(SolverMuJoCo.CtrlType.DCMOTOR) + if np.any(ctrl_type == dcmotor_type): + rows_by_world = [np.flatnonzero(actuator_world == world) for world in range(world_count)] + template_rows = rows_by_world[0] + template_dcmotor = ctrl_type[template_rows] == dcmotor_type + for world, rows in enumerate(rows_by_world[1:], start=1): + if len(rows) != len(template_rows) or not np.array_equal( + ctrl_type[rows] == dcmotor_type, + template_dcmotor, + ): + raise ValueError( + "SolverMuJoCo with separate_worlds=True requires matching high-level " + f"DC-motor actuator layouts; world {world} differs from world 0." + ) + + dcmotor_parameter_names = ( + "actuator_dcmotor_motorconst", + "actuator_dcmotor_resistance", + "actuator_dcmotor_nominal", + "actuator_dcmotor_saturation", + "actuator_dcmotor_inductance", + "actuator_dcmotor_cogging", + "actuator_dcmotor_controller", + "actuator_dcmotor_thermal", + "actuator_dcmotor_lugre", + "actuator_dcmotor_input", + ) + for name in dcmotor_parameter_names: + attribute = getattr(mujoco_attrs, name, None) + if attribute is None: + raise ValueError(f"High-level DC-motor actuator rows are missing mujoco:{name}.") + values = attribute.numpy() + expected = values[template_rows][template_dcmotor] + for world, rows in enumerate(rows_by_world[1:], start=1): + actual = values[rows][template_dcmotor] + if not np.array_equal(actual, expected): + raise ValueError( + "SolverMuJoCo with separate_worlds=True requires identical high-level " + f"DC-motor parameters; mujoco:{name} differs in world {world}." + ) + # --- Check entity count homogeneity --- # Count entities per world (excluding global shapes) non_global_shapes = shape_world[shape_world >= 0] diff --git a/newton/_src/utils/import_mjcf.py b/newton/_src/utils/import_mjcf.py index 632f4a3a92..9700ccdac0 100644 --- a/newton/_src/utils/import_mjcf.py +++ b/newton/_src/utils/import_mjcf.py @@ -3390,6 +3390,15 @@ def parse_actuators(actuator_section): parsing_mode="mjcf", context={"actuator_name": act_name}, ) + if actuator_type == "dcmotor": + # MuJoCo 3.12 stores the input signature separately from the + # compiled gain parameters. Keep both the high-level value and + # its generic compiled-model representation. + input_key = "mujoco:actuator_dcmotor_input" + parsed_attrs["mujoco:actuator_ctrlspec"] = parsed_attrs.get( + input_key, + builder.custom_attributes[input_key].default, + ) if crank_length is not None: parsed_attrs["mujoco:actuator_cranklength"] = crank_length diff --git a/newton/tests/test_mujoco_general_actuators.py b/newton/tests/test_mujoco_general_actuators.py index 51eda0a416..20e6e1f4ce 100644 --- a/newton/tests/test_mujoco_general_actuators.py +++ b/newton/tests/test_mujoco_general_actuators.py @@ -79,12 +79,26 @@ resistance="2" motorconst="0.05 0.06" nominal="24 0.2 100" inductance="0.01 20" thermal="0.004 10 30 0.001 0.4 90" saturation="2 4 7" cogging="0.1 6 0.2" - lugre="0.3 0.4 0.5 12 0.02" input="position" + lugre="0.3 0.4 0.5 12 0.02" input="pos" controller="5 1 0.2 10 2 3" damping="0.7" armature="0.02"/> """ +MJCF_DCMOTOR_SIMPLE = """ + + + + + + + + + + + +""" + USD_MJC_ACTUATOR_TEMPLATE = """#usda 1.0 ( defaultPrim = "Root" @@ -180,9 +194,10 @@ def Scope "Physics" uniform token mjc:gainType = "dcmotor" uniform token mjc:biasType = "dcmotor" uniform int mjc:actDim = 5 + uniform int mjc:ctrlSpec = 1 uniform bool mjc:actEarly = true uniform double[] mjc:dynPrm = [0.005, 7, 0.004, 10, 90, 0.3, 0.4, 10, 2, 0] - uniform double[] mjc:gainPrm = [2, 0.0547722558, 0.001, 0.4, 5, 1, 0.2, 3, 1, 0] + uniform double[] mjc:gainPrm = [2, 0.0547722558, 0.001, 0.4, 5, 1, 0.2, 3, 0, 0] uniform double[] mjc:biasPrm = [0.1, 6, 0.2, 0.5, 12, 0.02, 0, 0, 0, 0] uniform double[] mjc:gear = [3, 0, 0, 0, 0, 0] uniform double mjc:forceRange:min = -2 @@ -342,27 +357,36 @@ def test_usd_mjc_dcmotor_actuator_preserves_compiled_parameters(self): self.assertEqual(mj_model.actuator_dyntype[0], mujoco.mjtDyn.mjDYN_DCMOTOR) self.assertEqual(mj_model.actuator_gaintype[0], mujoco.mjtGain.mjGAIN_DCMOTOR) self.assertEqual(mj_model.actuator_biastype[0], mujoco.mjtBias.mjBIAS_DCMOTOR) + self.assertEqual(mj_model.actuator_ctrlspec[0], 1) np.testing.assert_allclose(mj_model.actuator_dynprm[0], [0.005, 7, 0.004, 10, 90, 0.3, 0.4, 10, 2, 0]) np.testing.assert_allclose( mj_model.actuator_gainprm[0], - [2, 0.0547722558, 0.001, 0.4, 5, 1, 0.2, 3, 1, 0], + [2, 0.0547722558, 0.001, 0.4, 5, 1, 0.2, 3, 0, 0], ) np.testing.assert_allclose(mj_model.actuator_biasprm[0], [0.1, 6, 0.2, 0.5, 12, 0.02, 0, 0, 0, 0]) np.testing.assert_allclose(mj_model.actuator_forcerange[0], [-2, 2]) np.testing.assert_allclose(mj_model.actuator_gear[0], [3, 0, 0, 0, 0, 0]) np.testing.assert_allclose(mj_model.actuator_damping[0], 0.7) np.testing.assert_allclose(mj_model.actuator_armature[0], 0.02) - for attribute in ( - "actuator_dynprm", - "actuator_gainprm", - "actuator_biasprm", - "actuator_forcerange", - "actuator_gear", - ): + for attribute in ("actuator_dynprm", "actuator_biasprm", "actuator_forcerange", "actuator_gear"): np.testing.assert_allclose( getattr(solver.mjw_model, attribute).numpy()[0], getattr(mj_model, attribute), ) + expected_gainprm = mj_model.actuator_gainprm.copy() + expected_gainprm[0, 8] = 1.0 # MuJoCo-Warp's legacy position-input slot + np.testing.assert_allclose(solver.mjw_model.actuator_gainprm.numpy()[0], expected_gainprm) + + @unittest.skipUnless(USD_AVAILABLE, "Requires usd-core") + def test_usd_mjc_dcmotor_rejects_unsupported_control_signatures(self): + """Reject compiled multi-input DC-motor rows that Newton cannot address.""" + builder = load_usd_mjc_actuator_builder( + USD_MJC_DCMOTOR_ACTUATOR.replace("mjc:ctrlSpec = 1", "mjc:ctrlSpec = 3") + ) + model = builder.finalize() + + with self.assertRaisesRegex(NotImplementedError, "one DC-motor control input"): + SolverMuJoCo(model, iterations=1, disable_contacts=True) def test_mjcf_dcmotor_actuator_matches_native_mujoco(self): """Recreate a stateful MJCF DC-motor actuator with native parameters.""" @@ -386,6 +410,7 @@ def test_mjcf_dcmotor_actuator_matches_native_mujoco(self): "actuator_biastype", "actuator_actearly", "actuator_actnum", + "actuator_ctrlspec", "actuator_dynprm", "actuator_gainprm", "actuator_biasprm", @@ -399,17 +424,14 @@ def test_mjcf_dcmotor_actuator_matches_native_mujoco(self): self.assertEqual(solver.mjw_model.actuator_dyntype.numpy()[0], mujoco.mjtDyn.mjDYN_DCMOTOR) self.assertEqual(solver.mjw_model.actuator_gaintype.numpy()[0], mujoco.mjtGain.mjGAIN_DCMOTOR) self.assertEqual(solver.mjw_model.actuator_biastype.numpy()[0], mujoco.mjtBias.mjBIAS_DCMOTOR) - for attribute in ( - "actuator_dynprm", - "actuator_gainprm", - "actuator_biasprm", - "actuator_forcerange", - "actuator_gear", - ): + for attribute in ("actuator_dynprm", "actuator_biasprm", "actuator_forcerange", "actuator_gear"): np.testing.assert_allclose( getattr(solver.mjw_model, attribute).numpy()[0], getattr(native_model, attribute), ) + expected_gainprm = native_model.actuator_gainprm.copy() + expected_gainprm[0, 8] = 1.0 # MuJoCo-Warp's legacy position-input slot + np.testing.assert_allclose(solver.mjw_model.actuator_gainprm.numpy()[0], expected_gainprm) state_0 = model.state() state_1 = model.state() @@ -420,14 +442,210 @@ def test_mjcf_dcmotor_actuator_matches_native_mujoco(self): self.assertTrue(np.all(np.isfinite(activation))) self.assertGreater(float(np.max(np.abs(activation))), 0.0) + def test_mjcf_dcmotor_defaults_match_native_mujoco(self): + """Preserve native defaults and scalar motor-constant expansion.""" + mujoco = SolverMuJoCo.import_mujoco()[0] + native_model = mujoco.MjModel.from_xml_string(MJCF_DCMOTOR_SIMPLE) + builder = ModelBuilder() + builder.add_mjcf(MJCF_DCMOTOR_SIMPLE) + model = builder.finalize() + solver = SolverMuJoCo(model, iterations=1, disable_contacts=True) + + self.assertEqual(solver.mj_model.na, 0) + self.assertEqual(solver.mj_model.actuator_ctrlspec[0], 8) + for attribute in ("actuator_dynprm", "actuator_gainprm", "actuator_biasprm"): + np.testing.assert_allclose(getattr(solver.mj_model, attribute), getattr(native_model, attribute)) + + def test_mjcf_dcmotor_rejects_unsupported_control_signatures(self): + """Reject DC-motor signatures that do not map one control to one actuator row.""" + for control_signature in ("ff", "none", "pos vel"): + with self.subTest(control_signature=control_signature): + mjcf = MJCF_DCMOTOR_ACTUATOR.replace('input="pos"', f'input="{control_signature}"') + with self.assertRaisesRegex(NotImplementedError, "one DC-motor control input"): + ModelBuilder().add_mjcf(mjcf) + + def test_mjcf_dcmotor_dynamics_match_native_mujoco(self): + """Match native DC-motor activation and force on the CPU and MuJoCo-Warp backends.""" + mujoco = SolverMuJoCo.import_mujoco()[0] + parity_mjcf = MJCF_DCMOTOR_ACTUATOR.replace( + 'controller="5 1 0.2 10 2 3"', + 'controller="5 1 0.2 0 2 3"', + ) + + for use_mujoco_cpu in (False, True): + with self.subTest(use_mujoco_cpu=use_mujoco_cpu): + builder = ModelBuilder() + builder.add_mjcf(parity_mjcf) + model = builder.finalize() + solver = SolverMuJoCo( + model, + iterations=1, + disable_contacts=True, + use_mujoco_cpu=use_mujoco_cpu, + ) + state_0 = model.state() + state_1 = model.state() + control = model.control() + control.mujoco.ctrl.assign([0.25]) + solver.mj_model.opt.timestep = 0.001 + reference_data = mujoco.MjData(solver.mj_model) + reference_data.ctrl[0] = 0.25 + + solver.step(state_0, state_1, control, None, dt=0.001) + state_0, state_1 = state_1, state_0 + mujoco.mj_step(solver.mj_model, reference_data) + + if use_mujoco_cpu: + actual_act = solver.mj_data.act + actual_actuator_force = solver.mj_data.actuator_force + actual_qfrc_actuator = solver.mj_data.qfrc_actuator + else: + actual_act = solver.mjw_data.act.numpy()[0] + actual_actuator_force = solver.mjw_data.actuator_force.numpy()[0] + actual_qfrc_actuator = solver.mjw_data.qfrc_actuator.numpy()[0] + + np.testing.assert_allclose(actual_act, reference_data.act, rtol=1.0e-4, atol=1.0e-7) + np.testing.assert_allclose( + actual_actuator_force, + reference_data.actuator_force, + rtol=1.0e-4, + atol=1.0e-7, + ) + np.testing.assert_allclose( + actual_qfrc_actuator, + reference_data.qfrc_actuator, + rtol=1.0e-4, + atol=1.0e-7, + ) + + solver.reset(state_0) + reset_act = solver.mj_data.act if use_mujoco_cpu else solver.mjw_data.act.numpy()[0] + np.testing.assert_array_equal(reset_act, np.zeros(solver.mj_model.na)) + + def test_mjcf_dcmotor_multiworld_control_and_reset(self): + """Keep direct-control ordering and activation resets independent across worlds.""" + robot_builder = ModelBuilder() + robot_builder.add_mjcf(MJCF_DCMOTOR_ACTUATOR) + builder = ModelBuilder() + builder.add_world(robot_builder) + builder.add_world(robot_builder) + model = builder.finalize() + + np.testing.assert_array_equal(model.mujoco.actuator_world.numpy(), [0, 1]) + np.testing.assert_array_equal( + model.mujoco.ctrl_source.numpy(), + [SolverMuJoCo.CtrlSource.CTRL_DIRECT, SolverMuJoCo.CtrlSource.CTRL_DIRECT], + ) + + solver = SolverMuJoCo(model, iterations=1, disable_contacts=True, separate_worlds=True) + state_0 = model.state() + state_1 = model.state() + control = model.control() + control.mujoco.ctrl.assign([0.25, -0.15]) + solver.step(state_0, state_1, control, None, dt=0.001) + + np.testing.assert_allclose(solver.mjw_data.ctrl.numpy(), [[0.25], [-0.15]]) + activation_before_reset = solver.mjw_data.act.numpy().copy() + self.assertGreater(float(np.max(np.abs(activation_before_reset))), 0.0) + self.assertFalse(np.allclose(activation_before_reset[0], activation_before_reset[1])) + + world_mask = wp.array([True, False, False], dtype=wp.bool, device=model.device) + solver.reset(state_1, world_mask=world_mask) + activation_after_reset = solver.mjw_data.act.numpy() + np.testing.assert_array_equal(activation_after_reset[0], np.zeros(solver.mj_model.na)) + np.testing.assert_array_equal(activation_after_reset[1], activation_before_reset[1]) + + def test_mjcf_dcmotor_multiworld_rejects_parameter_mismatch(self): + """Reject high-level parameters that cannot vary across replicated MuJoCo worlds.""" + first_world = ModelBuilder() + first_world.add_mjcf(MJCF_DCMOTOR_ACTUATOR) + second_world = ModelBuilder() + second_world.add_mjcf(MJCF_DCMOTOR_ACTUATOR.replace('resistance="2"', 'resistance="3"')) + builder = ModelBuilder() + builder.add_world(first_world) + builder.add_world(second_world) + model = builder.finalize() + + with self.assertRaisesRegex(ValueError, "identical high-level DC-motor parameters"): + SolverMuJoCo(model, iterations=1, disable_contacts=True, separate_worlds=True) + + def test_mjcf_dcmotor_runtime_updates_noncompiled_properties(self): + """Update independent DC-motor properties without replacing compiled parameters.""" + for use_mujoco_cpu in (False, True): + with self.subTest(use_mujoco_cpu=use_mujoco_cpu): + builder = ModelBuilder() + builder.add_mjcf(MJCF_DCMOTOR_ACTUATOR) + model = builder.finalize() + solver = SolverMuJoCo( + model, + iterations=1, + disable_contacts=True, + use_mujoco_cpu=use_mujoco_cpu, + ) + + if use_mujoco_cpu: + compiled = { + name: getattr(solver.mj_model, name).copy() + for name in ("actuator_gainprm", "actuator_biasprm", "actuator_dynprm", "actuator_forcerange") + } + else: + compiled = { + name: getattr(solver.mjw_model, name).numpy().copy() + for name in ("actuator_gainprm", "actuator_biasprm", "actuator_dynprm", "actuator_forcerange") + } + model.mujoco.actuator_ctrlrange.assign([[-0.5, 0.5]]) + model.mujoco.actuator_actrange.assign([[-0.25, 0.25]]) + model.mujoco.actuator_gear.assign([[4.0, 0.0, 0.0, 0.0, 0.0, 0.0]]) + model.mujoco.actuator_cranklength.assign([0.3]) + + solver.notify_model_changed(ModelFlags.ACTUATOR_PROPERTIES) + + attribute_names = ( + "actuator_ctrlrange", + "actuator_actrange", + "actuator_gear", + "actuator_cranklength", + *compiled, + ) + if use_mujoco_cpu: + actual_values = {name: getattr(solver.mj_model, name) for name in attribute_names} + else: + actual_values = {name: getattr(solver.mjw_model, name).numpy()[0] for name in attribute_names} + np.testing.assert_allclose(actual_values["actuator_ctrlrange"][0], [-0.5, 0.5]) + np.testing.assert_allclose(actual_values["actuator_actrange"][0], [-0.25, 0.25]) + np.testing.assert_allclose( + actual_values["actuator_gear"][0], + [4.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ) + np.testing.assert_allclose(actual_values["actuator_cranklength"][0], 0.3) + for name, expected in compiled.items(): + np.testing.assert_allclose( + actual_values[name], + expected[0] if not use_mujoco_cpu else expected, + ) + def test_mjcf_without_dcmotor_omits_high_level_parameters(self): """Avoid allocating high-level DC-motor parameters for ordinary actuators.""" builder = ModelBuilder() builder.add_mjcf(MJCF_ACTUATORS) - self.assertNotIn("mujoco:actuator_dcmotor_motorconst", builder.custom_attributes) + dcmotor_parameter_names = ( + "actuator_dcmotor_motorconst", + "actuator_dcmotor_resistance", + "actuator_dcmotor_nominal", + "actuator_dcmotor_saturation", + "actuator_dcmotor_inductance", + "actuator_dcmotor_cogging", + "actuator_dcmotor_controller", + "actuator_dcmotor_thermal", + "actuator_dcmotor_lugre", + "actuator_dcmotor_input", + ) + for name in dcmotor_parameter_names: + self.assertNotIn(f"mujoco:{name}", builder.custom_attributes) model = builder.finalize() - self.assertFalse(hasattr(model.mujoco, "actuator_dcmotor_motorconst")) + for name in dcmotor_parameter_names: + self.assertFalse(hasattr(model.mujoco, name)) def test_parsing_ctrl_direct_false(self): """Test parsing with ctrl_direct=False.""" diff --git a/newton/tests/test_mujoco_solver.py b/newton/tests/test_mujoco_solver.py index 1a7b1bb4c9..79c4bc66da 100644 --- a/newton/tests/test_mujoco_solver.py +++ b/newton/tests/test_mujoco_solver.py @@ -13019,12 +13019,24 @@ def test_types_match_native(self): def test_unsupported_type_warns(self): """Warn on an unsupported gaintype, naming the attribute, the value and the MJCF actuator.""" - for gaintype in ("dcmotor", "so3", "pid", "bogus"): + for gaintype in ("pid", "bogus"): with self.subTest(gaintype=gaintype): builder = newton.ModelBuilder() with self.assertWarnsRegex(RuntimeWarning, rf"gaintype '{gaintype}' on actuator 'a'"): builder.add_mjcf(self._mjcf(gaintype=gaintype)) + def test_supported_dcmotor_and_so3_types_parse(self): + """Parse supported DC-motor and SO3 type names without warnings.""" + builder = newton.ModelBuilder() + builder.add_mjcf(self._mjcf(dyntype="dcmotor", gaintype="dcmotor", biastype="dcmotor")) + builder.add_mjcf(self._mjcf(gaintype="so3", biastype="so3")) + + self.assertEqual(builder.custom_attributes["mujoco:actuator_dyntype"].values[0], _ActuatorDynamicsType.DCMOTOR) + self.assertEqual(builder.custom_attributes["mujoco:actuator_gaintype"].values[0], _ActuatorGainType.DCMOTOR) + self.assertEqual(builder.custom_attributes["mujoco:actuator_biastype"].values[0], _ActuatorBiasType.DCMOTOR) + self.assertEqual(builder.custom_attributes["mujoco:actuator_gaintype"].values[1], _ActuatorGainType.SO3) + self.assertEqual(builder.custom_attributes["mujoco:actuator_biastype"].values[1], _ActuatorBiasType.SO3) + if __name__ == "__main__": unittest.main(verbosity=2)