From 27d22994013ca3229222dbe4b4beeab01ff2b00a Mon Sep 17 00:00:00 2001 From: lemon-star608 Date: Mon, 24 Aug 2026 12:11:52 +0800 Subject: [PATCH 01/12] feat(assets): add T800 MuJoCo model contract --- .gitignore | 4 + .../assets/robots/t800/LICENSE.engineai.txt | 29 ++ src/unilab/assets/robots/t800/assets/.gitkeep | 1 + src/unilab/assets/robots/t800/scene_flat.xml | 68 +++ src/unilab/assets/robots/t800/t800.xml | 398 ++++++++++++++++++ .../assets/robots/t800/textures/.gitkeep | 1 + .../t800/test_t800_asset_contract.py | 374 ++++++++++++++++ 7 files changed, 875 insertions(+) create mode 100644 src/unilab/assets/robots/t800/LICENSE.engineai.txt create mode 100644 src/unilab/assets/robots/t800/assets/.gitkeep create mode 100644 src/unilab/assets/robots/t800/scene_flat.xml create mode 100644 src/unilab/assets/robots/t800/t800.xml create mode 100644 src/unilab/assets/robots/t800/textures/.gitkeep create mode 100644 tests/envs/locomotion/t800/test_t800_asset_contract.py diff --git a/.gitignore b/.gitignore index 0e0c0777f..fd84b48df 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,10 @@ src/unilab/assets/motions/x2/*.csv # Robot mesh assets (downloaded from HF at runtime) src/unilab/assets/robots/x2/meshes/*.STL +src/unilab/assets/robots/t800/assets/* +!src/unilab/assets/robots/t800/assets/.gitkeep +src/unilab/assets/robots/t800/textures/* +!src/unilab/assets/robots/t800/textures/.gitkeep # Grasp cache assets (downloaded from HF at runtime) src/unilab/assets/caches/*.npy diff --git a/src/unilab/assets/robots/t800/LICENSE.engineai.txt b/src/unilab/assets/robots/t800/LICENSE.engineai.txt new file mode 100644 index 000000000..bda48c827 --- /dev/null +++ b/src/unilab/assets/robots/t800/LICENSE.engineai.txt @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2025- Shenzhen Zhongqing Robot Technology Co., Ltd. ("EngineAI") +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/src/unilab/assets/robots/t800/assets/.gitkeep b/src/unilab/assets/robots/t800/assets/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/unilab/assets/robots/t800/assets/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/unilab/assets/robots/t800/scene_flat.xml b/src/unilab/assets/robots/t800/scene_flat.xml new file mode 100644 index 000000000..a9b65a910 --- /dev/null +++ b/src/unilab/assets/robots/t800/scene_flat.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/unilab/assets/robots/t800/t800.xml b/src/unilab/assets/robots/t800/t800.xml new file mode 100644 index 000000000..a01164189 --- /dev/null +++ b/src/unilab/assets/robots/t800/t800.xml @@ -0,0 +1,398 @@ + + + diff --git a/src/unilab/assets/robots/t800/textures/.gitkeep b/src/unilab/assets/robots/t800/textures/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/unilab/assets/robots/t800/textures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/envs/locomotion/t800/test_t800_asset_contract.py b/tests/envs/locomotion/t800/test_t800_asset_contract.py new file mode 100644 index 000000000..a13c42654 --- /dev/null +++ b/tests/envs/locomotion/t800/test_t800_asset_contract.py @@ -0,0 +1,374 @@ +"""Asset-level contracts for the EngineAI T800 walk-flat model.""" + +from __future__ import annotations + +import hashlib +import xml.etree.ElementTree as ET +from pathlib import Path + +import numpy as np +import pytest + +from unilab.assets import ASSETS_ROOT_PATH +from unilab.assets.hub import resolve_robot_asset_dir +from unilab.base.backend.mujoco.xml import create_discardvisual_xml + +ROBOT_XML = ASSETS_ROOT_PATH / "robots" / "t800" / "t800.xml" +SCENE_XML = ASSETS_ROOT_PATH / "robots" / "t800" / "scene_flat.xml" +G1_ROBOT_XML = ASSETS_ROOT_PATH / "robots" / "g1" / "g1.xml" +G1_SCENE_XML = ASSETS_ROOT_PATH / "robots" / "g1" / "scene_flat.xml" +TEXTURES_DIR = ROBOT_XML.parent / "textures" +ENGINEAI_LICENSE = ROBOT_XML.parent / "LICENSE.engineai.txt" + +EXPECTED_TEXTURES = { + "LINK_BASE": ( + "LINK_BASE.png", + "a298400671d0868d8a404801e280d2f9bd01b33ec76660354192baba7a9b12e0", + ), + "LINK_HIP_PITCH": ( + "LINK_HIP_PITCH_L.png", + "192c9753ad475eb1cd631c2de474e3f644dd2f1189a40ed25759299321265b08", + ), + "LINK_HIP_ROLL": ( + "LINK_HIP_ROLL_L.png", + "f123c3a3fed080c94c0296618e8a6712f7da7234a9a8611be3e08a7972fb58ab", + ), + "LINK_HIP_YAW": ( + "LINK_HIP_YAW_L.png", + "97b5f5c52a05f8315ed07c531f5a8834e92fbae0f89cd21853428ae858eacc9a", + ), + "LINK_KNEE_PITCH": ( + "LINK_KNEE_PITCH_L.png", + "ce66f28fc5dac742c73e9b4dfe735e20d2b89641afb4d6e033292a7e41a2de85", + ), + "LINK_ANKLE_ROLL": ( + "LINK_ANKLE_ROLL_L.png", + "c69f48f543389e72954d8ede6d93a9d4e96441576f3e35639454138d70b476b2", + ), + "LINK_TORSO_YAW": ( + "LINK_TORSO_YAW.png", + "b7f88ec8e9276ac9c62e19d52037ee30cbc4eefee44e36ca46974711cd8430f4", + ), + "LINK_SHOULDER_PITCH": ( + "LINK_SHOULDER_PITCH_L.png", + "6bb410836ee6431783ee0f69429f9853c14e884f069b6466b9d4b8bda0a95fea", + ), + "LINK_SHOULDER_ROLL": ( + "LINK_SHOULDER_ROLL_L.png", + "fb76b91c8155478cf6d92088d89914a6eb1b907f78a1dcf18128fffee8618311", + ), + "LINK_SHOULDER_YAW": ( + "LINK_SHOULDER_YAW_L.png", + "e7778698452bedb342bbaf198e5c782e17f1d2a9e10f94e2721b8a01fb815737", + ), + "LINK_ELBOW_PITCH_L": ( + "LINK_ELBOW_PITCH_L.png", + "cb9c31e56644890ba5e3c356bcc1ad266a3cf459104a82505aedd62efb66f260", + ), + "LINK_ELBOW_PITCH_R": ( + "LINK_ELBOW_PITCH_R.png", + "8aa0572b3ef8e819f69937d4be0536e0854dc6ef39bd821668dea9b73b0f270c", + ), + "LINK_ELBOW_YAW_L": ( + "LINK_ELBOW_YAW_L.png", + "672b2be4bcdce1863424685a1f681f0d7b23d143fd910311d833268e1246c05c", + ), + "LINK_ELBOW_YAW_R": ( + "LINK_ELBOW_YAW_R.png", + "6283738a1be141cdb676ad3b93cfc2145bcf1f4c7ae39ce8f886fcdcaa3beee6", + ), + "LINK_HEAD_YAW": ( + "LINK_HEAD_YAW.png", + "65fc0f09e4bd0bfc5c910e2e7267ff598a1cf13f85845b56d51055150d53765f", + ), +} + +JOINT_NAMES = ( + "J00_HIP_PITCH_L", + "J01_HIP_ROLL_L", + "J02_HIP_YAW_L", + "J03_KNEE_PITCH_L", + "J04_ANKLE_PITCH_L", + "J05_ANKLE_ROLL_L", + "J06_HIP_PITCH_R", + "J07_HIP_ROLL_R", + "J08_HIP_YAW_R", + "J09_KNEE_PITCH_R", + "J10_ANKLE_PITCH_R", + "J11_ANKLE_ROLL_R", + "J12_TORSO_YAW", + "J13_SHOULDER_PITCH_L", + "J14_SHOULDER_ROLL_L", + "J15_SHOULDER_YAW_L", + "J16_ELBOW_PITCH_L", + "J17_ELBOW_YAW_L", + "J18_SHOULDER_PITCH_R", + "J19_SHOULDER_ROLL_R", + "J20_SHOULDER_YAW_R", + "J21_ELBOW_PITCH_R", + "J22_ELBOW_YAW_R", + "J23_HEAD_PITCH", + "J24_HEAD_YAW", +) +EXPECTED_KP = np.asarray( + [ + 180, + 100, + 100, + 180, + 40, + 40, + 180, + 100, + 100, + 180, + 40, + 40, + 100, + 60, + 50, + 50, + 60, + 50, + 60, + 50, + 50, + 60, + 50, + 100, + 100, + ], + dtype=np.float64, +) +EXPECTED_KD = np.asarray( + [ + 5, + 3, + 3, + 5, + 0.3, + 0.3, + 5, + 3, + 3, + 5, + 0.3, + 0.3, + 5, + 0.3, + 0.3, + 0.3, + 0.3, + 0.3, + 0.3, + 0.3, + 0.3, + 0.3, + 0.3, + 1, + 1, + ], + dtype=np.float64, +) +EXPECTED_FORCE_LIMITS = np.asarray( + [ + 415, + 370, + 222, + 415, + 160, + 160, + 415, + 370, + 222, + 415, + 160, + 160, + 222, + 160, + 160, + 160, + 160, + 52, + 160, + 160, + 160, + 160, + 52, + 52, + 52, + ], + dtype=np.float64, +) + + +@pytest.fixture(scope="module", autouse=True) +def _resolve_t800_binary_assets() -> None: + resolve_robot_asset_dir("robots/t800/assets", marker="LINK_BASE.obj") + resolve_robot_asset_dir("robots/t800/textures", marker="LINK_BASE.png") + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _required_attrib(root: ET.Element, xpath: str) -> dict[str, str]: + element = root.find(xpath) + assert element is not None + return element.attrib + + +def test_t800_robot_materials_bind_original_textures(): + mujoco = pytest.importorskip("mujoco") + root = ET.parse(ROBOT_XML).getroot() + expected_files = { + material_name: f"textures/{file_name}" + for material_name, (file_name, _) in EXPECTED_TEXTURES.items() + } + material_textures = { + element.get("name"): element.get("texture") + for element in root.findall("./asset/material") + if element.get("name") in EXPECTED_TEXTURES + } + texture_files = { + element.get("name"): element.get("file") + for element in root.findall("./asset/texture") + if element.get("name") in EXPECTED_TEXTURES + } + + assert material_textures == {name: name for name in EXPECTED_TEXTURES} + assert texture_files == expected_files + assert {path.name for path in TEXTURES_DIR.glob("*.png")} == { + file_name for file_name, _ in EXPECTED_TEXTURES.values() + } + for file_name, expected_digest in EXPECTED_TEXTURES.values(): + assert _sha256(TEXTURES_DIR / file_name) == expected_digest + + model = mujoco.MjModel.from_xml_path(str(ROBOT_XML)) + rgb_role = int(mujoco.mjtTextureRole.mjTEXROLE_RGB) + for material_name in EXPECTED_TEXTURES: + material_id = mujoco.mj_name2id( + model, + mujoco.mjtObj.mjOBJ_MATERIAL, + material_name, + ) + assert material_id >= 0 + assert model.mat_texid[material_id, rgb_role] >= 0 + + +def test_t800_vendored_assets_include_engineai_license(): + assert ENGINEAI_LICENSE.is_file() + assert _sha256(ENGINEAI_LICENSE) == ( + "a3e5f08bf7ae0983cc7a9f602e9a555f5956a8dade26cf4cf9cc95c441fe0b6c" + ) + + +def test_t800_flat_scene_matches_g1_ground_visual_contract(): + t800_scene = ET.parse(SCENE_XML).getroot() + g1_scene = ET.parse(G1_SCENE_XML).getroot() + + for xpath in ( + "./visual/headlight", + "./visual/rgba", + "./visual/global", + "./asset/texture[@type='skybox']", + "./asset/texture[@name='groundplane']", + "./asset/material[@name='groundplane']", + "./worldbody/geom[@name='floor']", + ): + assert _required_attrib(t800_scene, xpath) == _required_attrib(g1_scene, xpath) + + g1_robot = ET.parse(G1_ROBOT_XML).getroot() + expected_light = _required_attrib(g1_robot, "./worldbody/light") + t800_lights = t800_scene.findall("./worldbody/light") + assert [light.attrib for light in t800_lights] == [expected_light] + assert ET.parse(ROBOT_XML).getroot().find("./worldbody/light") is None + + +def test_t800_robot_xml_is_robot_only_and_uses_25_position_actuators(): + mujoco = pytest.importorskip("mujoco") + root = ET.parse(ROBOT_XML).getroot() + assert root.find(".//keyframe") is None + + model = mujoco.MjModel.from_xml_path(str(ROBOT_XML)) + assert (model.nq, model.nv, model.nu) == (32, 31, 25) + names = tuple( + mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_ACTUATOR, actuator_id) + for actuator_id in range(model.nu) + ) + assert names == JOINT_NAMES + + affine = int(mujoco.mjtBias.mjBIAS_AFFINE) + assert np.all(model.actuator_biastype == affine) + np.testing.assert_allclose(model.actuator_gainprm[:, 0], EXPECTED_KP) + np.testing.assert_allclose(model.actuator_biasprm[:, 2], -EXPECTED_KD) + np.testing.assert_allclose(model.actuator_forcerange[:, 0], -EXPECTED_FORCE_LIMITS) + np.testing.assert_allclose(model.actuator_forcerange[:, 1], EXPECTED_FORCE_LIMITS) + np.testing.assert_allclose(model.actuator_ctrlrange, model.jnt_range[1:]) + + +def test_t800_flat_scene_has_walk_sensors_and_stand_keyframe(): + mujoco = pytest.importorskip("mujoco") + model = mujoco.MjModel.from_xml_path(str(SCENE_XML)) + + sensor_names = { + mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_SENSOR, sensor_id) + for sensor_id in range(model.nsensor) + } + required_sensors = { + "pelvis_local_linvel", + "torso_gyro", + "torso_upvector", + "left_foot_pos", + "left_foot_quat", + "right_foot_pos", + "right_foot_quat", + *(f"left_foot_contact_{index}" for index in range(4)), + *(f"right_foot_contact_{index}" for index in range(4)), + } + assert required_sensors <= sensor_names + + key_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_KEY, "stand") + assert key_id >= 0 + assert model.key_qpos[key_id].shape == (32,) + assert model.key_ctrl[key_id].shape == (25,) + + +def test_t800_training_compile_discards_visual_meshes(): + mujoco = pytest.importorskip("mujoco") + training_xml = Path(create_discardvisual_xml(str(SCENE_XML))) + try: + model = mujoco.MjModel.from_xml_path(str(training_xml)) + finally: + training_xml.unlink(missing_ok=True) + + assert model.nmesh == 0 + assert model.ntex == 0 + assert np.all(model.geom_type != int(mujoco.mjtGeom.mjGEOM_MESH)) + + +def test_t800_stand_foot_sites_track_the_collision_sole_height(): + mujoco = pytest.importorskip("mujoco") + model = mujoco.MjModel.from_xml_path(str(SCENE_XML)) + data = mujoco.MjData(model) + key_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_KEY, "stand") + mujoco.mj_resetDataKeyframe(model, data, key_id) + mujoco.mj_forward(model, data) + + for side in ("left", "right"): + site_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, f"{side}_foot") + sole_heights = [] + for index in range(4): + geom_id = mujoco.mj_name2id( + model, + mujoco.mjtObj.mjOBJ_GEOM, + f"{side}_foot_contact_{index}_geom", + ) + rotation = data.geom_xmat[geom_id].reshape(3, 3) + vertical_extent = np.abs(rotation[2]) @ model.geom_size[geom_id] + sole_heights.append(data.geom_xpos[geom_id, 2] - vertical_extent) + + site_height = data.site_xpos[site_id, 2] + lowest_collision = min(sole_heights) + assert site_height >= lowest_collision - 1.0e-3 + assert site_height - lowest_collision < 5.0e-3 From f1c4ad2c27387ff727e7b9e49856e964ffcfe3d1 Mon Sep 17 00:00:00 2001 From: lemon-star608 Date: Mon, 24 Aug 2026 12:14:10 +0800 Subject: [PATCH 02/12] feat(assets): add T800 binary asset resolver --- src/unilab/assets/pull.py | 31 +++++++++++--------- tests/test_pull_assets.py | 60 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 13 deletions(-) create mode 100644 tests/test_pull_assets.py diff --git a/src/unilab/assets/pull.py b/src/unilab/assets/pull.py index 00ad52d97..23985bd9c 100644 --- a/src/unilab/assets/pull.py +++ b/src/unilab/assets/pull.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 -"""Pre-fetch robot binary assets (meshes) from Hugging Face into their project paths. +"""Pre-fetch robot binary assets from Hugging Face into their project paths. -Robot STL meshes are hosted on Hugging Face rather than committed to git. They are -also downloaded automatically on first use, but this command lets you pull them -ahead of time (e.g. for CI or offline prep) with a single invocation. Files land -under ``src/unilab/assets/robots//meshes/`` — no manual file moving needed. +Robot meshes and textures are hosted on Hugging Face rather than committed to git. +They are also downloaded automatically on first use, but this command lets you pull +them ahead of time (e.g. for CI or offline prep) with a single invocation. Files land +under ``src/unilab/assets/robots//`` — no manual file moving needed. Usage: uv run unilab-pull-assets # pull the default robot (x2) uv run unilab-pull-assets --robot x2 + uv run unilab-pull-assets --robot t800 """ from __future__ import annotations @@ -19,9 +20,13 @@ from unilab.assets.hub import resolve_robot_asset_dir -# robot name -> (ASSETS_ROOT_PATH-relative dir, completeness marker file) -_ROBOT_ASSETS: dict[str, tuple[str, str]] = { - "x2": ("robots/x2/meshes", "pelvis.STL"), +# robot name -> ((ASSETS_ROOT_PATH-relative dir, marker, glob, label), ...) +_ROBOT_ASSETS: dict[str, tuple[tuple[str, str, str, str], ...]] = { + "x2": (("robots/x2/meshes", "pelvis.STL", "*.STL", "STL"),), + "t800": ( + ("robots/t800/assets", "LINK_BASE.obj", "*.obj", "OBJ"), + ("robots/t800/textures", "LINK_BASE.png", "*.png", "PNG"), + ), } @@ -31,7 +36,7 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: "--robot", default="x2", choices=sorted(_ROBOT_ASSETS), - help="Robot whose meshes to download (default: x2).", + help="Robot whose binary assets to download (default: x2).", ) return parser.parse_args(argv) @@ -40,10 +45,10 @@ def main(argv: Sequence[str] | None = None) -> int: logging.basicConfig(level=logging.INFO, format="%(message)s") args = _parse_args(argv) - directory, marker = _ROBOT_ASSETS[args.robot] - target = resolve_robot_asset_dir(directory, marker=marker) - count = len(list(target.glob("*.STL"))) - print(f"{args.robot} meshes ready at {target} ({count} STL files)") + for directory, marker, pattern, label in _ROBOT_ASSETS[args.robot]: + target = resolve_robot_asset_dir(directory, marker=marker) + count = len(list(target.glob(pattern))) + print(f"{args.robot} assets ready at {target} ({count} {label} files)") return 0 diff --git a/tests/test_pull_assets.py b/tests/test_pull_assets.py new file mode 100644 index 000000000..5393d0716 --- /dev/null +++ b/tests/test_pull_assets.py @@ -0,0 +1,60 @@ +"""Tests for the robot asset prefetch CLI.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from unilab.assets import pull as pull_assets + + +def _populate(directory: Path, *, suffix: str, count: int) -> Path: + directory.mkdir(parents=True) + for index in range(count): + (directory / f"asset_{index}{suffix}").write_bytes(b"asset") + return directory + + +def test_pull_assets_t800_resolves_both_asset_directories( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +): + targets = { + "robots/t800/assets": _populate(tmp_path / "assets", suffix=".obj", count=26), + "robots/t800/textures": _populate(tmp_path / "textures", suffix=".png", count=15), + } + calls: list[tuple[str, str]] = [] + + def fake_resolver(directory: str, *, marker: str) -> Path: + calls.append((directory, marker)) + return targets[directory] + + monkeypatch.setattr(pull_assets, "resolve_robot_asset_dir", fake_resolver) + + assert pull_assets.main(["--robot", "t800"]) == 0 + assert calls == [ + ("robots/t800/assets", "LINK_BASE.obj"), + ("robots/t800/textures", "LINK_BASE.png"), + ] + output = capsys.readouterr().out + assert "26 OBJ files" in output + assert "15 PNG files" in output + + +def test_pull_assets_x2_keeps_single_mesh_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + target = _populate(tmp_path / "meshes", suffix=".STL", count=2) + calls: list[tuple[str, str]] = [] + + def fake_resolver(directory: str, *, marker: str) -> Path: + calls.append((directory, marker)) + return target + + monkeypatch.setattr(pull_assets, "resolve_robot_asset_dir", fake_resolver) + + assert pull_assets.main(["--robot", "x2"]) == 0 + assert calls == [("robots/x2/meshes", "pelvis.STL")] From e117aab9de3dabe45a67b405e9b596344d522806 Mon Sep 17 00:00:00 2001 From: lemon-star608 Date: Mon, 24 Aug 2026 12:25:05 +0800 Subject: [PATCH 03/12] feat(t800): add Manager-Based action and reward terms --- .../tasks/locomotion/t800/manager_terms.py | 169 ++++++++++ .../t800/test_t800_manager_terms.py | 305 ++++++++++++++++++ 2 files changed, 474 insertions(+) create mode 100644 src/unilab/tasks/locomotion/t800/manager_terms.py create mode 100644 tests/envs/locomotion/t800/test_t800_manager_terms.py diff --git a/src/unilab/tasks/locomotion/t800/manager_terms.py b/src/unilab/tasks/locomotion/t800/manager_terms.py new file mode 100644 index 000000000..a3d9f8492 --- /dev/null +++ b/src/unilab/tasks/locomotion/t800/manager_terms.py @@ -0,0 +1,169 @@ +"""T800-specific terms for the Manager-Based locomotion environment.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from numbers import Real +from typing import TYPE_CHECKING, Any + +import numpy as np + +from unilab.envs.mdp import JointPositionAction, JointPositionActionCfg +from unilab.managers.manager_base import ManagerTermBaseCfg +from unilab.tasks.locomotion.common.sensor_terms import SensorTermBase +from unilab.utils.rotation import np_quat_apply_inverse, np_yaw_quat + +if TYPE_CHECKING: + from collections.abc import Sequence + + from unilab.managers._types import ManagerBasedRlEnv + + +_LATERAL_FOOT_SENSORS = ( + "left_foot_pos", + "right_foot_pos", + "base_link_quaternion", +) + + +def _finite_real(value: Any, *, label: str, strict_positive: bool = False) -> float: + if isinstance(value, (bool, np.bool_)) or not isinstance(value, Real): + raise TypeError(f"{label} must be a real number") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{label} must be finite") + if result < 0.0 or (strict_positive and result == 0.0): + relation = "greater than zero" if strict_positive else "non-negative" + raise ValueError(f"{label} must be {relation}") + return result + + +def compute_lateral_feet_penalty( + left_foot: np.ndarray, + right_foot: np.ndarray, + base_quat: np.ndarray, + min_width: float = 0.20, + sigma: float = 0.04, +) -> np.ndarray: + """Penalize signed foot separation below a minimum heading-frame width.""" + separation_w = left_foot - right_foot + separation_heading = np_quat_apply_inverse(np_yaw_quat(base_quat), separation_w) + signed_width = separation_heading[:, 1] + deficit = np.maximum(min_width - signed_width, 0.0) + return 1.0 - np.exp(-np.square(deficit / sigma)) + + +def _validate_unique_selectors(label: str, selectors: Sequence[str]) -> None: + if len(selectors) != len(set(selectors)): + raise ValueError(f"T800JointPositionAction {label} selectors contain duplicates") + + +@dataclass(kw_only=True) +class T800JointPositionActionCfg(JointPositionActionCfg): + """Control selected joints while holding the remaining T800 joints.""" + + held_actuator_names: tuple[str, ...] | list[str] + + def build(self, env: ManagerBasedRlEnv) -> "T800JointPositionAction": + return T800JointPositionAction(self, env) + + +class T800JointPositionAction(JointPositionAction): + """Joint-position action with cold-path-resolved non-policy hold targets.""" + + def __init__(self, cfg: T800JointPositionActionCfg, env: ManagerBasedRlEnv): + _validate_unique_selectors("active", cfg.actuator_names) + _validate_unique_selectors("held", cfg.held_actuator_names) + + super().__init__(cfg, env) + active_ids, _ = self._entity.find_joints_by_actuator_names(cfg.actuator_names) + held_ids, _ = self._entity.find_joints_by_actuator_names(cfg.held_actuator_names) + if len(set(active_ids)) != len(active_ids) or len(set(held_ids)) != len(held_ids): + raise ValueError("T800JointPositionAction resolved joint IDs must be unique") + if set(active_ids) & set(held_ids): + raise ValueError("T800JointPositionAction active and held joints overlap") + if set(active_ids) | set(held_ids) != set(range(self._entity.num_joints)): + raise ValueError( + "T800JointPositionAction active and held joints must form a complete partition" + ) + + self._held_ids = np.asarray(held_ids, dtype=np.intp) + self._held_ids.setflags(write=False) + held_shape = (self.num_envs, self._held_ids.size) + self._held_default_targets = np.empty( + held_shape, dtype=self._entity.data.default_joint_pos.dtype + ) + self._held_encoder_bias = np.empty(held_shape, dtype=self._entity.data.encoder_bias.dtype) + + def apply_actions(self) -> None: + super().apply_actions() + np.take( + self._entity.data.default_joint_pos, + self._held_ids, + axis=1, + out=self._held_default_targets, + ) + np.take( + self._entity.data.encoder_bias, + self._held_ids, + axis=1, + out=self._held_encoder_bias, + ) + np.subtract( + self._held_default_targets, + self._held_encoder_bias, + out=self._held_default_targets, + ) + self._entity.set_joint_position_target( + self._held_default_targets, + joint_ids=self._held_ids, + ) + + +class penalty_close_feet_lateral(SensorTermBase): + """Penalty for insufficient signed lateral foot separation.""" + + _allowed_params = frozenset({"min_width", "sigma"}) + + def __init__(self, cfg: ManagerTermBaseCfg, env: ManagerBasedRlEnv): + super().__init__(cfg, env) + self._min_width = _finite_real( + cfg.params.get("min_width", 0.20), label=f"{self.name} min_width" + ) + self._sigma = _finite_real( + cfg.params.get("sigma", 0.04), + label=f"{self.name} sigma", + strict_positive=True, + ) + self._sensor_view = self._bind(_LATERAL_FOOT_SENSORS) + if self._sensor_view.dimensions != (3, 3, 4): + raise ValueError( + f"{self.name} sensor dimensions must be (3, 3, 4); received " + f"{self._sensor_view.dimensions} on backend " + f"'{self._sensor_view.backend_type}'" + ) + + def __call__(self, env: ManagerBasedRlEnv, **params: Any) -> np.ndarray: + del params + values = self._read(self._sensor_view, self.name) + expected_shape = (env.num_envs, 10) + if values.shape != expected_shape: + raise ValueError( + f"{self.name} sensor view must have shape {expected_shape}, got {values.shape}" + ) + return compute_lateral_feet_penalty( + values[:, 0:3], + values[:, 3:6], + values[:, 6:10], + min_width=self._min_width, + sigma=self._sigma, + ) + + +__all__ = [ + "T800JointPositionAction", + "T800JointPositionActionCfg", + "compute_lateral_feet_penalty", + "penalty_close_feet_lateral", +] diff --git a/tests/envs/locomotion/t800/test_t800_manager_terms.py b/tests/envs/locomotion/t800/test_t800_manager_terms.py new file mode 100644 index 000000000..65dd1fe52 --- /dev/null +++ b/tests/envs/locomotion/t800/test_t800_manager_terms.py @@ -0,0 +1,305 @@ +"""Unit contracts for T800-specific Manager-Based terms.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import numpy as np +import pytest + +from unilab.base.backend.base import SimBackend +from unilab.base.entity import EntityCfg, EntityScene +from unilab.managers import RewardTermCfg +from unilab.managers._types import ManagerBasedRlEnv +from unilab.tasks.locomotion.t800.manager_terms import ( + T800JointPositionActionCfg, + compute_lateral_feet_penalty, + penalty_close_feet_lateral, +) + + +class _ActionBackend: + backend_type = "fake" + num_envs = 2 + num_actuators = 3 + + def __init__(self) -> None: + self.actuator_names = ("knee_motor", "hip_motor", "head_motor") + self.target_joint_names = ("knee", "hip", "head") + self.joint_index = {"hip": 0, "knee": 1, "head": 2} + self.dof_pos = np.zeros((self.num_envs, 3), dtype=np.float32) + + def get_actuator_names(self) -> tuple[str, ...]: + return self.actuator_names + + def get_actuator_joint_names(self) -> tuple[str, ...]: + return self.target_joint_names + + def get_actuator_ctrl_range(self) -> np.ndarray: + return np.tile(np.asarray([[-10.0, 10.0]], dtype=np.float32), (3, 1)) + + def get_joint_dof_pos_indices(self, names: list[str]) -> np.ndarray: + return np.asarray([self.joint_index[name] for name in names], dtype=np.int32) + + def get_joint_dof_vel_indices(self, names: list[str]) -> np.ndarray: + return self.get_joint_dof_pos_indices(names) + + def get_dof_pos(self) -> np.ndarray: + return self.dof_pos + + def get_dof_vel(self) -> np.ndarray: + return np.zeros_like(self.dof_pos) + + def get_default_dof_pos(self) -> np.ndarray: + return np.asarray([0.1, 0.2, 0.3], dtype=np.float32) + + def get_joint_range(self) -> np.ndarray: + return np.tile(np.asarray([[-1.0, 1.0]], dtype=np.float32), (3, 1)) + + +def _action( + *, + active: tuple[str, ...] = ("hip", "knee"), + held: tuple[str, ...] = ("head",), + clip: dict[str, tuple[float, float]] | None = None, +) -> tuple[Any, np.ndarray, EntityScene]: + backend = _ActionBackend() + control = np.zeros((backend.num_envs, backend.num_actuators), dtype=np.float32) + scene = EntityScene( + { + "robot": EntityCfg( + joint_names=("hip", "knee", "head"), + actuator_names=backend.actuator_names, + ) + }, + cast(SimBackend, backend), + control, + ) + env = cast( + ManagerBasedRlEnv, + SimpleNamespace(num_envs=backend.num_envs, scene=scene), + ) + scale = {name: {"hip": 2.0, "knee": 3.0}.get(name, 1.0) for name in active} + cfg = T800JointPositionActionCfg( + entity_name="robot", + actuator_names=active, + held_actuator_names=held, + scale=scale, + use_default_offset=True, + clip=clip, + ) + return cfg.build(env), control, scene + + +def test_t800_action_writes_active_targets_and_holds_non_policy_defaults() -> None: + action, control, scene = _action() + scene["robot"].data.encoder_bias[:] = np.asarray([[0.01, 0.02, 0.03]]) + raw = np.asarray([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + + action.process_actions(raw) + action.apply_actions() + + assert action.action_dim == 2 + assert action.target_names == ["hip", "knee"] + np.testing.assert_allclose(action.processed_action, raw * [2.0, 3.0] + [0.1, 0.2]) + np.testing.assert_allclose(control[:, 1], action.processed_action[:, 0] - 0.01) + np.testing.assert_allclose(control[:, 0], action.processed_action[:, 1] - 0.02) + np.testing.assert_allclose(control[:, 2], 0.3 - 0.03) + + +def test_t800_action_clips_active_targets_before_mapping_and_keeps_held_target() -> None: + action, control, scene = _action(clip={"hip": (-1.0, 1.0), "knee": (-2.0, 2.0)}) + scene["robot"].data.encoder_bias[:] = np.asarray( + [[0.01, 0.02, 0.03], [0.04, 0.05, 0.06]], dtype=np.float32 + ) + raw = np.asarray([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + + action.process_actions(raw) + action.apply_actions() + + np.testing.assert_allclose(action.processed_action, [[1.0, 2.0], [1.0, 2.0]]) + np.testing.assert_allclose(control[:, 1], [1.0 - 0.01, 1.0 - 0.04]) + np.testing.assert_allclose(control[:, 0], [2.0 - 0.02, 2.0 - 0.05]) + np.testing.assert_allclose(control[:, 2], [0.3 - 0.03, 0.3 - 0.06]) + + +@pytest.mark.parametrize( + ("active", "held", "message"), + [ + (("hip", "head"), ("head",), "overlap"), + (("hip",), ("head",), "partition"), + (("hip", "hip"), ("knee", "head"), "duplicate"), + (("hip", "knee"), ("head", "head"), "duplicate"), + (("hip", "knee"), ("missing",), "match"), + ], +) +def test_t800_action_partition_fails_closed( + active: tuple[str, ...], + held: tuple[str, ...], + message: str, +) -> None: + with pytest.raises(ValueError, match=message): + _action(active=active, held=held) + + +class _LateralScene: + def __init__(self, sensor_data: dict[str, np.ndarray]) -> None: + self.sensor_data = sensor_data + self.bound_names: tuple[str, ...] | None = None + + def bind_sensor_data(self, names: tuple[str, ...]): + self.bound_names = tuple(names) + arrays = [self.sensor_data[name] for name in names] + return SimpleNamespace( + dimensions=tuple(array.shape[1] for array in arrays), + backend_type="fake", + read=lambda: np.concatenate(arrays, axis=1), + ) + + +def _lateral_env( + *, + left: np.ndarray | None = None, + right: np.ndarray | None = None, + base_quat: np.ndarray | None = None, +) -> tuple[ManagerBasedRlEnv, _LateralScene]: + left = ( + np.asarray([[0.0, 0.05, 0.0], [0.0, -0.10, 0.0]], dtype=np.float32) + if left is None + else left + ) + right = ( + np.asarray([[0.0, -0.05, 0.0], [0.0, 0.10, 0.0]], dtype=np.float32) + if right is None + else right + ) + base_quat = ( + np.asarray([[1.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0]], dtype=np.float32) + if base_quat is None + else base_quat + ) + scene = _LateralScene( + { + "left_foot_pos": left, + "right_foot_pos": right, + "base_link_quaternion": base_quat, + } + ) + env = cast(ManagerBasedRlEnv, SimpleNamespace(num_envs=left.shape[0], scene=scene)) + return env, scene + + +def _lateral_term(env: ManagerBasedRlEnv, **params: Any) -> penalty_close_feet_lateral: + return penalty_close_feet_lateral( + RewardTermCfg( + func=penalty_close_feet_lateral, + weight=-1.0, + params={"min_width": 0.20, "sigma": 0.04, **params}, + ), + env, + ) + + +def test_lateral_penalty_is_invariant_to_fore_aft_foot_offsets() -> None: + left = np.asarray([[0.0, 0.05, 0.0], [3.0, 0.05, 1.0]], dtype=np.float32) + right = np.asarray([[0.0, -0.05, 0.0], [-4.0, -0.05, -2.0]], dtype=np.float32) + quat = np.asarray([[1.0, 0.0, 0.0, 0.0]] * 2, dtype=np.float32) + + penalty = compute_lateral_feet_penalty(left, right, quat, min_width=0.20, sigma=0.04) + + assert penalty[0] == pytest.approx(penalty[1]) + + +def test_lateral_penalty_is_invariant_to_world_yaw() -> None: + local_left = np.asarray([[1.0, 0.05, 0.0]], dtype=np.float32) + local_right = np.asarray([[-2.0, -0.05, 0.0]], dtype=np.float32) + yaw_90_left = np.asarray([[-0.05, 1.0, 0.0]], dtype=np.float32) + yaw_90_right = np.asarray([[0.05, -2.0, 0.0]], dtype=np.float32) + identity = np.asarray([[1.0, 0.0, 0.0, 0.0]], dtype=np.float32) + yaw_90 = np.asarray([[np.sqrt(0.5), 0.0, 0.0, np.sqrt(0.5)]], dtype=np.float32) + + local_penalty = compute_lateral_feet_penalty( + local_left, local_right, identity, min_width=0.20, sigma=0.04 + ) + yawed_penalty = compute_lateral_feet_penalty( + yaw_90_left, yaw_90_right, yaw_90, min_width=0.20, sigma=0.04 + ) + + np.testing.assert_allclose(yawed_penalty, local_penalty, atol=1.0e-6) + + +def test_lateral_penalty_is_zero_at_or_above_minimum_width() -> None: + left = np.asarray([[0.0, 0.10, 0.0], [0.0, 0.20, 0.0]], dtype=np.float32) + right = np.asarray([[0.0, -0.10, 0.0], [0.0, -0.10, 0.0]], dtype=np.float32) + quat = np.asarray([[1.0, 0.0, 0.0, 0.0]] * 2, dtype=np.float32) + + penalty = compute_lateral_feet_penalty(left, right, quat, min_width=0.20, sigma=0.04) + + np.testing.assert_array_equal(penalty, 0.0) + + +def test_lateral_penalty_is_near_one_for_crossed_feet() -> None: + left = np.asarray([[0.0, -0.10, 0.0]], dtype=np.float32) + right = np.asarray([[0.0, 0.10, 0.0]], dtype=np.float32) + quat = np.asarray([[1.0, 0.0, 0.0, 0.0]], dtype=np.float32) + + penalty = compute_lateral_feet_penalty(left, right, quat, min_width=0.20, sigma=0.04) + + assert penalty[0] > 0.999 + + +def test_lateral_term_binds_named_sensors_and_matches_helper() -> None: + env, scene = _lateral_env() + term = _lateral_term(env) + + result = term(env) + + assert scene.bound_names == ( + "left_foot_pos", + "right_foot_pos", + "base_link_quaternion", + ) + expected = compute_lateral_feet_penalty( + scene.sensor_data["left_foot_pos"], + scene.sensor_data["right_foot_pos"], + scene.sensor_data["base_link_quaternion"], + min_width=0.20, + sigma=0.04, + ) + np.testing.assert_allclose(result, expected) + + +def test_lateral_term_fails_closed_when_a_named_sensor_is_missing() -> None: + env, scene = _lateral_env() + del scene.sensor_data["right_foot_pos"] + + with pytest.raises(KeyError, match="right_foot_pos"): + _lateral_term(env) + + +def test_lateral_term_rejects_wrong_sensor_dimensions_at_construction() -> None: + env, _ = _lateral_env(base_quat=np.zeros((2, 3), dtype=np.float32)) + + with pytest.raises(ValueError, match=r"dimensions.*\(3, 3, 3\)"): + _lateral_term(env) + + +@pytest.mark.parametrize( + ("params", "error", "message"), + [ + ({"min_width": -0.01}, ValueError, "min_width"), + ({"sigma": 0.0}, ValueError, "sigma"), + ({"sigma": -0.01}, ValueError, "sigma"), + ({"min_width": float("nan")}, ValueError, "finite"), + ({"sigma": float("inf")}, ValueError, "finite"), + ({"min_width": True}, TypeError, "real number"), + ], +) +def test_lateral_term_rejects_invalid_parameters_at_construction( + params: dict[str, Any], error: type[Exception], message: str +) -> None: + env, _ = _lateral_env() + + with pytest.raises(error, match=message): + _lateral_term(env, **params) From 31a3dab568ce0ccf0eef2434d9b53fefa5e7f3ce Mon Sep 17 00:00:00 2001 From: lemon-star608 Date: Mon, 24 Aug 2026 12:52:22 +0800 Subject: [PATCH 04/12] feat(t800): register Manager-Based walk task --- src/unilab/tasks/__init__.py | 1 + src/unilab/tasks/locomotion/t800/__init__.py | 23 +++++++ src/unilab/tasks/migration_matrix.py | 12 ++++ .../t800/test_t800_owner_contract.py | 65 +++++++++++++++++++ tests/tasks/test_migration_matrix.py | 5 +- tests/tasks/test_package_boundary.py | 1 + .../test_production_registry_closeout.py | 5 +- 7 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 src/unilab/tasks/locomotion/t800/__init__.py create mode 100644 tests/envs/locomotion/t800/test_t800_owner_contract.py diff --git a/src/unilab/tasks/__init__.py b/src/unilab/tasks/__init__.py index d9c58aa9f..582a9e328 100644 --- a/src/unilab/tasks/__init__.py +++ b/src/unilab/tasks/__init__.py @@ -12,6 +12,7 @@ "unilab.tasks.locomotion.g1", "unilab.tasks.locomotion.go2_arm", "unilab.tasks.locomotion.a2", + "unilab.tasks.locomotion.t800", "unilab.tasks.manipulation.allegro_inhand", "unilab.tasks.manipulation.sharpa_inhand", "unilab.tasks.manipulation.stewart", diff --git a/src/unilab/tasks/locomotion/t800/__init__.py b/src/unilab/tasks/locomotion/t800/__init__.py new file mode 100644 index 000000000..c706436f4 --- /dev/null +++ b/src/unilab/tasks/locomotion/t800/__init__.py @@ -0,0 +1,23 @@ +"""EngineAI T800 walk-flat task on the shared Manager-Based runtime.""" + +from unilab.assets.hub import resolve_robot_asset_dir +from unilab.base import registry +from unilab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg, make_manager_based_rl_env + + +def make_t800_walk_env( + cfg: ManagerBasedRlEnvCfg, + num_envs: int = 1, + backend_type: str = "mujoco", +) -> ManagerBasedRlEnv: + """Resolve T800 binary assets before materializing the generic runtime.""" + resolve_robot_asset_dir("robots/t800/assets", marker="LINK_BASE.obj") + resolve_robot_asset_dir("robots/t800/textures", marker="LINK_BASE.png") + return make_manager_based_rl_env(cfg, num_envs=num_envs, backend_type=backend_type) + + +registry.register_env_config("T800WalkFlat", ManagerBasedRlEnvCfg) +registry.register_env("T800WalkFlat", make_t800_walk_env, sim_backend="mujoco") + + +__all__ = ["make_t800_walk_env"] diff --git a/src/unilab/tasks/migration_matrix.py b/src/unilab/tasks/migration_matrix.py index 2238f4ecc..94cf3ebc1 100644 --- a/src/unilab/tasks/migration_matrix.py +++ b/src/unilab/tasks/migration_matrix.py @@ -54,6 +54,8 @@ class TaskMigrationRecord: } ) +_T800_LOCOMOTION_TASKS = frozenset({"T800WalkFlat"}) + _CUSTOM_COMPAT_TASKS = frozenset( { "Go2ArmManipLoco", @@ -97,6 +99,7 @@ class TaskMigrationRecord: _MBA_TASKS | _ROUGH_TASKS | _G1_LOCOMOTION_TASKS + | _T800_LOCOMOTION_TASKS | _CUSTOM_COMPAT_TASKS | _MOTION_CORE_TASKS | _MOTION_TASKS @@ -143,6 +146,15 @@ def migration_record(task_name: str) -> TaskMigrationRecord: "Hydra owners materialize biped gait, sensor, command, and penalty-curriculum manager terms on the canonical runtime.", "Keep the manager contract and regression evidence current.", ) + if task_name in _T800_LOCOMOTION_TASKS: + return TaskMigrationRecord( + task_name, + "t800_locomotion", + "Compatible", + "complete", + "Hydra owner YAML materializes the T800 walk-flat task on the canonical NumPy Manager-Based runtime.", + "Keep the Manager-Based owner and cold-path asset resolver contract current.", + ) if task_name in _CUSTOM_COMPAT_TASKS: family = "go2_arm" if task_name == "Go2ArmManipLoco" else "sharpa" return TaskMigrationRecord( diff --git a/tests/envs/locomotion/t800/test_t800_owner_contract.py b/tests/envs/locomotion/t800/test_t800_owner_contract.py new file mode 100644 index 000000000..38e89dbc3 --- /dev/null +++ b/tests/envs/locomotion/t800/test_t800_owner_contract.py @@ -0,0 +1,65 @@ +"""Registry and cold-path owner contracts for the T800 walk-flat task.""" + +from __future__ import annotations + +import importlib +import importlib.util + +from unilab.base import registry +from unilab.envs import ManagerBasedRlEnvCfg +from unilab.tasks import __unilab_registry_modules__ + + +def test_t800_registry_metadata_is_manager_based_and_bootstrapped_explicitly() -> None: + """The production bootstrap exposes only the Manager-Based MuJoCo owner.""" + registry.ensure_registries() + + assert "unilab.tasks.locomotion.t800" in __unilab_registry_modules__ + metadata = registry.list_registered_envs() + assert metadata["T800WalkFlat"] == { + "config_factory": "ManagerBasedRlEnvCfg", + "available_backends": ["mujoco"], + } + + t800 = importlib.import_module("unilab.tasks.locomotion.t800") + factory = registry._envs["T800WalkFlat"].env_factory_dict["mujoco"] + assert factory is t800.make_t800_walk_env + assert callable(factory) + assert factory.__name__ == "make_t800_walk_env" + assert factory.__module__ == "unilab.tasks.locomotion.t800" + assert registry._envs["T800WalkFlat"].env_cfg_factory is ManagerBasedRlEnvCfg + + try: + legacy_spec = importlib.util.find_spec("unilab.envs.locomotion.t800") + except ModuleNotFoundError: + legacy_spec = None + assert legacy_spec is None + + +def test_t800_factory_resolves_assets_before_generic_runtime(monkeypatch) -> None: + """Asset markers resolve before the exact generic runtime invocation.""" + t800 = importlib.import_module("unilab.tasks.locomotion.t800") + calls: list[tuple[str, object, object]] = [] + sentinel = object() + + def fake_resolver(directory: str, *, marker: str): + calls.append(("resolve", directory, marker)) + return sentinel + + def fake_builder(cfg, *, num_envs: int, backend_type: str): + calls.append(("build", num_envs, backend_type)) + assert cfg is config + return sentinel + + config = ManagerBasedRlEnvCfg() + monkeypatch.setattr(t800, "resolve_robot_asset_dir", fake_resolver) + monkeypatch.setattr(t800, "make_manager_based_rl_env", fake_builder) + + result = t800.make_t800_walk_env(config, num_envs=4, backend_type="mujoco") + + assert result is sentinel + assert calls == [ + ("resolve", "robots/t800/assets", "LINK_BASE.obj"), + ("resolve", "robots/t800/textures", "LINK_BASE.png"), + ("build", 4, "mujoco"), + ] diff --git a/tests/tasks/test_migration_matrix.py b/tests/tasks/test_migration_matrix.py index 1da07a41c..5d9a28946 100644 --- a/tests/tasks/test_migration_matrix.py +++ b/tests/tasks/test_migration_matrix.py @@ -17,8 +17,8 @@ def test_registered_tasks_have_explicit_migration_records() -> None: assert PRODUCTION_TASK_NAMES <= registered.keys() assert {record.task_name for record in records} == set(PRODUCTION_TASK_NAMES) - assert len(records) == 39 - assert sum(record.status == "Compatible" for record in records) == 36 + assert len(records) == 40 + assert sum(record.status == "Compatible" for record in records) == 37 assert sum(record.target == "compatibility" for record in records) == 3 @@ -31,6 +31,7 @@ def test_registered_tasks_have_explicit_migration_records() -> None: ("G1WBTObs", "motion_tracking", "complete", "Compatible"), ("X2WallFlipTracking", "motion_tracking", "complete", "Compatible"), ("G1WalkRough", "g1_locomotion", "complete", "Compatible"), + ("T800WalkFlat", "t800_locomotion", "complete", "Compatible"), ("Go2JoystickRough", "quadruped_rough", "complete", "Compatible"), ], ) diff --git a/tests/tasks/test_package_boundary.py b/tests/tasks/test_package_boundary.py index 38cfb78b1..a46053088 100644 --- a/tests/tasks/test_package_boundary.py +++ b/tests/tasks/test_package_boundary.py @@ -19,6 +19,7 @@ "unilab.tasks.locomotion.g1", "unilab.tasks.locomotion.go2_arm", "unilab.tasks.locomotion.a2", + "unilab.tasks.locomotion.t800", "unilab.tasks.manipulation.allegro_inhand", "unilab.tasks.manipulation.sharpa_inhand", "unilab.tasks.manipulation.stewart", diff --git a/tests/tasks/test_production_registry_closeout.py b/tests/tasks/test_production_registry_closeout.py index 384d000eb..ff9f48062 100644 --- a/tests/tasks/test_production_registry_closeout.py +++ b/tests/tasks/test_production_registry_closeout.py @@ -6,7 +6,7 @@ - the production registry matches the #1042 migration matrix exactly, - only the three approved families use the frozen LegacyFactoryAdapter seam, - every other registered factory is one of the canonical manager-runtime - callables (generic factory plus the two maintainer-approved wrappers), + callables (generic factory plus the three maintainer-approved wrappers), - the deleted ``unilab.envs.{locomotion,manipulation,motion_tracking}`` packages stay removed (``unilab.envs`` itself remains: it owns the manager-based runtime such as ``manager_based_rl_env`` and ``mdp``). @@ -47,6 +47,9 @@ # Approved wrapper: cold-path untracked X2 mesh resolution before # delegating to the generic factory. ("unilab.tasks.motion_tracking.x2", "make_x2_wall_flip_env"), + # Approved wrapper: cold-path T800 OBJ/texture resolution before + # delegating to the generic factory. + ("unilab.tasks.locomotion.t800", "make_t800_walk_env"), ) REMOVED_LEGACY_ENV_PACKAGES = ( From d3926092a1370b200fe95583d29bae5e3ba2862f Mon Sep 17 00:00:00 2001 From: lemon-star608 Date: Mon, 24 Aug 2026 13:03:46 +0800 Subject: [PATCH 05/12] feat(ppo): add Manager-Based T800 walk owner --- conf/ppo/task/t800_walk_flat/base.yaml | 287 ++++++++++++++++++ conf/ppo/task/t800_walk_flat/mujoco.yaml | 28 ++ .../locomotion/t800/test_t800_ppo_owner.py | 234 ++++++++++++++ 3 files changed, 549 insertions(+) create mode 100644 conf/ppo/task/t800_walk_flat/base.yaml create mode 100644 conf/ppo/task/t800_walk_flat/mujoco.yaml create mode 100644 tests/envs/locomotion/t800/test_t800_ppo_owner.py diff --git a/conf/ppo/task/t800_walk_flat/base.yaml b/conf/ppo/task/t800_walk_flat/base.yaml new file mode 100644 index 000000000..d27397e86 --- /dev/null +++ b/conf/ppo/task/t800_walk_flat/base.yaml @@ -0,0 +1,287 @@ +# @package _global_ +# Canonical EngineAI T800 25-DoF walk-flat Manager-Based task declaration. +env: + scene: + model_file: src/unilab/assets/robots/t800/scene_flat.xml + default_keyframe_name: stand + entities: + robot: + root_body_name: LINK_BASE + joint_names: + - J00_HIP_PITCH_L + - J01_HIP_ROLL_L + - J02_HIP_YAW_L + - J03_KNEE_PITCH_L + - J04_ANKLE_PITCH_L + - J05_ANKLE_ROLL_L + - J06_HIP_PITCH_R + - J07_HIP_ROLL_R + - J08_HIP_YAW_R + - J09_KNEE_PITCH_R + - J10_ANKLE_PITCH_R + - J11_ANKLE_ROLL_R + - J12_TORSO_YAW + - J13_SHOULDER_PITCH_L + - J14_SHOULDER_ROLL_L + - J15_SHOULDER_YAW_L + - J16_ELBOW_PITCH_L + - J17_ELBOW_YAW_L + - J18_SHOULDER_PITCH_R + - J19_SHOULDER_ROLL_R + - J20_SHOULDER_YAW_R + - J21_ELBOW_PITCH_R + - J22_ELBOW_YAW_R + - J23_HEAD_PITCH + - J24_HEAD_YAW + actuator_names: + - J00_HIP_PITCH_L + - J01_HIP_ROLL_L + - J02_HIP_YAW_L + - J03_KNEE_PITCH_L + - J04_ANKLE_PITCH_L + - J05_ANKLE_ROLL_L + - J06_HIP_PITCH_R + - J07_HIP_ROLL_R + - J08_HIP_YAW_R + - J09_KNEE_PITCH_R + - J10_ANKLE_PITCH_R + - J11_ANKLE_ROLL_R + - J12_TORSO_YAW + - J13_SHOULDER_PITCH_L + - J14_SHOULDER_ROLL_L + - J15_SHOULDER_YAW_L + - J16_ELBOW_PITCH_L + - J17_ELBOW_YAW_L + - J18_SHOULDER_PITCH_R + - J19_SHOULDER_ROLL_R + - J20_SHOULDER_YAW_R + - J21_ELBOW_PITCH_R + - J22_ELBOW_YAW_R + - J23_HEAD_PITCH + - J24_HEAD_YAW + body_names: [LINK_BASE] + sim_dt: 0.002 + ctrl_dt: 0.01 + max_episode_seconds: 20.0 + observations: + policy: + enable_corruption: true + terms: + base_ang_vel: + func: unilab.envs.mdp.builtin_sensor + params: {sensor_name: torso_gyro} + noise: + _target_: unilab.managers._noise.UniformNoiseCfg + n_min: -0.2 + n_max: 0.2 + operation: add + projected_gravity: + func: unilab.envs.mdp.projected_gravity_from_sensor + params: {sensor_name: torso_upvector} + noise: + _target_: unilab.managers._noise.UniformNoiseCfg + n_min: -0.05 + n_max: 0.05 + operation: add + joint_pos: + func: unilab.envs.mdp.joint_pos_rel + params: + asset_cfg: + _target_: unilab.managers.SceneEntityCfg + name: robot + joint_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R] + noise: + _target_: unilab.managers._noise.UniformNoiseCfg + n_min: -0.01 + n_max: 0.01 + operation: add + joint_vel: + func: unilab.envs.mdp.joint_vel_rel + params: + asset_cfg: + _target_: unilab.managers.SceneEntityCfg + name: robot + joint_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R] + noise: + _target_: unilab.managers._noise.UniformNoiseCfg + n_min: -1.5 + n_max: 1.5 + operation: add + actions: + func: unilab.envs.mdp.last_action + params: {action_name: joint_pos} + command: + func: unilab.envs.mdp.generated_commands + params: {command_name: twist} + gait_phase: + func: unilab.tasks.locomotion.g1.manager_terms.G1GaitPhase + params: {frequency: 1.5, init_mode: offset_phase} + critic: + terms: + base_ang_vel: + func: unilab.envs.mdp.builtin_sensor + params: {sensor_name: torso_gyro} + projected_gravity: + func: unilab.envs.mdp.projected_gravity_from_sensor + params: {sensor_name: torso_upvector} + joint_pos: + func: unilab.envs.mdp.joint_pos_rel + params: + asset_cfg: + _target_: unilab.managers.SceneEntityCfg + name: robot + joint_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R] + joint_vel: + func: unilab.envs.mdp.joint_vel_rel + params: + asset_cfg: + _target_: unilab.managers.SceneEntityCfg + name: robot + joint_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R] + actions: + func: unilab.envs.mdp.last_action + params: {action_name: joint_pos} + command: + func: unilab.envs.mdp.generated_commands + params: {command_name: twist} + gait_phase: + func: unilab.tasks.locomotion.g1.manager_terms.G1GaitPhase + params: {frequency: 1.5, init_mode: offset_phase} + base_lin_vel: + func: unilab.envs.mdp.builtin_sensor + params: {sensor_name: pelvis_local_linvel} + policy_observation_group: policy + critic_observation_group: critic + actions: + joint_pos: + _target_: unilab.tasks.locomotion.t800.manager_terms.T800JointPositionActionCfg + entity_name: robot + actuator_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R] + held_actuator_names: [J12_TORSO_YAW, J23_HEAD_PITCH, J24_HEAD_YAW] + scale: + J00_HIP_PITCH_L: 0.5 + J01_HIP_ROLL_L: 0.2 + J02_HIP_YAW_L: 0.2 + J03_KNEE_PITCH_L: 0.5 + J04_ANKLE_PITCH_L: 0.5 + J05_ANKLE_ROLL_L: 0.2 + J06_HIP_PITCH_R: 0.5 + J07_HIP_ROLL_R: 0.2 + J08_HIP_YAW_R: 0.2 + J09_KNEE_PITCH_R: 0.5 + J10_ANKLE_PITCH_R: 0.5 + J11_ANKLE_ROLL_R: 0.2 + J13_SHOULDER_PITCH_L: 0.2 + J14_SHOULDER_ROLL_L: 0.2 + J15_SHOULDER_YAW_L: 0.05 + J16_ELBOW_PITCH_L: 0.2 + J17_ELBOW_YAW_L: 0.05 + J18_SHOULDER_PITCH_R: 0.2 + J19_SHOULDER_ROLL_R: 0.2 + J20_SHOULDER_YAW_R: 0.05 + J21_ELBOW_PITCH_R: 0.2 + J22_ELBOW_YAW_R: 0.05 + use_default_offset: true + commands: + twist: + _target_: unilab.tasks.locomotion.g1.manager_terms.G1VelocityCommandCfg + entity_name: robot + resampling_time_range: [20.0, 20.0] + heading_command: false + heading_control_stiffness: 0.5 + rel_standing_envs: 0.0 + rel_heading_envs: 0.0 + rel_world_envs: 0.0 + rel_forward_envs: 0.0 + init_velocity_prob: 0.0 + planar_dead_zone: 0.2 + ranges: + lin_vel_x: [-0.6, 1.0] + lin_vel_y: [-0.4, 0.4] + ang_vel_z: [-0.8, 0.8] + events: + reset_scene_to_default: + func: unilab.envs.mdp.reset_scene_to_default + mode: reset + reset_root_state_uniform: + func: unilab.envs.mdp.reset_root_state_uniform + mode: reset + params: + pose_range: + x: [-0.5, 0.5] + y: [-0.5, 0.5] + z: [0.0, 0.0] + roll: [0.0, 0.0] + pitch: [0.0, 0.0] + yaw: [-3.141592653589793, 3.141592653589793] + velocity_range: + x: [-0.5, 0.5] + y: [-0.5, 0.5] + z: [-0.5, 0.5] + roll: [-0.5, 0.5] + pitch: [-0.5, 0.5] + yaw: [-0.5, 0.5] + pd_gains: + func: unilab.envs.mdp.pd_gains + mode: reset + params: + kp_range: [0.9, 1.1] + kd_range: [0.9, 1.1] + operation: scale + terminations: + time_out: + func: unilab.envs.mdp.time_out + time_out: true + tilt: + func: unilab.tasks.locomotion.g1.manager_terms.g1_tilt_exceeded + params: {max_tilt_deg: 25.0} + base_height: + func: unilab.envs.mdp.root_height_below_minimum + params: {minimum_height: 0.7165} + +reward: + tracking_lin_vel: + func: unilab.tasks.locomotion.g1.manager_terms.track_lin_vel + weight: 2.0 + params: {tracking_sigma: 0.25, command_name: twist} + tracking_ang_vel: + func: unilab.tasks.locomotion.g1.manager_terms.track_ang_vel + weight: 0.2 + params: {tracking_sigma: 0.25, command_name: twist} + feet_phase: + func: unilab.tasks.locomotion.g1.manager_terms.feet_phase + weight: 1.5 + params: + frequency: 1.5 + swing_height: 0.13 + tracking_sigma: 0.014 + min_forward_speed: 0.0 + command_name: twist + lin_vel_z: + func: unilab.tasks.locomotion.g1.manager_terms.lin_vel_z + weight: -1.0 + ang_vel_xy: + func: unilab.tasks.locomotion.g1.manager_terms.ang_vel_xy + weight: -0.25 + base_height: + func: unilab.tasks.locomotion.g1.manager_terms.base_height + weight: -500.0 + params: {target_height: 1.0165} + orientation: + func: unilab.tasks.locomotion.g1.manager_terms.orientation + weight: -5.0 + penalty_feet_ori: + func: unilab.tasks.locomotion.g1.manager_terms.penalty_feet_ori + weight: -10.0 + action_rate: + func: unilab.envs.mdp.action_rate_l2 + weight: -0.01 + pose: + func: unilab.tasks.locomotion.g1.manager_terms.weighted_pose + weight: -0.1 + params: + asset_cfg: + _target_: unilab.managers.SceneEntityCfg + name: robot + joint_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R] + pose_weights: [0.01, 1.0, 5.0, 0.01, 5.0, 5.0, 0.01, 1.0, 5.0, 0.01, 5.0, 5.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0] diff --git a/conf/ppo/task/t800_walk_flat/mujoco.yaml b/conf/ppo/task/t800_walk_flat/mujoco.yaml new file mode 100644 index 000000000..d97ad4c96 --- /dev/null +++ b/conf/ppo/task/t800_walk_flat/mujoco.yaml @@ -0,0 +1,28 @@ +# @package _global_ +# MuJoCo owner for the standalone T800 Manager-Based PPO task. +defaults: + - /task/t800_walk_flat/base + - _self_ + +training: + task_name: T800WalkFlat + sim_backend: mujoco + play_steps: 2000 + +algo: + num_envs: 2048 + max_iterations: 2200 + empirical_normalization: false + obs_groups: + actor: + - policy + critic: + - critic + policy: + actor_hidden_dims: [512, 256, 128] + critic_hidden_dims: [512, 256, 128] + +play_profile: + enabled: true + env: + render_spacing: 2.0 diff --git a/tests/envs/locomotion/t800/test_t800_ppo_owner.py b/tests/envs/locomotion/t800/test_t800_ppo_owner.py new file mode 100644 index 000000000..81d865acf --- /dev/null +++ b/tests/envs/locomotion/t800/test_t800_ppo_owner.py @@ -0,0 +1,234 @@ +"""Hydra and cold-path contracts for the T800 Manager-Based PPO owner.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import numpy as np +import pytest +from hydra import compose, initialize_config_dir +from hydra.core.global_hydra import GlobalHydra + +from unilab.base import registry +from unilab.base.config_adapter import BackendAdapter +from unilab.base.config_materialization import apply_cfg_overrides +from unilab.envs import ManagerBasedRlEnvCfg +from unilab.tasks.locomotion.t800.manager_terms import T800JointPositionActionCfg + +ROOT_DIR = Path(__file__).parents[4] +CONF_DIR = ROOT_DIR / "conf" / "ppo" + +_JOINT_NAMES = ( + "J00_HIP_PITCH_L", + "J01_HIP_ROLL_L", + "J02_HIP_YAW_L", + "J03_KNEE_PITCH_L", + "J04_ANKLE_PITCH_L", + "J05_ANKLE_ROLL_L", + "J06_HIP_PITCH_R", + "J07_HIP_ROLL_R", + "J08_HIP_YAW_R", + "J09_KNEE_PITCH_R", + "J10_ANKLE_PITCH_R", + "J11_ANKLE_ROLL_R", + "J12_TORSO_YAW", + "J13_SHOULDER_PITCH_L", + "J14_SHOULDER_ROLL_L", + "J15_SHOULDER_YAW_L", + "J16_ELBOW_PITCH_L", + "J17_ELBOW_YAW_L", + "J18_SHOULDER_PITCH_R", + "J19_SHOULDER_ROLL_R", + "J20_SHOULDER_YAW_R", + "J21_ELBOW_PITCH_R", + "J22_ELBOW_YAW_R", + "J23_HEAD_PITCH", + "J24_HEAD_YAW", +) +_ACTIVE_JOINT_NAMES = _JOINT_NAMES[:12] + _JOINT_NAMES[13:23] +_HELD_JOINT_NAMES = (_JOINT_NAMES[12], _JOINT_NAMES[23], _JOINT_NAMES[24]) +_ACTION_SCALES = ( + 0.5, + 0.2, + 0.2, + 0.5, + 0.5, + 0.2, + 0.5, + 0.2, + 0.2, + 0.5, + 0.5, + 0.2, + 0.2, + 0.2, + 0.05, + 0.2, + 0.05, + 0.2, + 0.2, + 0.05, + 0.2, + 0.05, +) +_POSE_WEIGHTS = ( + 0.01, + 1.0, + 5.0, + 0.01, + 5.0, + 5.0, + 0.01, + 1.0, + 5.0, + 0.01, + 5.0, + 5.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, + 50.0, +) + + +def _compose_owner(): + GlobalHydra.instance().clear() + with initialize_config_dir(config_dir=str(CONF_DIR), version_base="1.3"): + return compose("config", overrides=["task=t800_walk_flat/mujoco"]) + + +def _materialize_owner() -> tuple[Any, ManagerBasedRlEnvCfg]: + cfg = _compose_owner() + registry.ensure_registries() + env_override = BackendAdapter( + cfg, root_dir=ROOT_DIR, algo_name="ppo" + ).build_task_env_cfg_override() + env_cfg = registry.materialize_env_config("T800WalkFlat") + assert isinstance(env_cfg, ManagerBasedRlEnvCfg) + apply_cfg_overrides(env_cfg, env_override) + env_cfg.validate() + return cfg, env_cfg + + +def test_t800_ppo_owner_materializes_the_complete_manager_contract() -> None: + cfg, env_cfg = _materialize_owner() + env_cfg = cast(Any, env_cfg) + + assert cfg.training.task_name == "T800WalkFlat" + assert cfg.training.sim_backend == "mujoco" + assert cfg.training.play_steps == 2000 + assert env_cfg.sim_dt == pytest.approx(0.002) + assert env_cfg.ctrl_dt == pytest.approx(0.01) + assert env_cfg.max_episode_seconds == pytest.approx(20.0) + assert env_cfg.policy_observation_group == "policy" + assert env_cfg.critic_observation_group == "critic" + + assert env_cfg.scene is not None + assert env_cfg.scene.model_file.endswith("robots/t800/scene_flat.xml") + assert env_cfg.scene.default_keyframe_name == "stand" + robot = env_cfg.scene.entities["robot"] + assert robot.root_body_name == "LINK_BASE" + assert robot.body_names == ["LINK_BASE"] + assert tuple(robot.joint_names or ()) == _JOINT_NAMES + assert tuple(robot.actuator_names or ()) == _JOINT_NAMES + + action = env_cfg.actions["joint_pos"] + assert isinstance(action, T800JointPositionActionCfg) + assert tuple(action.actuator_names) == _ACTIVE_JOINT_NAMES + assert tuple(action.held_actuator_names) == _HELD_JOINT_NAMES + action_scale = cast(Any, action.scale) + assert list(action_scale) == list(_ACTIVE_JOINT_NAMES) + assert tuple(action_scale.values()) == pytest.approx(_ACTION_SCALES) + assert action.use_default_offset is True + + policy_terms = env_cfg.observations["policy"].terms + critic_terms = env_cfg.observations["critic"].terms + expected_terms = ( + "base_ang_vel", + "projected_gravity", + "joint_pos", + "joint_vel", + "actions", + "command", + "gait_phase", + ) + assert tuple(policy_terms) == expected_terms + assert tuple(critic_terms) == (*expected_terms, "base_lin_vel") + assert sum((3, 3, 22, 22, 22, 3, 2)) == 77 + assert sum((3, 3, 22, 22, 22, 3, 2, 3)) == 80 + assert policy_terms["base_ang_vel"].params["sensor_name"] == "torso_gyro" + assert policy_terms["projected_gravity"].params["sensor_name"] == "torso_upvector" + assert critic_terms["base_lin_vel"].params["sensor_name"] == "pelvis_local_linvel" + + assert tuple(env_cfg.rewards) == ( + "tracking_lin_vel", + "tracking_ang_vel", + "feet_phase", + "lin_vel_z", + "ang_vel_xy", + "base_height", + "orientation", + "penalty_feet_ori", + "action_rate", + "pose", + ) + assert env_cfg.rewards["feet_phase"].weight == pytest.approx(1.5) + assert env_cfg.rewards["feet_phase"].params["swing_height"] == pytest.approx(0.13) + assert env_cfg.rewards["feet_phase"].params["tracking_sigma"] == pytest.approx(0.014) + assert env_cfg.rewards["base_height"].params["target_height"] == pytest.approx(1.0165) + assert env_cfg.terminations["tilt"].params["max_tilt_deg"] == pytest.approx(25.0) + assert env_cfg.terminations["base_height"].params["minimum_height"] == pytest.approx(0.7165) + assert tuple(env_cfg.rewards["pose"].params["pose_weights"]) == pytest.approx(_POSE_WEIGHTS) + + for name, bound in { + "base_ang_vel": 0.2, + "projected_gravity": 0.05, + "joint_pos": 0.01, + "joint_vel": 1.5, + }.items(): + noise = policy_terms[name].noise + assert noise is not None + assert noise.n_min == pytest.approx(-bound) + assert noise.n_max == pytest.approx(bound) + assert all(term.noise is None for term in critic_terms.values()) + + +@pytest.mark.slow +def test_t800_ppo_owner_builds_and_steps_real_mujoco_env() -> None: + pytest.importorskip("mujoco") + cfg, _ = _materialize_owner() + env = cast( + Any, + registry.make( + "T800WalkFlat", + sim_backend=str(cfg.training.sim_backend), + num_envs=1, + env_cfg_override=BackendAdapter( + cfg, root_dir=ROOT_DIR, algo_name="ppo" + ).build_task_env_cfg_override(), + ), + ) + try: + reset_obs, reset_info = env.reset() + assert isinstance(reset_info, dict) + assert env.action_space.shape == (22,) + assert reset_obs["obs"].shape == (1, 77) + assert reset_obs["critic"].shape == (1, 80) + + action = np.zeros((1, 22), dtype=np.float32) + state = env.step(action) + assert np.isfinite(state.obs["obs"]).all() + assert np.isfinite(state.obs["critic"]).all() + assert np.isfinite(state.reward).all() + controls = env._control + assert controls.shape == (1, 25) + assert np.isfinite(controls).all() + finally: + env.close() From 4537953b214277d6eed70645ef1f2c63c3774ddc Mon Sep 17 00:00:00 2001 From: lemon-star608 Date: Mon, 24 Aug 2026 13:20:05 +0800 Subject: [PATCH 06/12] feat(sac): add Manager-Based T800 walk owner --- conf/sac/task/t800_walk_flat/base.yaml | 111 +++++++++ conf/sac/task/t800_walk_flat/mujoco.yaml | 20 ++ .../locomotion/t800/test_t800_sac_owner.py | 216 ++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 conf/sac/task/t800_walk_flat/base.yaml create mode 100644 conf/sac/task/t800_walk_flat/mujoco.yaml create mode 100644 tests/envs/locomotion/t800/test_t800_sac_owner.py diff --git a/conf/sac/task/t800_walk_flat/base.yaml b/conf/sac/task/t800_walk_flat/base.yaml new file mode 100644 index 000000000..a2afb9829 --- /dev/null +++ b/conf/sac/task/t800_walk_flat/base.yaml @@ -0,0 +1,111 @@ +# @package _global_ +# Standalone EngineAI T800 25-DoF Manager-Based SAC walk-flat owner. +env: + scene: + model_file: src/unilab/assets/robots/t800/scene_flat.xml + default_keyframe_name: stand + entities: + robot: + root_body_name: LINK_BASE + body_names: [LINK_BASE] + joint_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J12_TORSO_YAW, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R, J23_HEAD_PITCH, J24_HEAD_YAW] + actuator_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J12_TORSO_YAW, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R, J23_HEAD_PITCH, J24_HEAD_YAW] + sim_dt: 0.002 + ctrl_dt: 0.01 + max_episode_seconds: 20.0 + observations: + policy: + enable_corruption: true + terms: + base_ang_vel: {func: unilab.envs.mdp.builtin_sensor, params: {sensor_name: torso_gyro}, scale: 0.25} + projected_gravity: {func: unilab.envs.mdp.projected_gravity_from_sensor, params: {sensor_name: torso_upvector}} + joint_pos: + func: unilab.envs.mdp.joint_pos_rel + params: + asset_cfg: {_target_: unilab.managers.SceneEntityCfg, name: robot, joint_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R]} + noise: {_target_: unilab.managers._noise.UniformNoiseCfg, n_min: -0.01, n_max: 0.01, operation: add} + joint_vel: + func: unilab.envs.mdp.joint_vel_rel + scale: 0.05 + params: + asset_cfg: {_target_: unilab.managers.SceneEntityCfg, name: robot, joint_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R]} + noise: {_target_: unilab.managers._noise.UniformNoiseCfg, n_min: -0.1, n_max: 0.1, operation: add} + actions: {func: unilab.envs.mdp.last_action, params: {action_name: joint_pos}} + command: {func: unilab.envs.mdp.generated_commands, params: {command_name: twist}} + gait_phase: {func: unilab.tasks.locomotion.g1.manager_terms.G1GaitPhase, params: {frequency: 1.5, init_mode: offset_phase}} + critic: + enable_corruption: false + terms: + base_ang_vel: {func: unilab.envs.mdp.builtin_sensor, params: {sensor_name: torso_gyro}, scale: 0.25} + projected_gravity: {func: unilab.envs.mdp.projected_gravity_from_sensor, params: {sensor_name: torso_upvector}} + joint_pos: + func: unilab.envs.mdp.joint_pos_rel + params: + asset_cfg: {_target_: unilab.managers.SceneEntityCfg, name: robot, joint_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R]} + joint_vel: + func: unilab.envs.mdp.joint_vel_rel + scale: 0.05 + params: + asset_cfg: {_target_: unilab.managers.SceneEntityCfg, name: robot, joint_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R]} + actions: {func: unilab.envs.mdp.last_action, params: {action_name: joint_pos}} + command: {func: unilab.envs.mdp.generated_commands, params: {command_name: twist}} + gait_phase: {func: unilab.tasks.locomotion.g1.manager_terms.G1GaitPhase, params: {frequency: 1.5, init_mode: offset_phase}} + base_lin_vel: {func: unilab.envs.mdp.builtin_sensor, params: {sensor_name: pelvis_local_linvel}, scale: 2.0} + policy_observation_group: policy + critic_observation_group: critic + actions: + joint_pos: + _target_: unilab.tasks.locomotion.t800.manager_terms.T800JointPositionActionCfg + entity_name: robot + actuator_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R] + held_actuator_names: [J12_TORSO_YAW, J23_HEAD_PITCH, J24_HEAD_YAW] + scale: {J00_HIP_PITCH_L: 1.0, J01_HIP_ROLL_L: 1.0, J02_HIP_YAW_L: 1.0, J03_KNEE_PITCH_L: 1.0, J04_ANKLE_PITCH_L: 1.0, J05_ANKLE_ROLL_L: 1.0, J06_HIP_PITCH_R: 1.0, J07_HIP_ROLL_R: 1.0, J08_HIP_YAW_R: 1.0, J09_KNEE_PITCH_R: 1.0, J10_ANKLE_PITCH_R: 1.0, J11_ANKLE_ROLL_R: 1.0, J13_SHOULDER_PITCH_L: 0.2, J14_SHOULDER_ROLL_L: 0.2, J15_SHOULDER_YAW_L: 0.05, J16_ELBOW_PITCH_L: 0.2, J17_ELBOW_YAW_L: 0.05, J18_SHOULDER_PITCH_R: 0.2, J19_SHOULDER_ROLL_R: 0.2, J20_SHOULDER_YAW_R: 0.05, J21_ELBOW_PITCH_R: 0.2, J22_ELBOW_YAW_R: 0.05} + use_default_offset: true + commands: + twist: + _target_: unilab.tasks.locomotion.g1.manager_terms.G1VelocityCommandCfg + entity_name: robot + resampling_time_range: [20.0, 20.0] + heading_command: false + heading_control_stiffness: 0.5 + rel_standing_envs: 0.0 + rel_heading_envs: 0.0 + rel_world_envs: 0.0 + rel_forward_envs: 0.0 + init_velocity_prob: 0.0 + planar_dead_zone: 0.2 + ranges: {lin_vel_x: [-0.6, 1.0], lin_vel_y: [-0.4, 0.4], ang_vel_z: [-0.8, 0.8]} + events: + reset_scene_to_default: {func: unilab.envs.mdp.reset_scene_to_default, mode: reset} + reset_root_state_uniform: + func: unilab.envs.mdp.reset_root_state_uniform + mode: reset + params: + pose_range: {x: [-0.5, 0.5], y: [-0.5, 0.5], z: [0.0, 0.0], roll: [0.0, 0.0], pitch: [0.0, 0.0], yaw: [-3.141592653589793, 3.141592653589793]} + velocity_range: {x: [-0.5, 0.5], y: [-0.5, 0.5], z: [-0.5, 0.5], roll: [-0.5, 0.5], pitch: [-0.5, 0.5], yaw: [-0.5, 0.5]} + pd_gains: {func: unilab.envs.mdp.pd_gains, mode: reset, params: {kp_range: [0.9, 1.1], kd_range: [0.9, 1.1], operation: scale}} + terminations: + time_out: {func: unilab.envs.mdp.time_out, time_out: true} + tilt: {func: unilab.tasks.locomotion.g1.manager_terms.g1_tilt_exceeded, params: {max_tilt_deg: 65.0}} + base_height: {func: unilab.envs.mdp.root_height_below_minimum, params: {minimum_height: 0.3}} + curriculum: + penalty_scaling: + func: unilab.tasks.locomotion.g1.manager_terms.G1PenaltyCurriculum + params: {initial_scale: 0.125, min_scale: 0.125, max_scale: 0.25, level_down_threshold: 150.0, level_up_threshold: 750.0, degree: 0.001} + +reward: + tracking_lin_vel: {func: unilab.tasks.locomotion.g1.manager_terms.track_lin_vel, weight: 2.0, params: {tracking_sigma: 0.25, command_name: twist}} + tracking_ang_vel: {func: unilab.tasks.locomotion.g1.manager_terms.track_ang_vel, weight: 1.5, params: {tracking_sigma: 0.25, command_name: twist}} + penalty_ang_vel_xy: {func: unilab.tasks.locomotion.g1.manager_terms.ang_vel_xy, weight: -1.0} + penalty_orientation: {func: unilab.tasks.locomotion.g1.manager_terms.orientation, weight: -10.0} + penalty_action_rate: {func: unilab.envs.mdp.action_rate_l2, weight: -4.0} + pose: + func: unilab.tasks.locomotion.g1.manager_terms.weighted_pose + weight: -0.5 + params: + asset_cfg: {_target_: unilab.managers.SceneEntityCfg, name: robot, joint_names: [J00_HIP_PITCH_L, J01_HIP_ROLL_L, J02_HIP_YAW_L, J03_KNEE_PITCH_L, J04_ANKLE_PITCH_L, J05_ANKLE_ROLL_L, J06_HIP_PITCH_R, J07_HIP_ROLL_R, J08_HIP_YAW_R, J09_KNEE_PITCH_R, J10_ANKLE_PITCH_R, J11_ANKLE_ROLL_R, J13_SHOULDER_PITCH_L, J14_SHOULDER_ROLL_L, J15_SHOULDER_YAW_L, J16_ELBOW_PITCH_L, J17_ELBOW_YAW_L, J18_SHOULDER_PITCH_R, J19_SHOULDER_ROLL_R, J20_SHOULDER_YAW_R, J21_ELBOW_PITCH_R, J22_ELBOW_YAW_R]} + pose_weights: [0.01, 2.0, 5.0, 0.01, 5.0, 5.0, 0.01, 2.0, 5.0, 0.01, 5.0, 5.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0] + penalty_feet_ori: {func: unilab.tasks.locomotion.g1.manager_terms.penalty_feet_ori, weight: -20.0} + penalty_close_feet_lateral: {func: unilab.tasks.locomotion.t800.manager_terms.penalty_close_feet_lateral, weight: -5.0, params: {min_width: 0.18, sigma: 0.04}} + feet_phase: {func: unilab.tasks.locomotion.g1.manager_terms.feet_phase, weight: 5.0, params: {frequency: 1.5, swing_height: 0.11, tracking_sigma: 0.014, min_forward_speed: 0.0, command_name: twist}} + alive: {func: unilab.tasks.locomotion.g1.manager_terms.alive, weight: 10.0} diff --git a/conf/sac/task/t800_walk_flat/mujoco.yaml b/conf/sac/task/t800_walk_flat/mujoco.yaml new file mode 100644 index 000000000..6351d969c --- /dev/null +++ b/conf/sac/task/t800_walk_flat/mujoco.yaml @@ -0,0 +1,20 @@ +# @package _global_ +# MuJoCo owner for the standalone T800 Manager-Based SAC task. +defaults: + - /task/t800_walk_flat/base + - _self_ + +training: + task_name: T800WalkFlat + sim_backend: mujoco + +algo: + num_envs: 2048 + max_iterations: 5000 + save_interval: 1000 + learning_starts: 10 + updates_per_step: 8 + gamma: 0.98488578 + algo_params: + alpha_init: 0.001 + target_entropy_ratio: 0.0 diff --git a/tests/envs/locomotion/t800/test_t800_sac_owner.py b/tests/envs/locomotion/t800/test_t800_sac_owner.py new file mode 100644 index 000000000..9e0634c69 --- /dev/null +++ b/tests/envs/locomotion/t800/test_t800_sac_owner.py @@ -0,0 +1,216 @@ +"""Hydra and cold-path contracts for the T800 Manager-Based SAC owner.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import numpy as np +import pytest +from hydra import compose, initialize_config_dir +from hydra.core.global_hydra import GlobalHydra + +from unilab.base import registry +from unilab.base.config_adapter import BackendAdapter +from unilab.base.config_materialization import apply_cfg_overrides +from unilab.envs import ManagerBasedRlEnvCfg +from unilab.tasks.locomotion.t800.manager_terms import ( + T800JointPositionActionCfg, + penalty_close_feet_lateral, +) + +ROOT_DIR = Path(__file__).parents[4] +CONF_DIR = ROOT_DIR / "conf" / "sac" + +_JOINT_NAMES = ( + "J00_HIP_PITCH_L", + "J01_HIP_ROLL_L", + "J02_HIP_YAW_L", + "J03_KNEE_PITCH_L", + "J04_ANKLE_PITCH_L", + "J05_ANKLE_ROLL_L", + "J06_HIP_PITCH_R", + "J07_HIP_ROLL_R", + "J08_HIP_YAW_R", + "J09_KNEE_PITCH_R", + "J10_ANKLE_PITCH_R", + "J11_ANKLE_ROLL_R", + "J12_TORSO_YAW", + "J13_SHOULDER_PITCH_L", + "J14_SHOULDER_ROLL_L", + "J15_SHOULDER_YAW_L", + "J16_ELBOW_PITCH_L", + "J17_ELBOW_YAW_L", + "J18_SHOULDER_PITCH_R", + "J19_SHOULDER_ROLL_R", + "J20_SHOULDER_YAW_R", + "J21_ELBOW_PITCH_R", + "J22_ELBOW_YAW_R", + "J23_HEAD_PITCH", + "J24_HEAD_YAW", +) +_ACTIVE = _JOINT_NAMES[:12] + _JOINT_NAMES[13:23] +_HELD = (_JOINT_NAMES[12], _JOINT_NAMES[23], _JOINT_NAMES[24]) +_SCALES = (1.0,) * 12 + (0.2, 0.2, 0.05, 0.2, 0.05, 0.2, 0.2, 0.05, 0.2, 0.05) +_POSE = (0.01, 2.0, 5.0, 0.01, 5.0, 5.0, 0.01, 2.0, 5.0, 0.01, 5.0, 5.0) + (50.0,) * 10 + + +def _compose_owner(): + GlobalHydra.instance().clear() + with initialize_config_dir(config_dir=str(CONF_DIR), version_base="1.3"): + return compose("config", overrides=["task=t800_walk_flat/mujoco"]) + + +def _materialize_owner() -> tuple[Any, ManagerBasedRlEnvCfg]: + cfg = _compose_owner() + registry.ensure_registries() + env_override = BackendAdapter( + cfg, root_dir=ROOT_DIR, algo_name="sac" + ).build_task_env_cfg_override() + env_cfg = registry.materialize_env_config("T800WalkFlat") + assert isinstance(env_cfg, ManagerBasedRlEnvCfg) + apply_cfg_overrides(env_cfg, env_override) + env_cfg.validate() + return cfg, env_cfg + + +def test_t800_sac_owner_materializes_complete_contract() -> None: + cfg, env_cfg = _materialize_owner() + env_cfg = cast(Any, env_cfg) + + assert CONF_DIR.name == "sac" + assert cfg.training.task_name == "T800WalkFlat" + assert cfg.training.sim_backend == "mujoco" + assert cfg.algo.gamma == pytest.approx(0.98488578) + assert cfg.algo.learning_starts == 10 + assert cfg.algo.updates_per_step == 8 + assert cfg.algo.algo_params.alpha_init == pytest.approx(0.001) + assert cfg.algo.algo_params.target_entropy_ratio == pytest.approx(0.0) + assert cfg.algo.max_iterations == 5000 + assert cfg.algo.save_interval == 1000 + assert env_cfg.sim_dt == pytest.approx(0.002) + assert env_cfg.ctrl_dt == pytest.approx(0.01) + assert env_cfg.max_episode_seconds == pytest.approx(20.0) + + robot = env_cfg.scene.entities["robot"] + assert env_cfg.scene.model_file.endswith("robots/t800/scene_flat.xml") + assert env_cfg.scene.default_keyframe_name == "stand" + assert robot.root_body_name == "LINK_BASE" + assert robot.body_names == ["LINK_BASE"] + assert tuple(robot.joint_names) == _JOINT_NAMES + assert tuple(robot.actuator_names) == _JOINT_NAMES + + action = env_cfg.actions["joint_pos"] + assert isinstance(action, T800JointPositionActionCfg) + assert tuple(action.actuator_names) == _ACTIVE + assert tuple(action.held_actuator_names) == _HELD + assert list(cast(Any, action.scale)) == list(_ACTIVE) + assert tuple(cast(Any, action.scale).values()) == pytest.approx(_SCALES) + assert action.use_default_offset is True + + policy = env_cfg.observations["policy"].terms + critic = env_cfg.observations["critic"].terms + assert tuple(policy) == ( + "base_ang_vel", + "projected_gravity", + "joint_pos", + "joint_vel", + "actions", + "command", + "gait_phase", + ) + assert tuple(critic) == (*tuple(policy), "base_lin_vel") + assert env_cfg.observations["policy"].enable_corruption is True + assert env_cfg.observations["critic"].enable_corruption is False + assert policy["base_ang_vel"].params["sensor_name"] == "torso_gyro" + assert policy["projected_gravity"].params["sensor_name"] == "torso_upvector" + assert policy["base_ang_vel"].scale == pytest.approx(0.25) + assert policy["joint_vel"].scale == pytest.approx(0.05) + assert critic["projected_gravity"].params["sensor_name"] == "torso_upvector" + assert critic["base_lin_vel"].params["sensor_name"] == "pelvis_local_linvel" + assert critic["base_lin_vel"].scale == pytest.approx(2.0) + assert policy["joint_pos"].params["asset_cfg"].joint_names == list(_ACTIVE) + assert policy["joint_vel"].params["asset_cfg"].joint_names == list(_ACTIVE) + assert critic["joint_pos"].params["asset_cfg"].joint_names == list(_ACTIVE) + assert critic["joint_vel"].params["asset_cfg"].joint_names == list(_ACTIVE) + assert policy["joint_pos"].noise.n_max == pytest.approx(0.01) + assert policy["joint_vel"].noise.n_max == pytest.approx(0.1) + assert all(term.noise is None for term in critic.values()) + + assert sum((3, 3, 22, 22, 22, 3, 2)) == 77 + assert sum((3, 3, 22, 22, 22, 3, 2, 3)) == 80 + assert tuple(env_cfg.rewards) == ( + "tracking_lin_vel", + "tracking_ang_vel", + "penalty_ang_vel_xy", + "penalty_orientation", + "penalty_action_rate", + "pose", + "penalty_feet_ori", + "penalty_close_feet_lateral", + "feet_phase", + "alive", + ) + expected_weights = { + "tracking_lin_vel": 2.0, + "tracking_ang_vel": 1.5, + "penalty_ang_vel_xy": -1.0, + "penalty_orientation": -10.0, + "penalty_action_rate": -4.0, + "pose": -0.5, + "penalty_feet_ori": -20.0, + "penalty_close_feet_lateral": -5.0, + "feet_phase": 5.0, + "alive": 10.0, + } + for name, weight in expected_weights.items(): + assert env_cfg.rewards[name].weight == pytest.approx(weight) + assert tuple(env_cfg.rewards["pose"].params["pose_weights"]) == pytest.approx(_POSE) + lateral = env_cfg.rewards["penalty_close_feet_lateral"] + assert lateral.func is penalty_close_feet_lateral + assert lateral.params["min_width"] == pytest.approx(0.18) + assert lateral.params["sigma"] == pytest.approx(0.04) + phase = env_cfg.rewards["feet_phase"] + assert phase.params["swing_height"] == pytest.approx(0.11) + assert phase.params["tracking_sigma"] == pytest.approx(0.014) + + curriculum = env_cfg.curriculum["penalty_scaling"] + assert curriculum.params == { + "initial_scale": 0.125, + "min_scale": 0.125, + "max_scale": 0.25, + "level_down_threshold": 150.0, + "level_up_threshold": 750.0, + "degree": 0.001, + } + + +@pytest.mark.slow +def test_t800_sac_owner_builds_and_steps_real_mujoco_env() -> None: + pytest.importorskip("mujoco") + cfg, _ = _materialize_owner() + env = cast( + Any, + registry.make( + "T800WalkFlat", + sim_backend="mujoco", + num_envs=1, + env_cfg_override=BackendAdapter( + cfg, root_dir=ROOT_DIR, algo_name="sac" + ).build_task_env_cfg_override(), + ), + ) + try: + obs, info = env.reset() + assert isinstance(info, dict) + assert env.action_space.shape == (22,) + assert obs["obs"].shape == (1, 77) + assert obs["critic"].shape == (1, 80) + state = env.step(np.zeros((1, 22), dtype=np.float32)) + assert np.isfinite(state.obs["obs"]).all() + assert np.isfinite(state.obs["critic"]).all() + assert np.isfinite(state.reward).all() + assert env._control.shape == (1, 25) + assert np.isfinite(env._control).all() + finally: + env.close() From 55ad4da5e00585383db204439c8f5f97b33a4c55 Mon Sep 17 00:00:00 2001 From: lemon-star608 Date: Mon, 24 Aug 2026 13:35:23 +0800 Subject: [PATCH 07/12] test: record T800 support matrix evidence --- tests/scripts/test_support_matrix.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/scripts/test_support_matrix.py b/tests/scripts/test_support_matrix.py index 4c732f698..29473a6f8 100644 --- a/tests/scripts/test_support_matrix.py +++ b/tests/scripts/test_support_matrix.py @@ -21,6 +21,17 @@ def test_support_matrix_marks_go2_ppo_backends_as_tested(): assert row.cells["motrix"].level == EvidenceLevel.TESTED +def test_support_matrix_marks_t800_ppo_and_sac_mujoco_tested_only(): + """T800 owner evidence is limited to the configured MuJoCo paths.""" + ppo_row = _row("PPO (torch)", "t800_walk_flat") + sac_row = _row("SAC (torch)", "t800_walk_flat") + + for row in (ppo_row, sac_row): + assert row.cells["mujoco"].level == EvidenceLevel.TESTED + assert row.cells["mjwarp"].level == EvidenceLevel.MISSING + assert row.cells["motrix"].level == EvidenceLevel.MISSING + + def test_support_matrix_marks_validated_g1_mjwarp_entrypoints_as_tested(): torch_row = _row("PPO (torch)", "g1_walk_flat") sac_row = _row("SAC (torch)", "g1_walk_flat") From adeb07a6f5a5aba3590d8a547eb7a541b9ca1720 Mon Sep 17 00:00:00 2001 From: lemon-star608 Date: Tue, 25 Aug 2026 14:48:47 +0800 Subject: [PATCH 08/12] tune(t800): extend default training iterations --- conf/ppo/task/t800_walk_flat/mujoco.yaml | 2 +- conf/sac/task/t800_walk_flat/mujoco.yaml | 2 +- tests/envs/locomotion/t800/test_t800_sac_owner.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/conf/ppo/task/t800_walk_flat/mujoco.yaml b/conf/ppo/task/t800_walk_flat/mujoco.yaml index d97ad4c96..b58966d5b 100644 --- a/conf/ppo/task/t800_walk_flat/mujoco.yaml +++ b/conf/ppo/task/t800_walk_flat/mujoco.yaml @@ -11,7 +11,7 @@ training: algo: num_envs: 2048 - max_iterations: 2200 + max_iterations: 5000 empirical_normalization: false obs_groups: actor: diff --git a/conf/sac/task/t800_walk_flat/mujoco.yaml b/conf/sac/task/t800_walk_flat/mujoco.yaml index 6351d969c..84a26f846 100644 --- a/conf/sac/task/t800_walk_flat/mujoco.yaml +++ b/conf/sac/task/t800_walk_flat/mujoco.yaml @@ -10,7 +10,7 @@ training: algo: num_envs: 2048 - max_iterations: 5000 + max_iterations: 100000 save_interval: 1000 learning_starts: 10 updates_per_step: 8 diff --git a/tests/envs/locomotion/t800/test_t800_sac_owner.py b/tests/envs/locomotion/t800/test_t800_sac_owner.py index 9e0634c69..5753755e3 100644 --- a/tests/envs/locomotion/t800/test_t800_sac_owner.py +++ b/tests/envs/locomotion/t800/test_t800_sac_owner.py @@ -86,7 +86,7 @@ def test_t800_sac_owner_materializes_complete_contract() -> None: assert cfg.algo.updates_per_step == 8 assert cfg.algo.algo_params.alpha_init == pytest.approx(0.001) assert cfg.algo.algo_params.target_entropy_ratio == pytest.approx(0.0) - assert cfg.algo.max_iterations == 5000 + assert cfg.algo.max_iterations == 100000 assert cfg.algo.save_interval == 1000 assert env_cfg.sim_dt == pytest.approx(0.002) assert env_cfg.ctrl_dt == pytest.approx(0.01) From 92a1b95ef3a01acc131f9584e26979ab496d7310 Mon Sep 17 00:00:00 2001 From: lemon-star608 Date: Tue, 25 Aug 2026 15:10:38 +0800 Subject: [PATCH 09/12] docs: document T800 assets and support --- .../en/4-developer_guide/7-motion_assets.md | 41 ++++++++++++------- .../source/en/5-reference/5-support_matrix.md | 2 + .../4-developer_guide/7-motion_assets.md | 34 ++++++++++----- .../zh_CN/5-reference/5-support_matrix.md | 2 + 4 files changed, 54 insertions(+), 25 deletions(-) diff --git a/docs/sphinx/source/en/4-developer_guide/7-motion_assets.md b/docs/sphinx/source/en/4-developer_guide/7-motion_assets.md index de3a966b7..cca872197 100644 --- a/docs/sphinx/source/en/4-developer_guide/7-motion_assets.md +++ b/docs/sphinx/source/en/4-developer_guide/7-motion_assets.md @@ -82,33 +82,45 @@ Alternatively, pre-download into the in-repo directory with `--local-dir` 3. Reference the new file path in the env config. -## Robot Mesh Assets +## Robot Binary Assets -Robot binary meshes (`.STL`) are externalized the same way, on the Hugging Face -dataset repo +Robot binary meshes and textures (for example `.STL`, `.obj`, and `.png`) are +externalized the same way, on the Hugging Face dataset repo [unilabsim/unilab-robots](https://huggingface.co/datasets/unilabsim/unilab-robots). X2 meshes download lazily on first use and land under their original path -`src/unilab/assets/robots/x2/meshes/`, so the XML `meshdir` references resolve -unchanged. Pre-fetch them without running a task: +`src/unilab/assets/robots/x2/meshes/`. T800 OBJ files and textures land under +`robots/t800/assets/` and `robots/t800/textures/`, respectively, so the original +relative XML paths remain valid. Pre-fetch them without running a task: ```bash uv run unilab-pull-assets --robot x2 +uv run unilab-pull-assets --robot t800 ``` -To add a new robot's meshes: +To add a new robot's binary assets: -1. Upload to the HF repo, keeping the directory layout identical: +1. Upload each directory to the HF repo while keeping the directory layout + identical. A robot with multiple asset directories requires one upload per + directory. For example, T800 uses: ```bash - huggingface-cli upload unilabsim/unilab-robots \ - src/unilab/assets/robots//meshes robots//meshes \ + uv run hf upload unilabsim/unilab-robots \ + src/unilab/assets/robots/t800/assets robots/t800/assets \ + --repo-type dataset + uv run hf upload unilabsim/unilab-robots \ + src/unilab/assets/robots/t800/textures robots/t800/textures \ --repo-type dataset ``` -2. Ignore the local `*.STL` in `.gitignore` (keep a `.gitkeep` so the directory - persists). -3. Resolve the directory once on a cold path from the env, e.g. - `resolve_robot_asset_dir("robots//meshes", marker=".STL")`. +2. Ignore the downloaded directory contents in `.gitignore` and keep a + `.gitkeep` so each directory persists. +3. Resolve every referenced directory on a cold path before the backend parses + the XML. The current API resolves one directory per call, so a T800 task uses: + + ```python + resolve_robot_asset_dir("robots/t800/assets", marker="LINK_BASE.obj") + resolve_robot_asset_dir("robots/t800/textures", marker="LINK_BASE.png") + ``` ## Architecture Notes @@ -120,7 +132,8 @@ To add a new robot's meshes: - Hot paths (`step` / `reset`) never trigger any file download or parsing. - `ASSETS_ROOT_PATH` is unchanged, so the download target matches the original local path exactly. -- Robot meshes use the same directory resolver (`resolve_robot_asset_dir`). The +- Robot binary assets use the same directory resolver + (`resolve_robot_asset_dir`). The thin `make_x2_wall_flip_env` factory in `src/unilab/tasks/motion_tracking/x2/__init__.py` resolves them once before delegating to the shared manager environment factory. The resolver is also diff --git a/docs/sphinx/source/en/5-reference/5-support_matrix.md b/docs/sphinx/source/en/5-reference/5-support_matrix.md index 595bf1b88..1f5b14ec5 100644 --- a/docs/sphinx/source/en/5-reference/5-support_matrix.md +++ b/docs/sphinx/source/en/5-reference/5-support_matrix.md @@ -90,6 +90,7 @@ recommendation metadata in the repo, so rows do not auto-promote to | PPO (torch) | `go2w_joystick_flat` (go2w joystick flat) | Tested | - | Tested | | PPO (torch) | `go2w_joystick_rough` (go2w joystick rough) | Tested | - | Tested | | PPO (torch) | `stewart_balance` (stewart balance) | Tested | - | Tested | +| PPO (torch) | `t800_walk_flat` (t800 walk flat) | Tested | - | - | | APPO (torch) | `go1_joystick_flat` (Go1 joystick) | Tested | - | Tested | | APPO (torch) | `go2_joystick_flat` (Go2 joystick) | Tested | - | Tested | | APPO (torch) | `g1_walk_flat` (G1 walk flat) | Tested | Registered | Registered | @@ -116,6 +117,7 @@ recommendation metadata in the repo, so rows do not auto-promote to | SAC (torch) | `g1_23dof_wall_flip_tracking` (g1 23dof wall flip tracking) | Tested | - | Registered | | SAC (torch) | `g1_23dof_wbt_obs` (g1 23dof wbt obs) | Tested | - | Registered | | SAC (torch) | `g1_wbt_obs` (g1 wbt obs) | Tested | - | Registered | +| SAC (torch) | `t800_walk_flat` (t800 walk flat) | Tested | - | - | | TD3 (torch) | `go1_joystick_flat` (Go1 joystick) | Registered | - | Tested | | TD3 (torch) | `go2_joystick_flat` (Go2 joystick) | Registered | - | Tested | | TD3 (torch) | `g1_walk_flat` (G1 walk flat) | Tested | Registered | Registered | diff --git a/docs/sphinx/source/zh_CN/4-developer_guide/7-motion_assets.md b/docs/sphinx/source/zh_CN/4-developer_guide/7-motion_assets.md index 7e04ba0b4..e59cff65c 100644 --- a/docs/sphinx/source/zh_CN/4-developer_guide/7-motion_assets.md +++ b/docs/sphinx/source/zh_CN/4-developer_guide/7-motion_assets.md @@ -75,30 +75,42 @@ env: 3. 在 env config 中引用新文件路径即可。 -## 机器人网格资产 +## 机器人二进制资产 -机器人二进制网格(`.STL`)采用相同方式外置,托管在 Hugging Face 数据集仓库 +机器人二进制网格和纹理(例如 `.STL`、`.obj`、`.png`)采用相同方式外置, +托管在 Hugging Face 数据集仓库 [unilabsim/unilab-robots](https://huggingface.co/datasets/unilabsim/unilab-robots)。 X2 网格在首次使用时按需下载,落盘到原始路径 `src/unilab/assets/robots/x2/meshes/`, -因此 XML 的 `meshdir` 引用保持有效。无需运行任务即可提前预拉取: +T800 的 OBJ 和纹理分别落盘到 `robots/t800/assets/` 和 `robots/t800/textures/`, +因此 XML 中的原始相对路径保持有效。无需运行任务即可提前预拉取: ```bash uv run unilab-pull-assets --robot x2 +uv run unilab-pull-assets --robot t800 ``` -新增某个机器人的网格: +新增某个机器人的二进制资产: -1. 上传到 HF 仓库,保持目录结构一致: +1. 按目录上传到 HF 仓库,保持目录结构一致。一个机器人有多个资产目录时, + 每个目录分别上传。例如 T800: ```bash - huggingface-cli upload unilabsim/unilab-robots \ - src/unilab/assets/robots//meshes robots//meshes \ + uv run hf upload unilabsim/unilab-robots \ + src/unilab/assets/robots/t800/assets robots/t800/assets \ + --repo-type dataset + uv run hf upload unilabsim/unilab-robots \ + src/unilab/assets/robots/t800/textures robots/t800/textures \ --repo-type dataset ``` -2. 在 `.gitignore` 中忽略本地 `*.STL`(保留 `.gitkeep` 以维持目录)。 -3. 在 env 的冷路径上调用一次目录 resolver,例如 - `resolve_robot_asset_dir("robots//meshes", marker="<某>.STL")`。 +2. 在 `.gitignore` 中忽略下载目录内容,并保留 `.gitkeep` 以维持目录。 +3. 在 backend 解析 XML 之前的冷路径解析每个引用目录。现有 API 一次只解析 + 一个目录,因此 T800 task 需要调用两次: + + ```python + resolve_robot_asset_dir("robots/t800/assets", marker="LINK_BASE.obj") + resolve_robot_asset_dir("robots/t800/textures", marker="LINK_BASE.png") + ``` ## 架构说明 @@ -107,7 +119,7 @@ uv run unilab-pull-assets --robot x2 `MotionLoader.__init__`,在冷路径上调用一次 resolver。 - 热路径(`step` / `reset`)**不会**触发任何文件下载或解析。 - `ASSETS_ROOT_PATH` 定义不变,下载落盘位置与原始本地路径完全一致。 -- 机器人网格使用同一目录 resolver(`resolve_robot_asset_dir`)。 +- 机器人二进制资产使用同一目录 resolver(`resolve_robot_asset_dir`)。 `src/unilab/tasks/motion_tracking/x2/__init__.py` 中的薄 `make_x2_wall_flip_env` factory 会先在冷路径解析一次,再委托给共享 manager env factory;同一 resolver 也通过 `unilab-pull-assets` CLI 暴露。 diff --git a/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md b/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md index a0218eed0..e8550dad7 100644 --- a/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md +++ b/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md @@ -84,6 +84,7 @@ uv run scripts/generate_support_matrix.py --write | PPO (torch) | `go2w_joystick_flat` (go2w joystick flat) | Tested | - | Tested | | PPO (torch) | `go2w_joystick_rough` (go2w joystick rough) | Tested | - | Tested | | PPO (torch) | `stewart_balance` (stewart balance) | Tested | - | Tested | +| PPO (torch) | `t800_walk_flat` (t800 walk flat) | Tested | - | - | | APPO (torch) | `go1_joystick_flat` (Go1 joystick) | Tested | - | Tested | | APPO (torch) | `go2_joystick_flat` (Go2 joystick) | Tested | - | Tested | | APPO (torch) | `g1_walk_flat` (G1 walk flat) | Tested | Registered | Registered | @@ -110,6 +111,7 @@ uv run scripts/generate_support_matrix.py --write | SAC (torch) | `g1_23dof_wall_flip_tracking` (g1 23dof wall flip tracking) | Tested | - | Registered | | SAC (torch) | `g1_23dof_wbt_obs` (g1 23dof wbt obs) | Tested | - | Registered | | SAC (torch) | `g1_wbt_obs` (g1 wbt obs) | Tested | - | Registered | +| SAC (torch) | `t800_walk_flat` (t800 walk flat) | Tested | - | - | | TD3 (torch) | `go1_joystick_flat` (Go1 joystick) | Registered | - | Tested | | TD3 (torch) | `go2_joystick_flat` (Go2 joystick) | Registered | - | Tested | | TD3 (torch) | `g1_walk_flat` (G1 walk flat) | Tested | Registered | Registered | From ed46527bae22717fb276c5e194e947cad2d99138 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Tue, 25 Aug 2026 17:35:37 +0800 Subject: [PATCH 10/12] style: apply ruff format to mjwarp benchmark test --- tests/benchmark/test_offpolicy_collector_active_benchmark.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/benchmark/test_offpolicy_collector_active_benchmark.py b/tests/benchmark/test_offpolicy_collector_active_benchmark.py index 0dea370bd..eec7b0de6 100644 --- a/tests/benchmark/test_offpolicy_collector_active_benchmark.py +++ b/tests/benchmark/test_offpolicy_collector_active_benchmark.py @@ -105,9 +105,7 @@ def test_mjwarp_backend_is_opt_in_and_never_part_of_all() -> None: with pytest.raises(SystemExit, match="mjwarp extra"): bench._resolve_backend_selection(backend="mjwarp", all_backends=False) else: - assert bench._resolve_backend_selection(backend="mjwarp", all_backends=False) == ( - "mjwarp", - ) + assert bench._resolve_backend_selection(backend="mjwarp", all_backends=False) == ("mjwarp",) def test_resolve_case_specs_deduplicates_explicit_specs() -> None: From 3139164d135f093e6248987a0b597f20c52e5467 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Tue, 25 Aug 2026 17:35:37 +0800 Subject: [PATCH 11/12] docs: regenerate support matrix for sac g1_motion_tracking mjwarp owner --- docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md b/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md index e8550dad7..24792adfa 100644 --- a/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md +++ b/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md @@ -101,7 +101,7 @@ uv run scripts/generate_support_matrix.py --write | APPO (torch) | `g1_climb_tracking` (g1 climb tracking) | Tested | - | Tested | | SAC (torch) | `g1_walk_flat` (G1 walk flat) | Tested | Tested | Tested | | SAC (torch) | `g1_walk_rough` (G1 walk rough) | Tested | - | Tested | -| SAC (torch) | `g1_motion_tracking` (G1 motion tracking) | Tested | - | Tested | +| SAC (torch) | `g1_motion_tracking` (G1 motion tracking) | Tested | Configured | Tested | | SAC (torch) | `g1_flip_tracking` (G1 flip tracking) | Tested | - | Registered | | SAC (torch) | `g1_wall_flip_tracking` (G1 wall flip tracking) | Tested | - | Registered | | SAC (torch) | `g1_23dof_flip_tracking` (g1 23dof flip tracking) | Tested | - | Registered | From 9cafe12292f78b6be41ec1d1d82c04c6b24e2306 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Tue, 25 Aug 2026 17:56:26 +0800 Subject: [PATCH 12/12] feat(sac): add T800 walk-flat mjwarp owner --- conf/sac/task/t800_walk_flat/mjwarp.yaml | 31 +++++++++++++++++++ .../source/en/5-reference/5-support_matrix.md | 20 +++++++----- .../zh_CN/5-reference/5-support_matrix.md | 6 ++-- scripts/tools/support_matrix.py | 6 ++-- src/unilab/tasks/locomotion/t800/__init__.py | 1 + .../t800/test_t800_owner_contract.py | 15 ++++----- tests/scripts/test_support_matrix.py | 8 +++-- 7 files changed, 64 insertions(+), 23 deletions(-) create mode 100644 conf/sac/task/t800_walk_flat/mjwarp.yaml diff --git a/conf/sac/task/t800_walk_flat/mjwarp.yaml b/conf/sac/task/t800_walk_flat/mjwarp.yaml new file mode 100644 index 000000000..333317edb --- /dev/null +++ b/conf/sac/task/t800_walk_flat/mjwarp.yaml @@ -0,0 +1,31 @@ +# @package _global_ +# Configured-only SAC mjwarp owner for the T800 Manager-Based task. Keeps +# DENYLIST parity with the MuJoCo owner plus the mjwarp capacity knobs; kp/kd +# randomization is disabled because the mjwarp backend does not advertise gain +# DR support. Offline record reuses MuJoCo rendering; native playback and +# device-resident runtime are intentionally absent. +defaults: + - /task/t800_walk_flat/base + - _self_ + +training: + task_name: T800WalkFlat + sim_backend: mjwarp + play_render_mode: record +algo: + num_envs: 2048 + max_iterations: 10000 + save_interval: 1000 + learning_starts: 10 + updates_per_step: 8 + gamma: 0.98488578 + algo_params: + alpha_init: 0.001 + target_entropy_ratio: 0.0 +env: + mjwarp_nconmax: 128 + mjwarp_njmax: 256 + render_spacing: 2.0 + events: + # mjwarp does not advertise kp/kd gain DR support. + pd_gains: null diff --git a/docs/sphinx/source/en/5-reference/5-support_matrix.md b/docs/sphinx/source/en/5-reference/5-support_matrix.md index 1f5b14ec5..cd37cb7af 100644 --- a/docs/sphinx/source/en/5-reference/5-support_matrix.md +++ b/docs/sphinx/source/en/5-reference/5-support_matrix.md @@ -10,9 +10,10 @@ mirrors that generated content. - The default backend is `mujoco`. - Switch to Motrix with `--sim motrix` on the unified CLI. -- `--sim mjwarp` currently maps only to the `g1_walk_flat` host adapter; PPO - (torch) and SAC (torch) are Tested, other entrypoints follow the matrix - below, and using it requires installing the `mjwarp` extra. +- `--sim mjwarp` has completed training validation only on the `g1_walk_flat` + host adapter, where PPO (torch) and SAC (torch) are Tested; the SAC + `t800_walk_flat` mjwarp owner is Configured only, other entrypoints follow + the matrix below, and using it requires installing the `mjwarp` extra. - `--algo`, `--task`, and `--sim` jointly select the owner YAML. - Do not treat `training.sim_backend` as a standalone backend switch. @@ -42,9 +43,12 @@ training validation; it does not imply the combination has all the backend capabilities of the same-named MuJoCo owner. For example, a phase-1 Motrix owner may only cover training smoke and an explicitly enabled DR subset. -`mjwarp` only supports the `g1_walk_flat` host adapter. The PPO (torch) and SAC -(torch) owners have completed training validation and have backend, contract, -and playback automated coverage, so they are marked `Tested`. mjwarp playback +`mjwarp` has completed training validation only on the `g1_walk_flat` host +adapter: the PPO (torch) and SAC (torch) owners have completed training +validation and have backend, contract, and playback automated coverage, so +they are marked `Tested`. The SAC `t800_walk_flat` mjwarp owner only has an +owner YAML and compose coverage, so it is marked `Configured`, which does not +imply training validation. mjwarp playback only supports explicit, finite-step `record` and reuses the MuJoCo offline renderer; it does not support `auto`, interactive, or native playback. A `Registered` mark on other entrypoints only denotes env/backend registry @@ -90,7 +94,7 @@ recommendation metadata in the repo, so rows do not auto-promote to | PPO (torch) | `go2w_joystick_flat` (go2w joystick flat) | Tested | - | Tested | | PPO (torch) | `go2w_joystick_rough` (go2w joystick rough) | Tested | - | Tested | | PPO (torch) | `stewart_balance` (stewart balance) | Tested | - | Tested | -| PPO (torch) | `t800_walk_flat` (t800 walk flat) | Tested | - | - | +| PPO (torch) | `t800_walk_flat` (t800 walk flat) | Tested | Registered | - | | APPO (torch) | `go1_joystick_flat` (Go1 joystick) | Tested | - | Tested | | APPO (torch) | `go2_joystick_flat` (Go2 joystick) | Tested | - | Tested | | APPO (torch) | `g1_walk_flat` (G1 walk flat) | Tested | Registered | Registered | @@ -117,7 +121,7 @@ recommendation metadata in the repo, so rows do not auto-promote to | SAC (torch) | `g1_23dof_wall_flip_tracking` (g1 23dof wall flip tracking) | Tested | - | Registered | | SAC (torch) | `g1_23dof_wbt_obs` (g1 23dof wbt obs) | Tested | - | Registered | | SAC (torch) | `g1_wbt_obs` (g1 wbt obs) | Tested | - | Registered | -| SAC (torch) | `t800_walk_flat` (t800 walk flat) | Tested | - | - | +| SAC (torch) | `t800_walk_flat` (t800 walk flat) | Tested | Configured | - | | TD3 (torch) | `go1_joystick_flat` (Go1 joystick) | Registered | - | Tested | | TD3 (torch) | `go2_joystick_flat` (Go2 joystick) | Registered | - | Tested | | TD3 (torch) | `g1_walk_flat` (G1 walk flat) | Tested | Registered | Registered | diff --git a/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md b/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md index 24792adfa..c9e2c0a15 100644 --- a/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md +++ b/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md @@ -45,7 +45,7 @@ uv run scripts/generate_support_matrix.py --write `Tested` 只描述仓库中已有自动化覆盖或显式 maintainer 训练验证,不代表该组合具备同名 MuJoCo owner 的全部 backend capability;例如 phase-1 Motrix owner 可能只覆盖训练 smoke 和明确启用的 DR 子集。 -`mjwarp` 只支持 `g1_walk_flat` host adapter。PPO (torch) 与 SAC (torch) owner 已完成训练验证,并有 backend、contract 与 playback 自动化覆盖,因此标记为 `Tested`。mjwarp playback 仅支持显式、有限步数的 `record` 并复用 MuJoCo 离线 renderer,不支持 `auto`、interactive 或 native playback。其他 entrypoint 中出现的 `Registered` 只表示 env/backend registry identity,不代表对应算法、terrain、完整 DR 或 production training 支持。 +`mjwarp` 完成训练验证的只有 `g1_walk_flat` host adapter:PPO (torch) 与 SAC (torch) owner 已完成训练验证,并有 backend、contract 与 playback 自动化覆盖,因此标记为 `Tested`。SAC `t800_walk_flat` 的 mjwarp owner 只有 owner YAML 与 compose 覆盖,标记为 `Configured`,不代表训练验证。mjwarp playback 仅支持显式、有限步数的 `record` 并复用 MuJoCo 离线 renderer,不支持 `auto`、interactive 或 native playback。其他 entrypoint 中出现的 `Registered` 只表示 env/backend registry identity,不代表对应算法、terrain、完整 DR 或 production training 支持。 未检测到与这些组合绑定的已提交 benchmark manifest,因此当前不会自动提升到 `Benchmarked`。 仓库中目前也没有单独的 recommendation 元数据,因此当前不会自动提升到 `Recommended`。 @@ -84,7 +84,7 @@ uv run scripts/generate_support_matrix.py --write | PPO (torch) | `go2w_joystick_flat` (go2w joystick flat) | Tested | - | Tested | | PPO (torch) | `go2w_joystick_rough` (go2w joystick rough) | Tested | - | Tested | | PPO (torch) | `stewart_balance` (stewart balance) | Tested | - | Tested | -| PPO (torch) | `t800_walk_flat` (t800 walk flat) | Tested | - | - | +| PPO (torch) | `t800_walk_flat` (t800 walk flat) | Tested | Registered | - | | APPO (torch) | `go1_joystick_flat` (Go1 joystick) | Tested | - | Tested | | APPO (torch) | `go2_joystick_flat` (Go2 joystick) | Tested | - | Tested | | APPO (torch) | `g1_walk_flat` (G1 walk flat) | Tested | Registered | Registered | @@ -111,7 +111,7 @@ uv run scripts/generate_support_matrix.py --write | SAC (torch) | `g1_23dof_wall_flip_tracking` (g1 23dof wall flip tracking) | Tested | - | Registered | | SAC (torch) | `g1_23dof_wbt_obs` (g1 23dof wbt obs) | Tested | - | Registered | | SAC (torch) | `g1_wbt_obs` (g1 wbt obs) | Tested | - | Registered | -| SAC (torch) | `t800_walk_flat` (t800 walk flat) | Tested | - | - | +| SAC (torch) | `t800_walk_flat` (t800 walk flat) | Tested | Configured | - | | TD3 (torch) | `go1_joystick_flat` (Go1 joystick) | Registered | - | Tested | | TD3 (torch) | `go2_joystick_flat` (Go2 joystick) | Registered | - | Tested | | TD3 (torch) | `g1_walk_flat` (G1 walk flat) | Tested | Registered | Registered | diff --git a/scripts/tools/support_matrix.py b/scripts/tools/support_matrix.py index 2ebbb172d..8cc0c12dd 100644 --- a/scripts/tools/support_matrix.py +++ b/scripts/tools/support_matrix.py @@ -294,8 +294,10 @@ def render_support_matrix(root: Path | None = None) -> str: "`Tested` 只描述仓库中已有自动化覆盖或显式 maintainer 训练验证,不代表该组合具备同名 MuJoCo " "owner 的全部 backend capability;例如 phase-1 Motrix owner 可能只覆盖训练 smoke 和明确启用的 DR 子集。", "", - "`mjwarp` 只支持 `g1_walk_flat` host adapter。PPO (torch) 与 SAC (torch) owner 已完成训练验证,并有 " - "backend、contract 与 playback 自动化覆盖,因此标记为 `Tested`。" + "`mjwarp` 完成训练验证的只有 `g1_walk_flat` host adapter:PPO (torch) 与 SAC (torch) owner " + "已完成训练验证,并有 backend、contract 与 playback 自动化覆盖,因此标记为 `Tested`。" + "SAC `t800_walk_flat` 的 mjwarp owner 只有 owner YAML 与 compose 覆盖,标记为 `Configured`," + "不代表训练验证。" "mjwarp playback 仅支持显式、有限步数的 `record` 并复用 MuJoCo 离线 renderer,不支持 `auto`、" "interactive 或 native playback。其他 entrypoint 中出现的 `Registered` 只表示 env/backend registry " "identity,不代表对应算法、terrain、完整 DR 或 production training 支持。", diff --git a/src/unilab/tasks/locomotion/t800/__init__.py b/src/unilab/tasks/locomotion/t800/__init__.py index c706436f4..5262d9a2e 100644 --- a/src/unilab/tasks/locomotion/t800/__init__.py +++ b/src/unilab/tasks/locomotion/t800/__init__.py @@ -18,6 +18,7 @@ def make_t800_walk_env( registry.register_env_config("T800WalkFlat", ManagerBasedRlEnvCfg) registry.register_env("T800WalkFlat", make_t800_walk_env, sim_backend="mujoco") +registry.register_env("T800WalkFlat", make_t800_walk_env, sim_backend="mjwarp") __all__ = ["make_t800_walk_env"] diff --git a/tests/envs/locomotion/t800/test_t800_owner_contract.py b/tests/envs/locomotion/t800/test_t800_owner_contract.py index 38e89dbc3..953131beb 100644 --- a/tests/envs/locomotion/t800/test_t800_owner_contract.py +++ b/tests/envs/locomotion/t800/test_t800_owner_contract.py @@ -11,22 +11,23 @@ def test_t800_registry_metadata_is_manager_based_and_bootstrapped_explicitly() -> None: - """The production bootstrap exposes only the Manager-Based MuJoCo owner.""" + """The production bootstrap exposes the Manager-Based MuJoCo and mjwarp owners.""" registry.ensure_registries() assert "unilab.tasks.locomotion.t800" in __unilab_registry_modules__ metadata = registry.list_registered_envs() assert metadata["T800WalkFlat"] == { "config_factory": "ManagerBasedRlEnvCfg", - "available_backends": ["mujoco"], + "available_backends": ["mujoco", "mjwarp"], } t800 = importlib.import_module("unilab.tasks.locomotion.t800") - factory = registry._envs["T800WalkFlat"].env_factory_dict["mujoco"] - assert factory is t800.make_t800_walk_env - assert callable(factory) - assert factory.__name__ == "make_t800_walk_env" - assert factory.__module__ == "unilab.tasks.locomotion.t800" + for backend in ("mujoco", "mjwarp"): + factory = registry._envs["T800WalkFlat"].env_factory_dict[backend] + assert factory is t800.make_t800_walk_env + assert callable(factory) + assert factory.__name__ == "make_t800_walk_env" + assert factory.__module__ == "unilab.tasks.locomotion.t800" assert registry._envs["T800WalkFlat"].env_cfg_factory is ManagerBasedRlEnvCfg try: diff --git a/tests/scripts/test_support_matrix.py b/tests/scripts/test_support_matrix.py index 29473a6f8..9b22867d5 100644 --- a/tests/scripts/test_support_matrix.py +++ b/tests/scripts/test_support_matrix.py @@ -21,16 +21,18 @@ def test_support_matrix_marks_go2_ppo_backends_as_tested(): assert row.cells["motrix"].level == EvidenceLevel.TESTED -def test_support_matrix_marks_t800_ppo_and_sac_mujoco_tested_only(): - """T800 owner evidence is limited to the configured MuJoCo paths.""" +def test_support_matrix_marks_t800_mujoco_tested_and_sac_mjwarp_configured_only(): + """T800 evidence stays on MuJoCo; the SAC mjwarp owner is configured but unvalidated.""" ppo_row = _row("PPO (torch)", "t800_walk_flat") sac_row = _row("SAC (torch)", "t800_walk_flat") for row in (ppo_row, sac_row): assert row.cells["mujoco"].level == EvidenceLevel.TESTED - assert row.cells["mjwarp"].level == EvidenceLevel.MISSING assert row.cells["motrix"].level == EvidenceLevel.MISSING + assert ppo_row.cells["mjwarp"].level == EvidenceLevel.REGISTERED + assert sac_row.cells["mjwarp"].level == EvidenceLevel.CONFIGURED + def test_support_matrix_marks_validated_g1_mjwarp_entrypoints_as_tested(): torch_row = _row("PPO (torch)", "g1_walk_flat")