"""Assembly-tuned DROID embodiment for Isaac Lab Arena. Registers ``droid_abs_joint_pos_softmimic``: the stock Arena DROID absolute- joint-position embodiment plus the assembly benchmark's contact-stability tuning, authored ONCE at construction instead of mutated imperatively inside ``AssemblyBenchEnvironment.get_env`` on every env build. Why the overlay exists ---------------------- The DROID hand is a Robotiq 2F-85 whose two fingers are mechanically linked; PhysX models that linkage as a "mimic joint" -- a penalty spring with a ``naturalFrequency`` (stiffness) and ``dampingRatio``. The stock asset authors it near-rigid and undamped (naturalFrequency 1e6, dampingRatio ~0). At the benchmark's 60 Hz step that spring is numerically unstable (omega*dt >> 1), so a hard ram self-amplifies and the gripper explodes (velocities -> NaN). The calibrated DROID USD must not be edited (its wrist camera is mounted frame-for-frame against the source demos), so we author a thin USD *overlay* that references the calibrated asset and overrides only the five mimic springs to a stiff, well-damped coupling (naturalFrequency 1000, dampingRatio 0.7). PhysX parses joint params at spawn, so this has to live on the joint prim, not an actuator. Why a registered embodiment (not imperative mutation) ----------------------------------------------------- The overlay is pure static data (it depends only on the source USD path), so it is authored once per process and reused. ``Usd.Stage.CreateNew`` raises if the layer identifier is already registered -- which is exactly what broke the served flow, where the env is built twice (serve-time build + first ``reset`` rebuild). Binding the tuning to a registered embodiment subclass makes the build path declarative and rebuild-safe, per Arena's external-package pattern. """ from __future__ import annotations import os import tempfile from typing import Any from isaaclab.managers import ObservationTermCfg as ObsTerm, SceneEntityCfg from isaaclab_arena.assets.register import register_asset from isaaclab_arena.embodiments.droid.droid import DroidAbsoluteJointPositionEmbodiment from assembly_bench.environments.assembly.observations import arm_joint_vel, part_pose # Robotiq 2F-85 mimic-joint prims (relative to the flattened DROID asset) and # the PhysX penalty-spring axis each one couples. _MIMIC_JOINTS = ( ("right_outer_knuckle_joint", "rotZ"), ("right_inner_finger_joint", "rotX"), ("right_inner_finger_knuckle_joint", "rotX"), ("left_inner_finger_knuckle_joint", "rotX"), ("left_inner_finger_joint", "rotX"), ) _MIMIC_NATURAL_FREQUENCY = 1000.0 _MIMIC_DAMPING_RATIO = 0.7 # Authored overlays, keyed by source USD path: the overlay is static, so build # it once per process and reuse it across every env (re)build. A second # Usd.Stage.CreateNew on the same layer identifier would raise. _OVERLAY_CACHE: dict[str, str] = {} def _softened_mimic_overlay(source_usd_path: str) -> str: """Path to a USD overlay referencing *source_usd_path* with the Robotiq mimic springs softened. Authored once per process, then reused.""" cached = _OVERLAY_CACHE.get(source_usd_path) if cached is not None and os.path.exists(cached): return cached from pxr import Sdf, Usd overlay = os.path.join(tempfile.gettempdir(), "asm_droid_softened_mimic.usd") # USD caches layers by identifier; reuse an already-open layer (authored by # an earlier build in this process) instead of re-creating it. if Sdf.Layer.Find(overlay) is None: if os.path.exists(overlay): os.remove(overlay) stage = Usd.Stage.CreateNew(overlay) root = stage.OverridePrim("/panda") root.GetReferences().AddReference(source_usd_path) stage.SetDefaultPrim(root.GetPrim()) for joint, axis in _MIMIC_JOINTS: prim = stage.OverridePrim(f"/panda/Gripper/Robotiq_2F_85/Joints/{joint}") prim.CreateAttribute( f"physxMimicJoint:{axis}:naturalFrequency", Sdf.ValueTypeNames.Float ).Set(_MIMIC_NATURAL_FREQUENCY) prim.CreateAttribute( f"physxMimicJoint:{axis}:dampingRatio", Sdf.ValueTypeNames.Float ).Set(_MIMIC_DAMPING_RATIO) stage.GetRootLayer().Save() _OVERLAY_CACHE[source_usd_path] = overlay return overlay def apply_assembly_droid_tuning(embodiment: Any) -> None: """Apply the assembly benchmark's contact-stability tuning to a DROID embodiment in place: softened-mimic spawn overlay, solver-iteration headroom, a compliant arm, and joint velocity in the policy observation. Kept as a module function so any DROID variant (e.g. a teleop embodiment) can opt into the same tuning without duplicating it. """ robot = embodiment.scene_config.robot # Point the spawn at the softened-mimic overlay (keeps the calibrated asset, # overrides only the mimic springs). The USD's native firm finger drive # stays as-is: it stops the pad firmly at the part surface, and the mimic # coupling -- not the finger drive -- was the divergence root cause. robot.spawn.usd_path = _softened_mimic_overlay(robot.spawn.usd_path) # Ramming the table should stall, not explode: keep depenetration bounded, # add articulation velocity iterations (0 -> 8) so the solver damps contact # velocity, and raise position iterations (64 -> 192) to give the stiff # mimic spring the headroom to hold without diverging. robot.spawn.rigid_props.max_depenetration_velocity = 5.0 robot.spawn.articulation_props.solver_velocity_iteration_count = 8 robot.spawn.articulation_props.solver_position_iteration_count = 192 # Compliant arm: soften PD (400/80 -> 150/40) so the arm yields under # contact instead of shoving the now-rigid gripper into the part ("arm # gives, gripper stays"). Restore the real panda joint-speed limits # (velocity_limit is a no-op on implicit actuators) and add base-panda # armature to damp high-PD jitter. for name, vlim in (("panda_shoulder", 2.175), ("panda_forearm", 2.61)): robot.actuators[name].velocity_limit_sim = vlim robot.actuators[name].armature = 1e-3 robot.actuators[name].stiffness = 150.0 robot.actuators[name].damping = 40.0 # Joint velocities alongside joint positions in the policy obs (recorded to # HDF5 via the flat policy-obs recorder term). embodiment.observation_config.policy.joint_vel = ObsTerm(func=arm_joint_vel) # Privileged part poses for the PA-RL critic (not fed to the VLA). embodiment.observation_config.policy.held_part_pose = ObsTerm( func=part_pose, params={"asset_cfg": SceneEntityCfg("held_part")}) embodiment.observation_config.policy.fixed_part_pose = ObsTerm( func=part_pose, params={"asset_cfg": SceneEntityCfg("fixed_part")}) @register_asset class DroidAbsoluteJointPositionSoftMimicEmbodiment(DroidAbsoluteJointPositionEmbodiment): """DROID absolute-joint-position embodiment with the assembly benchmark's contact-stability tuning baked in (softened Robotiq mimic + solver/PD). This is the benchmark's default embodiment; the pi0.5-DROID contract (8-D action: 7 joint targets + binary gripper) is unchanged from the stock absolute-joint-position embodiment it subclasses. """ name = "droid_abs_joint_pos_softmimic" tags = ["embodiment", "assembly"] def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) apply_assembly_droid_tuning(self)