diff --git a/agent_context/topics/robot-system/robot-system.md b/agent_context/topics/robot-system/robot-system.md index 139f64de5..bf7e4ce9a 100644 --- a/agent_context/topics/robot-system/robot-system.md +++ b/agent_context/topics/robot-system/robot-system.md @@ -33,7 +33,7 @@ ObjectBaseCfg uid, init_pos, init_rot, init_local_pose │ disable_self_collision, init_qpos, body_scale, │ build_pk_chain, use_usd_properties └─ RobotCfg control_parts, urdf_cfg, solver_cfg, drive_pros (override default to "force") - ├─ DexforceW1Cfg version, arm_kind, with_default_eef + ├─ DexforceW1Cfg version, hand_versions, with_default_eef └─ CobotMagicCfg (dual-arm defaults) ``` @@ -46,13 +46,13 @@ Key fields on `RobotCfg`: | `solver_cfg` | `SolverCfg \| Dict[str, SolverCfg] \| None` | IK solver config; dict keys must match `control_parts` keys | | `drive_pros` | `JointDrivePropertiesCfg` | Default drive type is `"force"` (overrides Articulation's `"none"`) | | `attrs` | `RigidBodyAttributesCfg` | Rigid-body physics attributes (mass, friction, damping, ...) | -| variant fields | `enum \| str \| bool` | Optional subclass fields (e.g. `version`, `arm_kind`, `with_default_eef`) | +| variant fields | `enum \| str \| bool` | Optional subclass fields (e.g. `version`, `with_default_eef`) | | `_pk_urdf_path` | `property \| method → str` | URDF for the FK/IK serial chain (one source, so it can't drift from sim) | ## The robot config protocol -Every robot config subclasses `RobotCfg` and overrides two hooks. `from_dict` is a -3-line template — do not reimplement it: +Every robot config subclasses `RobotCfg` and overrides the construction hooks. +The default `from_dict` implementation is this 3-line template: ```python @classmethod @@ -69,8 +69,26 @@ def from_dict(cls, init_dict): reading the PK URDF from a single `_pk_urdf_path` source (a property for constant-path robots, a method when the path depends on a variant). -Serialization (`to_dict` / `to_string` / `save_to_file`) is **inherited** from -`RobotCfg` and round-trips: `RobotCfg.from_dict(cfg.to_dict())` reproduces the cfg. +Serialization (`to_dict` / `to_string` / `save_to_file`) is inherited from +`RobotCfg` unless the config stores version-derived runtime transforms. +`DexforceW1Cfg` is the current exception: serialized hand transforms and solver +TCPs are raw end-effector values, while the in-memory values include the selected +W1 revision offset. Its `to_dict` removes that derived offset and `from_dict` +restores it. Every config, including this exception, must satisfy +`type(cfg).from_dict(cfg.to_dict())` without changing the selected components or +applying a derived transform twice. + +W1 robot and hand releases use separate types and registries: + +- `DexforceW1Version` selects body/arm assets, kinematics, and flange calibration + through `specs.py`. +- `DexforceW1HandVersion` selects external hand/gripper assets, joint metadata, + and raw mounting transforms through `hand_specs.py`. +- The current default is hand V021 for every W1 robot version. Never infer a + hand version from `DexforceW1Version`. +- `DexforceW1Cfg` always represents a complete dual-arm W1. Structural + `include_*`, `arm_sides`, and mixed `component_versions` options are not part + of its public protocol. .. note:: `merge_robot_cfg` calls the base `RobotCfg.from_dict` internally, so the @@ -119,7 +137,7 @@ Full guide: `docs/source/tutorial/add_robot.rst` · Quick reference: `docs/sourc Minimal checklist: 1. Create a `@configclass` inheriting `RobotCfg`. 2. Override `_build_defaults(self, init_dict=None)` — read variant fields from `init_dict`, then populate `urdf_cfg`, `control_parts`, `solver_cfg`, `drive_pros` and `attrs`. -3. Keep `from_dict` as the 3-line template (`cls()` → `_build_defaults` → `merge_robot_cfg`); do not reimplement. +3. Keep `from_dict` as the 3-line template (`cls()` → `_build_defaults` → `merge_robot_cfg`) unless version-derived state requires an explicitly documented post-merge step. 4. Define `control_parts` mapping part names to joint name lists. 5. Configure `solver_cfg` (one `SolverCfg` per control part). 6. Implement `build_pk_serial_chain` reading from `_pk_urdf_path` (property for constant paths, method for variant-dependent). @@ -128,13 +146,15 @@ Minimal checklist: 9. Add robot docs in `docs/source/resources/robot/` and update `docs/source/resources/robot/index.rst`. 10. Test — a `__main__` smoke test + the DOF drift guard + `preview-asset` CLI. -Serialization (`to_dict` / `save_to_file`) is inherited — no need to implement it. +Serialization (`to_dict` / `save_to_file`) is normally inherited. A robot-specific +override requires documented raw/final semantics and regression tests for default, +custom-transform, component-version, and public-builder round-trips. ## Available Robots | Robot | Config Class | Module | Structure | Notes | |---|---|---|---|---| -| DexForce W1 | `DexforceW1Cfg` | `embodichain/lab/sim/robots/dexforce_w1/` | Package (`cfg.py`, `types.py`, `params.py`, `utils.py`) | Humanoid; versions: V021; arm kinds: ANTHROPOMORPHIC, INDUSTRIAL; component types: chassis, torso, eyes, head, left/right arm/hand | +| DexForce W1 | `DexforceW1Cfg` | `embodichain/lab/sim/robots/dexforce_w1/` | Package (`cfg.py`, `types.py`, `specs.py`, `hand_specs.py`, `params.py`, `utils.py`) | Humanoid; robot and hand versions are independently registered | | CobotMagic | `CobotMagicCfg` | `embodichain/lab/sim/robots/cobotmagic.py` | Single file | Dual-arm; 6-DOF arms + 2-DOF grippers; uses OPW solver | ## Common Failure Modes @@ -148,4 +168,4 @@ Serialization (`to_dict` / `save_to_file`) is inherited — no need to implement - **`all` instead of `__all__`** — lowercase `all` does not work with `from module import *`; use `__all__`. - **`solver_cfg` set in multiple places** — set it once in `_build_defaults` only; setting it elsewhere (e.g. a build helper) gets overwritten and is dead code. - **PK URDF drifts from the sim URDF** — route `build_pk_serial_chain` through `_pk_urdf_path` and keep the DOF drift-guard test so silent drift is caught. -- **Reimplementing `from_dict`** — keep the 3-line template; put construction logic in `_build_defaults`. (Making the base `RobotCfg.from_dict` call `merge_robot_cfg` would infinite-recurse, since `merge_robot_cfg` calls `RobotCfg.from_dict`.) +- **Reimplementing `from_dict` without a serialization protocol** — keep the 3-line template by default. If derived version state requires post-merge processing, document the raw/final values and test round-trips. (Making the base `RobotCfg.from_dict` call `merge_robot_cfg` would infinite-recurse, since `merge_robot_cfg` calls `RobotCfg.from_dict`.) diff --git a/docs/source/_static/robots/dexforcew1_anthropomorphic.jpg b/docs/source/_static/robots/dexforcew1.jpg similarity index 100% rename from docs/source/_static/robots/dexforcew1_anthropomorphic.jpg rename to docs/source/_static/robots/dexforcew1.jpg diff --git a/docs/source/_static/robots/dexforcew1_industrial.jpg b/docs/source/_static/robots/dexforcew1_industrial.jpg deleted file mode 100644 index a3c4b9bbc..000000000 Binary files a/docs/source/_static/robots/dexforcew1_industrial.jpg and /dev/null differ diff --git a/docs/source/features/workspace_analyzer/cli.md b/docs/source/features/workspace_analyzer/cli.md index c1698fc7f..2e53a2de6 100644 --- a/docs/source/features/workspace_analyzer/cli.md +++ b/docs/source/features/workspace_analyzer/cli.md @@ -39,19 +39,19 @@ to `left_arm`/`right_arm` or the first available part). | Argument | Description | |----------|-------------| | `--robot NAME` | Predefined robot. Choices: `franka_panda`, `cobotmagic`, `dexforce_w1`, `ur`. | -| `--robot-params JSON` | JSON dict of variant overrides, e.g. `{"robot_type":"ur5"}` or `{"version":"v021","arm_kind":"industrial"}`. | +| `--robot-params JSON` | JSON dict of variant overrides, e.g. `{"robot_type":"ur5"}` or `{"version":"v025","with_default_eef":false}`. | | `--control-part NAME` | Control part to analyze (e.g. `arm`, `left_arm`, `right_arm`). Optional; auto-selected if omitted. | ```bash embodichain analyze-workspace \ --robot dexforce_w1 \ - --robot-params '{"version":"v021","arm_kind":"industrial"}' \ + --robot-params '{"version":"v025","with_default_eef":false}' \ --control-part left_arm --mode joint_space --num-samples 20000 ``` Available control parts per robot: `franka_panda` (`arm`, `hand`), `cobotmagic` (`left_arm`, `left_eef`, `right_arm`, `right_eef`), `dexforce_w1` -(`left_arm`, `right_arm` for industrial), `ur` (`arm`). +(`left_arm`, `left_eef`, `right_arm`, `right_eef`), `ur` (`arm`). ## Generic asset (`--asset`) diff --git a/docs/source/features/workspace_analyzer/workspace_analyzer.md b/docs/source/features/workspace_analyzer/workspace_analyzer.md index 8213dd10d..2e49ff0e0 100644 --- a/docs/source/features/workspace_analyzer/workspace_analyzer.md +++ b/docs/source/features/workspace_analyzer/workspace_analyzer.md @@ -33,8 +33,7 @@ sim = SimulationManager(SimulationManagerCfg(headless=False, sim_device="cpu")) # Add robot robot = sim.add_robot(DexforceW1Cfg.from_dict({ "uid": "dexforce_w1", - "version": "v021", - "arm_kind": "industrial" + "version": "v021" })) # Quick analysis with defaults @@ -175,7 +174,7 @@ sim = SimulationManager(SimulationManagerCfg(headless=False, sim_device="cpu")) # Add robot robot = sim.add_robot(DexforceW1Cfg.from_dict({ - "uid": "dexforce_w1", "version": "v021", "arm_kind": "industrial" + "uid": "dexforce_w1", "version": "v021" })) # 1. Joint Space Analysis diff --git a/docs/source/guides/add_robot.rst b/docs/source/guides/add_robot.rst index 5a4d01386..f6557f60e 100644 --- a/docs/source/guides/add_robot.rst +++ b/docs/source/guides/add_robot.rst @@ -73,7 +73,7 @@ Key parameters | ``attrs`` | RigidBodyAttributesCfg | Rigid-body physics attributes | +---------------------+----------------------------------+----------------------------------+ | variant fields | enum / str / bool | Optional subclass fields | -| | | (e.g. ``version``, ``arm_kind``) | +| | | (e.g. ``version``) | +---------------------+----------------------------------+----------------------------------+ | ``_pk_urdf_path`` | property or method → str | URDF for the FK/IK serial chain | +---------------------+----------------------------------+----------------------------------+ diff --git a/docs/source/overview/sim/solvers/srs_solver.md b/docs/source/overview/sim/solvers/srs_solver.md index 0cd7350dc..8e2d73563 100644 --- a/docs/source/overview/sim/solvers/srs_solver.md +++ b/docs/source/overview/sim/solvers/srs_solver.md @@ -31,7 +31,6 @@ SRSSolver is configured via the `SRSSolverCfg` class, allowing detailed control from embodichain.data import get_data_path from embodichain.lab.sim.robots.dexforce_w1.types import ( DexforceW1ArmSide, - DexforceW1ArmKind, DexforceW1Version, ) from embodichain.lab.sim.robots.dexforce_w1.params import ( @@ -41,7 +40,6 @@ from embodichain.lab.sim.solvers.srs_solver import SRSSolver, SRSSolverCfg arm_params = W1ArmKineParams( arm_side=DexforceW1ArmSide.RIGHT, - arm_kind=DexforceW1ArmKind.ANTHROPOMORPHIC, version=DexforceW1Version.V021, ) diff --git a/docs/source/resources/robot/dexforce_w1.md b/docs/source/resources/robot/dexforce_w1.md index 7160825bb..6acf8e3da 100644 --- a/docs/source/resources/robot/dexforce_w1.md +++ b/docs/source/resources/robot/dexforce_w1.md @@ -1,68 +1,49 @@ # Dexforce W1 -Dexforce W1 is a versatile robot developed by DexForce Technology Co., Ltd., supporting both industrial and anthropomorphic arm types. It is suitable for various simulation and real-world application scenarios. +Dexforce W1 is a dual-arm humanoid robot developed by DexForce Technology Co., Ltd.
- Anthropomorphic Version -
Anthropomorphic Version
-
-
- Industrial Version -
Industrial Version
+ Dexforce W1 +
Dexforce W1
## Key Features -- Supports multiple arm types (industrial, anthropomorphic) +- Supports dual 7-DOF arms +- Supports version-owned asset layouts and calibration parameters - Configurable left/right hand brand and version - Flexible URDF assembly and simulation configuration - Compatible with SimulationManager simulation environment -## Method 1: Fine-grained configuration with `build_dexforce_w1_cfg` +## Configuration with `DexforceW1Cfg.from_dict` -This method allows you to specify detailed parameters for each arm and hand. Recommended for advanced users who need full control over robot hardware options. +`DexforceW1Cfg.from_dict` is the single public construction entry point. A W1 +configuration always describes the complete chassis, torso, head, eyes, wrist +cameras, and both arms for one robot version. **Parameters:** -- `arm_kind`: Arm type, e.g., `DexforceW1ArmKind.ANTHROPOMORPHIC` or `DexforceW1ArmKind.INDUSTRIAL`. - `hand_types`: Dict specifying hand brand for each arm side (`LEFT`/`RIGHT`). - `hand_versions`: Dict specifying hand version for each arm side. - -```python -hand_types = { - DexforceW1ArmSide.LEFT: DexforceW1HandBrand.BRAINCO_HAND, - DexforceW1ArmSide.RIGHT: DexforceW1HandBrand.BRAINCO_HAND, -} -hand_versions = { - DexforceW1ArmSide.LEFT: DexforceW1Version.V021, - DexforceW1ArmSide.RIGHT: DexforceW1Version.V021, -} -cfg = build_dexforce_w1_cfg( - arm_kind=DexforceW1ArmKind.ANTHROPOMORPHIC, - hand_types=hand_types, - hand_versions=hand_versions, -) -robot = sim.add_robot(cfg=cfg) -print("DexforceW1 robot added to the simulation.") -``` - -## Method 2: Quick configuration with `DexforceW1Cfg.from_dict` - -This method allows fast setup using a dictionary, suitable for simple scenarios or when default options are sufficient. Recommended for rapid prototyping or when only basic parameters are needed. - -**Parameters:** - -- `uid`: Unique robot identifier (string). -- `version`: Robot version, e.g., `v021`. -- `arm_kind`: Arm type, e.g., `anthropomorphic` or `industrial` (string). +- `with_default_eef`: Whether to install the registered default end effectors. +- `hand_attach_xposes`: Optional hand-specific mounting transforms. ```python from embodichain.lab.sim.robots import DexforceW1Cfg + cfg = DexforceW1Cfg.from_dict( - {"uid": "dexforce_w1", "version": "v021", "arm_kind": "anthropomorphic"} + { + "uid": "dexforce_w1", + "version": "v025", + "hand_types": { + "left": "BRAINCO_HAND", + "right": "BRAINCO_HAND", + }, + "hand_versions": {"left": "v021", "right": "v021"}, + } ) robot = sim.add_robot(cfg=cfg) print("DexforceW1 robot added to the simulation.") @@ -84,21 +65,107 @@ robot.set_qpos(qpos=[0, np.pi/4, 0.0, np.pi/2, np.pi/4, 0.0, 0.0], joint_ids=rob This mirrored design simplifies motion planning and ensures that both arms can perform coordinated or symmetric actions efficiently. -## Configuration Method Selection - -Choose `build_dexforce_w1_cfg` for maximum flexibility and hardware customization. Use `DexforceW1Cfg.from_dict` for quick setup and prototyping. Both methods produce a configuration object (`cfg`) that can be passed to `sim.add_robot(cfg=cfg)` to add the robot to the simulation. - -**Note:** - -- Ensure parameter types match the expected enums or strings. -- For advanced simulation scenarios, prefer the fine-grained method. -- For most demos or simple tasks, the quick method is sufficient. - ## Type Descriptions | Type | Options / Values | Description | |-------------------------|-------------------------------------------------------|------------------------------------| -| `DexforceW1ArmKind` | `ANTHROPOMORPHIC`, `INDUSTRIAL` | Arm type | | `DexforceW1HandBrand` | `BRAINCO_HAND`, `DH_PGC_GRIPPER`, `DH_PGC_GRIPPER_M` | Hand brand | -| `DexforceW1Version` | `V021` | Release version | +| `DexforceW1Version` | `V021`, `V022`, `V025` | Release version | +| `DexforceW1HandVersion` | `V021` | External hand/gripper asset version | | `DexforceW1ArmSide` | `LEFT`, `RIGHT` | Left/right hand identifier | + +## Unified asset layout and version extension + +V022 and V025 use one unified Hugging Face archive per release: + +```text +dexforce_w1//w1.zip +└── w1/ + ├── robot.urdf + ├── chassis.urdf + ├── torso.urdf + ├── head.urdf + ├── left_arm.urdf + ├── right_arm.urdf + ├── visual/ + └── collision/ +``` + +The runtime downloads each release archive once. Direct FK/IK uses `robot.urdf` +or the arm URDFs, while configurable robot assembly reads all components from +the same extracted directory. V022 and V025 assets are resolved through the +registered Hugging Face dataset archives and the shared asset cache. + +### Version-owned end-effector offset + +Different arm revisions may place the physical mounting surface at different +positions relative to the arm `ee` frame. This difference belongs to the W1 +revision, not to a BrainCo hand, DH gripper, PIKA gripper, or any other +end-effector. + +`W1VersionSpec.default_eef_attach_xpos` is the single source of truth for this +revision offset: + +| Version | Left arm | Right arm | +|---------|----------|-----------| +| V021 | Identity | Identity | +| V022 | Identity (provisional; calibration required) | Identity (provisional; calibration required) | +| V025 | `+0.012 m` along the `ee` frame Z axis | `+0.012 m` along the `ee` frame Z axis | + +The final assembly transform and solver TCP are derived as follows: + +```python +final_attach_xpos = version_attach_xpos @ eef_attach_xpos +final_tcp = version_attach_xpos @ solver_tcp +``` + +Therefore, the V025 offset affects both the assembled end-effector position and +FK/IK results. The offset is also applied when callers provide a custom +`hand_attach_xposes` value, a custom `left_hand`/`right_hand` component +transform, or an explicit arm TCP through `DexforceW1Cfg.from_dict`. +Serialization removes the derived offset and restores it on loading, so a +configuration round trip does not apply the offset twice. + +Do not manually add the 12 mm correction to an end-effector transform or TCP. +Those values must describe the end-effector relative to the standard mounting +surface; the W1 version layer adds the robot revision correction. + +One `DexforceW1Version` selects the complete W1 robot release. Mixed body/arm +versions are intentionally unsupported because their assets, kinematics, TCP, +and camera calibration must remain consistent. + +Robot and hand versions are independent. `DexforceW1Version` selects the W1 +body, arm assets, kinematics, and flange calibration. `DexforceW1HandVersion` +selects an external hand or gripper asset from +`embodichain/lab/sim/robots/dexforce_w1/hand_specs.py`. + +The currently released BrainCo hand and DH gripper registrations all use +`DexforceW1HandVersion.V021`. Therefore W1 V021, V022, and V025 select hand V021 +by default. This is an explicit default, not a compatibility alias keyed by the +robot version. A future hand release is added by registering a new hand version +and hand spec; it does not require adding aliases for every W1 robot version. + +To add another W1 revision: + +1. Register the new `DexforceW1Version` value and dataset archive. +2. Add one `W1VersionSpec` entry in + `embodichain/lab/sim/robots/dexforce_w1/specs.py`. +3. Register component URDF paths and the full-robot URDF path. +4. Set the arm kinematic parameters and + `default_eef_attach_xpos` for both sides. Use identity only after confirming + that the arm `ee` frame is already at the physical mounting surface. +5. Verify that the same version offset is present in both the assembled + end-effector pose and the final FK/IK TCP. +6. Validate hand/gripper mounting, wrist cameras, FK/IK, VR teleoperation, and + real2sim task regression. + +To add another hand release: + +1. Add a `DexforceW1HandVersion` value. +2. Register each supported brand and side in `hand_specs.py`, including its + URDF, joint names, root/end links, and mounting transform. +3. Select it explicitly through `hand_versions`; an unregistered version fails + during config construction. + +Control parts, assembly, TCP selection, analytical parameters and FK/IK then use +the version specification without revision-specific branches. diff --git a/docs/source/tutorial/modular_env.rst b/docs/source/tutorial/modular_env.rst index af4d14992..f79ee6d36 100644 --- a/docs/source/tutorial/modular_env.rst +++ b/docs/source/tutorial/modular_env.rst @@ -105,7 +105,6 @@ The main environment configuration inherits from :class:`envs.EmbodiedEnvCfg` an Uses the pre-configured :class:`DexforceW1Cfg` with customizations: - **Version**: Specific robot variant (v021) -- **Arm Type**: Anthropomorphic configuration - **Position**: Initial placement in the scene **Sensor Configuration** @@ -241,4 +240,4 @@ This tutorial demonstrates the full power of EmbodiChain's modular environment s **Using an AI coding agent?** These skills can help you build on this tutorial: - **/add-task-env** — Scaffold a new task environment with the correct file structure, ``@register_env`` decorator, base class methods, ``__init__.py`` update, and test stub. - - **/add-functor** — Add observation, reward, event, or randomization functors with the correct signature and module placement. \ No newline at end of file + - **/add-functor** — Add observation, reward, event, or randomization functors with the correct signature and module placement. diff --git a/embodichain/data/assets/w1_assets.py b/embodichain/data/assets/w1_assets.py index d6ec94ce8..2fff2d0d3 100644 --- a/embodichain/data/assets/w1_assets.py +++ b/embodichain/data/assets/w1_assets.py @@ -28,8 +28,10 @@ # # Main Asset: # - DexforceW1V021: -# Represents the complete humanoid robot asset, -# including both industrial arms and anthropomorphic arms. +# Represents the complete humanoid robot asset. +# - DexforceW1V022 / DexforceW1V025: +# Unified release archives containing the full robot and all component +# URDFs used by runtime assembly. # # Component Assets: # - DexforceW1ChassisV021: Chassis component @@ -38,10 +40,8 @@ # - DexforceW1HeadV021: Head component # # Arm Assets: -# - DexforceW1LeftArm1V021 / DexforceW1RightArm1V021: -# Anthropomorphic (human-like) arms, left and right. -# - DexforceW1LeftArm2V021 / DexforceW1RightArm2V021: -# Industrial arms, left and right. +# - DexforceW1LeftArmV021 / DexforceW1RightArmV021: +# Left and right arms. # # All classes inherit from EmbodiChainDataset and are responsible for # downloading and managing the data resources for their respective components. @@ -60,7 +60,6 @@ class DexforceW1V021(EmbodiChainDataset): Example usage: >>> from embodichain.data import get_data_path >>> print(get_data_path("DexforceW1V021/DexforceW1_v02_1.urdf")) - >>> print(get_data_path("DexforceW1V021/DexforceW1_v02_2.urdf")) """ def __init__(self, data_root: str = None): @@ -74,15 +73,19 @@ def __init__(self, data_root: str = None): super().__init__(prefix, data_descriptor, path) -class DexforceW1V021_INDUSTRIAL_DH_PGC_GRIPPER_M(EmbodiChainDataset): - """Dataset class for the industrial Dexforce W1 V021 with DH_PGC_gripper. +class DexforceW1V022(EmbodiChainDataset): + """Dataset class for the unified Dexforce W1 V022 release archive. - Directory structure: - DexforceW1V021_INDUSTRIAL_DH_PGC_GRIPPER_M/DexforceW1V021.urdf + Expected Hugging Face path and archive layout:: + + dexforce_w1/v022/w1.zip + w1/robot.urdf + w1/chassis.urdf + w1/torso.urdf + w1/head.urdf + w1/left_arm.urdf + w1/right_arm.urdf - Example usage: - >>> from embodichain.data import get_data_path - >>> print(get_data_path("DexforceW1V021_INDUSTRIAL_DH_PGC_GRIPPER_M/DexforceW1V021.urdf")) """ def __init__(self, data_root: str = None): @@ -90,9 +93,11 @@ def __init__(self, data_root: str = None): os.path.join( EMBODICHAIN_DOWNLOAD_PREFIX, w1_assets, - "DexforceW1V021_INDUSTRIAL_DH_PGC_GRIPPER_M.zip", + "v022", + "w1.zip", ), - "06ec5dfa76dc69160d7ff9bc537a6a7b", + # Recalculate this if the archive is cleaned or rebuilt before upload. + "867f52d684b8cf5161f9c8e53ff493d2", ) prefix = type(self).__name__ path = EMBODICHAIN_DEFAULT_DATA_ROOT if data_root is None else data_root @@ -100,15 +105,21 @@ def __init__(self, data_root: str = None): super().__init__(prefix, data_descriptor, path) -class DexforceW1V021_ANTHROPOMORPHIC_BRAINCO_HAND_REVO1(EmbodiChainDataset): - """Dataset class for the anthropomorphic Dexforce W1 V021 with BrainCo_hand_revo_1. +class DexforceW1V025(EmbodiChainDataset): + """Dataset class for the unified Dexforce W1 V025 release archive. - Directory structure: - DexforceW1V021_ANTHROPOMORPHIC_BRAINCO_HAND_REVO1/DexforceW1V021.urdf + Expected Hugging Face path and archive layout:: - Example usage: - >>> from embodichain.data import get_data_path - >>> print(get_data_path("DexforceW1V021_ANTHROPOMORPHIC_BRAINCO_HAND_REVO1/DexforceW1V021.urdf")) + dexforce_w1/v025/w1.zip + w1/robot.urdf + w1/chassis.urdf + w1/torso.urdf + w1/head.urdf + w1/left_arm.urdf + w1/right_arm.urdf + + The same archive supports both direct full-robot loading and component + assembly, so runtime code does not download duplicate component archives. """ def __init__(self, data_root: str = None): @@ -116,9 +127,10 @@ def __init__(self, data_root: str = None): os.path.join( EMBODICHAIN_DOWNLOAD_PREFIX, w1_assets, - "DexforceW1V021_ANTHROPOMORPHIC_BRAINCO_HAND_REVO1.zip", + "v025", + "w1.zip", ), - "ef19d247799e79233863b558c47b32cd", + "a983814a05b20ba12fce02883cfd1d7e", ) prefix = type(self).__name__ path = EMBODICHAIN_DEFAULT_DATA_ROOT if data_root is None else data_root @@ -174,7 +186,7 @@ def __init__(self, data_root: str = None): super().__init__(prefix, data_descriptor, path) -class DexforceW1LeftArm1V021(EmbodiChainDataset): +class DexforceW1LeftArmV021(EmbodiChainDataset): def __init__(self, data_root: str = None): data_descriptor = o3d.data.DataDescriptor( os.path.join( @@ -188,7 +200,7 @@ def __init__(self, data_root: str = None): super().__init__(prefix, data_descriptor, path) -class DexforceW1RightArm1V021(EmbodiChainDataset): +class DexforceW1RightArmV021(EmbodiChainDataset): def __init__(self, data_root: str = None): data_descriptor = o3d.data.DataDescriptor( os.path.join( @@ -200,31 +212,3 @@ def __init__(self, data_root: str = None): path = EMBODICHAIN_DEFAULT_DATA_ROOT if data_root is None else data_root super().__init__(prefix, data_descriptor, path) - - -class DexforceW1LeftArm2V021(EmbodiChainDataset): - def __init__(self, data_root: str = None): - data_descriptor = o3d.data.DataDescriptor( - os.path.join( - EMBODICHAIN_DOWNLOAD_PREFIX, w1_assets, "W1_LeftArm_2_v021.zip" - ), - "b99bd0587cc9a36fed3cdaa4f9fd62e7", - ) - prefix = type(self).__name__ - path = EMBODICHAIN_DEFAULT_DATA_ROOT if data_root is None else data_root - - super().__init__(prefix, data_descriptor, path) - - -class DexforceW1RightArm2V021(EmbodiChainDataset): - def __init__(self, data_root: str = None): - data_descriptor = o3d.data.DataDescriptor( - os.path.join( - EMBODICHAIN_DOWNLOAD_PREFIX, w1_assets, "W1_RightArm_2_v021.zip" - ), - "d9f25b2d5244ca5a859040327273a99e", - ) - prefix = type(self).__name__ - path = EMBODICHAIN_DEFAULT_DATA_ROOT if data_root is None else data_root - - super().__init__(prefix, data_descriptor, path) diff --git a/embodichain/lab/scripts/analyze_workspace.py b/embodichain/lab/scripts/analyze_workspace.py index ee1c22301..7f1649c14 100644 --- a/embodichain/lab/scripts/analyze_workspace.py +++ b/embodichain/lab/scripts/analyze_workspace.py @@ -29,7 +29,7 @@ embodichain analyze-workspace \\ --robot dexforce_w1 \\ - --robot-params '{"version":"v021","arm_kind":"industrial"}' \\ + --robot-params '{"version":"v025","with_default_eef":false}' \\ --control-part left_arm --mode joint_space # Generic URDF/USD asset -- requires --ee-link (and --joints for grippers) @@ -247,7 +247,7 @@ def build_preset_robot_cfg( Uses the robot's built-in ``control_parts`` and ``solver_cfg``, so the end-effector link and joint names come from the preset. Pass ``--robot-params`` (JSON) for variant overrides such as - ``{"robot_type": "ur5"}`` or ``{"version": "v021", "arm_kind": "industrial"}``. + ``{"robot_type": "ur5"}`` or ``{"version": "v025", "with_default_eef": false}``. Args: args: Parsed CLI arguments with ``args.robot`` and optionally @@ -775,7 +775,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: type=str, default=None, help="JSON dict of variant overrides for --robot, e.g. " - '{"robot_type":"ur5"} or {"version":"v021","arm_kind":"industrial"}.', + '{"robot_type":"ur5"} or {"version":"v025","with_default_eef":false}.', ) robot.add_argument( "--control-part", diff --git a/embodichain/lab/sim/cfg.py b/embodichain/lab/sim/cfg.py index 5f35cabe6..815008c25 100644 --- a/embodichain/lab/sim/cfg.py +++ b/embodichain/lab/sim/cfg.py @@ -1263,8 +1263,7 @@ class URDFCfg: """Case normalization policy applied to joint/link names during URDF assembly. Supported values per key are ``"upper"``, ``"lower"`` or ``"original"`` - (legacy alias ``"none"``). The default upper-cases joints and lower-cases - links. Set ``{"joint": "original"}`` to preserve the source URDF casing. + (legacy alias ``"none"``). The default preserves source URDF casing. """ def __init__( @@ -1843,6 +1842,8 @@ def to_dict(self): def serialize(obj, _visited=None): if _visited is None: _visited = set() + if isinstance(obj, enum.Enum): + return obj.value if isinstance(obj, (dict, object)) and not isinstance( obj, (str, int, float, bool, type(None)) ): @@ -1851,12 +1852,15 @@ def serialize(obj, _visited=None): return None _visited.add(obj_id) - if isinstance(obj, enum.Enum): - return obj.value if isinstance(obj, np.ndarray): return obj.tolist() if isinstance(obj, dict): - return {str(k): serialize(v, _visited) for k, v in obj.items()} + return { + (k.value if isinstance(k, enum.Enum) else str(k)): serialize( + v, _visited + ) + for k, v in obj.items() + } if isinstance(obj, (list, tuple)): return [serialize(v, _visited) for v in obj] if hasattr(obj, "to_dict") and obj is not self: diff --git a/embodichain/lab/sim/robots/dexforce_w1/cfg.py b/embodichain/lab/sim/robots/dexforce_w1/cfg.py index ae0b53c5d..85ace00f9 100644 --- a/embodichain/lab/sim/robots/dexforce_w1/cfg.py +++ b/embodichain/lab/sim/robots/dexforce_w1/cfg.py @@ -16,30 +16,34 @@ from __future__ import annotations -import numpy as np import typing + +import numpy as np import torch from typing import TYPE_CHECKING, Dict from embodichain.lab.sim.robots.dexforce_w1.types import ( - DexforceW1HandBrand, DexforceW1ArmSide, - DexforceW1ArmKind, + DexforceW1HandBrand, + DexforceW1HandVersion, DexforceW1Version, ) from embodichain.lab.sim.robots.dexforce_w1.utils import ( - build_dexforce_w1_cfg, + build_dexforce_w1_assembly_urdf_cfg, + build_dexforce_w1_control_parts, +) +from embodichain.lab.sim.robots.dexforce_w1.hand_specs import ( + get_default_w1_hand_version, ) -from embodichain.lab.sim.solvers import SolverCfg +from embodichain.lab.sim.robots.dexforce_w1.specs import get_w1_version_spec from embodichain.lab.sim.cfg import ( RobotCfg, JointDrivePropertiesCfg, RigidBodyAttributesCfg, ) from embodichain.lab.sim.utility.cfg_utils import merge_robot_cfg -from embodichain.data import get_data_path -from embodichain.utils import configclass, logger +from embodichain.utils import configclass if TYPE_CHECKING: import pytorch_kinematics as pk @@ -50,8 +54,10 @@ class DexforceW1Cfg(RobotCfg): """DexforceW1 specific configuration, inherits from RobotCfg and allows custom parameters.""" version: DexforceW1Version = DexforceW1Version.V021 - arm_kind: DexforceW1ArmKind = DexforceW1ArmKind.ANTHROPOMORPHIC with_default_eef: bool = True + hand_types: dict | None = None + hand_versions: dict[DexforceW1ArmSide, DexforceW1HandVersion] | None = None + hand_attach_xposes: dict | None = None @classmethod def from_dict( @@ -66,162 +72,181 @@ def from_dict( A DexforceW1Cfg instance. Defaults are built via :meth:`_build_defaults`, then ``init_dict`` overrides are merged. """ + unknown_fields = set(init_dict) - set(cls.__dataclass_fields__) + if unknown_fields: + unknown = ", ".join(sorted(unknown_fields)) + raise ValueError(f"Unknown DexforceW1 configuration fields: {unknown}") + cfg = cls() cfg._build_defaults(init_dict) - return merge_robot_cfg(cfg, init_dict) + cfg = merge_robot_cfg(cfg, init_dict) + cfg._compose_user_eef_overrides(init_dict) + return cfg + + def _compose_user_eef_overrides(self, init_dict: dict) -> None: + """Apply the version offset to user-supplied EEF transforms and TCPs.""" + side_by_component = { + "left_hand": DexforceW1ArmSide.LEFT, + "right_hand": DexforceW1ArmSide.RIGHT, + } + urdf_cfg = init_dict.get("urdf_cfg") + components = ( + urdf_cfg.get("components", []) if isinstance(urdf_cfg, dict) else [] + ) + if isinstance(components, dict): + component_names = set(components) + else: + component_names = { + component.get("component_type") + for component in components + if isinstance(component, dict) + } + + for component_name, arm_side in side_by_component.items(): + if component_name not in component_names: + continue + component = self.urdf_cfg.components.get(component_name) + if component is None: + continue + spec = get_w1_version_spec(self.version) + component["transform"] = spec.compose_eef_attach_xpos( + arm_side, component["transform"] + ) + + solver_cfg = init_dict.get("solver_cfg") + if not isinstance(solver_cfg, dict): + return + for arm_side in DexforceW1ArmSide: + part_name = f"{arm_side.value}_arm" + part_override = solver_cfg.get(part_name) + if not isinstance(part_override, dict) or "tcp" not in part_override: + continue + solver = self.solver_cfg.get(part_name) + if solver is None: + continue + spec = get_w1_version_spec(self.version) + solver.tcp = spec.compose_eef_attach_xpos(arm_side, solver.tcp) + + def to_dict(self): + """Serialize EEF-specific transforms without the derived version offset.""" + data = super().to_dict() + side_by_component = { + "left_hand": DexforceW1ArmSide.LEFT, + "right_hand": DexforceW1ArmSide.RIGHT, + } + components = data.get("urdf_cfg", {}).get("components", {}) + for component_name, arm_side in side_by_component.items(): + component = components.get(component_name) + if not isinstance(component, dict) or component.get("transform") is None: + continue + spec = get_w1_version_spec(self.version) + offset_inv = np.linalg.inv(spec.eef_attach_xpos(arm_side)) + component["transform"] = ( + offset_inv @ np.asarray(component["transform"], dtype=float) + ).tolist() + + solver_cfg = data.get("solver_cfg", {}) + for arm_side in DexforceW1ArmSide: + part_name = f"{arm_side.value}_arm" + solver = solver_cfg.get(part_name) + if not isinstance(solver, dict) or solver.get("tcp") is None: + continue + spec = get_w1_version_spec(self.version) + offset_inv = np.linalg.inv(spec.eef_attach_xpos(arm_side)) + solver["tcp"] = ( + offset_inv @ np.asarray(solver["tcp"], dtype=float) + ).tolist() + return data def _build_defaults(self, init_dict: dict | None = None) -> None: """Build default urdf/control/solver/physics from variant fields. - Reads ``version``/``arm_kind``/``with_default_eef`` from ``init_dict``, + Reads ``version``/``with_default_eef`` from ``init_dict``, sets them on ``self``, then populates ``urdf_cfg``, ``control_parts``, ``solver_cfg``, ``drive_pros`` and ``attrs``. """ init_dict = init_dict or {} - version = init_dict.get("version", DexforceW1Version.V021) - arm_kind = init_dict.get("arm_kind", DexforceW1ArmKind.ANTHROPOMORPHIC) - with_default_eef = init_dict.get("with_default_eef", True) - - self.version = ( - DexforceW1Version(version) if isinstance(version, str) else version - ) - self.arm_kind = ( - DexforceW1ArmKind(arm_kind) if isinstance(arm_kind, str) else arm_kind + self.version = DexforceW1Version.parse( + init_dict.get("version", DexforceW1Version.V021) ) - self.with_default_eef = with_default_eef + self.with_default_eef = bool(init_dict.get("with_default_eef", True)) - # urdf_cfg + control_parts (build_dexforce_w1_cfg no longer sets solver_cfg) - if self.arm_kind == DexforceW1ArmKind.INDUSTRIAL: - hand_types = { - DexforceW1ArmSide.LEFT: DexforceW1HandBrand.DH_PGC_GRIPPER_M, - DexforceW1ArmSide.RIGHT: DexforceW1HandBrand.DH_PGC_GRIPPER_M, - } - else: - hand_types = { - DexforceW1ArmSide.LEFT: DexforceW1HandBrand.BRAINCO_HAND, - DexforceW1ArmSide.RIGHT: DexforceW1HandBrand.BRAINCO_HAND, - } - hand_versions = { - DexforceW1ArmSide.LEFT: self.version, - DexforceW1ArmSide.RIGHT: self.version, + self.hand_types = { + DexforceW1ArmSide.parse(side): DexforceW1HandBrand.parse(brand) + for side, brand in init_dict.get("hand_types", {}).items() + } + configured_hand_versions = { + DexforceW1ArmSide.parse(side): DexforceW1HandVersion.parse(version) + for side, version in init_dict.get("hand_versions", {}).items() + } + self.hand_versions = { + side: configured_hand_versions.get( + side, + get_default_w1_hand_version( + self.hand_types.get(side, DexforceW1HandBrand.BRAINCO_HAND) + ), + ) + for side in DexforceW1ArmSide + } + self.hand_attach_xposes = { + DexforceW1ArmSide.parse(side): np.asarray(transform, dtype=float) + for side, transform in init_dict.get("hand_attach_xposes", {}).items() } - base_cfg = build_dexforce_w1_cfg( - arm_kind=self.arm_kind, - hand_types=hand_types, - hand_versions=hand_versions, - include_hand=with_default_eef, + + self.urdf_cfg = build_dexforce_w1_assembly_urdf_cfg( + version=self.version, + hand_types=self.hand_types, + hand_versions=self.hand_versions, + hand_attach_xposes=self.hand_attach_xposes, + include_hand=self.with_default_eef, + ) + self.control_parts = build_dexforce_w1_control_parts( + version=self.version, + hand_types=self.hand_types, + hand_versions=self.hand_versions, + include_hand=self.with_default_eef, ) - self.urdf_cfg = base_cfg.urdf_cfg - self.control_parts = base_cfg.control_parts # physics physics = self._build_default_physics_cfgs( - arm_kind=self.arm_kind, with_default_eef=with_default_eef + with_default_eef=self.with_default_eef ) for key, value in physics.items(): setattr(self, key, value) # solver (set exactly once -- was previously double-set) - self.solver_cfg = self._build_default_solver_cfg(arm_kind=self.arm_kind) - - def _build_default_solver_cfg(self, arm_kind: DexforceW1ArmKind): - """Build the default SRS solver config for the given arm kind. + self.solver_cfg = self._build_default_solver_cfg() - Note: the W1ArmKineParams below intentionally use DexforceW1Version.V021 - (matching the original behavior -- version does not flow into the solver). - """ + def _build_default_solver_cfg(self): + """Build the version-matched default SRS solver configuration.""" from embodichain.lab.sim.solvers import SRSSolverCfg from embodichain.lab.sim.robots.dexforce_w1.params import ( W1ArmKineParams, ) - if arm_kind == DexforceW1ArmKind.INDUSTRIAL: - w1_left_arm_params = W1ArmKineParams( - arm_side=DexforceW1ArmSide.LEFT, - arm_kind=DexforceW1ArmKind.INDUSTRIAL, - version=DexforceW1Version.V021, - ) - w1_right_arm_params = W1ArmKineParams( - arm_side=DexforceW1ArmSide.RIGHT, - arm_kind=DexforceW1ArmKind.INDUSTRIAL, - version=DexforceW1Version.V021, - ) - left_arm_tcp = np.array( - [ - [1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.15], - [0.0, 0.0, 0.0, 1.0], - ] - ) - right_arm_tcp = np.array( - [ - [1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.15], - [0.0, 0.0, 0.0, 1.0], - ] - ) - else: - w1_left_arm_params = W1ArmKineParams( - arm_side=DexforceW1ArmSide.LEFT, - arm_kind=DexforceW1ArmKind.ANTHROPOMORPHIC, - version=DexforceW1Version.V021, - ) - w1_right_arm_params = W1ArmKineParams( - arm_side=DexforceW1ArmSide.RIGHT, - arm_kind=DexforceW1ArmKind.ANTHROPOMORPHIC, - version=DexforceW1Version.V021, - ) - left_arm_tcp = np.array( - [ - [-1.0, 0.0, 0.0, 0.012], - [0.0, 0.0, 1.0, 0.0675], - [0.0, 1.0, 0.0, 0.127], - [0.0, 0.0, 0.0, 1.0], - ] - ) - right_arm_tcp = np.array( - [ - [1.0, 0.0, 0.0, 0.012], - [0.0, 0.0, -1.0, -0.0675], - [0.0, 1.0, 0.0, 0.127], - [0.0, 0.0, 0.0, 1.0], - ] + solver_cfg = {} + for arm_side in DexforceW1ArmSide: + params = W1ArmKineParams(arm_side=arm_side, version=self.version) + part_name = f"{arm_side.value}_arm" + solver_cfg[part_name] = SRSSolverCfg( + end_link_name=f"{arm_side.value}_ee", + root_link_name=f"{arm_side.value}_arm_base", + dh_params=params.dh_params, + user_qpos_limits=params.qpos_limits, + T_e_oe=params.T_e_oe, + T_b_ob=params.T_b_ob, + link_lengths=params.link_lengths, + rotation_directions=params.rotation_directions, + tcp=get_w1_version_spec(self.version).tcp(arm_side), ) - - return { - "right_arm": SRSSolverCfg( - end_link_name="right_ee", - root_link_name="right_arm_base", - dh_params=w1_right_arm_params.dh_params, - user_qpos_limits=w1_right_arm_params.qpos_limits, - T_e_oe=w1_right_arm_params.T_e_oe, - T_b_ob=w1_right_arm_params.T_b_ob, - link_lengths=w1_right_arm_params.link_lengths, - rotation_directions=w1_right_arm_params.rotation_directions, - tcp=right_arm_tcp, - ), - "left_arm": SRSSolverCfg( - end_link_name="left_ee", - root_link_name="left_arm_base", - dh_params=w1_left_arm_params.dh_params, - user_qpos_limits=w1_left_arm_params.qpos_limits, - T_e_oe=w1_left_arm_params.T_e_oe, - T_b_ob=w1_left_arm_params.T_b_ob, - link_lengths=w1_left_arm_params.link_lengths, - rotation_directions=w1_left_arm_params.rotation_directions, - tcp=left_arm_tcp, - ), - } + return solver_cfg def _build_default_physics_cfgs( - self, arm_kind: DexforceW1ArmKind, with_default_eef: bool = True + self, with_default_eef: bool = True ) -> typing.Dict[str, typing.Any]: """Build default physics configurations for DexforceW1. Args: - arm_kind: The arm kind enum. with_default_eef: Whether to include default end-effector configurations Returns: @@ -236,7 +261,6 @@ def _build_default_physics_cfgs( DEFAULT_EEF_HAND_JOINT_NAMES = ( "(LEFT|RIGHT)_HAND_(THUMB[12]|INDEX|MIDDLE|RING|PINKY)" ) - DEFAULT_EEF_GRIPPER_JOINT_NAMES = "(LEFT|RIGHT)_FINGER[1-2]" ARM_JOINTS = "(RIGHT|LEFT)_J[0-9]" BODY_JOINTS = "(ANKLE|KNEE|BUTTOCK|WAIST)" HEAD_JOINTS = "(NECK1|NECK2)" @@ -253,11 +277,7 @@ def _build_default_physics_cfgs( drive_pros = JointDrivePropertiesCfg(**joint_params) if with_default_eef: - eef_joint_names = ( - DEFAULT_EEF_HAND_JOINT_NAMES - if arm_kind == DexforceW1ArmKind.ANTHROPOMORPHIC - else DEFAULT_EEF_GRIPPER_JOINT_NAMES - ) + eef_joint_names = DEFAULT_EEF_HAND_JOINT_NAMES drive_pros.stiffness.update( {eef_joint_names: DEFAULT_EEF_JOINT_DRIVE_PARAMS["stiffness"]} ) @@ -281,16 +301,16 @@ def _build_default_physics_cfgs( # to_dict, to_string, save_to_file inherited from RobotCfg - def _pk_urdf_path(self) -> str: - """URDF used for FK/IK serial chains, by arm kind. + def _pk_urdf_path(self, arm_side: DexforceW1ArmSide) -> str: + """Return the selected arm component URDF for FK/IK. .. attention:: The root_link->end_link kinematics here must match the arms in the simulation (assembled) URDF. A DOF drift guard in the tests checks this. """ - if self.arm_kind == DexforceW1ArmKind.INDUSTRIAL: - return get_data_path("DexforceW1V021/DexforceW1_v02_2.urdf") - return get_data_path("DexforceW1V021/DexforceW1_v02_1.urdf") + from embodichain.lab.sim.robots.dexforce_w1.utils import arm_manager + + return arm_manager.get_urdf(side=arm_side, version=self.version) def build_pk_serial_chain( self, device: torch.device = torch.device("cpu"), **kwargs @@ -299,24 +319,14 @@ def build_pk_serial_chain( create_pk_serial_chain, ) - urdf_path = self._pk_urdf_path() - - left_arm_chain = create_pk_serial_chain( - urdf_path=urdf_path, - device=device, - end_link_name="left_ee", - root_link_name="left_arm_base", - ) - right_arm_chain = create_pk_serial_chain( - urdf_path=urdf_path, - device=device, - end_link_name="right_ee", - root_link_name="right_arm_base", - ) - return { - "left_arm": left_arm_chain, - "right_arm": right_arm_chain, + f"{arm_side.value}_arm": create_pk_serial_chain( + urdf_path=self._pk_urdf_path(arm_side), + device=device, + end_link_name=f"{arm_side.value}_ee", + root_link_name=f"{arm_side.value}_arm_base", + ) + for arm_side in DexforceW1ArmSide } @@ -326,16 +336,11 @@ def build_pk_serial_chain( np.set_printoptions(precision=5, suppress=True) from embodichain.lab.sim import SimulationManager, SimulationManagerCfg - from embodichain.lab.sim.robots.dexforce_w1.types import ( - DexforceW1ArmKind, - ) config = SimulationManagerCfg(headless=True, sim_device="cpu", num_envs=4) sim = SimulationManager(config) - cfg = DexforceW1Cfg.from_dict( - {"uid": "dexforce_w1", "version": "v021", "arm_kind": "anthropomorphic"} - ) + cfg = DexforceW1Cfg.from_dict({"uid": "dexforce_w1", "version": "v021"}) robot = sim.add_robot(cfg=cfg) sim.update(step=1) diff --git a/embodichain/lab/sim/robots/dexforce_w1/hand_specs.py b/embodichain/lab/sim/robots/dexforce_w1/hand_specs.py new file mode 100644 index 000000000..7df0d66a2 --- /dev/null +++ b/embodichain/lab/sim/robots/dexforce_w1/hand_specs.py @@ -0,0 +1,199 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Asset and mounting specifications for W1 external end effectors.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType + +import numpy as np +from scipy.spatial.transform import Rotation as R + +from .types import ( + DexforceW1ArmSide, + DexforceW1HandBrand, + DexforceW1HandVersion, +) + +__all__ = [ + "W1HandSideSpec", + "W1HandSpec", + "get_default_w1_hand_version", + "get_w1_hand_spec", +] + + +def _attach_xpos( + rotation_xyz_degrees: tuple[float, float, float], + translation: tuple[float, float, float] = (0.0, 0.0, 0.0), +) -> tuple[tuple[float, ...], ...]: + transform = np.eye(4) + transform[:3, :3] = R.from_euler( + "xyz", rotation_xyz_degrees, degrees=True + ).as_matrix() + transform[:3, 3] = translation + return tuple(tuple(float(value) for value in row) for row in transform) + + +@dataclass(frozen=True) +class W1HandSideSpec: + """Side-specific asset metadata for one hand release.""" + + urdf_path: str + joint_names: tuple[str, ...] + end_link_name: str + root_link_name: str + attach_xpos: tuple[tuple[float, ...], ...] + + +@dataclass(frozen=True) +class W1HandSpec: + """A hand release independent of the W1 body/arm version.""" + + brand: DexforceW1HandBrand + version: DexforceW1HandVersion + sides: Mapping[DexforceW1ArmSide, W1HandSideSpec] + + def __post_init__(self) -> None: + object.__setattr__(self, "sides", MappingProxyType(dict(self.sides))) + + def for_side(self, side: DexforceW1ArmSide | str) -> W1HandSideSpec: + side = DexforceW1ArmSide.parse(side) + try: + return self.sides[side] + except KeyError as exc: + raise ValueError( + f"Hand {self.brand.value} {self.version.value} has no " + f"{side.value} asset registered" + ) from exc + + +def _brainco_side(side: DexforceW1ArmSide) -> W1HandSideSpec: + prefix = "LEFT" if side == DexforceW1ArmSide.LEFT else "RIGHT" + side_name = "Left" if side == DexforceW1ArmSide.LEFT else "Right" + rotation = (90.0, 0.0, 180.0 if side == DexforceW1ArmSide.LEFT else 0.0) + return W1HandSideSpec( + urdf_path=( + f"BrainCoHandRevo1/BrainCo{side_name}Hand/" f"BrainCo{side_name}Hand.urdf" + ), + joint_names=( + f"{prefix}_HAND_THUMB1", + f"{prefix}_HAND_THUMB2", + f"{prefix}_HAND_INDEX", + f"{prefix}_HAND_MIDDLE", + f"{prefix}_HAND_RING", + f"{prefix}_HAND_PINKY", + ), + end_link_name=f"{side.value}_hand_base", + root_link_name=f"{side.value}_thumb_dist", + attach_xpos=_attach_xpos(rotation), + ) + + +def _dh_gripper_side( + side: DexforceW1ArmSide, + modified: bool, +) -> W1HandSideSpec: + prefix = "LEFT" if side == DexforceW1ArmSide.LEFT else "RIGHT" + suffix = "_M" if modified else "" + joint_suffixes = ( + ("FINGER1", "FINGER2") + if modified + else ( + "FINGER1_JOINT", + "FINGER2_JOINT", + ) + ) + return W1HandSideSpec( + urdf_path=f"DH_PGC_140_50{suffix}/DH_PGC_140_50{suffix}.urdf", + joint_names=tuple(f"{prefix}_{name}" for name in joint_suffixes), + end_link_name=f"{side.value}_base_link_1", + root_link_name=( + f"{side.value}_finger2" if modified else f"{side.value}_finger2_link" + ), + attach_xpos=_attach_xpos( + (0.0, 0.0, 90.0), + (0.0, 0.0, 0.0 if modified else 0.015), + ), + ) + + +_W1_HAND_SPECS = { + ( + DexforceW1HandBrand.BRAINCO_HAND, + DexforceW1HandVersion.V021, + ): W1HandSpec( + brand=DexforceW1HandBrand.BRAINCO_HAND, + version=DexforceW1HandVersion.V021, + sides={side: _brainco_side(side) for side in DexforceW1ArmSide}, + ), + ( + DexforceW1HandBrand.DH_PGC_GRIPPER, + DexforceW1HandVersion.V021, + ): W1HandSpec( + brand=DexforceW1HandBrand.DH_PGC_GRIPPER, + version=DexforceW1HandVersion.V021, + sides={side: _dh_gripper_side(side, False) for side in DexforceW1ArmSide}, + ), + ( + DexforceW1HandBrand.DH_PGC_GRIPPER_M, + DexforceW1HandVersion.V021, + ): W1HandSpec( + brand=DexforceW1HandBrand.DH_PGC_GRIPPER_M, + version=DexforceW1HandVersion.V021, + sides={side: _dh_gripper_side(side, True) for side in DexforceW1ArmSide}, + ), +} + +_DEFAULT_W1_HAND_VERSIONS = MappingProxyType( + { + DexforceW1HandBrand.BRAINCO_HAND: DexforceW1HandVersion.V021, + DexforceW1HandBrand.DH_PGC_GRIPPER: DexforceW1HandVersion.V021, + DexforceW1HandBrand.DH_PGC_GRIPPER_M: DexforceW1HandVersion.V021, + } +) + + +def get_default_w1_hand_version( + brand: DexforceW1HandBrand | str, +) -> DexforceW1HandVersion: + """Return the explicitly selected default release for a hand brand.""" + brand = DexforceW1HandBrand.parse(brand) + try: + return _DEFAULT_W1_HAND_VERSIONS[brand] + except KeyError as exc: + raise ValueError( + f"No default hand version registered for {brand.value}" + ) from exc + + +def get_w1_hand_spec( + brand: DexforceW1HandBrand | str, + version: DexforceW1HandVersion | str, +) -> W1HandSpec: + """Return an explicitly registered hand release.""" + brand = DexforceW1HandBrand.parse(brand) + version = DexforceW1HandVersion.parse(version) + try: + return _W1_HAND_SPECS[(brand, version)] + except KeyError as exc: + raise ValueError( + f"No hand asset registered for brand={brand.value}, " + f"version={version.value}" + ) from exc diff --git a/embodichain/lab/sim/robots/dexforce_w1/params.py b/embodichain/lab/sim/robots/dexforce_w1/params.py index 472c8a775..da759c2d2 100644 --- a/embodichain/lab/sim/robots/dexforce_w1/params.py +++ b/embodichain/lab/sim/robots/dexforce_w1/params.py @@ -19,24 +19,21 @@ from dataclasses import dataclass, field from embodichain.lab.sim.robots.dexforce_w1.types import ( - DexforceW1HandBrand, DexforceW1ArmSide, - DexforceW1ArmKind, DexforceW1Version, ) +from embodichain.lab.sim.robots.dexforce_w1.specs import get_w1_version_spec @dataclass class W1ArmKineParams: - """Kinematics parameters for W1 arm variants. + """Kinematics parameters for a W1 arm. - - arm_kind and W1Version enum types expected to be defined elsewhere. - dh_params stored as numpy array of shape (7,4). - qpos_limits stored as numpy array of shape (7,2) in radians. """ arm_side: "DexforceW1ArmSide" - arm_kind: "DexforceW1ArmKind" version: "DexforceW1Version" = field(default_factory=lambda: DexforceW1Version.V021) # (initialized in __post_init__) @@ -54,18 +51,16 @@ class W1ArmKineParams: qpos_limits: np.ndarray = field(init=False) def __post_init__(self): - if self.version == DexforceW1Version.V021: - self.d_list = np.array([0.0, 0.0, 0.260, 0.0, 0.166, 0.098, 0.0]) - self.link_lengths = np.array( - [ - self.d_list[0] + self.d_list[1], - self.d_list[2] + self.d_list[3], - self.d_list[4] + self.d_list[5], - self.d_list[6], - ] - ) - else: - raise ValueError(f"W1Version {self.version} are not supported.") + spec = get_w1_version_spec(self.version) + self.d_list = np.asarray(spec.arm_d_list, dtype=float) + self.link_lengths = np.array( + [ + self.d_list[0] + self.d_list[1], + self.d_list[2] + self.d_list[3], + self.d_list[4] + self.d_list[5], + self.d_list[6], + ] + ) # helpers: create DH rows and clamp limits def dh_row(d, alpha, a, theta): @@ -78,104 +73,52 @@ def deg2rad_list(list_of_pairs): [ [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.1025], + [0.0, 0.0, 1.0, spec.arm_base_z], [0.0, 0.0, 0.0, 1.0], ] ) - # Build parameters per arm_kind and side, minimizing duplication - if self.arm_kind == DexforceW1ArmKind.INDUSTRIAL: - # default tcp for industrial - T_e_oe = np.array( - [ - [-1.0, 0.0, 0.0, 0.0], - [0.0, -1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.066], - [0.0, 0.0, 0.0, 1.0], - ] - ) - - # fmt: off - dh = [ - dh_row(self.link_lengths[0], -np.pi / 2, 0.0, 0.0), - dh_row(0.0, np.pi / 2, 0.0, 0.0), - dh_row(self.link_lengths[1], np.pi / 2, 0.0, np.pi / 2), - dh_row(0.0, -np.pi / 2, 0.0, 0.0), - dh_row(self.link_lengths[2], -np.pi / 2, 0.0, 0.0), - dh_row(0.0, np.pi / 2, 0.0, 0.0), - dh_row(self.link_lengths[3], 0.0, 0.0, 0.0), + T_e_oe = np.array( + [ + [0.0, 0.0, -1.0, -0.066], + [0.0, 1.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0], ] - - # fmt: on - if self.arm_side == DexforceW1ArmSide.LEFT: - limits = [ - [-170.0, 170.0], - [-120.0, 90.0], - [-170.0, 170.0], - [-135.0, 90.0], - [-170.0, 170.0], - [-90.0, 90.0], - [-170.0, 170.0], - ] - rotation_directions = np.array([1, 1, 1, 1, 1, -1, 1]) - else: - limits = [ - [-170.0, 170.0], - [-90.0, 120.0], - [-170.0, 170.0], - [-90.0, 135.0], - [-170.0, 170.0], - [-90.0, 90.0], - [-170.0, 170.0], - ] - rotation_directions = np.array([1, 1, 1, -1, 1, 1, 1]) - - self.T_e_oe = T_e_oe - - elif self.arm_kind == DexforceW1ArmKind.ANTHROPOMORPHIC: - T_e_oe = np.array( - [ - [0.0, 0.0, -1.0, -0.066], - [0.0, 1.0, 0.0, 0.0], - [1.0, 0.0, 0.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ] - ) - # fmt: off - dh = [ - dh_row(self.link_lengths[0], -np.pi / 2, 0.0, 0.0), - dh_row(0.0, np.pi / 2, 0.0, 0.0), - dh_row(self.link_lengths[1], np.pi / 2, 0.0, np.pi / 2), - dh_row(0.0, -np.pi / 2, 0.0, 0.0), - dh_row(self.link_lengths[2], -np.pi / 2, 0.0, 0.0), - dh_row(0.0, np.pi / 2, 0.0, np.pi / 2), - dh_row(self.link_lengths[3], 0.0, 0.0, 0.0), + ) + # fmt: off + dh = [ + dh_row(self.link_lengths[0], -np.pi / 2, 0.0, 0.0), + dh_row(0.0, np.pi / 2, 0.0, 0.0), + dh_row(self.link_lengths[1], np.pi / 2, 0.0, np.pi / 2), + dh_row(0.0, -np.pi / 2, 0.0, 0.0), + dh_row(self.link_lengths[2], -np.pi / 2, 0.0, 0.0), + dh_row(0.0, np.pi / 2, 0.0, np.pi / 2), + dh_row(self.link_lengths[3], 0.0, 0.0, 0.0), + ] + # fmt: on + + if self.arm_side == DexforceW1ArmSide.LEFT: + limits = [ + [-170.0, 170.0], + [-120.0, 90.0], + [-170.0, 170.0], + [-135.0, 90.0], + [-170.0, 170.0], + [-45.0, 45.0], + [-90.0, 60.0], ] - # fmt: on - - if self.arm_side == DexforceW1ArmSide.LEFT: - limits = [ - [-170.0, 170.0], - [-120.0, 90.0], - [-170.0, 170.0], - [-135.0, 90.0], - [-170.0, 170.0], - [-45.0, 45.0], - [-90.0, 60.0], - ] - rotation_directions = np.array([1, 1, 1, 1, 1, -1, 1]) - else: - limits = [ - [-170.0, 170.0], - [-90.0, 120.0], - [-170.0, 170.0], - [-90.0, 135.0], - [-170.0, 170.0], - [-45.0, 45.0], - [-60.0, 90.0], - ] - rotation_directions = np.array([1, 1, 1, -1, 1, 1, 1]) + rotation_directions = np.array([1, 1, 1, 1, 1, -1, 1]) else: - raise ValueError(f"Unsupported arm_kind: {self.arm_kind}") + limits = [ + [-170.0, 170.0], + [-90.0, 120.0], + [-170.0, 170.0], + [-90.0, 135.0], + [-170.0, 170.0], + [-45.0, 45.0], + [-60.0, 90.0], + ] + rotation_directions = np.array([1, 1, 1, -1, 1, 1, 1]) self.T_b_ob = T_b_ob self.T_e_oe = T_e_oe @@ -192,7 +135,6 @@ def deg2rad_list(list_of_pairs): def as_dict(self) -> dict: return { "arm_side": self.arm_side.name, - "arm_kind": self.arm_kind.name, "version": self.version.name, "link_lengths": self.link_lengths.tolist(), "T_b_ob": self.T_b_ob.tolist(), @@ -204,23 +146,9 @@ def as_dict(self) -> dict: @classmethod def from_dict(cls, data: dict) -> "W1ArmKineParams": - arm_side = ( - DexforceW1ArmSide[data["arm_side"]] - if isinstance(data.get("arm_side"), str) - else data.get("arm_side") - ) - - arm_kind = ( - DexforceW1ArmKind[data["arm_kind"]] - if isinstance(data.get("arm_kind"), str) - else data.get("arm_kind") - ) - version = ( - DexforceW1Version[data["version"]] - if isinstance(data.get("version"), str) - else data.get("version", DexforceW1Version.V021) - ) - inst = cls(arm_side=arm_side, arm_kind=arm_kind, version=version) + arm_side = DexforceW1ArmSide.parse(data["arm_side"]) + version = DexforceW1Version.parse(data.get("version", DexforceW1Version.V021)) + inst = cls(arm_side=arm_side, version=version) # allow overriding computed arrays if provided if "dh_params" in data: diff --git a/embodichain/lab/sim/robots/dexforce_w1/specs.py b/embodichain/lab/sim/robots/dexforce_w1/specs.py new file mode 100644 index 000000000..986bb7819 --- /dev/null +++ b/embodichain/lab/sim/robots/dexforce_w1/specs.py @@ -0,0 +1,245 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Single source of truth for released Dexforce W1 hardware revisions.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType + +import numpy as np +from scipy.spatial.transform import Rotation as R + +from .types import ( + DexforceW1ArmSide, + DexforceW1Type, + DexforceW1Version, +) + +__all__ = [ + "W1VersionSpec", + "get_w1_version_spec", +] + + +_LEFT_TCP = ( + (-1.0, 0.0, 0.0, 0.012), + (0.0, 0.0, 1.0, 0.0675), + (0.0, 1.0, 0.0, 0.127), + (0.0, 0.0, 0.0, 1.0), +) +_RIGHT_TCP = ( + (1.0, 0.0, 0.0, 0.012), + (0.0, 0.0, -1.0, -0.0675), + (0.0, 1.0, 0.0, 0.127), + (0.0, 0.0, 0.0, 1.0), +) +_DEFAULT_TCP = { + DexforceW1ArmSide.LEFT: _LEFT_TCP, + DexforceW1ArmSide.RIGHT: _RIGHT_TCP, +} + + +@dataclass(frozen=True) +class W1VersionSpec: + """Asset layout and calibrated defaults belonging to one W1 revision.""" + + version: DexforceW1Version + component_urdfs: Mapping[DexforceW1Type, str] + full_robot_urdf_path: str + arm_d_list: tuple[float, ...] + arm_base_z: float + default_eef_attach_xpos: Mapping[DexforceW1ArmSide, tuple] + solver_tcp: Mapping[DexforceW1ArmSide, tuple] + eyes_attach_xpos: tuple[tuple[float, ...], ...] + wrist_camera_rpy: tuple[float, float, float] + wrist_camera_xyz: tuple[float, float, float] + head_contains_eyes: bool = False + + def __post_init__(self) -> None: + object.__setattr__( + self, "component_urdfs", MappingProxyType(dict(self.component_urdfs)) + ) + object.__setattr__( + self, + "default_eef_attach_xpos", + MappingProxyType(dict(self.default_eef_attach_xpos)), + ) + object.__setattr__(self, "solver_tcp", MappingProxyType(dict(self.solver_tcp))) + + @property + def assembly_name(self) -> str: + return f"DexforceW1V{self.version.value.removeprefix('v')}" + + def component_urdf(self, component_type: DexforceW1Type) -> str: + try: + return self.component_urdfs[component_type] + except KeyError as exc: + raise ValueError( + f"W1 {self.version.value} has no registered " + f"{component_type.value} component asset." + ) from exc + + def full_robot_urdf(self) -> str: + return self.full_robot_urdf_path + + def eef_attach_xpos(self, arm_side: DexforceW1ArmSide) -> np.ndarray: + """Return the version-owned transform applied before every EEF.""" + return np.asarray(self.default_eef_attach_xpos[arm_side], dtype=float).copy() + + def compose_eef_attach_xpos( + self, + arm_side: DexforceW1ArmSide, + eef_xpos: np.ndarray, + ) -> np.ndarray: + """Compose the W1 revision offset with an EEF-specific transform.""" + eef_xpos = np.asarray(eef_xpos, dtype=float) + if eef_xpos.shape != (4, 4): + raise ValueError( + f"EEF transform must have shape (4, 4), got {eef_xpos.shape}." + ) + return self.eef_attach_xpos(arm_side) @ eef_xpos + + def tcp(self, arm_side: DexforceW1ArmSide) -> np.ndarray: + """Return the final EE-to-TCP transform for this W1 revision.""" + return self.compose_eef_attach_xpos( + arm_side, + np.asarray(self.solver_tcp[arm_side], dtype=float), + ) + + def eyes_xpos(self) -> np.ndarray: + return np.asarray(self.eyes_attach_xpos, dtype=float).copy() + + def wrist_camera_xpos(self, arm_side: DexforceW1ArmSide) -> np.ndarray: + attach_xpos = np.eye(4) + attach_xpos[:3, :3] = R.from_euler("xyz", self.wrist_camera_rpy).as_matrix() + attach_xpos[:3, 3] = self.wrist_camera_xyz + + frame_xpos = np.eye(4) + yaw = -90 if arm_side == DexforceW1ArmSide.LEFT else 90 + frame_xpos[:3, :3] = R.from_rotvec([0, 0, yaw], degrees=True).as_matrix() + return frame_xpos @ attach_xpos + + +_V021_COMPONENT_URDFS = { + DexforceW1Type.CHASSIS: "DexforceW1ChassisV021/chassis.urdf", + DexforceW1Type.TORSO: "DexforceW1TorsoV021/torso.urdf", + DexforceW1Type.EYES: "DexforceW1EyesV021/eyes.urdf", + DexforceW1Type.HEAD: "DexforceW1HeadV021/head.urdf", + DexforceW1Type.LEFT_ARM: "DexforceW1LeftArmV021/left_arm.urdf", + DexforceW1Type.RIGHT_ARM: "DexforceW1RightArmV021/right_arm.urdf", +} +_V022_COMPONENT_URDFS = { + DexforceW1Type.CHASSIS: "DexforceW1V022/w1/chassis.urdf", + DexforceW1Type.TORSO: "DexforceW1V022/w1/torso.urdf", + DexforceW1Type.HEAD: "DexforceW1V022/w1/head.urdf", + DexforceW1Type.LEFT_ARM: "DexforceW1V022/w1/left_arm.urdf", + DexforceW1Type.RIGHT_ARM: "DexforceW1V022/w1/right_arm.urdf", +} +_V025_COMPONENT_URDFS = { + DexforceW1Type.CHASSIS: "DexforceW1V025/w1/chassis.urdf", + DexforceW1Type.TORSO: "DexforceW1V025/w1/torso.urdf", + DexforceW1Type.HEAD: "DexforceW1V025/w1/head.urdf", + DexforceW1Type.LEFT_ARM: "DexforceW1V025/w1/left_arm.urdf", + DexforceW1Type.RIGHT_ARM: "DexforceW1V025/w1/right_arm.urdf", +} +# Verified against the released V022 and V025 left/right arm URDF joint origins. +_SHARED_D_LIST = (0.0, 0.0, 0.260, 0.0, 0.166, 0.098, 0.0) +_EYES_ATTACH_XPOS = ( + (-0.0, 0.25959, -0.96572, 0.091), + (0.0, -0.96572, -0.25959, -0.051), + (-1.0, -0.0, 0.0, 0.0), + (0.0, 0.0, 0.0, 1.0), +) +_WRIST_CAMERA_RPY = (2.79252648, 0.0, 1.57079633) +_WRIST_CAMERA_XYZ = (0.08, 0.0, 0.06) +_IDENTITY_XPOS = ( + (1.0, 0.0, 0.0, 0.0), + (0.0, 1.0, 0.0, 0.0), + (0.0, 0.0, 1.0, 0.0), + (0.0, 0.0, 0.0, 1.0), +) +_V021_DEFAULT_EEF_ATTACH_XPOS = { + DexforceW1ArmSide.LEFT: _IDENTITY_XPOS, + DexforceW1ArmSide.RIGHT: _IDENTITY_XPOS, +} +# Provisional V022 baseline. Replace this with the measured arm-flange +# transform before declaring the release calibrated. +_V022_DEFAULT_EEF_ATTACH_XPOS = { + DexforceW1ArmSide.LEFT: _IDENTITY_XPOS, + DexforceW1ArmSide.RIGHT: _IDENTITY_XPOS, +} +# Calibrated outward flange offset shared by every V025 end effector. +_V025_EEF_ATTACH_XPOS = ( + (1.0, 0.0, 0.0, 0.0), + (0.0, 1.0, 0.0, 0.0), + (0.0, 0.0, 1.0, 0.012), + (0.0, 0.0, 0.0, 1.0), +) +_V025_DEFAULT_EEF_ATTACH_XPOS = { + DexforceW1ArmSide.LEFT: _V025_EEF_ATTACH_XPOS, + DexforceW1ArmSide.RIGHT: _V025_EEF_ATTACH_XPOS, +} + +_W1_VERSION_SPECS = { + DexforceW1Version.V021: W1VersionSpec( + version=DexforceW1Version.V021, + component_urdfs=_V021_COMPONENT_URDFS, + full_robot_urdf_path="DexforceW1V021/DexforceW1_v02_1.urdf", + arm_d_list=_SHARED_D_LIST, + arm_base_z=0.1025, + default_eef_attach_xpos=_V021_DEFAULT_EEF_ATTACH_XPOS, + solver_tcp=_DEFAULT_TCP, + eyes_attach_xpos=_EYES_ATTACH_XPOS, + wrist_camera_rpy=_WRIST_CAMERA_RPY, + wrist_camera_xyz=_WRIST_CAMERA_XYZ, + ), + DexforceW1Version.V022: W1VersionSpec( + version=DexforceW1Version.V022, + component_urdfs=_V022_COMPONENT_URDFS, + full_robot_urdf_path="DexforceW1V022/w1/robot.urdf", + # Verified from the V022 left/right arm URDF joint origins. + arm_d_list=_SHARED_D_LIST, + arm_base_z=0.1025, + default_eef_attach_xpos=_V022_DEFAULT_EEF_ATTACH_XPOS, + solver_tcp=_DEFAULT_TCP, + eyes_attach_xpos=_EYES_ATTACH_XPOS, + wrist_camera_rpy=_WRIST_CAMERA_RPY, + wrist_camera_xyz=_WRIST_CAMERA_XYZ, + # Verified from V022 head.urdf: it contains the eyes link and EYES joint. + head_contains_eyes=True, + ), + DexforceW1Version.V025: W1VersionSpec( + version=DexforceW1Version.V025, + component_urdfs=_V025_COMPONENT_URDFS, + full_robot_urdf_path="DexforceW1V025/w1/robot.urdf", + # Verified against left_arm.urdf/right_arm.urdf in the V025 release. + arm_d_list=_SHARED_D_LIST, + arm_base_z=0.1025, + default_eef_attach_xpos=_V025_DEFAULT_EEF_ATTACH_XPOS, + solver_tcp=_DEFAULT_TCP, + eyes_attach_xpos=_EYES_ATTACH_XPOS, + wrist_camera_rpy=_WRIST_CAMERA_RPY, + wrist_camera_xyz=_WRIST_CAMERA_XYZ, + head_contains_eyes=True, + ), +} + + +def get_w1_version_spec(version: DexforceW1Version | str) -> W1VersionSpec: + return _W1_VERSION_SPECS[DexforceW1Version.parse(version)] diff --git a/embodichain/lab/sim/robots/dexforce_w1/types.py b/embodichain/lab/sim/robots/dexforce_w1/types.py index fa65d07e0..27bcf756e 100644 --- a/embodichain/lab/sim/robots/dexforce_w1/types.py +++ b/embodichain/lab/sim/robots/dexforce_w1/types.py @@ -15,53 +15,76 @@ # ---------------------------------------------------------------------------- import enum +import re +from typing import TypeVar __all__ = [ "DexforceW1Version", - "DexforceW1ArmKind", + "DexforceW1HandVersion", "DexforceW1ArmSide", "DexforceW1Type", "DexforceW1HandBrand", ] +_W1EnumT = TypeVar("_W1EnumT", bound="_W1Enum") -class DexforceW1Version(enum.Enum): - """Versioning for DexforceW1 components.""" + +class _W1Enum(enum.Enum): + @classmethod + def _parse_label(cls) -> str: + name = cls.__name__.removeprefix("DexforceW1") + words = re.sub(r"(? _W1EnumT: + """Parse an enum instance, member name, or serialized value.""" + if isinstance(value, cls): + return value + if isinstance(value, str): + normalized = value.lower() + for member in cls: + if normalized in (member.name.lower(), str(member.value).lower()): + return member + raise ValueError(f"Invalid {cls._parse_label()}: {value!r}") + + +class DexforceW1Version(_W1Enum): + """Released version of the W1 robot body and arms.""" V021 = "v021" + V022 = "v022" + V025 = "v025" -class DexforceW1ArmKind(enum.Enum): - """Arm type for DexforceW1: anthropomorphic or industrial.""" +class DexforceW1HandVersion(_W1Enum): + """Released version of an external W1 hand or gripper asset.""" - ANTHROPOMORPHIC = "anthropomorphic" - INDUSTRIAL = "industrial" + V021 = "v021" -class DexforceW1ArmSide(enum.Enum): +class DexforceW1ArmSide(_W1Enum): """Arm side for DexforceW1: left or right.""" LEFT = "left" RIGHT = "right" -class DexforceW1Type(enum.Enum): +class DexforceW1Type(_W1Enum): """Component type for DexforceW1.""" CHASSIS = "chassis" TORSO = "torso" EYES = "eyes" HEAD = "head" - LEFT_ARM1 = "left_arm" # Anthropomorphic left arm - RIGHT_ARM1 = "right_arm" # Anthropomorphic right arm - LEFT_ARM2 = "left_arm2" # Industrial left arm - RIGHT_ARM2 = "right_arm2" # Industrial right arm + LEFT_ARM = "left_arm" + RIGHT_ARM = "right_arm" LEFT_HAND = "left_hand" RIGHT_HAND = "right_hand" FULL_BODY = "full_body" # Full robot -class DexforceW1HandBrand(enum.Enum): +class DexforceW1HandBrand(_W1Enum): BRAINCO_HAND = "BRAINCO_HAND" DH_PGC_GRIPPER = "DH_PGC_GRIPPER" DH_PGC_GRIPPER_M = "DH_PGC_GRIPPER_M" diff --git a/embodichain/lab/sim/robots/dexforce_w1/utils.py b/embodichain/lab/sim/robots/dexforce_w1/utils.py index 9402b5657..1a6d73c1b 100644 --- a/embodichain/lab/sim/robots/dexforce_w1/utils.py +++ b/embodichain/lab/sim/robots/dexforce_w1/utils.py @@ -13,20 +13,26 @@ # See the License for the specific language governing permissions and # limitations under the License. # ---------------------------------------------------------------------------- +import xml.etree.ElementTree as ET + import numpy as np -from scipy.spatial.transform import Rotation as R -from typing import List, Dict from embodichain.lab.sim.robots.dexforce_w1.types import ( - DexforceW1ArmKind, DexforceW1Type, DexforceW1ArmSide, DexforceW1Version, DexforceW1HandBrand, + DexforceW1HandVersion, +) +from embodichain.lab.sim.robots.dexforce_w1.hand_specs import ( + get_default_w1_hand_version, + get_w1_hand_spec, ) from embodichain.data import get_data_path -from embodichain.lab.sim.solvers import SolverCfg -from embodichain.lab.sim.cfg import RobotCfg, URDFCfg +from embodichain.lab.sim.cfg import URDFCfg +from embodichain.lab.sim.robots.dexforce_w1.specs import ( + get_w1_version_spec, +) __all__ = [ "ChassisManager", @@ -36,18 +42,14 @@ "HandManager", "EyesManager", "build_dexforce_w1_assembly_urdf_cfg", - "build_dexforce_w1_cfg", ] class ChassisManager: - def __init__(self): - self._urdf_rel_paths = { - DexforceW1Version.V021: "DexforceW1ChassisV021/chassis.urdf", - } - def get_urdf(self, version=DexforceW1Version.V021): - return get_data_path(self._urdf_rel_paths[version]) + return get_data_path( + get_w1_version_spec(version).component_urdf(DexforceW1Type.CHASSIS) + ) def get_config(self, version=DexforceW1Version.V021): return { @@ -60,13 +62,12 @@ def get_config(self, version=DexforceW1Version.V021): class TorsoManager: def __init__(self): - self._urdf_rel_paths = { - DexforceW1Version.V021: "DexforceW1TorsoV021/torso.urdf", - } self.joint_names = ["ANKLE", "KNEE", "BUTTOCK", "WAIST"] def get_urdf(self, version=DexforceW1Version.V021): - return get_data_path(self._urdf_rel_paths[version]) + return get_data_path( + get_w1_version_spec(version).component_urdf(DexforceW1Type.TORSO) + ) def get_config(self, version=DexforceW1Version.V021): return { @@ -79,13 +80,12 @@ def get_config(self, version=DexforceW1Version.V021): class HeadManager: def __init__(self): - self._urdf_rel_paths = { - DexforceW1Version.V021: "DexforceW1HeadV021/head.urdf", - } self.joint_names = ["NECK1", "NECK2"] def get_urdf(self, version=DexforceW1Version.V021): - return get_data_path(self._urdf_rel_paths[version]) + return get_data_path( + get_w1_version_spec(version).component_urdf(DexforceW1Type.HEAD) + ) def get_config(self, version=DexforceW1Version.V021): return { @@ -97,13 +97,10 @@ def get_config(self, version=DexforceW1Version.V021): class EyesManager: - def __init__(self): - self._urdf_rel_paths = { - DexforceW1Version.V021: "DexforceW1EyesV021/eyes.urdf", - } - def get_urdf(self, version=DexforceW1Version.V021): - return get_data_path(self._urdf_rel_paths[version]) + return get_data_path( + get_w1_version_spec(version).component_urdf(DexforceW1Type.EYES) + ) def get_config(self, version=DexforceW1Version.V021): return { @@ -115,37 +112,22 @@ def get_config(self, version=DexforceW1Version.V021): class ArmManager: - def __init__(self): - self._urdf_rel_paths = { - ( - DexforceW1ArmKind.ANTHROPOMORPHIC, - DexforceW1ArmSide.LEFT, - DexforceW1Version.V021, - ): "DexforceW1LeftArm1V021/left_arm.urdf", - ( - DexforceW1ArmKind.ANTHROPOMORPHIC, - DexforceW1ArmSide.RIGHT, - DexforceW1Version.V021, - ): "DexforceW1RightArm1V021/right_arm.urdf", - ( - DexforceW1ArmKind.INDUSTRIAL, - DexforceW1ArmSide.LEFT, - DexforceW1Version.V021, - ): "DexforceW1LeftArm2V021/left_arm.urdf", - ( - DexforceW1ArmKind.INDUSTRIAL, - DexforceW1ArmSide.RIGHT, - DexforceW1Version.V021, - ): "DexforceW1RightArm2V021/right_arm.urdf", - } - - def get_urdf(self, kind, side, version=DexforceW1Version.V021): - return get_data_path(self._urdf_rel_paths[(kind, side, version)]) + def get_urdf(self, side, version=DexforceW1Version.V021): + spec = get_w1_version_spec(version) + return get_data_path(spec.component_urdf(self.get_component_type(side))) + + @staticmethod + def get_component_type(side): + return ( + DexforceW1Type.LEFT_ARM + if side == DexforceW1ArmSide.LEFT + else DexforceW1Type.RIGHT_ARM + ) - def get_config(self, kind, side, version=DexforceW1Version.V021): + def get_config(self, side, version=DexforceW1Version.V021): prefix = "LEFT" if side == DexforceW1ArmSide.LEFT else "RIGHT" return { - "urdf_path": self.get_urdf(kind, side, version), + "urdf_path": self.get_urdf(side, version), "joint_names": [f"{prefix}_J{i}" for i in range(1, 8)], "end_link_name": f"{prefix.lower()}_ee", "root_link_name": f"{prefix.lower()}_arm_base", @@ -153,132 +135,40 @@ def get_config(self, kind, side, version=DexforceW1Version.V021): class HandManager: - def __init__(self): - self._urdf_rel_paths = { - ( - DexforceW1HandBrand.BRAINCO_HAND, - DexforceW1ArmSide.LEFT, - DexforceW1Version.V021, - ): "BrainCoHandRevo1/BrainCoLeftHand/BrainCoLeftHand.urdf", - ( - DexforceW1HandBrand.BRAINCO_HAND, - DexforceW1ArmSide.RIGHT, - DexforceW1Version.V021, - ): "BrainCoHandRevo1/BrainCoRightHand/BrainCoRightHand.urdf", - ( - DexforceW1HandBrand.DH_PGC_GRIPPER, - DexforceW1ArmSide.LEFT, - DexforceW1Version.V021, - ): "DH_PGC_140_50/DH_PGC_140_50.urdf", - ( - DexforceW1HandBrand.DH_PGC_GRIPPER, - DexforceW1ArmSide.RIGHT, - DexforceW1Version.V021, - ): "DH_PGC_140_50/DH_PGC_140_50.urdf", - ( - DexforceW1HandBrand.DH_PGC_GRIPPER_M, - DexforceW1ArmSide.LEFT, - DexforceW1Version.V021, - ): "DH_PGC_140_50_M/DH_PGC_140_50_M.urdf", - ( - DexforceW1HandBrand.DH_PGC_GRIPPER_M, - DexforceW1ArmSide.RIGHT, - DexforceW1Version.V021, - ): "DH_PGC_140_50_M/DH_PGC_140_50_M.urdf", - } - def get_config( self, brand: DexforceW1HandBrand, side: DexforceW1ArmSide, - version: DexforceW1Version = DexforceW1Version.V021, + version: DexforceW1HandVersion | None = None, ): - prefix = "LEFT" if side == DexforceW1ArmSide.LEFT else "RIGHT" - if brand == DexforceW1HandBrand.BRAINCO_HAND: - if side == DexforceW1ArmSide.LEFT: - base_link_name = f"{prefix.lower()}_hand_base" - root_link_name = f"{prefix.lower()}_thumb_dist" - joint_names = [ - f"{prefix}_HAND_THUMB1", # Left thumb flexion - f"{prefix}_HAND_THUMB2", # Left thumb abduction/adduction - f"{prefix}_HAND_INDEX", # Left index finger flexion - f"{prefix}_HAND_MIDDLE", # Left middle finger flexion - f"{prefix}_HAND_RING", # Left ring finger flexion - f"{prefix}_HAND_PINKY", # Left pinky finger flexion - ] - else: - base_link_name = f"{prefix.lower()}_hand_base" - root_link_name = f"{prefix.lower()}_thumb_dist" - joint_names = [ - f"{prefix}_HAND_THUMB1", # Right thumb flexion - f"{prefix}_HAND_THUMB2", # Right thumb abduction/adduction - f"{prefix}_HAND_INDEX", # Right index finger flexion - f"{prefix}_HAND_MIDDLE", # Right middle finger flexion - f"{prefix}_HAND_RING", # Right ring finger flexion - f"{prefix}_HAND_PINKY", # Right pinky finger flexion - ] - elif brand == DexforceW1HandBrand.DH_PGC_GRIPPER: - base_link_name = f"{prefix.lower()}_base_link_1" - root_link_name = f"{prefix.lower()}_finger2_link" - joint_names = [f"{prefix}_FINGER1_JOINT", f"{prefix}_FINGER2_JOINT"] - elif brand == DexforceW1HandBrand.DH_PGC_GRIPPER_M: - base_link_name = f"{prefix.lower()}_base_link_1" - root_link_name = f"{prefix.lower()}_finger2" - joint_names = [f"{prefix}_FINGER1", f"{prefix}_FINGER2"] - else: - raise ValueError(f"Unknown hand brand: {brand}") - + version = version or get_default_w1_hand_version(brand) + side_spec = get_w1_hand_spec(brand, version).for_side(side) return { - "urdf_path": self.get_urdf(brand, side, version), - "joint_names": joint_names, - "end_link_name": base_link_name, - "root_link_name": root_link_name, + "urdf_path": get_data_path(side_spec.urdf_path), + "joint_names": list(side_spec.joint_names), + "end_link_name": side_spec.end_link_name, + "root_link_name": side_spec.root_link_name, } def get_urdf( self, brand: DexforceW1HandBrand, side: DexforceW1ArmSide, - version: DexforceW1Version = DexforceW1Version.V021, + version: DexforceW1HandVersion | None = None, ): - return get_data_path(self._urdf_rel_paths[(brand, side, version)]) + version = version or get_default_w1_hand_version(brand) + side_spec = get_w1_hand_spec(brand, version).for_side(side) + return get_data_path(side_spec.urdf_path) def get_attach_xpos( self, brand: DexforceW1HandBrand, - arm_kind: DexforceW1ArmKind = DexforceW1ArmKind.INDUSTRIAL, - is_left: bool = True, + arm_side: DexforceW1ArmSide = DexforceW1ArmSide.LEFT, + version: DexforceW1HandVersion | None = None, ): - if brand == DexforceW1HandBrand.BRAINCO_HAND: - rot_params = { - (DexforceW1ArmKind.INDUSTRIAL, True): [90, 0, 0], - (DexforceW1ArmKind.INDUSTRIAL, False): [90, 0, 180], - (DexforceW1ArmKind.ANTHROPOMORPHIC, True): [90, 0, 180], - (DexforceW1ArmKind.ANTHROPOMORPHIC, False): [90, 0, 0], - } - attach_xpos = np.eye(4) - rot = R.from_euler("xyz", rot_params[(arm_kind, is_left)], degrees=True) - attach_xpos[:3, :3] = rot.as_matrix() - attach_xpos[2, 3] = 0.0 - return attach_xpos - elif brand == DexforceW1HandBrand.DH_PGC_GRIPPER: - attach_xpos = np.array( - [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0.015], [0, 0, 0, 1]] - ) - attach_xpos[:3, :3] = ( - attach_xpos[:3, :3] - @ R.from_rotvec([0, 0, 90], degrees=True).as_matrix() - ) - return attach_xpos - elif brand == DexforceW1HandBrand.DH_PGC_GRIPPER_M: - attach_xpos = np.eye(4) - attach_xpos[:3, :3] = ( - attach_xpos[:3, :3] - @ R.from_rotvec([0, 0, 90], degrees=True).as_matrix() - ) - return attach_xpos - else: - raise ValueError(f"Unknown brand: {brand}") + version = version or get_default_w1_hand_version(brand) + side_spec = get_w1_hand_spec(brand, version).for_side(arm_side) + return np.asarray(side_spec.attach_xpos, dtype=float).copy() eyes_manager = EyesManager() @@ -290,86 +180,60 @@ def get_attach_xpos( def build_dexforce_w1_assembly_urdf_cfg( - arm_kind: DexforceW1ArmKind, - arm_sides: List[DexforceW1ArmSide] = [ - DexforceW1ArmSide.LEFT, - DexforceW1ArmSide.RIGHT, - ], - fname: str | None = "DexforceW1V021", + version: DexforceW1Version = DexforceW1Version.V021, + fname: str | None = None, hand_types: dict[DexforceW1ArmSide, DexforceW1HandBrand] | None = None, - hand_versions: dict[DexforceW1ArmSide, DexforceW1Version] | None = None, + hand_versions: dict[DexforceW1ArmSide, DexforceW1HandVersion] | None = None, hand_attach_xposes: dict[DexforceW1ArmSide, np.ndarray] | None = None, - include_chassis: bool = True, - include_torso: bool = True, - include_head: bool = True, include_hand: bool = True, - include_eyes: bool = True, - include_wrist_cameras: bool = True, - component_versions: dict[DexforceW1Type, DexforceW1Version] | None = None, ) -> URDFCfg: """ Assemble DexforceW1 robot urdf configuration. Args: - arm_kind: Arm type (anthropomorphic or industrial). - arm_sides: List of arm sides to include (left/right). Default both sides. - fname: Output configuration name. Default "DexforceW1V021". + version: W1 version used by every robot component. + fname: Output configuration name. Defaults to the version assembly name. hand_types: Dict specifying hand brand (DexforceW1HandBrand) for each arm side. Default None, which uses the default brand. - hand_versions: Dict specifying hand version for each arm side. Default None, which uses the default version. + hand_versions: Hand asset version for each side. Defaults to hand V021, + independently of the W1 robot version. hand_attach_xposes: Dict specifying hand attachment pose for each arm side. Default None, which uses the default attachment pose. - include_chassis: Whether to include chassis. Default True. - include_torso: Whether to include torso. Default True. - include_head: Whether to include head. Default True. include_hand: Whether to include hand. Default True. - include_wrist_cameras: Whether to include wrist cameras. Default True. - component_versions: Dict specifying version for each robot component. Default all V021. Returns: URDFCfg: Assembled URDF configuration. """ - def get_version(t, default=DexforceW1Version.V021): - return (component_versions or {}).get(t, default) + version = DexforceW1Version.parse(version) + hand_versions = { + DexforceW1ArmSide.parse(side): DexforceW1HandVersion.parse(hand_version) + for side, hand_version in (hand_versions or {}).items() + } - components = [] - if include_chassis: - components.append( - { - "component_type": "chassis", - "urdf_path": chassis_manager.get_urdf( - get_version(DexforceW1Type.CHASSIS) - ), - } - ) - if include_torso: - components.append( - { - "component_type": "torso", - "urdf_path": torso_manager.get_urdf(get_version(DexforceW1Type.TORSO)), - } - ) - if include_head: - components.append( - { - "component_type": "head", - "urdf_path": head_manager.get_urdf(get_version(DexforceW1Type.HEAD)), - } - ) + if fname is None: + fname = get_w1_version_spec(version).assembly_name + + components = [ + { + "component_type": "chassis", + "urdf_path": chassis_manager.get_urdf(version), + }, + { + "component_type": "torso", + "urdf_path": torso_manager.get_urdf(version), + }, + { + "component_type": "head", + "urdf_path": head_manager.get_urdf(version), + }, + ] sensors = [] - if include_eyes: + head_spec = get_w1_version_spec(version) + head_contains_eyes = head_spec.head_contains_eyes + if not head_contains_eyes: # TODO: Support user-defined eye transforms - import xml.etree.ElementTree as ET - - attach_xpos = np.array( - [ - [-0.0, 0.25959, -0.96572, 0.091], - [0.0, -0.96572, -0.25959, -0.051], - [-1.0, -0.0, 0.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ] - ) + attach_xpos = head_spec.eyes_xpos() joint_xml = """ @@ -402,73 +266,43 @@ def get_version(t, default=DexforceW1Version.V021): "sensor_type": "camera", } ) - if include_wrist_cameras: - for arm_side in arm_sides: - # TODO: Support user-defined eye transforms - import xml.etree.ElementTree as ET - - if arm_side == DexforceW1ArmSide.LEFT: - rpy = [2.79252648, 0.0, 1.57079633] - xyz = [0.08, 0.0, 0.06] - tf_xpos = np.eye(4) - tf_xpos[:3, :3] = R.from_rotvec([0, 0, -90], degrees=True).as_matrix() - else: - rpy = [2.79252648, 0.0, 1.57079633] - xyz = [0.08, 0.0, 0.06] - tf_xpos = np.eye(4) - tf_xpos[:3, :3] = R.from_rotvec([0, 0, 90], degrees=True).as_matrix() - - attach_xpos = np.eye(4) - attach_xpos[:3, :3] = R.from_euler("xyz", rpy).as_matrix() - attach_xpos[:3, 3] = xyz - attach_xpos = tf_xpos @ attach_xpos - - joint_xml = f""" - - - - - - """ - - link_xml = f""" - - - - - - - - """ - - joint_elem = ET.fromstring(joint_xml) - link_elem = ET.fromstring(link_xml) - sensors.append( - { - "sensor_name": f"{arm_side.value.lower()}_wrist_camera", - "sensor_source": ([link_elem], [joint_elem]), - "parent_component": f"{arm_side.value}_arm", - "parent_link": f"{arm_side.value}_ee", - "transform": attach_xpos, - "sensor_type": "camera", - } - ) + for arm_side in DexforceW1ArmSide: + camera_spec = get_w1_version_spec(version) + attach_xpos = camera_spec.wrist_camera_xpos(arm_side) - for arm_side in arm_sides: - if arm_kind == DexforceW1ArmKind.ANTHROPOMORPHIC: - arm_type = ( - DexforceW1Type.LEFT_ARM1 - if arm_side == DexforceW1ArmSide.LEFT - else DexforceW1Type.RIGHT_ARM1 - ) - else: - arm_type = ( - DexforceW1Type.LEFT_ARM2 - if arm_side == DexforceW1ArmSide.LEFT - else DexforceW1Type.RIGHT_ARM2 - ) - arm_version = get_version(arm_type) - arm_cfg = arm_manager.get_config(arm_kind, arm_side, arm_version) + joint_xml = f""" + + + + + + """ + + link_xml = f""" + + + + + + + + """ + + joint_elem = ET.fromstring(joint_xml) + link_elem = ET.fromstring(link_xml) + sensors.append( + { + "sensor_name": f"{arm_side.value.lower()}_wrist_camera", + "sensor_source": ([link_elem], [joint_elem]), + "parent_component": f"{arm_side.value}_arm", + "parent_link": f"{arm_side.value}_ee", + "transform": attach_xpos, + "sensor_type": "camera", + } + ) + + for arm_side in DexforceW1ArmSide: + arm_cfg = arm_manager.get_config(arm_side, version) components.append( { "component_type": f"{arm_side.value}_arm", @@ -477,27 +311,30 @@ def get_version(t, default=DexforceW1Version.V021): ) if include_hand: - for arm_side in arm_sides: + for arm_side in DexforceW1ArmSide: # hand_brand: DexforceW1HandBrand hand_brand = (hand_types or {}).get( arm_side, DexforceW1HandBrand.BRAINCO_HAND ) - hand_version = (hand_versions or {}).get( - arm_side, - get_version( - DexforceW1Type.LEFT_HAND - if arm_side == DexforceW1ArmSide.LEFT - else DexforceW1Type.RIGHT_HAND - ), + hand_version = hand_versions.get( + arm_side, get_default_w1_hand_version(hand_brand) ) urdf_path = hand_manager.get_urdf(hand_brand, arm_side, hand_version) - attach_xpos = (hand_attach_xposes or {}).get( - arm_side, - hand_manager.get_attach_xpos( - hand_brand, arm_kind, arm_side == DexforceW1ArmSide.LEFT - ), - ) + custom_attach_xpos = (hand_attach_xposes or {}).get(arm_side) + if custom_attach_xpos is None: + hand_attach_xpos = hand_manager.get_attach_xpos( + hand_brand, arm_side, hand_version + ) + arm_spec = get_w1_version_spec(version) + attach_xpos = arm_spec.compose_eef_attach_xpos( + arm_side, hand_attach_xpos + ) + else: + arm_spec = get_w1_version_spec(version) + attach_xpos = arm_spec.compose_eef_attach_xpos( + arm_side, custom_attach_xpos + ) components.append( { "component_type": f"{arm_side.value}_hand", @@ -505,239 +342,62 @@ def get_version(t, default=DexforceW1Version.V021): "transform": attach_xpos, } ) - return URDFCfg(components=components, sensors=sensors, fname=fname) - - -def build_dexforce_w1_solver_cfg( - arm_kind: DexforceW1ArmKind, - arm_sides: List[DexforceW1ArmSide] = [ - DexforceW1ArmSide.LEFT, - DexforceW1ArmSide.RIGHT, - ], - component_versions: dict[DexforceW1Type, DexforceW1Version] | None = None, - urdf_cfg: URDFCfg | None = None, -) -> Dict[str, SolverCfg]: - """ - Build DexforceW1 solver configuration dict. - - Args: - arm_kind: Arm type. - arm_sides: Included arm sides. Optional, default both sides. - component_versions: Component version dict. Optional, default all V021. - urdf_cfg: Optional, URDFCfg object from build_dexforce_w1_assembly_urdf_cfg. - - Returns: - Dict[str, SolverCfg]: solver config keyed by control part name - (e.g. ``"left_arm"``, ``"full_body"``). - """ - - def get_version(t, default=DexforceW1Version.V021): - return (component_versions or {}).get(t, default) - - solver_cfg = {} - - for arm_side in arm_sides: - if arm_kind == DexforceW1ArmKind.ANTHROPOMORPHIC: - arm_type = ( - DexforceW1Type.LEFT_ARM1 - if arm_side == DexforceW1ArmSide.LEFT - else DexforceW1Type.RIGHT_ARM1 - ) - else: - arm_type = ( - DexforceW1Type.LEFT_ARM2 - if arm_side == DexforceW1ArmSide.LEFT - else DexforceW1Type.RIGHT_ARM2 - ) - arm_version = get_version(arm_type) - arm_cfg = arm_manager.get_config(arm_kind, arm_side, arm_version) - # Use control_parts-aligned key (e.g. "left_arm") so init_solver - # can match this entry to the corresponding control part. - solver_key = f"{arm_side.value}_arm" - solver_cfg[solver_key] = SolverCfg.from_dict( - { - "class_type": "PytorchSolver", - "urdf_path": arm_cfg["urdf_path"], - "joint_names": arm_cfg["joint_names"], - "end_link_name": arm_cfg["end_link_name"], - "root_link_name": arm_cfg["root_link_name"], - } - ) - - # Use urdf_cfg.fpath if provided, otherwise fallback to default path - full_body_urdf_path = ( - urdf_cfg.fpath or get_data_path("DexforceW1FullBodyV021/full_body.urdf") - if urdf_cfg is not None - else get_data_path("DexforceW1FullBodyV021/full_body.urdf") + # W1 exposes stable uppercase joint names and lowercase link names + # independently of the casing used by each source component URDF. + return URDFCfg( + components=components, + sensors=sensors, + fname=fname, + name_case={"joint": "upper", "link": "lower"}, ) - solver_cfg[DexforceW1Type.FULL_BODY.value] = SolverCfg.from_dict( - { - "class_type": "PytorchSolver", - "urdf_path": full_body_urdf_path, - "joint_names": [ - "ANKLE", - "KNEE", - "BUTTOCK", - "WAIST", - "NECK1", - "NECK2", - "LEFT_J1", - "LEFT_J2", - "LEFT_J3", - "LEFT_J4", - "LEFT_J5", - "LEFT_J6", - "LEFT_J7", - "RIGHT_J1", - "RIGHT_J2", - "RIGHT_J3", - "RIGHT_J4", - "RIGHT_J5", - "RIGHT_J6", - "RIGHT_J7", - ], - "end_link_name": "right_ee", - "root_link_name": "base_link", - } - ) - return solver_cfg +def build_dexforce_w1_control_parts( + version: DexforceW1Version, + hand_types: dict[DexforceW1ArmSide, DexforceW1HandBrand] | None, + hand_versions: dict[DexforceW1ArmSide, DexforceW1HandVersion] | None, + include_hand: bool, +) -> dict[str, list[str]]: + """Build control-part joint lists for a complete dual-arm W1.""" + version = DexforceW1Version.parse(version) + hand_versions = { + DexforceW1ArmSide.parse(side): DexforceW1HandVersion.parse(hand_version) + for side, hand_version in (hand_versions or {}).items() + } -def build_dexforce_w1_cfg( - arm_kind: DexforceW1ArmKind, - arm_sides: List[DexforceW1ArmSide] = [ - DexforceW1ArmSide.LEFT, - DexforceW1ArmSide.RIGHT, - ], - hand_types: dict[DexforceW1ArmSide, DexforceW1HandBrand] | None = None, - hand_versions: dict[DexforceW1ArmSide, DexforceW1Version] | None = None, - hand_attach_xposes: dict[DexforceW1ArmSide, np.ndarray] | None = None, - include_chassis: bool = True, - include_torso: bool = True, - include_head: bool = True, - include_hand: bool = True, - component_versions: dict[DexforceW1Type, DexforceW1Version] | None = None, - solver_cfg: dict[DexforceW1Type, SolverCfg] | None = None, -) -> "DexforceW1Cfg": - """ - Build DexforceW1 robot configuration object. + arm_joints = {} + for arm_side in DexforceW1ArmSide: + arm_joints[arm_side] = arm_manager.get_config(arm_side, version)["joint_names"] - Args: - arm_kind: Arm type (anthropomorphic or industrial). - arm_sides: List of arm sides to include (left/right). Default both sides. - hand_types: Dict specifying hand brand (DexforceW1HandBrand) for each arm side. Default None, which uses the default brand. - hand_versions: Dict specifying hand version for each arm side. Default None, which uses the default version. - hand_attach_xposes: Dict specifying hand attachment pose for each arm side. Default None, which uses the default attachment pose. - include_chassis: Whether to include chassis. Optional, default True. - include_torso: Whether to include torso. Optional, default True. - include_head: Whether to include head. Optional, default True. - include_hand: Whether to include hand. Optional, default True. - include_wrist_cameras: Whether to include wrist cameras. Optional, default True. - component_versions: Dict specifying version for each robot component. - solver_cfg: Optional, pre-defined solver configuration dict. - - Returns: - DexforceW1Cfg: Robot configuration object. - """ - urdf_cfg = build_dexforce_w1_assembly_urdf_cfg( - arm_kind=arm_kind, - arm_sides=arm_sides, - hand_types=hand_types, - hand_versions=hand_versions, - hand_attach_xposes=hand_attach_xposes, - include_chassis=include_chassis, - include_torso=include_torso, - include_head=include_head, - include_hand=include_hand, - component_versions=component_versions, - ) + torso_joints = torso_manager.get_config(version)["joint_names"] + head_joints = head_manager.get_config(version)["joint_names"] - left_arm_joints = [] - right_arm_joints = [] - for arm_side in arm_sides: - if arm_kind == DexforceW1ArmKind.ANTHROPOMORPHIC: - arm_type = ( - DexforceW1Type.LEFT_ARM1 - if arm_side == DexforceW1ArmSide.LEFT - else DexforceW1Type.RIGHT_ARM1 - ) - else: - arm_type = ( - DexforceW1Type.LEFT_ARM2 - if arm_side == DexforceW1ArmSide.LEFT - else DexforceW1Type.RIGHT_ARM2 - ) - arm_version = (component_versions or {}).get(arm_type, DexforceW1Version.V021) - arm_cfg = arm_manager.get_config(arm_kind, arm_side, arm_version) - if arm_side == DexforceW1ArmSide.LEFT: - left_arm_joints = arm_cfg["joint_names"] - elif arm_side == DexforceW1ArmSide.RIGHT: - right_arm_joints = arm_cfg["joint_names"] - - torso_joints = [] - head_joints = [] - left_hand_joints = [] - right_hand_joints = [] - - if include_torso: - torso_joints = torso_manager.get_config()["joint_names"] - if include_head: - head_joints = head_manager.get_config()["joint_names"] + hand_joints = {} if include_hand: - if DexforceW1ArmSide.LEFT in arm_sides: - left_hand_brand = (hand_types or {}).get( - DexforceW1ArmSide.LEFT, DexforceW1HandBrand.BRAINCO_HAND - ) - left_hand_version = (hand_versions or {}).get( - DexforceW1ArmSide.LEFT, DexforceW1Version.V021 - ) - left_hand_cfg = hand_manager.get_config( - left_hand_brand, DexforceW1ArmSide.LEFT, left_hand_version - ) - left_hand_joints = left_hand_cfg["joint_names"] - if DexforceW1ArmSide.RIGHT in arm_sides: - right_hand_brand = (hand_types or {}).get( - DexforceW1ArmSide.RIGHT, DexforceW1HandBrand.BRAINCO_HAND - ) - right_hand_version = (hand_versions or {}).get( - DexforceW1ArmSide.RIGHT, DexforceW1Version.V021 + for arm_side in DexforceW1ArmSide: + hand_brand = (hand_types or {}).get( + arm_side, DexforceW1HandBrand.BRAINCO_HAND ) - right_hand_cfg = hand_manager.get_config( - right_hand_brand, DexforceW1ArmSide.RIGHT, right_hand_version + hand_version = hand_versions.get( + arm_side, get_default_w1_hand_version(hand_brand) ) - right_hand_joints = right_hand_cfg["joint_names"] + hand_joints[arm_side] = hand_manager.get_config( + hand_brand, arm_side, hand_version + )["joint_names"] + left_arm_joints = arm_joints.get(DexforceW1ArmSide.LEFT, []) + right_arm_joints = arm_joints.get(DexforceW1ArmSide.RIGHT, []) control_parts = {} - - if torso_joints: - control_parts["torso"] = torso_joints - if head_joints: - control_parts["head"] = head_joints - if left_arm_joints: - control_parts["left_arm"] = left_arm_joints - if right_arm_joints: - control_parts["right_arm"] = right_arm_joints - if left_arm_joints and right_arm_joints: - control_parts["dual_arm"] = left_arm_joints + right_arm_joints - if left_hand_joints: - control_parts["left_eef"] = left_hand_joints - if right_hand_joints: - control_parts["right_eef"] = right_hand_joints - - if torso_joints and head_joints and left_arm_joints and right_arm_joints: - control_parts["full_body"] = ( - torso_joints + head_joints + left_arm_joints + right_arm_joints - ) - - from embodichain.lab.sim.robots.dexforce_w1.cfg import DexforceW1Cfg - - cfg = DexforceW1Cfg() - cfg.arm_kind = arm_kind - cfg.urdf_cfg = urdf_cfg - cfg.control_parts = control_parts - - if solver_cfg is not None: - cfg.solver_cfg = solver_cfg - - return cfg + control_parts["torso"] = torso_joints + control_parts["head"] = head_joints + control_parts["left_arm"] = left_arm_joints + control_parts["right_arm"] = right_arm_joints + control_parts["dual_arm"] = left_arm_joints + right_arm_joints + if DexforceW1ArmSide.LEFT in hand_joints: + control_parts["left_eef"] = hand_joints[DexforceW1ArmSide.LEFT] + if DexforceW1ArmSide.RIGHT in hand_joints: + control_parts["right_eef"] = hand_joints[DexforceW1ArmSide.RIGHT] + control_parts["full_body"] = ( + torso_joints + head_joints + left_arm_joints + right_arm_joints + ) + return control_parts diff --git a/embodichain/lab/sim/utility/cfg_utils.py b/embodichain/lab/sim/utility/cfg_utils.py index 12246e848..51ce7d028 100644 --- a/embodichain/lab/sim/utility/cfg_utils.py +++ b/embodichain/lab/sim/utility/cfg_utils.py @@ -84,7 +84,7 @@ def merge_robot_cfg(base_cfg: RobotCfg, override_cfg_dict: dict[str, any]) -> Ro """ # Only parse keys the base RobotCfg recognizes, so subclass-only variant - # fields (version, arm_kind, ...) set by _build_defaults don't trigger + # fields (version, ...) set by _build_defaults don't trigger # spurious "Key not found in RobotCfg" warnings from the base from_dict. # NOTE: check RobotCfg.__dataclass_fields__ (not hasattr(base_cfg, k)) # because base_cfg is the subclass instance which has subclass-only fields, @@ -104,9 +104,12 @@ def merge_robot_cfg(base_cfg: RobotCfg, override_cfg_dict: dict[str, any]) -> Ro # 2. Part dict lacks "class_type" → attribute overrides for an # existing solver part (e.g. {"tcp": ..., "stiffness": ...}). provided_solver_cfg = override_cfg_dict.get("solver_cfg") - if provided_solver_cfg and isinstance(provided_solver_cfg, dict): + if isinstance(provided_solver_cfg, dict): if base_cfg.solver_cfg is None: base_cfg.solver_cfg = {} + if not provided_solver_cfg: + base_cfg.solver_cfg = {} + continue for part, item in provided_solver_cfg.items(): if isinstance(item, dict) and "class_type" in item: # New or replacement solver part — use the deserialized @@ -217,7 +220,7 @@ def merge_robot_cfg(base_cfg: RobotCfg, override_cfg_dict: dict[str, any]) -> Ro ) else: # Only apply keys the base RobotCfg.from_dict recognized. - # Subclass-only variant fields (e.g. version, arm_kind) are not + # Subclass-only variant fields (e.g. version) are not # present on a plain RobotCfg and are already set by _build_defaults; # skip them instead of raising AttributeError. if hasattr(robot_cfg, key): diff --git a/embodichain/lab/sim/workspace/analyzer.py b/embodichain/lab/sim/workspace/analyzer.py index e9f427698..c2decb5af 100644 --- a/embodichain/lab/sim/workspace/analyzer.py +++ b/embodichain/lab/sim/workspace/analyzer.py @@ -2102,17 +2102,34 @@ def _build_cache_key_metadata(self, num_samples: int) -> Dict[str, Any]: robot_name = Path(fpath).stem else: robot_name = config_class.removesuffix("Cfg") + + def serialize_parameter(value): + if isinstance(value, Enum): + return value.value + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, dict): + return { + serialize_parameter(key): serialize_parameter(item) + for key, item in value.items() + } + if isinstance(value, (list, tuple)): + return [serialize_parameter(item) for item in value] + return value + robot_parameters = {} for parameter_name in ( "robot_type", "version", - "arm_kind", "with_default_eef", + "hand_types", + "hand_versions", + "hand_attach_xposes", ): if not hasattr(self.robot.cfg, parameter_name): continue value = getattr(self.robot.cfg, parameter_name) - robot_parameters[parameter_name] = getattr(value, "value", value) + robot_parameters[parameter_name] = serialize_parameter(value) robot_info = { "name": robot_name, "config_class": config_class, diff --git a/examples/sim/demo/grasp_cup_to_caffe.py b/examples/sim/demo/grasp_cup_to_caffe.py index 536549c76..a36f68925 100644 --- a/examples/sim/demo/grasp_cup_to_caffe.py +++ b/examples/sim/demo/grasp_cup_to_caffe.py @@ -33,6 +33,7 @@ from embodichain.lab.sim.cfg import ( RenderCfg, LightCfg, + MarkerCfg, JointDrivePropertiesCfg, RigidObjectCfg, RigidBodyAttributesCfg, @@ -97,6 +98,7 @@ def create_robot(sim: SimulationManager) -> Robot: cfg = DexforceW1Cfg.from_dict( { "uid": "dexforce_w1", + "version": "v025", "init_pos": [0.4, -0.5, 0.0], } ) @@ -423,6 +425,7 @@ def main(): table = create_table(sim) caffe = create_caffe(sim) cup = create_cup(sim) + sim.update(step=1) # apply random perturbation diff --git a/examples/sim/gizmo/gizmo_scene.py b/examples/sim/gizmo/gizmo_scene.py index 011f5814e..554517a83 100644 --- a/examples/sim/gizmo/gizmo_scene.py +++ b/examples/sim/gizmo/gizmo_scene.py @@ -72,9 +72,7 @@ def main(): sim.set_manual_update(False) # Get DexForce W1 URDF path - urdf_path = get_data_path( - "DexforceW1V021_INDUSTRIAL_DH_PGC_GRIPPER_M/DexforceW1V021.urdf" - ) + urdf_path = get_data_path("DexforceW1V021/DexforceW1_v02_1.urdf") # Create DexForce W1 robot robot_cfg = RobotCfg( diff --git a/examples/sim/robot/dexforce_w1.py b/examples/sim/robot/dexforce_w1.py index bf97ed486..9a4e78383 100644 --- a/examples/sim/robot/dexforce_w1.py +++ b/examples/sim/robot/dexforce_w1.py @@ -43,7 +43,6 @@ def main(visualization: VisualizationCfg | None = None) -> None: { "uid": "dexforce_w1", "version": "v021", - "arm_kind": "anthropomorphic", "with_default_eef": False, "control_parts": { "left_eef": ["LEFT_FINGER1_JOINT", "LEFT_FINGER2_JOINT"], diff --git a/examples/sim/scene/scene_demo.py b/examples/sim/scene/scene_demo.py index ad0c8e62b..aeb39a3a5 100644 --- a/examples/sim/scene/scene_demo.py +++ b/examples/sim/scene/scene_demo.py @@ -169,7 +169,6 @@ def main(): { "uid": "dexforce_w1", "version": "v021", - "arm_kind": "anthropomorphic", "init_pos": [-1, -0.5, 0], "init_rot": [0, 0, 90], } diff --git a/examples/sim/workspace/analyze_cartesian_workspace.py b/examples/sim/workspace/analyze_cartesian_workspace.py index e5f0efa49..fb9160067 100644 --- a/examples/sim/workspace/analyze_cartesian_workspace.py +++ b/examples/sim/workspace/analyze_cartesian_workspace.py @@ -97,7 +97,6 @@ def main() -> None: { "uid": "dexforce_w1", "version": "v021", - "arm_kind": "industrial", "with_default_eef": False, } ) diff --git a/examples/sim/workspace/analyze_joint_workspace.py b/examples/sim/workspace/analyze_joint_workspace.py index 2566fa17e..3695bdb79 100644 --- a/examples/sim/workspace/analyze_joint_workspace.py +++ b/examples/sim/workspace/analyze_joint_workspace.py @@ -92,7 +92,6 @@ def main() -> None: { "uid": "dexforce_w1", "version": "v021", - "arm_kind": "industrial", # Workspace analysis only needs the arms. Excluding the grippers # avoids loading unrelated joints and assets. "with_default_eef": False, diff --git a/examples/sim/workspace/analyze_plane_workspace.py b/examples/sim/workspace/analyze_plane_workspace.py index 96e5e31cb..95e381e1e 100644 --- a/examples/sim/workspace/analyze_plane_workspace.py +++ b/examples/sim/workspace/analyze_plane_workspace.py @@ -97,7 +97,6 @@ def main() -> None: { "uid": "dexforce_w1", "version": "v021", - "arm_kind": "industrial", "with_default_eef": False, } ) diff --git a/scripts/tutorials/gym/modular_env.py b/scripts/tutorials/gym/modular_env.py index 37e186524..c882b258d 100644 --- a/scripts/tutorials/gym/modular_env.py +++ b/scripts/tutorials/gym/modular_env.py @@ -105,7 +105,6 @@ class ExampleCfg(EmbodiedEnvCfg): { "uid": "dexforce_w1", "version": "v021", - "arm_kind": "anthropomorphic", "init_pos": [0.0, 0, 0.0], } ) diff --git a/tests/sim/objects/test_robot.py b/tests/sim/objects/test_robot.py index b3a37837b..876781a60 100644 --- a/tests/sim/objects/test_robot.py +++ b/tests/sim/objects/test_robot.py @@ -63,7 +63,6 @@ def setup_simulation(cls, sim_device): { "uid": "dexforce_w1", "version": "v021", - "arm_kind": "anthropomorphic", } ) @@ -409,7 +408,6 @@ def test_configured_qpos_limits_sync_to_solver_after_initialization(self): { "uid": "dexforce_w1_solver_limit_sync", "version": "v021", - "arm_kind": "anthropomorphic", "qpos_limits": {"LEFT_J[1-7]": configured_limits}, } ) diff --git a/tests/sim/objects/test_robot_cfg.py b/tests/sim/objects/test_robot_cfg.py index 6ccd89ec7..750588300 100644 --- a/tests/sim/objects/test_robot_cfg.py +++ b/tests/sim/objects/test_robot_cfg.py @@ -26,34 +26,305 @@ ) from embodichain.lab.sim.workspace import RobotWorkspaceCfg from embodichain.lab.sim.robots.dexforce_w1 import DexforceW1Cfg +from embodichain.lab.sim.robots.dexforce_w1.params import W1ArmKineParams from embodichain.lab.sim.robots.dexforce_w1.types import ( - DexforceW1ArmKind, + DexforceW1ArmSide, + DexforceW1HandBrand, + DexforceW1HandVersion, + DexforceW1Type, DexforceW1Version, ) +from embodichain.lab.sim.robots.dexforce_w1.hand_specs import ( + get_default_w1_hand_version, + get_w1_hand_spec, +) +from embodichain.lab.sim.robots.dexforce_w1.specs import get_w1_version_spec from embodichain.lab.sim.solvers import SRSSolverCfg from embodichain.utils import configclass from embodichain.lab.sim.utility.cfg_utils import merge_robot_cfg +def _mock_w1_asset_paths(monkeypatch, tmp_path): + import embodichain.lab.sim.cfg as sim_cfg + from embodichain.lab.sim.robots.dexforce_w1 import utils as w1_utils + + def resolve(path): + resolved = tmp_path / path + resolved.parent.mkdir(parents=True, exist_ok=True) + resolved.write_text("") + return str(resolved) + + monkeypatch.setattr(w1_utils, "get_data_path", resolve) + monkeypatch.setattr(sim_cfg, "get_data_path", resolve) + + def test_dexforce_w1_roundtrip(): - cfg = DexforceW1Cfg.from_dict( - {"uid": "dexforce_w1", "version": "v021", "arm_kind": "anthropomorphic"} - ) + cfg = DexforceW1Cfg.from_dict({"uid": "dexforce_w1", "version": "v021"}) d = cfg.to_dict() assert d["uid"] == "dexforce_w1" - assert d["arm_kind"] == "anthropomorphic" cfg2 = DexforceW1Cfg.from_dict(d) assert cfg2.uid == "dexforce_w1" - assert cfg2.arm_kind == DexforceW1ArmKind.ANTHROPOMORPHIC assert cfg2.version == DexforceW1Version.V021 def test_dexforce_w1_solver_cfg_is_srs_and_set_once(): - cfg = DexforceW1Cfg.from_dict({"arm_kind": "industrial"}) + cfg = DexforceW1Cfg.from_dict({}) assert isinstance(cfg.solver_cfg["left_arm"], SRSSolverCfg) assert isinstance(cfg.solver_cfg["right_arm"], SRSSolverCfg) +def test_dexforce_w1_rejects_unknown_fields(): + with pytest.raises(ValueError, match="Unknown DexforceW1 configuration fields"): + DexforceW1Cfg.from_dict({"unsupported_variant": "value"}) + + +@pytest.mark.parametrize( + "removed_field", + [ + "arm_sides", + "include_chassis", + "include_torso", + "include_head", + "include_eyes", + "include_wrist_cameras", + "component_versions", + ], +) +def test_dexforce_w1_rejects_removed_builder_fields(removed_field): + with pytest.raises(ValueError, match="Unknown DexforceW1 configuration fields"): + DexforceW1Cfg.from_dict({removed_field: None}) + + +def test_w1_v025_eef_offset_applies_to_attach_and_tcp(): + v021 = get_w1_version_spec(DexforceW1Version.V021) + v025 = get_w1_version_spec(DexforceW1Version.V025) + expected_offset = np.eye(4) + expected_offset[2, 3] = 0.012 + hand_spec = get_w1_hand_spec( + DexforceW1HandBrand.BRAINCO_HAND, DexforceW1HandVersion.V021 + ) + + for arm_side in DexforceW1ArmSide: + np.testing.assert_allclose(v021.eef_attach_xpos(arm_side), np.eye(4)) + np.testing.assert_allclose(v025.eef_attach_xpos(arm_side), expected_offset) + np.testing.assert_allclose( + v025.tcp(arm_side), + expected_offset @ v021.tcp(arm_side), + ) + np.testing.assert_allclose( + v025.compose_eef_attach_xpos( + arm_side, + hand_spec.for_side(arm_side).attach_xpos, + ), + expected_offset @ np.asarray(hand_spec.for_side(arm_side).attach_xpos), + ) + + +def test_w1_v025_eef_offset_composes_with_custom_attach(): + spec = get_w1_version_spec(DexforceW1Version.V025) + custom_attach = np.eye(4) + custom_attach[:3, 3] = [0.01, -0.02, 0.03] + + result = spec.compose_eef_attach_xpos(DexforceW1ArmSide.RIGHT, custom_attach) + + np.testing.assert_allclose(result[:3, 3], [0.01, -0.02, 0.042]) + np.testing.assert_allclose(result[:3, :3], np.eye(3)) + + +def test_w1_v022_version_spec_is_registered(): + spec = get_w1_version_spec("v022") + + assert spec.version == DexforceW1Version.V022 + assert spec.assembly_name == "DexforceW1V022" + assert spec.full_robot_urdf() == "DexforceW1V022/w1/robot.urdf" + assert ( + spec.component_urdf(DexforceW1Type.LEFT_ARM) + == "DexforceW1V022/w1/left_arm.urdf" + ) + assert ( + spec.component_urdf(DexforceW1Type.RIGHT_ARM) + == "DexforceW1V022/w1/right_arm.urdf" + ) + + +def test_w1_v022_provisional_eef_baseline_is_composed_consistently(): + v021 = get_w1_version_spec(DexforceW1Version.V021) + v022 = get_w1_version_spec(DexforceW1Version.V022) + + for arm_side in DexforceW1ArmSide: + np.testing.assert_allclose(v022.eef_attach_xpos(arm_side), np.eye(4)) + np.testing.assert_allclose(v022.tcp(arm_side), v021.tcp(arm_side)) + + +def test_w1_v022_cfg_uses_registered_asset_paths(tmp_path, monkeypatch): + import embodichain.lab.sim.cfg as sim_cfg + from embodichain.lab.sim.robots.dexforce_w1 import utils as w1_utils + + registered_paths = [] + + def resolve_registered_path(path): + registered_paths.append(path) + resolved = tmp_path / path + resolved.parent.mkdir(parents=True, exist_ok=True) + resolved.write_text("") + return str(resolved) + + monkeypatch.setattr(w1_utils, "get_data_path", resolve_registered_path) + monkeypatch.setattr(sim_cfg, "get_data_path", resolve_registered_path) + cfg = DexforceW1Cfg.from_dict( + { + "uid": "dexforce_w1_v022", + "version": "v022", + "with_default_eef": False, + } + ) + + assert cfg.version == DexforceW1Version.V022 + assert cfg.uid == "dexforce_w1_v022" + assert cfg.urdf_cfg.fname == "DexforceW1V022" + assert "DexforceW1V022/w1/left_arm.urdf" in registered_paths + assert "DexforceW1V022/w1/right_arm.urdf" in registered_paths + + +def test_w1_cfg_builds_complete_dual_arm_robot(monkeypatch, tmp_path): + _mock_w1_asset_paths(monkeypatch, tmp_path) + cfg = DexforceW1Cfg.from_dict({"version": "v021", "with_default_eef": False}) + + assert set(cfg.urdf_cfg.components) == { + "chassis", + "torso", + "head", + "left_arm", + "right_arm", + } + assert set(cfg.control_parts) == { + "torso", + "head", + "left_arm", + "right_arm", + "dual_arm", + "full_body", + } + assert set(cfg.solver_cfg) == {"left_arm", "right_arm"} + + +def test_w1_hand_version_is_independent_of_robot_version(monkeypatch, tmp_path): + _mock_w1_asset_paths(monkeypatch, tmp_path) + from embodichain.lab.sim.robots.dexforce_w1 import utils as w1_utils + + selected_versions = [] + original_get_urdf = w1_utils.hand_manager.get_urdf + + def capture_version(brand, side, version): + selected_versions.append((side, version)) + return original_get_urdf(brand, side, version) + + monkeypatch.setattr(w1_utils.hand_manager, "get_urdf", capture_version) + DexforceW1Cfg.from_dict( + { + "version": "v025", + } + ) + + assert selected_versions + assert all( + version == DexforceW1HandVersion.V021 for _, version in selected_versions + ) + + +@pytest.mark.parametrize("brand", list(DexforceW1HandBrand)) +def test_w1_hand_brand_defaults_are_explicitly_registered(brand): + version = get_default_w1_hand_version(brand) + + assert version == DexforceW1HandVersion.V021 + assert get_w1_hand_spec(brand, version).brand == brand + + +def test_w1_hand_version_roundtrip(monkeypatch, tmp_path): + _mock_w1_asset_paths(monkeypatch, tmp_path) + cfg = DexforceW1Cfg.from_dict( + {"version": "v025", "hand_versions": {"left": "v021"}} + ) + + data = cfg.to_dict() + restored = DexforceW1Cfg.from_dict(data) + + assert data["hand_versions"] == {"left": "v021", "right": "v021"} + assert restored.hand_versions == { + DexforceW1ArmSide.LEFT: DexforceW1HandVersion.V021, + DexforceW1ArmSide.RIGHT: DexforceW1HandVersion.V021, + } + + +def test_w1_rejects_unregistered_hand_version(): + with pytest.raises(ValueError, match="Invalid Dexforce W1 hand version"): + DexforceW1Cfg.from_dict({"hand_versions": {"left": "v025"}}) + + +def test_w1_rejects_robot_version_as_hand_version(): + with pytest.raises(ValueError, match="Invalid Dexforce W1 hand version"): + DexforceW1Cfg.from_dict({"hand_versions": {"left": DexforceW1Version.V021}}) + + +@pytest.mark.parametrize("version", ["v025", "V025", DexforceW1Version.V025]) +def test_w1_kine_params_accept_consistent_version_forms(version): + params = W1ArmKineParams.from_dict({"arm_side": "left", "version": version}) + + assert params.arm_side == DexforceW1ArmSide.LEFT + assert params.version == DexforceW1Version.V025 + + +def test_w1_version_spec_mappings_are_immutable(): + spec = get_w1_version_spec("v025") + + with pytest.raises(TypeError): + spec.solver_tcp[DexforceW1ArmSide.LEFT] = np.eye(4) + with pytest.raises(TypeError): + spec.component_urdfs[DexforceW1Type.LEFT_ARM] = "other.urdf" + + +def test_w1_v025_custom_eef_and_tcp_roundtrip(monkeypatch, tmp_path): + _mock_w1_asset_paths(monkeypatch, tmp_path) + baseline = DexforceW1Cfg.from_dict({"version": "v025"}) + raw_attach = np.eye(4) + raw_attach[:3, 3] = [0.01, -0.02, 0.03] + raw_tcp = np.eye(4) + raw_tcp[:3, 3] = [0.04, 0.05, 0.06] + cfg = DexforceW1Cfg.from_dict( + { + "version": "v025", + "urdf_cfg": { + "components": { + "left_hand": { + "urdf_path": baseline.urdf_cfg.components["left_hand"][ + "urdf_path" + ], + "transform": raw_attach.tolist(), + } + } + }, + "solver_cfg": {"left_arm": {"tcp": raw_tcp.tolist()}}, + } + ) + + expected_attach = get_w1_version_spec("v025").compose_eef_attach_xpos( + DexforceW1ArmSide.LEFT, raw_attach + ) + expected_tcp = get_w1_version_spec("v025").compose_eef_attach_xpos( + DexforceW1ArmSide.LEFT, raw_tcp + ) + restored = DexforceW1Cfg.from_dict(cfg.to_dict()) + + np.testing.assert_allclose( + cfg.urdf_cfg.components["left_hand"]["transform"], expected_attach + ) + np.testing.assert_allclose(cfg.solver_cfg["left_arm"].tcp, expected_tcp) + np.testing.assert_allclose( + restored.urdf_cfg.components["left_hand"]["transform"], expected_attach + ) + np.testing.assert_allclose(restored.solver_cfg["left_arm"].tcp, expected_tcp) + + class _RoundTripVariant(enum.Enum): A = "a" B = "b" @@ -162,7 +433,7 @@ def _dof_of_pk_chain(chain) -> int: def test_dexforce_w1_pk_dof_matches_control_parts(): pytest.importorskip("pytorch_kinematics") - cfg = DexforceW1Cfg.from_dict({"arm_kind": "anthropomorphic"}) + cfg = DexforceW1Cfg.from_dict({}) try: chains = cfg.build_pk_serial_chain() except Exception as exc: diff --git a/tests/sim/solvers/test_srs_solver.py b/tests/sim/solvers/test_srs_solver.py index 83cd4a61b..540814987 100644 --- a/tests/sim/solvers/test_srs_solver.py +++ b/tests/sim/solvers/test_srs_solver.py @@ -31,7 +31,6 @@ from embodichain.lab.sim.solvers.srs_solver import SRSSolver, SRSSolverCfg from embodichain.lab.sim.robots.dexforce_w1.types import ( DexforceW1ArmSide, - DexforceW1ArmKind, DexforceW1Version, ) from embodichain.lab.sim.robots.dexforce_w1.params import ( @@ -44,23 +43,17 @@ class BaseSolverTest: def get_arm_config(self): return [ - (DexforceW1ArmSide.LEFT, DexforceW1ArmKind.ANTHROPOMORPHIC, "left_arm"), - (DexforceW1ArmSide.RIGHT, DexforceW1ArmKind.ANTHROPOMORPHIC, "right_arm"), - (DexforceW1ArmSide.LEFT, DexforceW1ArmKind.INDUSTRIAL, "left_arm"), - (DexforceW1ArmSide.RIGHT, DexforceW1ArmKind.INDUSTRIAL, "right_arm"), + (DexforceW1ArmSide.LEFT, "left_arm"), + (DexforceW1ArmSide.RIGHT, "right_arm"), ] def setup_solver(self, solver_type: str, device: str = "cpu"): - for arm_side, arm_kind, arm_name in self.get_arm_config(): + for arm_side, arm_name in self.get_arm_config(): arm_params = W1ArmKineParams( arm_side=arm_side, - arm_kind=arm_kind, version=DexforceW1Version.V021, ) - if arm_kind == DexforceW1ArmKind.ANTHROPOMORPHIC: - urdf = get_data_path("DexforceW1V021/DexforceW1_v02_1.urdf") - else: - urdf = get_data_path("DexforceW1V021/DexforceW1_v02_2.urdf") + urdf = get_data_path("DexforceW1V021/DexforceW1_v02_1.urdf") cfg = SRSSolverCfg() cfg.joint_names = [ @@ -83,24 +76,18 @@ def setup_solver(self, solver_type: str, device: str = "cpu"): cfg.link_lengths = arm_params.link_lengths cfg.rotation_directions = arm_params.rotation_directions - solver_key = f"{arm_name}_{arm_kind.name}" - self.solver[solver_key] = SRSSolver(cfg=cfg, num_envs=1, device=device) + self.solver[arm_name] = SRSSolver(cfg=cfg, num_envs=1, device=device) @pytest.mark.parametrize( - "arm_side, arm_kind, arm_name", + "arm_side, arm_name", [ - (DexforceW1ArmSide.LEFT, DexforceW1ArmKind.ANTHROPOMORPHIC, "left_arm"), - (DexforceW1ArmSide.RIGHT, DexforceW1ArmKind.ANTHROPOMORPHIC, "right_arm"), - (DexforceW1ArmSide.LEFT, DexforceW1ArmKind.INDUSTRIAL, "left_arm"), - (DexforceW1ArmSide.RIGHT, DexforceW1ArmKind.INDUSTRIAL, "right_arm"), + (DexforceW1ArmSide.LEFT, "left_arm"), + (DexforceW1ArmSide.RIGHT, "right_arm"), ], ) - def test_ik( - self, arm_side: DexforceW1ArmSide, arm_kind: DexforceW1ArmKind, arm_name: str - ): + def test_ik(self, arm_side: DexforceW1ArmSide, arm_name: str): # Test inverse kinematics (IK) with a 1x4x4 homogeneous matrix pose and a joint_seed - solver_key = f"{arm_name}_{arm_kind.name}" - device = self.solver[solver_key].device + device = self.solver[arm_name].device qpos_fk = torch.tensor( [[0.0, 0.0, 0.0, -np.pi / 4, 0.0, 0.0, 0.0]], @@ -108,15 +95,15 @@ def test_ik( device=device, ) - fk_xpos = self.solver[solver_key].get_fk(qpos=qpos_fk) + fk_xpos = self.solver[arm_name].get_fk(qpos=qpos_fk) - _, ik_qpos = self.solver[solver_key].get_ik(fk_xpos, return_all_solutions=False) + _, ik_qpos = self.solver[arm_name].get_ik(fk_xpos, return_all_solutions=False) - ik_xpos = self.solver[solver_key].get_fk(qpos=ik_qpos[:, 0, :]) + ik_xpos = self.solver[arm_name].get_fk(qpos=ik_qpos[:, 0, :]) assert torch.allclose( fk_xpos, ik_xpos, atol=1e-3, rtol=1e-3 - ), f"FK and IK results do not match for {solver_key}" + ), f"FK and IK results do not match for {arm_name}" def test_update_with_robot_limit_intersects_existing_solver_limits(self): """Test robot limit sync only tightens solver limits and never widens them.""" @@ -212,12 +199,10 @@ def setup_simulation(self, solver_type: str, device: str = "cpu"): w1_left_arm_params = W1ArmKineParams( arm_side=DexforceW1ArmSide.LEFT, - arm_kind=DexforceW1ArmKind.ANTHROPOMORPHIC, version=DexforceW1Version.V021, ) w1_right_arm_params = W1ArmKineParams( arm_side=DexforceW1ArmSide.RIGHT, - arm_kind=DexforceW1ArmKind.ANTHROPOMORPHIC, version=DexforceW1Version.V021, )