Skip to content

Feat/microduck sim - #3937

Open
Nabla7 wants to merge 5 commits into
mainfrom
feat/microduck-sim
Open

Feat/microduck sim#3937
Nabla7 wants to merge 5 commits into
mainfrom
feat/microduck-sim

Conversation

@Nabla7

@Nabla7 Nabla7 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

What

Adds microduck-sim: the official MicroDuck MJCF and seven non-roller ONNX policies running in MuJoCo, with Rerun visualization and controls.

Run

uv sync --extra cpu --extra sim --extra visualization
uv run dimos --simulation mujoco --viewer rerun run microduck-sim

Test

WASD locomotion, sit/stand, head control, one-shot motions, E-stop, and reset.

AI assistance

Codex (GPT-5), extensive.

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

❌ 3 Tests Failed:

Tests completed Failed Passed Skipped
4826 3 4823 182
View the top 3 failed test(s) by shortest run time
dimos.codebase_checks.test_docs_branding::test_docs_use_current_branding
Stack Traces | 0.013s run time
def test_docs_use_current_branding() -> None:
        """Fail if any file under docs/ spells the brand "DimOS" instead of "dimOS"."""
        hits = find_old_branding()
        if hits:
            listing = "\n".join(
                f"  - {p.relative_to(DIMOS_PROJECT_ROOT)}:{lineno}: {line.strip()}"
                for p, lineno, line in hits
            )
>           raise AssertionError(f'Found "DimOS" in docs/:\n{listing}\n\nThe brand is spelled "dimOS".')
E           AssertionError: Found "DimOS" in docs/:
E             - docs/usage/microduck-simulation.md:59: MuJoCo is always headless in this blueprint. `--viewer` controls DimOS
E             - docs/usage/microduck-simulation.md:66: Focus the DimOS Rerun viewer and use W/S for forward/reverse, Q/E for strafe,
E           
E           The brand is spelled "dimOS".

hits       = [(PosixPath('.../docs/usage/microduck-simulation.md'), 59, 'MuJoCo is always headless in thi.../usage/microduck-simulation.md'), 66, 'Focus the DimOS Rerun viewer and use W/S for forward/reverse, Q/E for strafe,')]
listing    = '  - docs/usage/microduck-simulation.md:59: MuJoCo is always headless in this blueprint. `--viewer` controls DimOS\n  - docs/usage/microduck-simulation.md:66: Focus the DimOS Rerun viewer and use W/S for forward/reverse, Q/E for strafe,'

dimos/codebase_checks/test_docs_branding.py:43: AssertionError
dimos.hardware.test_adapter_registries::test_declared_names_match_golden_set[whole_body]
Stack Traces | 0.191s run time
registry = <dimos.hardware.whole_body.registry.WholeBodyAdapterRegistry object at 0xff08d7c8dcd0>
family = 'whole_body'

    @pytest.mark.parametrize(("registry", "family"), FAMILIES)
    def test_declared_names_match_golden_set(registry, family) -> None:
>       assert set(registry._factory_paths) == EXPECTED_NAMES[family]
E       AssertionError: assert {'dual_openya...croduck', ...} == {'dual_openya...ort_lcm', ...}
E         
E         Extra items in the left set:
E         #x1B[0m#x1B[33m'#x1B[39;49;00m#x1B[33msim_mujoco_microduck#x1B[39;49;00m#x1B[33m'#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
E         
E         Full diff:
E         #x1B[0m#x1B[90m #x1B[39;49;00m {#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m     'dual_openyam_damiao',#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m     'mock_whole_body',#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m     'openarm_damiao',#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m     'openyam_damiao',#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m     'sim_mujoco_g1',#x1B[90m#x1B[39;49;00m
E         #x1B[92m+     'sim_mujoco_microduck',#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m     'transport_lcm',#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m     'transport_ros',#x1B[90m#x1B[39;49;00m
E         #x1B[90m #x1B[39;49;00m }#x1B[90m#x1B[39;49;00m

family     = 'whole_body'
registry   = <dimos.hardware.whole_body.registry.WholeBodyAdapterRegistry object at 0xff08d7c8dcd0>

dimos/hardware/test_adapter_registries.py:106: AssertionError
dimos.robot.pollen.microduck.test_simulation::test_scene_package_uses_standard_mujoco_composition
Stack Traces | 3.15s run time
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0xff8ab5e3fe60>
tmp_path = PosixPath('.../pytest-0/popen-gw1/test_scene_package_uses_standa0')

    def test_scene_package_uses_standard_mujoco_composition(
        monkeypatch: pytest.MonkeyPatch,
        tmp_path: Path,
    ) -> None:
        scene_xml = tmp_path / "scene.xml"
        package = ScenePackage(
            package_dir=tmp_path,
            source_path=tmp_path / "source.glb",
            alignment=SceneMeshAlignment(),
            mujoco_scene_path=scene_xml,
            entities=[{"id": "test_entity"}],
        )
        monkeypatch.setattr(
            microduck_blueprint,
            "resolve_scene_package",
            lambda _scene: package,
        )
    
        backend, adapter_address = microduck_blueprint._microduck_mujoco_backend("office")
        simulator = backend.active_blueprints[0]
    
        assert simulator.module is MujocoSimModule
        assert simulator.kwargs["scene_xml"] == scene_xml
>       assert simulator.kwargs["robot_mjcf"] == MICRODUCK_ROBOT_MJCF

adapter_address = <[RuntimeError("Failed to pull LFS file .../dimos/data/.lfs/microduck.tar.gz after 3 attempts: Com...icroduck.tar.gz', '--exclude=']' returned non-zero exit status 1.") raised in repr()] LfsPath object at 0xff8a99f13b50>
backend    = <[RuntimeError("Failed to pull LFS file .../dimos/data/.lfs/microduck.tar.gz after 3 attempts: Com...roduck.tar.gz', '--exclude=']' returned non-zero exit status 1.") raised in repr()] Blueprint object at 0xff8ab5e3da60>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0xff8ab5e3fe60>
package    = ScenePackage(package_dir=PosixPath('.../pytest-0/popen-gw1/test_scene_package_uses_standa0'), source..., mujoco_binary_path=None, mujoco_composed_binaries={}, metadata_path=None, entities=[{'id': 'test_entity'}], stats={})
scene_xml  = PosixPath('.../pytest-0/popen-gw1/test_scene_package_uses_standa0/scene.xml')
simulator  = <[RuntimeError("Failed to pull LFS file .../dimos/data/.lfs/microduck.tar.gz after 3 attempts: Com...ck.tar.gz', '--exclude=']' returned non-zero exit status 1.") raised in repr()] BlueprintAtom object at 0xff8ab5e3f7d0>
tmp_path   = PosixPath('.../pytest-0/popen-gw1/test_scene_package_uses_standa0')

.../pollen/microduck/test_simulation.py:114: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../usr/lib/python3.12/pathlib.py:522: in __eq__
    return self._str_normcase == other._str_normcase and self._flavour is other._flavour
        other      = <[RuntimeError("Failed to pull LFS file .../dimos/data/.lfs/microduck.tar.gz after 3 attempts: Com...icroduck.tar.gz', '--exclude=']' returned non-zero exit status 1.") raised in repr()] LfsPath object at 0xff8a99f13b50>
        self       = <[RuntimeError("Failed to pull LFS file .../dimos/data/.lfs/microduck.tar.gz after 3 attempts: Com...icroduck.tar.gz', '--exclude=']' returned non-zero exit status 1.") raised in repr()] LfsPath object at 0xff8a99f13b50>
dimos/utils/data.py:370: in __getattribute__
    resolved = object.__getattribute__(self, "_ensure_downloaded")()
        name       = '_str_normcase'
        self       = <[RuntimeError("Failed to pull LFS file .../dimos/data/.lfs/microduck.tar.gz after 3 attempts: Com...icroduck.tar.gz', '--exclude=']' returned non-zero exit status 1.") raised in repr()] LfsPath object at 0xff8a99f13b50>
dimos/utils/data.py:353: in _ensure_downloaded
    cache = get_data(filename)
        cache      = None
        filename   = 'microduck/robot_groundcontact.xml'
        self       = <[RuntimeError("Failed to pull LFS file .../dimos/data/.lfs/microduck.tar.gz after 3 attempts: Com...icroduck.tar.gz', '--exclude=']' returned non-zero exit status 1.") raised in repr()] LfsPath object at 0xff8a99f13b50>
dimos/utils/data.py:310: in get_data
    archive_path = _decompress_archive(_pull_lfs_archive(archive_name))
        archive_name = 'microduck'
        data_dir   = PosixPath('.../dimos/dimos/data')
        file_path  = PosixPath('.../dimos/dimos/data/microduck/robot_groundcontact.xml')
        name       = 'microduck/robot_groundcontact.xml'
        nested_path = PosixPath('robot_groundcontact.xml')
        path_parts = ('microduck', 'robot_groundcontact.xml')
dimos/utils/data.py:254: in _pull_lfs_archive
    _lfs_pull(file_path, repo_root)
        file_path  = PosixPath('.../dimos/data/.lfs/microduck.tar.gz')
        filename   = 'microduck'
        repo_root  = PosixPath('.../work/dimos/dimos')
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

file_path = PosixPath('.../dimos/data/.lfs/microduck.tar.gz')
repo_root = PosixPath('.../work/dimos/dimos')

    def _lfs_pull(file_path: Path, repo_root: Path, *, retries: int = 2) -> None:
        relative_path = file_path.relative_to(repo_root)
    
        env = os.environ.copy()
        env["GIT_LFS_FORCE_PROGRESS"] = "1"
    
        last_err: subprocess.CalledProcessError | None = None
        for attempt in range(1, retries + 2):  # retries + 1 total attempts
            try:
                subprocess.run(
                    # --exclude= overrides lfs.fetchexclude from .lfsconfig, which
                    # otherwise silently skips data/.lfs/* even when --include matches.
                    ["git", "lfs", "pull", "--include", str(relative_path), "--exclude="],
                    cwd=repo_root,
                    check=True,
                    env=env,
                )
                return
            except subprocess.CalledProcessError as e:
                last_err = e
                if attempt <= retries:
                    time.sleep(attempt)  # 1s, 2s backoff
    
>       raise RuntimeError(
            f"Failed to pull LFS file {file_path} after {retries + 1} attempts: {last_err}"
        )
E       RuntimeError: Failed to pull LFS file .../dimos/data/.lfs/microduck.tar.gz after 3 attempts: Command '['git', 'lfs', 'pull', '--include', 'data/.lfs/microduck.tar.gz', '--exclude=']' returned non-zero exit status 1.

attempt    = 3
env        = {'ACCEPT_EULA': 'Y', 'ACTIONS_ID_TOKEN_REQUEST_TOKEN': 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjM4ODI2YjE3LTZhMzAtNWY5Yi1iMTY5LT...-version=2.0', 'ACTIONS_ORCHESTRATION_ID': 'a46e7d00-ad1f-404e-905e-395f2854d312.tests.ubuntu-24_04-arm_3_14_fal', ...}
file_path  = PosixPath('.../dimos/data/.lfs/microduck.tar.gz')
last_err   = CalledProcessError(1, ['git', 'lfs', 'pull', '--include', 'data/.lfs/microduck.tar.gz', '--exclude='])
relative_path = PosixPath('data/.lfs/microduck.tar.gz')
repo_root  = PosixPath('.../work/dimos/dimos')
retries    = 2

dimos/utils/data.py:224: RuntimeError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@greptile-apps

greptile-apps Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a complete MicroDuck MuJoCo simulation stack, including seven ONNX policies, whole-body control and shared-memory adaptation, robot configuration, blueprint registration, Rerun MJCF visualization, documentation, and integration tests.

  • Adds passive policy scheduling for locomotion, posture, gaze, body pose, and one-shot motions.
  • Adds the official MicroDuck model and policy assets through Git LFS.
  • Adds a headless MuJoCo blueprint with camera, IMU, odometry, joint-state, teleoperation, and optional scene-package support.
  • Adds generic MJCF-to-Rerun mesh and articulation visualization.
  • One control-safety issue remains: disabling policy output leaves the simulator’s last actuator targets latched.

Confidence Score: 4/5

The PR should not merge until E-stop and disarm replace or clear previously latched actuator targets.

The new task stops producing output when disabled, but MuJoCo continues applying its last position command, allowing joints to continue moving toward stale targets after E-stop.

Files Needing Attention: dimos/control/tasks/microduck_policy_task/microduck_policy_task.py

Important Files Changed

Filename Overview
dimos/control/tasks/microduck_policy_task/microduck_policy_task.py Implements policy loading, observations, inference, motion scheduling, and lifecycle handling; disabled states fail to replace the simulator’s latched actuator targets.
dimos/control/tasks/microduck_policy_task/head_kinematics.py Adds forward kinematics and damped gaze IK with policy-envelope clamping.
dimos/robot/pollen/microduck/blueprints/simulation.py Assembles the simulator, coordinator, policy task, visualization, transports, and optional scene-package composition.
dimos/robot/pollen/microduck/config.py Centralizes the policy joint ABI, limits, model assets, and explicit MuJoCo robot binding.
dimos/simulation/adapters/whole_body/microduck.py Adds the 14-joint shared-memory adapter using the model’s native position actuators.
dimos/simulation/engines/mujoco_sim_module.py Adds a validated timestep override and applies it consistently during model composition.
dimos/visualization/rerun/mjcf_robot.py Adds reusable MJCF mesh extraction and articulated body-transform generation for Rerun.
docs/usage/microduck-simulation.md Documents setup, controls, public interfaces, architecture, policy ABI, and safety behavior.

Reviews (1): Last reviewed commit: "Merge branch 'main' into feat/microduck-..." | Re-trigger Greptile

Comment on lines +352 to +353
if not self._active or not self._armed or self._estopped:
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale targets survive E-stop

When E-stop, disarm, or an inference failure makes compute() return None, arbitration sends no replacement command for these joints. MuJoCo then continues applying its previously latched position target on every physics step. If a joint has not reached that target, it can continue moving after E-stop instead of becoming inert within one coordinator tick. Please emit an explicit safe hold/stop command or clear the adapter's latched command when disabling output.

Knowledge Base Used:

aromeoes added a commit that referenced this pull request Sep 4, 2026
Every dimos package named after a specific robot model sits two levels deep,
under its vendor or class: unitree/{b1,g1,go2}, manipulators/{a1z,a750,
dual_openyam,openarm,openyam,piper,xarm}, diy/alfred - eleven of eleven. Ours
was the only one placed flat, and the flat path also discarded provenance our
own code already knows: assets_fetch.py pulls from pollen-robotics/microduck
and pollen-robotics/microduck_rl.

PR #3937, which adds Microduck support independently, puts it at
dimos/robot/pollen/microduck/. That is the unitree/g1 shape and it is right, so
follow it. A single-tenant vendor directory has precedent in diy/alfred, so the
"extra empty level" objection does not apply. No __init__.py: the repo uses
implicit namespace packages throughout dimos/robot.

Mechanical: git mv plus 49 import references and 5 path strings; the blueprint
registry regenerated to match. Blueprint and module resolution verified for all
six registry entries.

Also fixes a fragility a review flagged in the render_depth change: depth_needed
was bound AFTER the add_camera closure that reads it. It worked only because
the sole call site sits below the assignment, so any future caller placed above
it would have raised NameError. Bound before the closure now, and primary_needed
reduced to `enable_color or depth_needed` - it had been spelling out the same
two conditions a second time.

Tests: 970 python + 24 mujoco-marked, ruff clean.
@omarespejel

Copy link
Copy Markdown
Contributor

lol good idea

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we now support pulling robot asset from remote directly :), so better use that instead of copying files to our repo: https://docs.dimensional.org/capabilities/manipulation/adding_a_custom_arm/#4a-add-your-urdf

from numpy.typing import NDArray

_Vec3 = NDArray[np.float64]
_Quat = NDArray[np.float64]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we doing all the quaternion calculation by hand?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have these in repo better align with those:

from dimos.msgs.geometry_msgs.Quaternion import Quaternion
dimos.utils.transform_utils


# rest position, rest quaternion (wxyz), then a rotation about local +z
_HEAD_CHAIN: tuple[tuple[tuple[float, ...], tuple[float, ...]], ...] = (
((0.026, 0.0145, 0.0324215), (0.0, 0.0, 0.707107, -0.707107)),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry for being pedantic I just feel like these math representation deserves more unified types... this is just an SE3 pose with quaternion as rotation repr so should be quite standard type

((0.0, 0.0186931, -0.0145), (0.0, 0.0, -0.707107, -0.707107)),
((-0.0179, 0.0, 0.0145), (0.707107, 0.0, -0.707107, 0.0)),
)
_CAMERA_POS = np.asarray((0.0155, -9.13778e-05, -0.0733), dtype=np.float64)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these information not present in the mjcf? if so shouldn't we use proper fk lib for these calculation like pinocchio?

@dataclass
class MicroDuckPolicyTaskConfig:
policy_dir: str | Path
joint_names: list[str]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should only put variables that's really changable in the config. if joint_names are never gonna change why not just hardcode as a constant? also can avoid the action_len check below

Comment on lines +139 to +144
if config.timeout <= 0.0:
raise ValueError("MicroDuck velocity timeout must be positive")
for field_name in ("command_alpha", "head_alpha", "body_alpha"):
value = float(getattr(config, field_name))
if not 0.0 < value <= 1.0:
raise ValueError(f"{field_name} must be in (0, 1]")

@TomCC7 TomCC7 Sep 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these config verification can be done in pydantic field declaration or custom validator if you declare config to be a pydantic model or dataclass

Comment on lines +201 to +206
expected = {
"schema_version": 2,
"model_api": 1,
"obs_len": OBS_LEN,
"action_len": ACTION_LEN,
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should just declare a pydantic model for this and use it's serialization directly

)

@staticmethod
def _xyzw(quaternion_wxyz: Any) -> list[float]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bunch of Any across the PR, most of them can be actually properly typed

Comment on lines +163 to +172
{
("cmd_vel", Twist): LCMTransport("/microduck/cmd_vel", Twist),
("microduck_joints", JointState): LCMTransport("/microduck/joints", JointState),
("imu", Imu): LCMTransport("/microduck/imu", Imu),
("odom", PoseStamped): LCMTransport("/microduck/odom", PoseStamped),
("color_image", Image): pSHMTransport("/microduck/color_image"),
("depth_image", Image): pSHMTransport("/microduck/depth_image"),
("camera_info", CameraInfo): LCMTransport("/microduck/camera_info", CameraInfo),
("depth_camera_info", CameraInfo): LCMTransport(
"/microduck/depth_camera_info", CameraInfo

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you testing on macos? that explains the manual lcm override lol. we need to fix zenoh

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants