From 132c7056dcba3796955d54e568072099b8c9bd39 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Mon, 7 Sep 2026 18:12:10 +0800 Subject: [PATCH] refactor: extract Go2 arm task and dedicated locomotion helpers --- .gitignore | 1 - docs/sphinx/source/_static/demos/locomani.jpg | Bin 119086 -> 0 bytes .../source/api_reference/algos/index.md | 1 - .../source/api_reference/tasks/locomotion.md | 1 - docs/sphinx/source/changelog.md | 9 + docs/sphinx/source/en/0-index.md | 3 +- .../en/1-getting_started/1-quick_demo.md | 2 +- .../1-getting_started/4-project_structure.md | 5 +- docs/sphinx/source/en/2-user_guide/0-index.md | 2 +- .../1-training/1-cli_reference.md | 3 +- .../2-user_guide/1-training/2-hydra_config.md | 1 - .../en/2-user_guide/2-algorithms/0-index.md | 2 - .../en/2-user_guide/2-algorithms/6-him_ppo.md | 28 - .../source/en/2-user_guide/4-tasks/0-index.md | 7 - .../en/2-user_guide/4-tasks/1-locomotion.md | 1 - .../en/2-user_guide/4-tasks/3-manipulation.md | 14 +- .../en/2-user_guide/4-tasks/4-manip_loco.md | 26 - .../5-domain_randomization/0-index.md | 55 +- .../5-domain_randomization/1-configuration.md | 16 +- .../2-writing_providers.md | 7 +- .../2-user_guide/7-tooling/1-onnx_export.md | 3 +- .../en/2-user_guide/8-manipulation/0-index.md | 7 - .../8-manipulation/2-manip_loco.md | 47 - .../1-sim_to_real/3-go2_locomotion.md | 3 +- .../1-sim_to_real/5-onnx_runtime.md | 1 - .../1-sim_to_real/6-domain_randomization.md | 49 +- .../1-sim_to_real/9-troubleshooting.md | 2 +- .../2-sim_to_sim/7-config_guard.md | 2 +- .../3-framework_migration/1-from_isaac_lab.md | 11 +- .../2-from_legged_gym.md | 12 +- .../3-framework_migration/6-reward_porting.md | 117 +- .../2-contracts/3-task_owner.md | 3 +- .../2-contracts/4-dr_contract.md | 4 +- .../3-extending/3-new_algorithm.md | 2 +- .../6-agent_quick_reference.md | 1 - .../en/4-developer_guide/7-motion_assets.md | 2 +- .../source/en/5-reference/5-support_matrix.md | 1 - docs/sphinx/source/zh_CN/0-index.md | 3 +- .../zh_CN/1-getting_started/1-quick_demo.md | 2 +- .../1-getting_started/4-project_structure.md | 5 +- .../source/zh_CN/2-user_guide/0-index.md | 2 +- .../1-training/1-cli_reference.md | 3 +- .../2-user_guide/1-training/2-hydra_config.md | 1 - .../2-user_guide/2-algorithms/0-index.md | 2 - .../2-user_guide/2-algorithms/6-him_ppo.md | 26 - .../zh_CN/2-user_guide/4-tasks/0-index.md | 7 - .../2-user_guide/4-tasks/1-locomotion.md | 1 - .../2-user_guide/4-tasks/3-manipulation.md | 14 +- .../2-user_guide/4-tasks/4-manip_loco.md | 26 - .../5-domain_randomization/0-index.md | 46 +- .../5-domain_randomization/1-configuration.md | 13 +- .../2-writing_providers.md | 7 +- .../2-user_guide/7-tooling/1-onnx_export.md | 2 +- .../2-user_guide/8-manipulation/0-index.md | 7 - .../8-manipulation/2-manip_loco.md | 38 - .../1-sim_to_real/3-go2_locomotion.md | 3 +- .../1-sim_to_real/5-onnx_runtime.md | 1 - .../1-sim_to_real/6-domain_randomization.md | 46 +- .../1-sim_to_real/9-troubleshooting.md | 2 +- .../2-sim_to_sim/7-config_guard.md | 2 +- .../3-framework_migration/1-from_isaac_lab.md | 11 +- .../2-from_legged_gym.md | 12 +- .../3-framework_migration/6-reward_porting.md | 111 +- .../2-contracts/3-task_owner.md | 3 +- .../2-contracts/4-dr_contract.md | 4 +- .../3-extending/3-new_algorithm.md | 2 +- .../6-agent_quick_reference.md | 1 - .../4-developer_guide/7-motion_assets.md | 2 +- .../9-sim2sim_contract_status.md | 4 +- .../zh_CN/5-reference/5-support_matrix.md | 1 - pyproject.toml | 3 +- scripts/manip_loco/benchmark_site_jacobian.py | 335 ------ .../calibrate_go2_arm_ee_orientation.py | 348 ------ scripts/manip_loco/diagnose_go2_arm_ik.py | 363 ------ scripts/manip_loco/play_go2_arm_ik_only.py | 503 -------- scripts/train_him_ppo.py | 273 ----- src/unilab/assets/hub.py | 5 - .../go2_with_arm_mjx_full_collision.xml | 498 -------- .../assets/robots/go2_arm/scene_flat.xml | 67 -- .../ppo/task/go2_arm_manip_loco/motrix.yaml | 117 -- .../ppo/task/go2_arm_manip_loco/mujoco.yaml | 112 -- src/unilab/conf/ppo_him/config.yaml | 92 -- .../task/go2_arm_manip_loco/mujoco.yaml | 99 -- src/unilab/demo.py | 4 - src/unilab/dr/dr_utils.py | 28 - src/unilab/scripts/train_rsl_rl.py | 3 + src/unilab/tasks/__init__.py | 1 - .../tasks/locomotion/common/__init__.py | 20 - src/unilab/tasks/locomotion/common/base.py | 149 --- .../tasks/locomotion/common/commands.py | 20 - .../tasks/locomotion/common/domain_rand.py | 33 - .../tasks/locomotion/common/dr_provider.py | 209 ---- src/unilab/tasks/locomotion/common/rewards.py | 150 --- .../tasks/locomotion/go2_arm/__init__.py | 3 - src/unilab/tasks/locomotion/go2_arm/base.py | 238 ---- .../tasks/locomotion/go2_arm/manip_loco.py | 1039 ----------------- src/unilab/tasks/migration_matrix.py | 6 +- src/unilab/utils/geometry.py | 56 - tests/assets/test_hub.py | 24 +- .../backend/test_mujoco_chunk_size_wiring.py | 5 +- .../base/backend/test_mujoco_site_jacobian.py | 5 +- tests/base/test_backend_imports.py | 36 - tests/config/test_config_system.py | 14 - tests/envs/locomotion/go2_arm/__init__.py | 0 tests/envs/locomotion/go2_arm/test_base_ik.py | 93 -- .../go2_arm/test_manip_loco_contract.py | 377 ------ tests/fixtures/free_chain.xml | 26 + tests/ipc/test_dp_launcher.py | 2 +- tests/scripts/test_train_scripts.py | 130 --- .../scripts/test_visualization_entrypoints.py | 10 +- tests/tasks/test_legacy_task_compatibility.py | 11 +- tests/tasks/test_migration_matrix.py | 1 - tests/tasks/test_package_boundary.py | 1 - tests/test_cli.py | 52 +- tests/test_completion.py | 1 - .../test_interactive_playback.py | 4 +- uv.lock | 8 +- 117 files changed, 312 insertions(+), 6123 deletions(-) delete mode 100644 docs/sphinx/source/_static/demos/locomani.jpg delete mode 100644 docs/sphinx/source/en/2-user_guide/2-algorithms/6-him_ppo.md delete mode 100644 docs/sphinx/source/en/2-user_guide/4-tasks/4-manip_loco.md delete mode 100644 docs/sphinx/source/en/2-user_guide/8-manipulation/2-manip_loco.md delete mode 100644 docs/sphinx/source/zh_CN/2-user_guide/2-algorithms/6-him_ppo.md delete mode 100644 docs/sphinx/source/zh_CN/2-user_guide/4-tasks/4-manip_loco.md delete mode 100644 docs/sphinx/source/zh_CN/2-user_guide/8-manipulation/2-manip_loco.md delete mode 100644 scripts/manip_loco/benchmark_site_jacobian.py delete mode 100644 scripts/manip_loco/calibrate_go2_arm_ee_orientation.py delete mode 100644 scripts/manip_loco/diagnose_go2_arm_ik.py delete mode 100644 scripts/manip_loco/play_go2_arm_ik_only.py delete mode 100644 scripts/train_him_ppo.py delete mode 100644 src/unilab/assets/robots/go2_arm/go2_with_arm_mjx_full_collision.xml delete mode 100644 src/unilab/assets/robots/go2_arm/scene_flat.xml delete mode 100644 src/unilab/conf/ppo/task/go2_arm_manip_loco/motrix.yaml delete mode 100644 src/unilab/conf/ppo/task/go2_arm_manip_loco/mujoco.yaml delete mode 100644 src/unilab/conf/ppo_him/config.yaml delete mode 100644 src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml delete mode 100644 src/unilab/tasks/locomotion/common/base.py delete mode 100644 src/unilab/tasks/locomotion/common/commands.py delete mode 100644 src/unilab/tasks/locomotion/common/domain_rand.py delete mode 100644 src/unilab/tasks/locomotion/common/dr_provider.py delete mode 100644 src/unilab/tasks/locomotion/common/rewards.py delete mode 100644 src/unilab/tasks/locomotion/go2_arm/__init__.py delete mode 100644 src/unilab/tasks/locomotion/go2_arm/base.py delete mode 100644 src/unilab/tasks/locomotion/go2_arm/manip_loco.py delete mode 100644 tests/envs/locomotion/go2_arm/__init__.py delete mode 100644 tests/envs/locomotion/go2_arm/test_base_ik.py delete mode 100644 tests/envs/locomotion/go2_arm/test_manip_loco_contract.py create mode 100644 tests/fixtures/free_chain.xml diff --git a/.gitignore b/.gitignore index 21c4f9122..32d4f98ef 100644 --- a/.gitignore +++ b/.gitignore @@ -87,7 +87,6 @@ src/unilab/assets/robots/go2/assets/ src/unilab/assets/robots/a2/assets/ src/unilab/assets/robots/allegro_hand/assets/ src/unilab/assets/robots/sharpa_wave/meshes/ -src/unilab/assets/robots/go2_arm/assets/ # Grasp cache assets (downloaded from HF at runtime) src/unilab/assets/caches/*.npy diff --git a/docs/sphinx/source/_static/demos/locomani.jpg b/docs/sphinx/source/_static/demos/locomani.jpg deleted file mode 100644 index 649a8ece2c8e68d6cdb98e2a212d53d982646578..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 119086 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�#9h}1DJ@0#!f~}Pu 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 z≷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.1.3`, including the ROCm diff --git a/docs/sphinx/source/en/0-index.md b/docs/sphinx/source/en/0-index.md index 397157f50..a122605fe 100644 --- a/docs/sphinx/source/en/0-index.md +++ b/docs/sphinx/source/en/0-index.md @@ -108,7 +108,7 @@ capability requirements. :::{grid-item-card} Pick an algorithm :link: 2-user_guide/2-algorithms/0-index :link-type: doc -Compare PPO, APPO, SAC, TD3, FlashSAC, HIM-PPO, and HORA entrypoints. +Compare PPO, APPO, SAC, TD3, FlashSAC, and HORA entrypoints. ::: :::{grid-item-card} Deploy or switch sims @@ -155,7 +155,6 @@ committed benchmark manifest or separate recommendation metadata. | --- | --- | --- | | Go1 joystick | PPO, APPO, TD3 | PPO has tested MuJoCo and Motrix rows. APPO has tested MuJoCo rows and Motrix registered rows. TD3 has a Motrix owner YAML for `go1_joystick_flat`. | | Go2 joystick | PPO, FlashSAC, TD3 | PPO has tested MuJoCo and Motrix rows. FlashSAC has MuJoCo owner YAMLs for `go2_joystick_flat`; TD3 has a Motrix owner YAML for `go2_joystick_flat`. | -| Go2 arm manip-loco | PPO, HIM-PPO | Committed MuJoCo owner YAMLs are present under `src/unilab/conf/ppo/task/go2_arm_manip_loco/` and `src/unilab/conf/ppo_him/task/go2_arm_manip_loco/`. | | Go2W joystick | PPO | PPO owner YAMLs exist for MuJoCo and Motrix flat/rough variants under `src/unilab/conf/ppo/task/go2w_joystick_*`. | | G1 locomotion / tracking | PPO, APPO, SAC, TD3 | PPO, APPO, and SAC include committed MuJoCo and Motrix owner YAMLs for G1 tasks; TD3 has a `g1_walk_flat` MuJoCo owner. | | Allegro in-hand | PPO, APPO | PPO and APPO have committed MuJoCo and Motrix owner YAMLs for Allegro in-hand tasks. | diff --git a/docs/sphinx/source/en/1-getting_started/1-quick_demo.md b/docs/sphinx/source/en/1-getting_started/1-quick_demo.md index 91e35b3af..8bd867c55 100644 --- a/docs/sphinx/source/en/1-getting_started/1-quick_demo.md +++ b/docs/sphinx/source/en/1-getting_started/1-quick_demo.md @@ -43,7 +43,7 @@ uv run demo dance ``` Available demo names are `teaser`, `dance`, `wallflip`, `boxtracking`, -`locomani`, and `inhandgrasp`. Use `uv run demo --help` for device and refresh +and `inhandgrasp`. Use `uv run demo --help` for device and refresh options. ## Train A Task diff --git a/docs/sphinx/source/en/1-getting_started/4-project_structure.md b/docs/sphinx/source/en/1-getting_started/4-project_structure.md index fa19760fc..0114b6602 100644 --- a/docs/sphinx/source/en/1-getting_started/4-project_structure.md +++ b/docs/sphinx/source/en/1-getting_started/4-project_structure.md @@ -10,7 +10,7 @@ changing behavior. | `src/unilab/conf/` | Hydra roots and task owner YAMLs. The top-level CLI exposes backend selection as `--task` plus `--sim`, then composes the matching owner YAML. | | `src/unilab/base/` | Registry, env state, scene, and backend contracts. | | `src/unilab/envs/` | Task env implementations and task-specific reset, reward, observation, and DR logic. | -| `uni_rl` (unilab-rl repo) | PPO, APPO, off-policy, HIM-PPO, and HORA algorithm code. | +| `uni_rl` (unilab-rl repo) | PPO, APPO, off-policy, and HORA algorithm code. | | `uni_rl.ipc` (unilab-rl repo) | Shared-memory and async runner primitives. | | `src/unilab/training/` | Shared training helpers for logging, playback, seed handling, and config guards. | | `src/unilab/visualization/` | Playback, rendering, NaN inspection, and scene/export utilities. | @@ -27,8 +27,7 @@ The main config roots are: - `src/unilab/conf/sac/config.yaml`, `src/unilab/conf/td3/config.yaml`, and `src/unilab/conf/flashsac/config.yaml` for SAC, TD3, and FlashSAC, each with its algorithm hyperparameters inlined. -- `src/unilab/conf/ppo_him/config.yaml` and `src/unilab/conf/hora_distill/config.yaml` for the - specialized HIM-PPO and HORA paths. +- `src/unilab/conf/hora_distill/config.yaml` for HORA student distillation. Task owner YAMLs are the backend identity. Examples: diff --git a/docs/sphinx/source/en/2-user_guide/0-index.md b/docs/sphinx/source/en/2-user_guide/0-index.md index 30c149ae9..c3cdd8b36 100644 --- a/docs/sphinx/source/en/2-user_guide/0-index.md +++ b/docs/sphinx/source/en/2-user_guide/0-index.md @@ -15,7 +15,7 @@ CLI routes, Hydra owner YAMLs, logs, checkpoints, and Docker. :::{grid-item-card} Algorithms :link: 2-algorithms/0-index :link-type: doc -Compare PPO, APPO, SAC, TD3, FlashSAC, HIM-PPO, and HORA. +Compare PPO, APPO, SAC, TD3, FlashSAC, and HORA. ::: :::{grid-item-card} Backends diff --git a/docs/sphinx/source/en/2-user_guide/1-training/1-cli_reference.md b/docs/sphinx/source/en/2-user_guide/1-training/1-cli_reference.md index 914ef33b6..030ea438b 100644 --- a/docs/sphinx/source/en/2-user_guide/1-training/1-cli_reference.md +++ b/docs/sphinx/source/en/2-user_guide/1-training/1-cli_reference.md @@ -110,12 +110,11 @@ directly to `play_interactive.py` and always rolls out one environment. uv run demo dance uv run demo wallflip uv run demo boxtracking -uv run demo locomani uv run demo inhandgrasp uv run demo dance --refresh --device cpu ``` -Available demos: `teaser`, `dance`, `wallflip`, `boxtracking`, `locomani`, `inhandgrasp`. +Available demos: `teaser`, `dance`, `wallflip`, `boxtracking`, `inhandgrasp`. Each demo fetches a pre-trained checkpoint from the `unilabsim/unilab-checkpoints` Hugging Face dataset on first run and caches it under `src/unilab/assets/checkpoints//model_0.pt`. Pass `--refresh` to diff --git a/docs/sphinx/source/en/2-user_guide/1-training/2-hydra_config.md b/docs/sphinx/source/en/2-user_guide/1-training/2-hydra_config.md index cd2ed7937..365a19165 100644 --- a/docs/sphinx/source/en/2-user_guide/1-training/2-hydra_config.md +++ b/docs/sphinx/source/en/2-user_guide/1-training/2-hydra_config.md @@ -10,7 +10,6 @@ identity of the task, backend, reward, scene, and task-specific runtime fields. | PPO | `src/unilab/conf/ppo/task//.yaml` | | APPO | `src/unilab/conf/appo/task//.yaml` | | SAC / TD3 / FlashSAC | `src/unilab/conf//task//.yaml` | -| HIM-PPO | `src/unilab/conf/ppo_him/task//.yaml` | | HORA distillation | `src/unilab/conf/hora_distill/task//.yaml` | Examples: diff --git a/docs/sphinx/source/en/2-user_guide/2-algorithms/0-index.md b/docs/sphinx/source/en/2-user_guide/2-algorithms/0-index.md index e79f2dfd3..debdb9e2f 100644 --- a/docs/sphinx/source/en/2-user_guide/2-algorithms/0-index.md +++ b/docs/sphinx/source/en/2-user_guide/2-algorithms/0-index.md @@ -11,7 +11,6 @@ lives, and which command shape selects it. For general flags, see | SAC | off-policy | `src/unilab/scripts/train_sac.py` | `src/unilab/conf/sac/config.yaml` | | TD3 | off-policy | `src/unilab/scripts/train_td3.py` | `src/unilab/conf/td3/config.yaml` | | FlashSAC | off-policy | `src/unilab/scripts/train_flashsac.py` | `src/unilab/conf/flashsac/config.yaml` | -| HIM-PPO | height-estimator PPO path | `scripts/train_him_ppo.py` | `src/unilab/conf/ppo_him/config.yaml` | | HORA | teacher/student distillation path | `scripts/train_hora_distill.py` | `src/unilab/conf/hora_distill/config.yaml` | ```{toctree} @@ -22,6 +21,5 @@ lives, and which command shape selects it. For general flags, see 3-sac 4-td3 5-flash_sac -6-him_ppo 7-hora ``` diff --git a/docs/sphinx/source/en/2-user_guide/2-algorithms/6-him_ppo.md b/docs/sphinx/source/en/2-user_guide/2-algorithms/6-him_ppo.md deleted file mode 100644 index d0c196b91..000000000 --- a/docs/sphinx/source/en/2-user_guide/2-algorithms/6-him_ppo.md +++ /dev/null @@ -1,28 +0,0 @@ -# HIM-PPO - -HIM-PPO has its own config group and script. The entrypoint is -`scripts/train_him_ppo.py`, the base config is `src/unilab/conf/ppo_him/config.yaml`, and -the committed task owner is `src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml`. - -## Current Entrypoint - -`src/unilab/cli.py` currently exposes `1-ppo`, `2-appo`, `3-sac`, `4-td3`, -and `flashsac` through the top-level `uv run train` CLI. HIM-PPO is implemented -by `scripts/train_him_ppo.py`, but it does not yet have a top-level `--algo` -route. - -## Owner Details - -The Go2 arm owner fills the required history dimensions from the base config: - -- `algo.num_one_step_obs=76` -- `algo.num_actor_history=5` -- `algo.num_critic_history=1` -- `training.task_name=Go2ArmManipLoco` - -Playback uses the same HIM-PPO implementation entrypoint once a checkpoint is -available. Keep user-facing PPO examples on the supported top-level CLI shape; -use the HIM-PPO script path only when debugging that specialized stack. - -HIM-PPO is not the default PPO path; use it for the Go2 arm manip-loco owner -that explicitly selects the HIM-PPO config group. diff --git a/docs/sphinx/source/en/2-user_guide/4-tasks/0-index.md b/docs/sphinx/source/en/2-user_guide/4-tasks/0-index.md index 58db8455a..83fe54bc6 100644 --- a/docs/sphinx/source/en/2-user_guide/4-tasks/0-index.md +++ b/docs/sphinx/source/en/2-user_guide/4-tasks/0-index.md @@ -24,12 +24,6 @@ G1 motion tracking, flips, climbs, wall flips, and box tracking. Allegro and Sharpa in-hand rotation and grasp generation. ::: -:::{grid-item-card} Mobile manipulation -:link: 4-manip_loco -:link-type: doc -Go2 plus Airbot arm locomotion and manipulation. -::: - :::: ```{toctree} @@ -38,5 +32,4 @@ Go2 plus Airbot arm locomotion and manipulation. 1-locomotion 2-motion_tracking 3-manipulation -4-manip_loco ``` diff --git a/docs/sphinx/source/en/2-user_guide/4-tasks/1-locomotion.md b/docs/sphinx/source/en/2-user_guide/4-tasks/1-locomotion.md index fd34e6cdd..578a54d8a 100644 --- a/docs/sphinx/source/en/2-user_guide/4-tasks/1-locomotion.md +++ b/docs/sphinx/source/en/2-user_guide/4-tasks/1-locomotion.md @@ -12,7 +12,6 @@ define which algorithm and backend combinations are runnable. - G1 walking: `g1_walk_flat`, `g1_walk_rough` - G1 motion tracking: `g1_motion_tracking`, `g1_flip_tracking`, `g1_wall_flip_tracking`, `g1_climb_tracking`, `g1_box_tracking` -- Go2 arm: `go2_arm_manip_loco` ## Examples diff --git a/docs/sphinx/source/en/2-user_guide/4-tasks/3-manipulation.md b/docs/sphinx/source/en/2-user_guide/4-tasks/3-manipulation.md index 10d586adf..b9e55f62d 100644 --- a/docs/sphinx/source/en/2-user_guide/4-tasks/3-manipulation.md +++ b/docs/sphinx/source/en/2-user_guide/4-tasks/3-manipulation.md @@ -1,7 +1,6 @@ # Manipulation -Manipulation tasks live in `src/unilab/tasks/manipulation/` and the Go2 arm -manip-loco env lives in `src/unilab/tasks/locomotion/go2_arm/`. +Manipulation tasks live in `src/unilab/tasks/manipulation/`. ## In-Hand @@ -36,13 +35,4 @@ training-stable under load. uv run train --algo ppo --task stewart_balance --sim motrix training.no_play=true ``` -## Mobile Manipulation - -`go2_arm_manip_loco` is the committed Go2 + Airbot owner path: - -```bash -uv run train --algo ppo --task go2_arm_manip_loco --sim mujoco training.no_play=true -``` - -See {doc}`../8-manipulation/1-dexterous_inhand` and -{doc}`../8-manipulation/2-manip_loco` for task-specific notes. +See {doc}`../8-manipulation/1-dexterous_inhand` for in-hand task notes. diff --git a/docs/sphinx/source/en/2-user_guide/4-tasks/4-manip_loco.md b/docs/sphinx/source/en/2-user_guide/4-tasks/4-manip_loco.md deleted file mode 100644 index 4beac31d4..000000000 --- a/docs/sphinx/source/en/2-user_guide/4-tasks/4-manip_loco.md +++ /dev/null @@ -1,26 +0,0 @@ -# Manip-Loco - -`go2_arm_manip_loco` combines Go2 locomotion with the Airbot arm. The registered -env is `Go2ArmManipLoco`. - -## Owner Configs - -- PPO owner: `src/unilab/conf/ppo/task/go2_arm_manip_loco/mujoco.yaml` -- HIM-PPO owner: `src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml` -- Scene entry: `src/unilab/assets/robots/go2_arm/scene_flat.xml` - -## PPO - -```bash -uv run train --algo ppo --task go2_arm_manip_loco --sim mujoco training.no_play=true -``` - -## HIM-PPO - -The HIM-PPO owner is `src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml`. -`src/unilab/cli.py` does not currently expose HIM-PPO as a top-level -`uv run train --algo ...` route. - -The current committed owner path is MuJoCo. Keep backend selection in -`--task go2_arm_manip_loco --sim mujoco`, and do not override -`training.sim_backend` alone. diff --git a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md index 9e0f57346..171c2ccda 100644 --- a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md +++ b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md @@ -6,7 +6,7 @@ This page only describes the current domain randomization status of registered t Two DR declaration paths exist today: - **Manager-Based (Compatible) tasks**: reset / interval randomization is declared through Hydra `events:` manager terms in the owner YAML; reset-lifecycle events sample at reset, interval-lifecycle events perturb between steps. See the `events:` block of `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml` for an example. -- **Legacy provider path**: only the 3 Adapted families (`sharpa_inhand` / `sharpa_inhand_grasp` / `go2_arm_manip_loco`, including their appo / hora / ppo_him owners) still declare `env.domain_rand.*` configuration through a `DomainRandomizationProvider` + `DomainRandomizationManager`. +- **Legacy provider path**: only the 2 Adapted families (`sharpa_inhand` / `sharpa_inhand_grasp`, including their appo / hora owners) still declare `env.domain_rand.*` configuration through a `DomainRandomizationProvider` + `DomainRandomizationManager`. The unified entry point of the legacy provider path lives in `NpEnv._init_domain_randomization()` and `DomainRandomizationManager`: @@ -24,7 +24,7 @@ These three paths correspond to three lifecycle classes: 1. Manager-Based tasks do not register a DR provider; their reset/interval randomization consists of `events:` manager terms in the owner YAML, executed uniformly by the manager lifecycle. Only the frozen compatibility factories of the Adapted families still go through the `DomainRandomizationManager` unified entry point. 2. Adapted-family owners define a `domain_rand` config dataclass, a `DomainRandomizationProvider`, and a `ResetPlan`; Manager-Based owners declare reset behavior through Hydra command/event terms. G1 motion reset perturbations belong to `MotionCommandCfg`, while WBT adds `EventTermCfg` reset and interval terms. -3. What is "unified" today is mainly the entry point and execution flow, not every randomization item itself. The legacy path's shared helper `build_common_reset_randomization()` currently generates `base_mass_delta`, `base_com_offset`, `gravity`, `kp`, `kd`; the shared interval helper currently only generates push. +3. What is "unified" today is mainly the entry point and execution flow, not every randomization item itself. The legacy path's shared helper `build_common_reset_randomization()` currently generates `base_mass_delta`, `base_com_offset`, `gravity`, `kp`, `kd`. 4. `ResetRandomizationPayload` can already express `gravity`, `body_iquat`, `body_inertia`, `kp`, `kd`, and `MuJoCoBackend` has declared support. Whether these are actually used still depends on whether the task provider samples and dispatches them. 5. `MotrixBackend` currently supports `base_mass_delta`, `base_com_offset`, `kp`, `kd`, and interval push; and it requires all model actuators to be position actuators during initialization. 6. `geom_size` is not a reset-lifecycle field; Sharpa-hand object geom scale is handled by init-lifecycle model materialization. @@ -43,7 +43,6 @@ These three paths correspond to three lifecycle classes: | `AllegroInhandRotationGrasp` | Hydra `events:` terms | Yes: reuses the rotation reset event + `RecorderTermCfg` | noisy hand reset + grasp collection | none | `allegro_inhand/grasp_gen.py` | | `SharpaInhandRotation` | legacy provider | Yes: `InitRandomizationPlan + ResetPlan + IntervalRandomizationPlan` | grasp cache sampling + common payload | object `body_force` | `sharpa_inhand/rotation.py` | | `SharpaInhandRotationGrasp` | legacy provider | Yes: reuses the Sharpa rotation provider and overrides reset sampling | grasp collection reset + common payload | none | `sharpa_inhand/grasp_gen.py` | -| `Go2ArmManipLoco` | legacy provider | Yes: `DomainRandConfig + LocomotionDRProvider subclass + ResetPlan` | task state sampling + common payload | push | `go2_arm/manip_loco.py` | ## Per-task Domain Randomization List @@ -77,14 +76,13 @@ in the owner YAML through the manager lifecycle. ### 2. The Shared Helpers Are Still Narrow -The legacy path's `dr_utils.py` currently has only two classes of shared helpers: +The legacy path's `dr_utils.py` builds and validates common reset payloads: - reset common payload: `base_mass_delta`, `base_com_offset`, `gravity`, `kp`, `kd` -- interval common payload: push This means: -- The go2_arm / sharpa families still on the legacy provider path sample their +- The Sharpa families still on the legacy provider path sample their task-specific state directly inside each provider - `G1MotionTracking`'s pose / velocity / joint noise is owned by its manager command - Allegro's grasp / object initial state sampling is entirely task-specific logic @@ -126,10 +124,9 @@ But on the task side, the current reality is: not every provider constructs thes - Lifecycle: only sampled and written at reset; the env retains that gravity until the next reset re-samples it. - Backend: currently in UniLab, only the MuJoCo backend declares support for this reset term; the Motrix backend does not. Some tasks filter it by capability and skip it; others raise an error in the validate stage. -The config entry exists only under `env.domain_rand` of the Adapted-family -owners still on the legacy provider path (`sharpa_inhand_grasp`, -`go2_arm_manip_loco`, and their hora / appo / ppo_him variants); Manager-Based -tasks have no `env.domain_rand`: +The config entry lives under `env.domain_rand` in Sharpa owners on the legacy +provider path, such as `sharpa_inhand_grasp`; Manager-Based tasks have no +`env.domain_rand`: ```yaml env: @@ -172,43 +169,15 @@ Notes: ## Interval push Usage -The `env.domain_rand.push_robots` family of fields exists only in the go2_arm -Adapted-family owners (`src/unilab/conf/ppo/task/go2_arm_manip_loco/mujoco.yaml` etc.); -Manager-Based tasks declare push through a `push_by_setting_velocity` interval -event term instead (for example `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml` and -`src/unilab/conf/ppo/task/quadruped_joystick_rough/base.yaml`). - -The go2_arm owners configure push under `env.domain_rand`: - -```yaml -env: - domain_rand: - push_robots: true - push_interval: 750 - max_force: [1.0, 1.0, 0.5] - push_body_name: null -``` - -- `push_robots`: whether to enable push. -- `push_interval`: trigger every N env steps. -- `max_force`: a length-3 external-force upper limit; each dimension is sampled within `[-max_force, max_force]`. -- `push_body_name`: the target body / link to apply the force to. Defaults to `null`, meaning the backend's `base_name` is used. +Manager-Based tasks configure interval push through the `env.events.push_robot` +term. For example, `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml` uses +`push_by_setting_velocity` with a 15-second interval and per-axis velocity ranges. ```bash -uv run train --algo ppo --task go2_arm_manip_loco --sim mujoco \ - env.domain_rand.push_robots=true \ - env.domain_rand.push_interval=500 \ - 'env.domain_rand.max_force=[20.0,20.0,5.0]' \ - env.domain_rand.push_body_name=base +uv run train --algo ppo --task go1_joystick_flat --sim mujoco \ + 'env.events.push_robot.interval_range_s=[10.0,10.0]' ``` -Notes: - -- MuJoCo resolves by body name, Motrix resolves by link name; a missing name raises an error during env/backend initialization. -- `push_body_name` is an init config; changing it after env creation does not change the already-resolved target. -- The hot path only samples and applies the external force; it does not parse XML / asset and does not probe backend-private capability. -- MuJoCo push is implemented via `xfrc_applied` external force and does not directly overwrite base velocity. - ## `geom_size` Lifecycle Boundary `geom_size` is explicitly not part of `ResetRandomizationPayload`, and must not be modified on the hot path via `BatchEnvPool.reset(..., randomization=...)`. diff --git a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/1-configuration.md b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/1-configuration.md index 38369261e..78dca0951 100644 --- a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/1-configuration.md +++ b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/1-configuration.md @@ -9,8 +9,8 @@ Two declaration paths exist today: - Manager-Based (Compatible) tasks declare reset / interval randomization through Hydra `events:` manager terms in the owner YAML, for example `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml`. -- Only the Adapted families (sharpa / go2_arm and their hora / appo / ppo_him - owners) still configure legacy provider fields under `env.domain_rand`. +- The Sharpa Adapted families and their hora / appo owners configure legacy + provider fields under `env.domain_rand`. ```bash uv run train --algo ppo --task sharpa_inhand_grasp --sim mujoco \ @@ -47,15 +47,13 @@ uv run train --algo ppo --task sharpa_inhand_grasp --sim mujoco \ ## Interval Push -Manager-Based tasks declare push through a `push_by_setting_velocity` interval -event term; `env.domain_rand.push_robots` is only available on the go2_arm -Adapted-family owners. +Manager-Based tasks configure interval push through the `env.events.push_robot` +term. For example, `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml` uses +`push_by_setting_velocity` with a 15-second interval and per-axis velocity ranges. ```bash -uv run train --algo ppo --task go2_arm_manip_loco --sim mujoco \ - env.domain_rand.push_robots=true \ - env.domain_rand.push_interval=500 \ - 'env.domain_rand.max_force=[20.0,20.0,5.0]' +uv run train --algo ppo --task go1_joystick_flat --sim mujoco \ + 'env.events.push_robot.interval_range_s=[10.0,10.0]' ``` ## Owner-Local Defaults diff --git a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/2-writing_providers.md b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/2-writing_providers.md index 00799b7db..1c35d55bc 100644 --- a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/2-writing_providers.md +++ b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/2-writing_providers.md @@ -1,7 +1,7 @@ # Writing Providers -This page describes the legacy provider path: only the 3 Adapted families -(`sharpa_inhand` / `sharpa_inhand_grasp` / `go2_arm_manip_loco`) still declare +This page describes the legacy provider path: only the 2 Adapted families +(`sharpa_inhand` / `sharpa_inhand_grasp`) still declare domain randomization through a task-level `DomainRandomizationProvider`. Migrated Manager-Based tasks do not write providers; they declare randomization through Hydra `events:` manager terms in the owner YAML (see {doc}`0-index` @@ -62,9 +62,6 @@ manager lives in `src/unilab/dr/manager.py`. Representative provider implementations are in (all on the Adapted-family compatibility path): -- `src/unilab/tasks/locomotion/common/dr_provider.py` (`LocomotionDRProvider`, - used by the go2_arm family) -- `src/unilab/tasks/locomotion/go2_arm/manip_loco.py` - `src/unilab/tasks/manipulation/sharpa_inhand/rotation.py` Developer contract details are in diff --git a/docs/sphinx/source/en/2-user_guide/7-tooling/1-onnx_export.md b/docs/sphinx/source/en/2-user_guide/7-tooling/1-onnx_export.md index c8b1b59d2..b18077987 100644 --- a/docs/sphinx/source/en/2-user_guide/7-tooling/1-onnx_export.md +++ b/docs/sphinx/source/en/2-user_guide/7-tooling/1-onnx_export.md @@ -1,7 +1,6 @@ # ONNX Export -ONNX export is tied to playback in the training scripts. The PPO and HIM-PPO -scripts set `EXPORT_POLICY=True` when run as scripts, then export during +ONNX export is tied to playback in the training scripts. The PPO script sets `EXPORT_POLICY=True` when run directly, then exports during `training.play_only=true` playback. APPO and off-policy playback paths also export `policy.onnx` and verify it with ONNX Runtime in their script code. diff --git a/docs/sphinx/source/en/2-user_guide/8-manipulation/0-index.md b/docs/sphinx/source/en/2-user_guide/8-manipulation/0-index.md index 30558ddb2..f11b3938d 100644 --- a/docs/sphinx/source/en/2-user_guide/8-manipulation/0-index.md +++ b/docs/sphinx/source/en/2-user_guide/8-manipulation/0-index.md @@ -12,17 +12,10 @@ detail than the category overview in {doc}`../4-tasks/3-manipulation`. Allegro and Sharpa owner YAMLs, grasp caches, and train commands. ::: -:::{grid-item-card} Manip-loco -:link: 2-manip_loco -:link-type: doc -Go2 plus Airbot locomotion/manipulation owner paths. -::: - :::: ```{toctree} :hidden: 1-dexterous_inhand -2-manip_loco ``` diff --git a/docs/sphinx/source/en/2-user_guide/8-manipulation/2-manip_loco.md b/docs/sphinx/source/en/2-user_guide/8-manipulation/2-manip_loco.md deleted file mode 100644 index a0ff64f51..000000000 --- a/docs/sphinx/source/en/2-user_guide/8-manipulation/2-manip_loco.md +++ /dev/null @@ -1,47 +0,0 @@ -# Manip-Loco - -`go2_arm_manip_loco` combines Go2 locomotion with the Airbot arm. The registered -env is `Go2ArmManipLoco`, the PPO owner is -`src/unilab/conf/ppo/task/go2_arm_manip_loco/mujoco.yaml`, and the HIM-PPO owner is -`src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml`. - -## PPO - -```bash -uv run train --algo ppo --task go2_arm_manip_loco --sim mujoco training.no_play=true -``` - -## HIM-PPO - -HIM-PPO is configured by `src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml` and -implemented by `scripts/train_him_ppo.py`. It is not currently declared as a -top-level `uv run train --algo ...` route in `src/unilab/cli.py`. - -The env currently raises if constructed with a backend other than MuJoCo. Keep -backend selection in `--task go2_arm_manip_loco --sim mujoco`, and do not -override `training.sim_backend` alone. - -## Tuning Hints - -- `env.control_config.arm_action_scale`: magnitude of the arm residual action. -- `env.goal_ee`: end-effector goal sampling ranges and trajectory timing. -- `reward.scales.tracking_lin_vel`, `reward.scales.tracking_ang_vel`, - `reward.scales.stand_still`: base-behavior trade-offs. -- `env.domain_rand`: mass, friction, push, and PD randomization change the - training difficulty. - -## Near-Risk Checks - -Run the env contract and site-Jacobian tests after touching this task: - -```bash -uv run pytest tests/envs/locomotion/go2_arm tests/base/backend/test_mujoco_site_jacobian.py -``` - -If you changed the XML or assets, at least confirm MuJoCo can load the scene: - -```bash -uv run python -c "import mujoco; m=mujoco.MjModel.from_xml_path('src/unilab/assets/robots/go2_arm/scene_flat.xml'); print(m.nq, m.nv, m.nu, m.nsensor)" -``` - -See {doc}`../4-tasks/4-manip_loco` for the task entry. diff --git a/docs/sphinx/source/en/3-deployment/1-sim_to_real/3-go2_locomotion.md b/docs/sphinx/source/en/3-deployment/1-sim_to_real/3-go2_locomotion.md index a1b506270..17600395b 100644 --- a/docs/sphinx/source/en/3-deployment/1-sim_to_real/3-go2_locomotion.md +++ b/docs/sphinx/source/en/3-deployment/1-sim_to_real/3-go2_locomotion.md @@ -43,8 +43,7 @@ deltas. :class: warning The policy is trained against the observation terms emitted by the selected env owner. If deployment cannot provide the same base-velocity signal, train a -variant whose actor observation matches the estimator you can run on the robot -(see HIM-PPO at {doc}`../../2-user_guide/2-algorithms/6-him_ppo`). +variant whose actor observation matches the estimator you can run on the robot. :::: ## Rough terrain caveat diff --git a/docs/sphinx/source/en/3-deployment/1-sim_to_real/5-onnx_runtime.md b/docs/sphinx/source/en/3-deployment/1-sim_to_real/5-onnx_runtime.md index 6af2f30a4..fd9377e39 100644 --- a/docs/sphinx/source/en/3-deployment/1-sim_to_real/5-onnx_runtime.md +++ b/docs/sphinx/source/en/3-deployment/1-sim_to_real/5-onnx_runtime.md @@ -10,7 +10,6 @@ graph when that path implements ONNX Runtime checking. | Algorithm path | Entry script | Export behavior in repo | | --- | --- | --- | | PPO (torch) | `src/unilab/scripts/train_rsl_rl.py` | `EXPORT_POLICY=True` in the script entrypoint; playback calls `runner.export_policy_to_onnx(...)` and `runner.export_policy_to_jit(...)`. | -| HIM-PPO | `scripts/train_him_ppo.py` | Same script-level export pattern as PPO. | | APPO | `src/unilab/scripts/train_appo.py` | Playback writes `policy.onnx` and verifies ONNX Runtime output against PyTorch. | | SAC / TD3 / FlashSAC | `src/unilab/scripts/train_sac.py` / `src/unilab/scripts/train_td3.py` / `src/unilab/scripts/train_flashsac.py` | Playback writes `policy.onnx`; SAC and FlashSAC use `actor.as_export_module()` before export. | diff --git a/docs/sphinx/source/en/3-deployment/1-sim_to_real/6-domain_randomization.md b/docs/sphinx/source/en/3-deployment/1-sim_to_real/6-domain_randomization.md index 6158efc08..2618f0e9f 100644 --- a/docs/sphinx/source/en/3-deployment/1-sim_to_real/6-domain_randomization.md +++ b/docs/sphinx/source/en/3-deployment/1-sim_to_real/6-domain_randomization.md @@ -42,41 +42,34 @@ range in the task owner only after recording why that range is plausible. ## How UniLab structures DR -Tasks that use DR attach a provider through the env initialization path: - -```python -from unilab.tasks.locomotion.common.dr_provider import LocomotionDRProvider - -class MyTaskEnv(NpEnv): - def __init__(self, cfg): - super().__init__(cfg) - self._init_domain_randomization(LocomotionDRProvider(cfg.domain_rand)) -``` - -The manager lives in `src/unilab/dr/manager.py`; providers live near their env -owners and conform to the contract in +Manager-Based tasks declare reset and interval randomization through +`env.events` in their owner YAML, executed by the manager lifecycle. See +`src/unilab/conf/ppo/task/quadruped_joystick_rough/base.yaml`. + +The Sharpa Adapted tasks still attach a task provider to +`src/unilab/dr/manager.py`. The current example is +`SharpaInhandRotationDRProvider` in +`src/unilab/tasks/manipulation/sharpa_inhand/rotation.py`. The capability +boundary for both paths is described in {doc}`../../4-developer_guide/2-contracts/4-dr_contract`. ## Recipe: starting ranges -Use the selected owner YAML as the source of truth. For example, -`src/unilab/conf/ppo/task/go2_joystick_rough/mujoco.yaml` enables base-mass, COM, kp/kd, -and push randomization; `src/unilab/conf/ppo/task/sharpa_inhand/mujoco.yaml` configures -PD-gain, friction, COM, mass, joint-noise, and contact-noise fields. +Use the selected owner YAML as the source of truth. Go2 rough owners compose +`src/unilab/conf/ppo/task/quadruped_joystick_rough/base.yaml`, which declares +base mass, COM, PD gains, and interval push. This excerpt shows its PD-gain +term; evaluate absolute gain ranges together with the robot's control settings. ```yaml -# src/unilab/conf/ppo/task/go2_joystick_rough/mujoco.yaml env: - domain_rand: - randomize_base_mass: true - added_mass_range: [-1.0, 3.0] - random_com: true - randomize_kp: true - kp_multiplier_range: [0.5, 2.0] - randomize_kd: true - kd_multiplier_range: [0.5, 2.0] - push_robots: true - push_interval: 625 + events: + pd_gains: + func: unilab.envs.mdp.pd_gains + mode: reset + params: + kp_range: [17.5, 70.0] + kd_range: [0.25, 1.0] + operation: abs ``` ## Curriculum: ramp DR with skill diff --git a/docs/sphinx/source/en/3-deployment/1-sim_to_real/9-troubleshooting.md b/docs/sphinx/source/en/3-deployment/1-sim_to_real/9-troubleshooting.md index 6bb340779..5020148c1 100644 --- a/docs/sphinx/source/en/3-deployment/1-sim_to_real/9-troubleshooting.md +++ b/docs/sphinx/source/en/3-deployment/1-sim_to_real/9-troubleshooting.md @@ -7,6 +7,7 @@ sideways, start here. | Likely cause | Check | Fix | |---|---|---| +| State estimator velocity bias | Log `base_lin_vel` vs ground truth (motion capture) | Tune the state estimator | | PD gains too high vs trained | Compare driver Kp/Kd against owner YAML | Match training values; or retrain with realistic Kp/Kd DR | | Action latency too low in training | Sweep `torque_delay_ms` in DR | Retrain with measured-latency × 1.5 | | Velocity noise too low | Compare encoder σ in sim vs hardware | Increase `joint_vel_noise_std` in DR | @@ -15,7 +16,6 @@ sideways, start here. | Likely cause | Check | Fix | |---|---|---| -| State estimator velocity bias | Log `base_lin_vel` vs ground truth (motion capture) | Tune KF or switch to HIM-PPO | | IMU bias not calibrated | Robot static, check `gyro_bias` | Run 30-second calibration before policy start | | Foot contact misclassified | Check contact event timestamps | Hysteresis on contact force threshold | diff --git a/docs/sphinx/source/en/3-deployment/2-sim_to_sim/7-config_guard.md b/docs/sphinx/source/en/3-deployment/2-sim_to_sim/7-config_guard.md index 1e92c252e..60e696305 100644 --- a/docs/sphinx/source/en/3-deployment/2-sim_to_sim/7-config_guard.md +++ b/docs/sphinx/source/en/3-deployment/2-sim_to_sim/7-config_guard.md @@ -18,7 +18,7 @@ uv run eval --algo ppo --task go2_joystick_flat --sim motrix --load-run -1 1. **At train time**: `ExperimentTracker` snapshots the contract fields that define policy I/O into `contract_snapshot` in `run_config.json` (the checkpoint format is untouched, so historical checkpoints stay compatible). 2. **At replay time**: `eval` loads the **target backend** owner config selected by `--sim` (e.g. `src/unilab/conf/ppo/task/go2_joystick_flat/motrix.yaml`) and injects `training.play_only=true`. If the task has no owner config for the requested backend, `eval` falls back to a sibling backend owner of the same task and re-applies the requested backend through the allowlisted `training.sim_backend` override (`train` still requires the owner config to exist); the guard chain below is unaffected. -3. **Before env creation**: the play entrypoints (rsl_rl / appo / sac / td3 / flashsac / him_ppo) call `resolve_sim2sim_config`, comparing the target config against the source run's contract snapshot field by field. +3. **Before env creation**: the play entrypoints (rsl_rl / appo / sac / td3 / flashsac) call `resolve_sim2sim_config`, comparing the target config against the source run's contract snapshot field by field. 4. **At weight load**: `policy_load_dim_guard` wraps checkpoint loading, re-raising cryptic tensor shape-mismatch errors as a clear sim2sim diagnostic. ## What the guard covers diff --git a/docs/sphinx/source/en/3-deployment/3-framework_migration/1-from_isaac_lab.md b/docs/sphinx/source/en/3-deployment/3-framework_migration/1-from_isaac_lab.md index 39a78b4a4..48d208c80 100644 --- a/docs/sphinx/source/en/3-deployment/3-framework_migration/1-from_isaac_lab.md +++ b/docs/sphinx/source/en/3-deployment/3-framework_migration/1-from_isaac_lab.md @@ -169,18 +169,17 @@ benchmark only after semantic migration is complete. ## Final task status -The #1042 migration closeout covers 39 production tasks and 86 task/backend -registrations. The fail-closed source of truth is +The task migration status is maintained in the registry and migration matrix. +The fail-closed source of truth is `src/unilab/tasks/migration_matrix.py`: `migration_record()` raises `KeyError` for a production task name with no entry, so adding a production registration requires an explicit migration decision. - 36 tasks are **Compatible** (`target=complete`): the Hydra owner YAML materializes the canonical NumPy Manager-Based runtime. -- 3 tasks are **Adapted** (`target=compatibility`): `Go2ArmManipLoco`, - `SharpaInhandRotation`, and `SharpaInhandRotationGrasp` keep custom - IK/history or tactile/contact/cache behavior behind one frozen compatibility - factory each; they migrate only when the formal capability exists. +- 2 tasks are **Adapted** (`target=compatibility`): `SharpaInhandRotation` + and `SharpaInhandRotationGrasp` keep tactile/contact/cache behavior behind one + frozen compatibility factory each; they migrate only when the formal capability exists. ## Repository evidence diff --git a/docs/sphinx/source/en/3-deployment/3-framework_migration/2-from_legged_gym.md b/docs/sphinx/source/en/3-deployment/3-framework_migration/2-from_legged_gym.md index a95b48c69..7990557d7 100644 --- a/docs/sphinx/source/en/3-deployment/3-framework_migration/2-from_legged_gym.md +++ b/docs/sphinx/source/en/3-deployment/3-framework_migration/2-from_legged_gym.md @@ -9,10 +9,10 @@ mostly mechanical. | Legged Gym | UniLab | |---|---| -| `LeggedRobot` env class | `unilab.tasks.locomotion.common.base` | -| `compute_observations()` | env-side obs builder + `unilab.base.observations` | -| `_reward_*` methods | env's `compute_reward()` + reward term registry | -| `command_ranges` | task owner YAML's `commands` block | +| `LeggedRobot` env class | `unilab.envs.manager_based_rl_env.ManagerBasedRlEnv` | +| `compute_observations()` | owner `env.observations` terms + `unilab.managers.observation_manager` | +| `_reward_*` methods | owner `reward` terms + `unilab.managers.reward_manager` | +| `command_ranges` | task owner YAML's `env.commands` block | | Terrain curriculum | {doc}`../../2-user_guide/6-terrain/1-procedural` | | RSL-RL PPO | `uni_rl.algos.rsl_rl_ppo` | @@ -35,8 +35,8 @@ mostly mechanical. 2. Create a task module under `src/unilab/tasks/locomotion//`. 3. Mirror your reward terms; keep the same names so reward parity is diff-able. -4. Translate command sampling — Legged Gym's `_resample_commands` becomes - a curriculum provider in UniLab. +4. Translate command sampling — configure `UniformVelocityCommandCfg` under + the owner YAML's `env.commands` (see the Go1 flat owner). 5. Translate terrain — Legged Gym's heightfield generator has a UniLab counterpart at `unilab.terrains.heightfield_terrains`. diff --git a/docs/sphinx/source/en/3-deployment/3-framework_migration/6-reward_porting.md b/docs/sphinx/source/en/3-deployment/3-framework_migration/6-reward_porting.md index 267e1efb9..9e2a7a60c 100644 --- a/docs/sphinx/source/en/3-deployment/3-framework_migration/6-reward_porting.md +++ b/docs/sphinx/source/en/3-deployment/3-framework_migration/6-reward_porting.md @@ -1,80 +1,65 @@ # Reward Porting -Reward terms are where most porting bugs hide. This cookbook captures the -common terms and their UniLab idiom. - -## Pattern: linear / quadratic tracking error - -```python -# Legged Gym -def _reward_tracking_lin_vel(self): - err = torch.sum(torch.square(self.commands[:, :2] - self.base_lin_vel[:, :2]), dim=1) - return torch.exp(-err / self.cfg.rewards.tracking_sigma) - -# UniLab -def reward_tracking_lin_vel(self, state): - err = np.sum((state.commands[:, :2] - state.base_lin_vel[:, :2]) ** 2, axis=1) - return np.exp(-err / self.cfg.tracking_sigma) +Map Legged Gym's `_reward_*` methods to `reward` terms in the owner YAML. +Manager-Based reward terms receive the env, read batched state through the +entity facade and managers, and return NumPy arrays of shape `(num_envs,)`. + +## Tracking, smoothness, and joint limits + +The `twist` command below must be defined in the owner's `env.commands`. +The tracking and action-rate entries follow +`src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml`; the joint-limit and +termination entries illustrate existing helpers whose weights need task-specific +evaluation. + +```yaml +reward: + tracking_lin_vel: + func: unilab.tasks.locomotion.common.manager_terms.track_lin_vel_xy_exp + weight: 1.0 + params: + std: 0.5 + command_name: twist + action_rate: + func: unilab.envs.mdp.action_rate_l2 + weight: -0.005 + joint_limits: + func: unilab.envs.mdp.joint_pos_limits + weight: -1.0 + termination: + func: unilab.envs.mdp.is_terminated + weight: -1.0 ``` -Notes: - -- UniLab reward terms operate on a `state` *batch* (NumPy on CPU); no - per-env loop, no `torch`. -- Return per-env scalar reward (shape `(n_envs,)`). +`track_lin_vel_xy_exp` uses body-frame xy velocity error and computes +`exp(-error_squared / std**2)`. If the source denominator is `tracking_sigma`, +use `std = sqrt(tracking_sigma)` and preserve coordinate frames and command +dimensions. -## Pattern: contact-conditional bonus +`action_rate_l2` reads current and previous actions from the action manager. +`joint_pos_limits` reads soft joint limits through the entity facade. +Both return nonnegative costs; a negative `weight` supplies the penalty sign. +Do not negate the cost a second time. -```python -def reward_feet_air_time(self, state): - contact = state.foot_contact # bool, (n_envs, n_feet) - air_time = state.last_air_time # float, (n_envs, n_feet) - first_contact = contact & ~state.prev_contact - reward = (air_time - self.cfg.air_time_threshold) * first_contact - return reward.sum(axis=1) -``` - -Notes: +## Contact-conditional rewards -- UniLab's `state` carries `prev_contact` so you don't need to manage - edge detection yourself. See - `unilab.tasks.locomotion.common.rewards`. - -## Pattern: action smoothness penalty - -```python -def reward_action_rate(self, state): - return -np.sum((state.action - state.prev_action) ** 2, axis=1) -``` - -Already a stock helper in `unilab.tasks.locomotion.common.rewards`. - -## Pattern: posture penalty - -```python -def reward_dof_pos_limits(self, state): - lower = self.cfg.dof_pos_lower - upper = self.cfg.dof_pos_upper - deviation = ( - np.maximum(0, lower - state.dof_pos) + - np.maximum(0, state.dof_pos - upper) - ) - return -np.sum(deviation, axis=1) -``` +Contact history is not a generic `state.prev_contact` field. Rewards needing +timers or edge detection use stateful manager terms and reset the corresponding +env rows on partial resets. Examples live in +`unilab.tasks.locomotion.common.gait_terms`: `feet_air_time` is a time-window +reward, while `foot_air_time` provides current airborne durations. These differ +from Legged Gym's first-contact bonus. Check trigger timing, time units, and +command gating, then compare per-term outputs on a fixed trajectory. ## Termination handling -UniLab separates **terminal signal** from **terminal penalty**. The env's -`terminations()` returns a boolean mask; the reward registry can include -a `termination_penalty` term that consumes it. - -```python -def reward_termination(self, state): - return -state.termination.astype(np.float32) * self.cfg.termination_penalty -``` +`unilab.envs.mdp.is_terminated` reads the termination manager's non-timeout +termination mask. Give it a negative weight for a terminal penalty, and check +whether timeouts should contribute to the source task's penalty separately. ## See also - {doc}`5-task_config_translation` -- `unilab.utils.reward` -- `unilab.tasks.locomotion.common.rewards` +- `src/unilab/envs/mdp/rewards.py` +- `src/unilab/tasks/locomotion/common/manager_terms.py` +- `src/unilab/tasks/locomotion/common/gait_terms.py` diff --git a/docs/sphinx/source/en/4-developer_guide/2-contracts/3-task_owner.md b/docs/sphinx/source/en/4-developer_guide/2-contracts/3-task_owner.md index 095d6c5c2..619447775 100644 --- a/docs/sphinx/source/en/4-developer_guide/2-contracts/3-task_owner.md +++ b/docs/sphinx/source/en/4-developer_guide/2-contracts/3-task_owner.md @@ -10,8 +10,7 @@ contract is recorded in `src/unilab/conf/{ppo,appo}/task//.yaml`. - Off-policy algorithms (SAC / TD3 / FlashSAC) each have their own config tree: `src/unilab/conf//task//.yaml`. -- Other existing config roots, such as `src/unilab/conf/ppo_him/` and - `src/unilab/conf/hora_distill/`, follow the same owner-YAML identity rule for their +- `src/unilab/conf/hora_distill/` follows the same owner-YAML identity rule for its supported tasks. ## Required Semantics diff --git a/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md b/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md index 30d6b4999..895a2654e 100644 --- a/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md +++ b/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md @@ -136,6 +136,4 @@ payloads. `src/unilab/dr/__init__.py` - DR manager: `src/unilab/dr/manager.py` - Backend interface: `unisim.backend.base` -- Example providers: `src/unilab/tasks/locomotion/common/dr_provider.py`, - `src/unilab/tasks/locomotion/go2_arm/manip_loco.py`, - `src/unilab/tasks/manipulation/sharpa_inhand/rotation.py` +- Example provider: `src/unilab/tasks/manipulation/sharpa_inhand/rotation.py` diff --git a/docs/sphinx/source/en/4-developer_guide/3-extending/3-new_algorithm.md b/docs/sphinx/source/en/4-developer_guide/3-extending/3-new_algorithm.md index 0b83b1ddc..38e2c4601 100644 --- a/docs/sphinx/source/en/4-developer_guide/3-extending/3-new_algorithm.md +++ b/docs/sphinx/source/en/4-developer_guide/3-extending/3-new_algorithm.md @@ -80,7 +80,7 @@ convention-discovered). Notes: - Config trees that have a conf directory but no entrypoint script (such as - `hora_distill` and `ppo_him`) are not routable — they are not standalone + `hora_distill`) are not routable — they are not standalone CLI algos. - The special script-name mappings for built-in algorithms are preserved: `ppo` → `train_rsl_rl.py`, `appo` → `train_appo.py`. diff --git a/docs/sphinx/source/en/4-developer_guide/6-agent_quick_reference.md b/docs/sphinx/source/en/4-developer_guide/6-agent_quick_reference.md index 666fa3414..d9b19f5b3 100644 --- a/docs/sphinx/source/en/4-developer_guide/6-agent_quick_reference.md +++ b/docs/sphinx/source/en/4-developer_guide/6-agent_quick_reference.md @@ -13,7 +13,6 @@ repo facts. - APPO entrypoint: `src/unilab/scripts/train_appo.py` - SAC / TD3 / FlashSAC entrypoints: `src/unilab/scripts/train_sac.py` / `src/unilab/scripts/train_td3.py` / `src/unilab/scripts/train_flashsac.py` -- HIM-PPO entrypoint: `scripts/train_him_ppo.py` - HORA distillation entrypoint: `scripts/train_hora_distill.py` ## Contracts To Keep In Mind diff --git a/docs/sphinx/source/en/4-developer_guide/7-motion_assets.md b/docs/sphinx/source/en/4-developer_guide/7-motion_assets.md index 9e1c3f143..e32ed5838 100644 --- a/docs/sphinx/source/en/4-developer_guide/7-motion_assets.md +++ b/docs/sphinx/source/en/4-developer_guide/7-motion_assets.md @@ -87,7 +87,7 @@ Alternatively, pre-download into the in-repo directory with `--local-dir` Robot binary meshes and textures (for example `.STL`, `.obj`, and `.png`) are externalized the same way, on the Hugging Face dataset repo [unilabsim/unilab-robots](https://huggingface.co/datasets/unilabsim/unilab-robots). -The registered robots are a2, allegro_hand, g1, go2, go2_arm, +The registered robots are a2, allegro_hand, g1, go2, sharpa_wave, and x2 (`ROBOT_ASSET_SPECS` in `src/unilab/assets/hub.py`). Their mesh/texture directories download lazily on first use and land under their original paths (for example `src/unilab/assets/robots/g1/assets/` and diff --git a/docs/sphinx/source/en/5-reference/5-support_matrix.md b/docs/sphinx/source/en/5-reference/5-support_matrix.md index 9283e39fe..07e743a2e 100644 --- a/docs/sphinx/source/en/5-reference/5-support_matrix.md +++ b/docs/sphinx/source/en/5-reference/5-support_matrix.md @@ -103,7 +103,6 @@ rendering/playback paths remain unsupported. | PPO (torch) | `g1_climb_tracking` (g1 climb tracking) | Tested | - | Tested | - | - | | PPO (torch) | `g1_motion_tracking_deploy` (g1 motion tracking deploy) | Tested | - | Tested | - | - | | PPO (torch) | `go1_joystick_rough` (go1 joystick rough) | Tested | - | Tested | - | - | -| PPO (torch) | `go2_arm_manip_loco` (go2 arm manip loco) | Tested | - | Tested | - | - | | PPO (torch) | `go2_footstand` (go2 footstand) | Tested | - | Tested | - | - | | PPO (torch) | `go2w_joystick_flat` (go2w joystick flat) | Tested | - | Tested | - | - | | PPO (torch) | `go2w_joystick_rough` (go2w joystick rough) | Tested | - | Tested | - | - | diff --git a/docs/sphinx/source/zh_CN/0-index.md b/docs/sphinx/source/zh_CN/0-index.md index 3f25d832c..c8d9fd6a5 100644 --- a/docs/sphinx/source/zh_CN/0-index.md +++ b/docs/sphinx/source/zh_CN/0-index.md @@ -101,7 +101,7 @@ uv run train --algo ppo --task go2_joystick_flat --sim motrix \ :::{grid-item-card} 挑选算法 :link: 2-user_guide/2-algorithms/0-index :link-type: doc -对比 PPO、APPO、SAC、TD3、FlashSAC、HIM-PPO 和 HORA 的入口。 +对比 PPO、APPO、SAC、TD3、FlashSAC 和 HORA 的入口。 ::: :::{grid-item-card} 部署或切换仿真 @@ -148,7 +148,6 @@ recommendation 元数据。 | --- | --- | --- | | Go1 joystick | PPO、APPO、TD3 | PPO 有已测试的 MuJoCo 与 Motrix 行。APPO 有已测试的 MuJoCo 行和 Motrix registered 行。TD3 有 `go1_joystick_flat` 的 Motrix owner YAML。 | | Go2 joystick | PPO、FlashSAC、TD3 | PPO 有已测试的 MuJoCo 与 Motrix 行。FlashSAC 有 `go2_joystick_flat` 的 MuJoCo owner YAML;TD3 有 `go2_joystick_flat` 的 Motrix owner YAML。 | -| Go2 arm manip-loco | PPO、HIM-PPO | `src/unilab/conf/ppo/task/go2_arm_manip_loco/` 和 `src/unilab/conf/ppo_him/task/go2_arm_manip_loco/` 下有已提交的 MuJoCo owner YAML。 | | Go2W joystick | PPO | `src/unilab/conf/ppo/task/go2w_joystick_*` 下存在 MuJoCo 与 Motrix flat/rough 变体的 PPO owner YAML。 | | G1 locomotion / tracking | PPO、APPO、SAC、TD3 | PPO、APPO、SAC 都为 G1 任务提供了已提交的 MuJoCo 与 Motrix owner YAML;TD3 有一个 `g1_walk_flat` 的 MuJoCo owner。 | | Allegro in-hand | PPO、APPO | PPO 和 APPO 为 Allegro in-hand 任务提供了已提交的 MuJoCo 与 Motrix owner YAML。 | diff --git a/docs/sphinx/source/zh_CN/1-getting_started/1-quick_demo.md b/docs/sphinx/source/zh_CN/1-getting_started/1-quick_demo.md index 9caa4ae23..7d7205990 100644 --- a/docs/sphinx/source/zh_CN/1-getting_started/1-quick_demo.md +++ b/docs/sphinx/source/zh_CN/1-getting_started/1-quick_demo.md @@ -41,7 +41,7 @@ make setup-motrix uv run demo dance ``` -可用的 demo 名称为 `teaser`、`dance`、`wallflip`、`boxtracking`、`locomani` 和 +可用的 demo 名称为 `teaser`、`dance`、`wallflip`、`boxtracking` 和 `inhandgrasp`。运行 `uv run demo --help` 查看 device 和 refresh 选项。 中国大陆用户:运动、场景、机器人网格和 demo 检查点在首次运行时从 Hugging Face 拉取。如果 diff --git a/docs/sphinx/source/zh_CN/1-getting_started/4-project_structure.md b/docs/sphinx/source/zh_CN/1-getting_started/4-project_structure.md index e630650cc..50d6eadd1 100644 --- a/docs/sphinx/source/zh_CN/1-getting_started/4-project_structure.md +++ b/docs/sphinx/source/zh_CN/1-getting_started/4-project_structure.md @@ -8,7 +8,7 @@ UniLab 将运行时 contract、配置、训练脚本和文档分置于不同的 | `src/unilab/conf/` | Hydra 根配置和任务 owner YAML。顶层 CLI 将后端选择暴露为 `--task` 加 `--sim`,然后组合出匹配的 owner YAML。 | | `src/unilab/base/` | Registry、env state、scene 以及 backend contract。 | | `src/unilab/envs/` | 任务 env 实现,以及任务专属的 reset、reward、observation 和 DR 逻辑。 | -| `uni_rl` (unilab-rl repo) | PPO、APPO、off-policy、HIM-PPO 和 HORA 算法代码。 | +| `uni_rl` (unilab-rl repo) | PPO、APPO、off-policy 和 HORA 算法代码。 | | `uni_rl.ipc` (unilab-rl repo) | 共享内存与异步 runner 原语。 | | `src/unilab/training/` | 共享的训练辅助工具,用于日志、回放、种子处理和配置守卫(config guard)。 | | `src/unilab/visualization/` | 回放、渲染、NaN 检查以及 scene/export 工具。 | @@ -24,8 +24,7 @@ UniLab 将运行时 contract、配置、训练脚本和文档分置于不同的 - `src/unilab/conf/appo/config.yaml`,用于 APPO。 - `src/unilab/conf/sac/config.yaml`、`src/unilab/conf/td3/config.yaml` 和 `src/unilab/conf/flashsac/config.yaml`, 分别用于 SAC、TD3 和 FlashSAC,算法超参数内联在各自的 config.yaml 中。 -- `src/unilab/conf/ppo_him/config.yaml` 和 `src/unilab/conf/hora_distill/config.yaml`,用于 - 专门的 HIM-PPO 和 HORA 路径。 +- `src/unilab/conf/hora_distill/config.yaml`,用于 HORA student distillation。 任务 owner YAML 即后端身份。示例: diff --git a/docs/sphinx/source/zh_CN/2-user_guide/0-index.md b/docs/sphinx/source/zh_CN/2-user_guide/0-index.md index 08cdd6f6d..376723d59 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/0-index.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/0-index.md @@ -15,7 +15,7 @@ CLI 路由、Hydra owner YAML、日志、检查点与 Docker。 :::{grid-item-card} 算法 :link: 2-algorithms/0-index :link-type: doc -对比 PPO、APPO、SAC、TD3、FlashSAC、HIM-PPO 与 HORA。 +对比 PPO、APPO、SAC、TD3、FlashSAC 与 HORA。 ::: :::{grid-item-card} 后端 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/1-training/1-cli_reference.md b/docs/sphinx/source/zh_CN/2-user_guide/1-training/1-cli_reference.md index 487759b14..e66b458e0 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/1-training/1-cli_reference.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/1-training/1-cli_reference.md @@ -106,12 +106,11 @@ rollout 一个环境。 uv run demo dance uv run demo wallflip uv run demo boxtracking -uv run demo locomani uv run demo inhandgrasp uv run demo dance --refresh --device cpu ``` -可用的 demo:`teaser`、`dance`、`wallflip`、`boxtracking`、`locomani`、`inhandgrasp`。 +可用的 demo:`teaser`、`dance`、`wallflip`、`boxtracking`、`inhandgrasp`。 每个 demo 在首次运行时会从 `unilabsim/unilab-checkpoints` 这个 Hugging Face 数据集拉取预训练检查点,并缓存到 `src/unilab/assets/checkpoints//model_0.pt`。 传入 `--refresh` 可重新下载。 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/1-training/2-hydra_config.md b/docs/sphinx/source/zh_CN/2-user_guide/1-training/2-hydra_config.md index 998fca78e..567db3862 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/1-training/2-hydra_config.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/1-training/2-hydra_config.md @@ -10,7 +10,6 @@ reward、scene 以及 task 专属运行时字段的身份标识。 | PPO | `src/unilab/conf/ppo/task//.yaml` | | APPO | `src/unilab/conf/appo/task//.yaml` | | SAC / TD3 / FlashSAC | `src/unilab/conf//task//.yaml` | -| HIM-PPO | `src/unilab/conf/ppo_him/task//.yaml` | | HORA 蒸馏 | `src/unilab/conf/hora_distill/task//.yaml` | 示例: diff --git a/docs/sphinx/source/zh_CN/2-user_guide/2-algorithms/0-index.md b/docs/sphinx/source/zh_CN/2-user_guide/2-algorithms/0-index.md index 33c4ccaef..d0fe592ab 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/2-algorithms/0-index.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/2-algorithms/0-index.md @@ -10,7 +10,6 @@ | SAC | off-policy | `src/unilab/scripts/train_sac.py` | `src/unilab/conf/sac/config.yaml` | | TD3 | off-policy | `src/unilab/scripts/train_td3.py` | `src/unilab/conf/td3/config.yaml` | | FlashSAC | off-policy | `src/unilab/scripts/train_flashsac.py` | `src/unilab/conf/flashsac/config.yaml` | -| HIM-PPO | 高度估计器 PPO 路径 | `scripts/train_him_ppo.py` | `src/unilab/conf/ppo_him/config.yaml` | | HORA | teacher/student 蒸馏路径 | `scripts/train_hora_distill.py` | `src/unilab/conf/hora_distill/config.yaml` | ```{toctree} @@ -21,6 +20,5 @@ 3-sac 4-td3 5-flash_sac -6-him_ppo 7-hora ``` diff --git a/docs/sphinx/source/zh_CN/2-user_guide/2-algorithms/6-him_ppo.md b/docs/sphinx/source/zh_CN/2-user_guide/2-algorithms/6-him_ppo.md deleted file mode 100644 index 2ce347a9a..000000000 --- a/docs/sphinx/source/zh_CN/2-user_guide/2-algorithms/6-him_ppo.md +++ /dev/null @@ -1,26 +0,0 @@ -# HIM-PPO - -HIM-PPO 有自己的配置组和脚本。入口是 `scripts/train_him_ppo.py`,基础配置是 -`src/unilab/conf/ppo_him/config.yaml`,已提交的 task owner 是 -`src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml`。 - -## 当前入口 - -`src/unilab/cli.py` 目前通过顶层的 `uv run train` CLI 暴露了 `1-ppo`、 -`2-appo`、`3-sac`、`4-td3` 和 `flashsac`。HIM-PPO 由 `scripts/train_him_ppo.py` -实现,但它尚未拥有顶层的 `--algo` 路由。 - -## Owner 细节 - -Go2 机械臂 owner 从基础配置中填充所需的历史维度: - -- `algo.num_one_step_obs=76` -- `algo.num_actor_history=5` -- `algo.num_critic_history=1` -- `training.task_name=Go2ArmManipLoco` - -一旦有可用的检查点,回放将使用相同的 HIM-PPO 实现入口。请将面向用户的 PPO 示例保 -持在受支持的顶层 CLI 形式上;仅在调试该专用技术栈时才使用 HIM-PPO 脚本路径。 - -HIM-PPO 不是默认的 PPO 路径;请将其用于明确选择 HIM-PPO 配置组的 Go2 机械臂 -manip-loco owner。 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/0-index.md b/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/0-index.md index c7879c6e8..c97e5b68d 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/0-index.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/0-index.md @@ -24,12 +24,6 @@ G1 动作追踪、翻转、攀爬、墙面翻转和箱体追踪。 Allegro 和 Sharpa 手内旋转与抓取生成。 ::: -:::{grid-item-card} 移动操作 -:link: 4-manip_loco -:link-type: doc -Go2 加 Airbot 机械臂的运动控制与操作。 -::: - :::: ```{toctree} @@ -38,5 +32,4 @@ Go2 加 Airbot 机械臂的运动控制与操作。 1-locomotion 2-motion_tracking 3-manipulation -4-manip_loco ``` diff --git a/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/1-locomotion.md b/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/1-locomotion.md index cb37cc5e4..33b1bbb92 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/1-locomotion.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/1-locomotion.md @@ -12,7 +12,6 @@ - G1 行走:`g1_walk_flat`、`g1_walk_rough` - G1 动作追踪:`g1_motion_tracking`、`g1_flip_tracking`、 `g1_wall_flip_tracking`、`g1_climb_tracking`、`g1_box_tracking` -- Go2 机械臂:`go2_arm_manip_loco` ## 示例 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/3-manipulation.md b/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/3-manipulation.md index fa4d18b88..1dac402ba 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/3-manipulation.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/3-manipulation.md @@ -1,7 +1,6 @@ # 操作 -操作任务位于 `src/unilab/tasks/manipulation/` 中,Go2 机械臂 manip-loco -env 位于 `src/unilab/tasks/locomotion/go2_arm/` 中。 +操作任务位于 `src/unilab/tasks/manipulation/`。 ## 手内操作 @@ -33,13 +32,4 @@ HORA student 蒸馏由 uv run train --algo ppo --task stewart_balance --sim motrix training.no_play=true ``` -## 移动操作 - -`go2_arm_manip_loco` 是已提交的 Go2 + Airbot owner 路径: - -```bash -uv run train --algo ppo --task go2_arm_manip_loco --sim mujoco training.no_play=true -``` - -有关任务专属的说明,请参阅 {doc}`../8-manipulation/1-dexterous_inhand` 和 -{doc}`../8-manipulation/2-manip_loco`。 +手内操作任务说明见 {doc}`../8-manipulation/1-dexterous_inhand`。 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/4-manip_loco.md b/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/4-manip_loco.md deleted file mode 100644 index b0fecb0e2..000000000 --- a/docs/sphinx/source/zh_CN/2-user_guide/4-tasks/4-manip_loco.md +++ /dev/null @@ -1,26 +0,0 @@ -# Manip-Loco - -`go2_arm_manip_loco` 将 Go2 运动控制与 Airbot 机械臂结合。其注册的 -env 是 `Go2ArmManipLoco`。 - -## Owner Configs - -- PPO owner:`src/unilab/conf/ppo/task/go2_arm_manip_loco/mujoco.yaml` -- HIM-PPO owner:`src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml` -- 场景入口:`src/unilab/assets/robots/go2_arm/scene_flat.xml` - -## PPO - -```bash -uv run train --algo ppo --task go2_arm_manip_loco --sim mujoco training.no_play=true -``` - -## HIM-PPO - -HIM-PPO owner 是 `src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml`。 -`src/unilab/cli.py` 当前未将 HIM-PPO 作为顶层 -`uv run train --algo ...` 路线暴露。 - -当前已提交的 owner 路径是 MuJoCo。后端选择请保留在 -`--task go2_arm_manip_loco --sim mujoco` 中,不要单独 override -`training.sim_backend`。 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md index 1d902faac..1ee39f40f 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md @@ -6,7 +6,7 @@ 当前存在两条 DR 声明路径: - **Manager-Based(Compatible)任务**:reset / interval 随机化通过 owner YAML 中的 Hydra `events:` manager term 声明;reset 生命周期的 event 在 reset 时采样,interval 生命周期的 event 在 step 之间施加扰动。例如 `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml` 的 `events:` 段。 -- **legacy provider 路径**:只有 3 个 Adapted family(`sharpa_inhand` / `sharpa_inhand_grasp` / `go2_arm_manip_loco`,含 appo / hora / ppo_him owner)仍通过 `DomainRandomizationProvider` + `DomainRandomizationManager` 声明 `env.domain_rand.*` 配置。 +- **legacy provider 路径**:只有 2 个 Adapted family(`sharpa_inhand` / `sharpa_inhand_grasp`,含 appo / hora owner)仍通过 `DomainRandomizationProvider` + `DomainRandomizationManager` 声明 `env.domain_rand.*` 配置。 legacy provider 路径的统一入口点位于 `NpEnv._init_domain_randomization()` 和 `DomainRandomizationManager`: @@ -24,7 +24,7 @@ legacy provider 路径的统一入口点位于 `NpEnv._init_domain_randomization 1. Manager-Based 任务不注册 DR provider;它们的 reset/interval 随机化是 owner YAML 中的 `events:` manager term,由 manager 生命周期统一执行。只有 Adapted family 的冻结兼容工厂仍走 `DomainRandomizationManager` 统一入口。 2. Adapted family owner 定义 `domain_rand` 配置 dataclass、`DomainRandomizationProvider` 和 `ResetPlan`;Manager-Based owner 则通过 Hydra command/event term 声明 reset 行为。G1 motion reset 扰动归 `MotionCommandCfg` 所有,WBT 另加 `EventTermCfg` reset 与 interval term。 -3. 今天所"统一"的主要是入口点和执行流程,而不是每一个随机化项本身。legacy 路径的共享辅助函数 `build_common_reset_randomization()` 目前生成 `base_mass_delta`、`base_com_offset`、`gravity`、`kp`、`kd`;共享的 interval 辅助函数目前只生成 push。 +3. 今天所"统一"的主要是入口点和执行流程,而不是每一个随机化项本身。legacy 路径的共享辅助函数 `build_common_reset_randomization()` 目前生成 `base_mass_delta`、`base_com_offset`、`gravity`、`kp`、`kd`。 4. `ResetRandomizationPayload` 已经可以表达 `gravity`、`body_iquat`、`body_inertia`、`kp`、`kd`,并且 `MuJoCoBackend` 已声明支持。这些是否实际被使用,仍取决于 task provider 是否对它们进行采样和 dispatch。 5. `MotrixBackend` 目前支持 `base_mass_delta`、`base_com_offset`、`kp`、`kd` 和 interval push;并且它要求在初始化期间所有模型 actuator 都是 position actuator。 6. `geom_size` 不是 reset 生命周期字段;Sharpa 手物体的 geom 缩放由 init 生命周期的模型 materialization 处理。 @@ -43,7 +43,6 @@ legacy provider 路径的统一入口点位于 `NpEnv._init_domain_randomization | `AllegroInhandRotationGrasp` | Hydra `events:` term | 是:复用 rotation reset event + `RecorderTermCfg` | 带噪声的手部 reset + grasp 收集 | 无 | `allegro_inhand/grasp_gen.py` | | `SharpaInhandRotation` | legacy provider | 是:`InitRandomizationPlan + ResetPlan + IntervalRandomizationPlan` | grasp cache 采样 + common payload | 物体 `body_force` | `sharpa_inhand/rotation.py` | | `SharpaInhandRotationGrasp` | legacy provider | 是:复用 Sharpa rotation provider 并 override reset 采样 | grasp 收集 reset + common payload | 无 | `sharpa_inhand/grasp_gen.py` | -| `Go2ArmManipLoco` | legacy provider | 是:`DomainRandConfig + LocomotionDRProvider 子类 + ResetPlan` | task 状态采样 + common payload | push | `go2_arm/manip_loco.py` | ## 各任务域随机化清单 @@ -74,14 +73,13 @@ legacy provider 路径的统一入口点由 `NpEnv` 和 `DomainRandomizationMana ### 2. 共享辅助函数仍然较窄 -legacy 路径的 `dr_utils.py` 目前只有两类共享辅助函数: +legacy 路径的 `dr_utils.py` 构造并校验通用 reset payload: - reset common payload:`base_mass_delta`、`base_com_offset`、`gravity`、`kp`、`kd` -- interval common payload:push 这意味着: -- 仍走 legacy provider 的 go2_arm / sharpa family,其 task 专属状态仍直接在各自的 provider 内部采样 +- 仍走 legacy provider 的 sharpa family,其 task 专属状态仍直接在各自的 provider 内部采样 - `G1MotionTracking` 的 pose / velocity / joint 噪声由其 manager command 所有 - Allegro 的 grasp / 物体初始状态采样完全是 task 专属逻辑 - Sharpa 的 `geom_size` 缩放是 init 生命周期的模型 materialization,不属于 reset common payload @@ -122,7 +120,7 @@ legacy 路径的 `dr_utils.py` 目前只有两类共享辅助函数: - 生命周期:仅在 reset 时采样和写入;env 会保留该重力,直到下一次 reset 重新采样。 - 后端:当前在 UniLab 中,只有 MuJoCo 后端声明支持该 reset 项;Motrix 后端不支持。一些任务按能力过滤并跳过它;另一些任务在 validate 阶段抛出错误。 -配置入口仅在仍走 legacy provider 路径的 Adapted family owner 的 `env.domain_rand` 下(`sharpa_inhand_grasp`、`go2_arm_manip_loco` 及对应 hora / appo / ppo_him 变体);Manager-Based 任务没有 `env.domain_rand`: +配置入口位于采用 legacy provider 路径的 Sharpa owner(如 `sharpa_inhand_grasp`)的 `env.domain_rand` 下;Manager-Based 任务没有 `env.domain_rand`: ```yaml env: @@ -165,39 +163,15 @@ uv run train --algo ppo --task sharpa_inhand_grasp --sim mujoco \ ## Interval push 用法 -`env.domain_rand.push_robots` 系列字段只存在于 go2_arm Adapted family 的 owner(`src/unilab/conf/ppo/task/go2_arm_manip_loco/mujoco.yaml` 等);Manager-Based 任务改用 `push_by_setting_velocity` interval event term 声明 push(例如 `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml` 和 `src/unilab/conf/ppo/task/quadruped_joystick_rough/base.yaml`)。 - -go2_arm owner 在 `env.domain_rand` 下配置 push: - -```yaml -env: - domain_rand: - push_robots: true - push_interval: 750 - max_force: [1.0, 1.0, 0.5] - push_body_name: null -``` - -- `push_robots`:是否启用 push。 -- `push_interval`:每 N 个 env step 触发一次。 -- `max_force`:一个长度为 3 的外力上限;每个维度在 `[-max_force, max_force]` 内采样。 -- `push_body_name`:施加力的目标 body / link。默认为 `null`,表示使用后端的 `base_name`。 +Manager-Based 任务通过 `env.events.push_robot` term 配置周期推扰。例如, +`src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml` 使用 +`push_by_setting_velocity`,间隔为 15 秒,并按轴声明速度范围。 ```bash -uv run train --algo ppo --task go2_arm_manip_loco --sim mujoco \ - env.domain_rand.push_robots=true \ - env.domain_rand.push_interval=500 \ - 'env.domain_rand.max_force=[20.0,20.0,5.0]' \ - env.domain_rand.push_body_name=base +uv run train --algo ppo --task go1_joystick_flat --sim mujoco \ + 'env.events.push_robot.interval_range_s=[10.0,10.0]' ``` -说明: - -- MuJoCo 按 body name 解析,Motrix 按 link name 解析;缺失的 name 会在 env/backend 初始化期间抛出错误。 -- `push_body_name` 是一个 init 配置;在 env 创建之后修改它不会改变已经解析的目标。 -- 热路径只采样和施加外力;它不解析 XML / asset,也不探测后端私有能力。 -- MuJoCo push 通过 `xfrc_applied` 外力实现,不直接覆盖 base 速度。 - ## `geom_size` 生命周期边界 `geom_size` 明确不属于 `ResetRandomizationPayload`,并且不得在热路径上通过 `BatchEnvPool.reset(..., randomization=...)` 修改。 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/1-configuration.md b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/1-configuration.md index c89685af7..c15ef3cb7 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/1-configuration.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/1-configuration.md @@ -7,7 +7,7 @@ - Manager-Based(Compatible)任务通过 owner YAML 的 `events:` manager term 声明 reset / interval 随机化,例如 `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml`。 -- 只有 Adapted family(sharpa / go2_arm 及对应 hora / appo / ppo_him owner)仍在 +- 只有 Adapted family(sharpa 及对应 hora / appo owner)仍在 `env.domain_rand` 下配置 legacy provider 字段。 ```bash @@ -42,14 +42,13 @@ uv run train --algo ppo --task sharpa_inhand_grasp --sim mujoco \ ## Interval Push -Manager-Based 任务通过 `push_by_setting_velocity` interval event term 声明 push; -`env.domain_rand.push_robots` 只在 go2_arm Adapted family owner 上可用。 +Manager-Based 任务通过 `env.events.push_robot` term 配置周期推扰。例如, +`src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml` 使用 +`push_by_setting_velocity`,间隔为 15 秒,并按轴声明速度范围。 ```bash -uv run train --algo ppo --task go2_arm_manip_loco --sim mujoco \ - env.domain_rand.push_robots=true \ - env.domain_rand.push_interval=500 \ - 'env.domain_rand.max_force=[20.0,20.0,5.0]' +uv run train --algo ppo --task go1_joystick_flat --sim mujoco \ + 'env.events.push_robot.interval_range_s=[10.0,10.0]' ``` ## Owner 本地默认值 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/2-writing_providers.md b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/2-writing_providers.md index a32798504..902d0f065 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/2-writing_providers.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/2-writing_providers.md @@ -1,7 +1,7 @@ # 编写 Provider -本页描述 legacy provider 路径:只有 3 个 Adapted family(`sharpa_inhand` / -`sharpa_inhand_grasp` / `go2_arm_manip_loco`)仍通过任务级 +本页描述 legacy provider 路径:只有 2 个 Adapted family(`sharpa_inhand` / +`sharpa_inhand_grasp`)仍通过任务级 `DomainRandomizationProvider` 声明域随机化。已迁移的 Manager-Based 任务不写 provider;它们在 owner YAML 中通过 Hydra `events:` manager term 声明随机化(见 {doc}`0-index` 与 {doc}`1-configuration`)。 @@ -58,9 +58,6 @@ def build_interval_randomization_plan(self, env, step_counter): 具有代表性的 provider 实现位于(全部属于 Adapted family 的兼容路径): -- `src/unilab/tasks/locomotion/common/dr_provider.py`(`LocomotionDRProvider`, - 由 go2_arm family 使用) -- `src/unilab/tasks/locomotion/go2_arm/manip_loco.py` - `src/unilab/tasks/manipulation/sharpa_inhand/rotation.py` 开发者 contract 详情见 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/7-tooling/1-onnx_export.md b/docs/sphinx/source/zh_CN/2-user_guide/7-tooling/1-onnx_export.md index 58549fdb7..cd9cc9eca 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/7-tooling/1-onnx_export.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/7-tooling/1-onnx_export.md @@ -1,6 +1,6 @@ # ONNX 导出 -ONNX 导出与训练脚本中的回放绑定。PPO 和 HIM-PPO 脚本在作为脚本运行时会设置 `EXPORT_POLICY=True`,然后在 `training.play_only=true` 回放期间导出。APPO、off-policy 回放路径也会在它们的脚本代码中导出 `policy.onnx`,并用 ONNX Runtime 验证它。 +ONNX 导出与训练脚本中的回放绑定。PPO 脚本在作为脚本运行时会设置 `EXPORT_POLICY=True`,然后在 `training.play_only=true` 回放期间导出。APPO、off-policy 回放路径也会在它们的脚本代码中导出 `policy.onnx`,并用 ONNX Runtime 验证它。 ## 示例 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/8-manipulation/0-index.md b/docs/sphinx/source/zh_CN/2-user_guide/8-manipulation/0-index.md index 8c9c284eb..444082b7e 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/8-manipulation/0-index.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/8-manipulation/0-index.md @@ -11,17 +11,10 @@ Allegro 和 Sharpa 的 owner YAML、抓取缓存以及训练命令。 ::: -:::{grid-item-card} Manip-loco -:link: 2-manip_loco -:link-type: doc -Go2 加 Airbot 的运动/操作 owner 路径。 -::: - :::: ```{toctree} :hidden: 1-dexterous_inhand -2-manip_loco ``` diff --git a/docs/sphinx/source/zh_CN/2-user_guide/8-manipulation/2-manip_loco.md b/docs/sphinx/source/zh_CN/2-user_guide/8-manipulation/2-manip_loco.md deleted file mode 100644 index a3cf5365d..000000000 --- a/docs/sphinx/source/zh_CN/2-user_guide/8-manipulation/2-manip_loco.md +++ /dev/null @@ -1,38 +0,0 @@ -# Manip-Loco - -`go2_arm_manip_loco` 将 Go2 运动与 Airbot 机械臂结合。已注册的 env 是 `Go2ArmManipLoco`,PPO owner 是 `src/unilab/conf/ppo/task/go2_arm_manip_loco/mujoco.yaml`,HIM-PPO owner 是 `src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml`。 - -## PPO - -```bash -uv run train --algo ppo --task go2_arm_manip_loco --sim mujoco training.no_play=true -``` - -## HIM-PPO - -HIM-PPO 由 `src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml` 配置,由 `scripts/train_him_ppo.py` 实现。它目前没有在 `src/unilab/cli.py` 中声明为顶层 `uv run train --algo ...` 路由。 - -如果用 MuJoCo 以外的后端构造该 env,它目前会抛出异常。请将后端选择保持在 `--task go2_arm_manip_loco --sim mujoco`,不要单独覆盖 `training.sim_backend`。 - -## 调参提示 - -- `env.control_config.arm_action_scale`:机械臂 residual action 的幅度。 -- `env.goal_ee`:末端目标采样范围和轨迹时间。 -- `reward.scales.tracking_lin_vel`、`reward.scales.tracking_ang_vel`、`reward.scales.stand_still`:底盘行为的权衡。 -- `env.domain_rand`:质量、摩擦、推力和 PD 随机化会改变训练难度。 - -## 近风险检查 - -改动该任务后,运行 env contract 和 site-Jacobian 测试: - -```bash -uv run pytest tests/envs/locomotion/go2_arm tests/base/backend/test_mujoco_site_jacobian.py -``` - -如果改过 XML 或 asset,至少确认 MuJoCo 能加载场景: - -```bash -uv run python -c "import mujoco; m=mujoco.MjModel.from_xml_path('src/unilab/assets/robots/go2_arm/scene_flat.xml'); print(m.nq, m.nv, m.nu, m.nsensor)" -``` - -关于任务入口,参见 {doc}`../4-tasks/4-manip_loco`。 diff --git a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/3-go2_locomotion.md b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/3-go2_locomotion.md index 2b0d3e02c..7ff50ed7d 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/3-go2_locomotion.md +++ b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/3-go2_locomotion.md @@ -41,8 +41,7 @@ ::::{admonition} 状态估计器注意事项 :class: warning 策略是针对所选环境 owner 发出的观测项训练的。如果部署无法提供同样的基座速度信号, -请训练一个变体,使其 actor 观测与你能在机器人上运行的估计器相匹配(参见 HIM-PPO, -见 {doc}`../../2-user_guide/2-algorithms/6-him_ppo`)。 +请训练一个变体,使其 actor 观测与你能在机器人上运行的估计器相匹配。 :::: ## 崎岖地形注意事项 diff --git a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/5-onnx_runtime.md b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/5-onnx_runtime.md index 0a56d2eb6..76c77168e 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/5-onnx_runtime.md +++ b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/5-onnx_runtime.md @@ -9,7 +9,6 @@ owner;回放代码加载检查点、导出 `policy.onnx`,并在该路径实 | 算法路径 | 入口脚本 | 仓库中的导出行为 | | --- | --- | --- | | PPO(torch) | `src/unilab/scripts/train_rsl_rl.py` | 脚本入口处 `EXPORT_POLICY=True`;回放调用 `runner.export_policy_to_onnx(...)` 与 `runner.export_policy_to_jit(...)`。 | -| HIM-PPO | `scripts/train_him_ppo.py` | 与 PPO 相同的脚本级导出模式。 | | APPO | `src/unilab/scripts/train_appo.py` | 回放写出 `policy.onnx` 并将 ONNX Runtime 输出与 PyTorch 比对校验。 | | SAC / TD3 / FlashSAC | `src/unilab/scripts/train_sac.py` / `src/unilab/scripts/train_td3.py` / `src/unilab/scripts/train_flashsac.py` | 回放写出 `policy.onnx`;SAC 与 FlashSAC 在导出前使用 `actor.as_export_module()`。 | diff --git a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/6-domain_randomization.md b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/6-domain_randomization.md index 3a3a065c5..0d70c9adb 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/6-domain_randomization.md +++ b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/6-domain_randomization.md @@ -40,40 +40,32 @@ ## UniLab 如何组织 DR -使用 DR 的任务通过环境初始化路径挂接一个 provider: +Manager-Based 任务在 owner YAML 的 `env.events` 中声明 reset 与 interval +随机化,由 manager 生命周期执行。示例见 +`src/unilab/conf/ppo/task/quadruped_joystick_rough/base.yaml`。 -```python -from unilab.tasks.locomotion.common.dr_provider import LocomotionDRProvider - -class MyTaskEnv(NpEnv): - def __init__(self, cfg): - super().__init__(cfg) - self._init_domain_randomization(LocomotionDRProvider(cfg.domain_rand)) -``` - -管理器位于 `src/unilab/dr/manager.py`;provider 位于其环境 owner 附近,并遵循 -{doc}`../../4-developer_guide/2-contracts/4-dr_contract` 中的契约。 +Sharpa Adapted 任务仍通过任务 provider 接入 `src/unilab/dr/manager.py`; +现有实现为 `src/unilab/tasks/manipulation/sharpa_inhand/rotation.py` 中的 +`SharpaInhandRotationDRProvider`。两条路径的能力边界见 +{doc}`../../4-developer_guide/2-contracts/4-dr_contract`。 ## 配方:起始范围 -把所选的 owner YAML 作为权威来源。例如, -`src/unilab/conf/ppo/task/go2_joystick_rough/mujoco.yaml` 启用了基座质量、质心、kp/kd 以及推力 -随机化;`src/unilab/conf/ppo/task/sharpa_inhand/mujoco.yaml` 配置了 PD 增益、摩擦、质心、质量、 -关节噪声与接触噪声字段。 +以所选 owner YAML 为准。Go2 rough owner 组合 +`src/unilab/conf/ppo/task/quadruped_joystick_rough/base.yaml`,其中声明基座质量、 +质心、PD 增益和周期推扰。以下是该共享 owner 的 PD 增益片段;绝对增益范围应 +与机器人的控制参数一起评估。 ```yaml -# src/unilab/conf/ppo/task/go2_joystick_rough/mujoco.yaml env: - domain_rand: - randomize_base_mass: true - added_mass_range: [-1.0, 3.0] - random_com: true - randomize_kp: true - kp_multiplier_range: [0.5, 2.0] - randomize_kd: true - kd_multiplier_range: [0.5, 2.0] - push_robots: true - push_interval: 625 + events: + pd_gains: + func: unilab.envs.mdp.pd_gains + mode: reset + params: + kp_range: [17.5, 70.0] + kd_range: [0.25, 1.0] + operation: abs ``` ## 课程:随技能逐步加大 DR diff --git a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/9-troubleshooting.md b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/9-troubleshooting.md index 7186eae18..b0d832e3b 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/9-troubleshooting.md +++ b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/9-troubleshooting.md @@ -6,6 +6,7 @@ | 可能原因 | 检查 | 修复 | |---|---|---| +| 状态估计器速度偏置 | 记录 `base_lin_vel` 与真值(动作捕捉) | 调整状态估计器 | | PD 增益相对训练值过高 | 将驱动器 Kp/Kd 与 owner YAML 比较 | 匹配训练值;或用真实的 Kp/Kd DR 重新训练 | | 训练中动作延迟过低 | 在 DR 中扫动 `torque_delay_ms` | 用 实测延迟 × 1.5 重新训练 | | 速度噪声过低 | 比较仿真与硬件中的编码器 σ | 在 DR 中增大 `joint_vel_noise_std` | @@ -14,7 +15,6 @@ | 可能原因 | 检查 | 修复 | |---|---|---| -| 状态估计器速度偏置 | 记录 `base_lin_vel` 与真值(动作捕捉) | 调 KF 或切换到 HIM-PPO | | IMU 偏置未标定 | 机器人静止,检查 `gyro_bias` | 在策略启动前运行 30 秒标定 | | 足端接触分类错误 | 检查接触事件时间戳 | 对接触力阈值加滞回 | diff --git a/docs/sphinx/source/zh_CN/3-deployment/2-sim_to_sim/7-config_guard.md b/docs/sphinx/source/zh_CN/3-deployment/2-sim_to_sim/7-config_guard.md index 06523e833..8055272bb 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/2-sim_to_sim/7-config_guard.md +++ b/docs/sphinx/source/zh_CN/3-deployment/2-sim_to_sim/7-config_guard.md @@ -18,7 +18,7 @@ uv run eval --algo ppo --task go2_joystick_flat --sim motrix --load-run -1 1. **训练时**:`ExperimentTracker` 把决定策略 I/O 的契约字段快照进 `run_config.json` 的 `contract_snapshot`(不改动 checkpoint 格式,历史 checkpoint 天然兼容)。 2. **回放时**:`eval` 读取 `--sim` 指定的**目标后端** owner 配置(如 `src/unilab/conf/ppo/task/go2_joystick_flat/motrix.yaml`),并注入 `training.play_only=true`。若该任务没有所请求后端的 owner 配置,`eval` 会回退到同任务的兄弟后端 owner,并通过 ALLOWLIST 中的 `training.sim_backend` override 重新指定目标后端(`train` 仍要求 owner 配置必须存在);下面的守卫链路不受影响。 -3. **建 env 前**:各 play 入口(rsl_rl / appo / sac / td3 / flashsac / him_ppo)调用 `resolve_sim2sim_config`,把目标配置与源 run 的契约快照逐字段比对。 +3. **建 env 前**:各 play 入口(rsl_rl / appo / sac / td3 / flashsac)调用 `resolve_sim2sim_config`,把目标配置与源 run 的契约快照逐字段比对。 4. **加载权重时**:`policy_load_dim_guard` 包裹 checkpoint 加载,把底层 tensor 维度不匹配的晦涩报错重抛为清晰的 sim2sim 诊断。 ## 守卫的字段 diff --git a/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/1-from_isaac_lab.md b/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/1-from_isaac_lab.md index 38ffe1aa1..795433b2f 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/1-from_isaac_lab.md +++ b/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/1-from_isaac_lab.md @@ -149,17 +149,16 @@ observation/action shape、局部 reset,以及至少一个真实已注册 back ## 任务迁移最终状态 -#1042 迁移收尾覆盖 39 个 production task、86 个 task/backend 注册。fail-closed 的 -source of truth 是 `src/unilab/tasks/migration_matrix.py`:`migration_record()` 对没有 +任务迁移状态由 registry 与迁移矩阵维护。fail-closed 的 source of truth 是 +`src/unilab/tasks/migration_matrix.py`:`migration_record()` 对没有 entry 的 production task 名称抛出 `KeyError`,因此新增 production 注册必须显式做出 迁移决策。 - 36 个 task 为 **Compatible**(`target=complete`):Hydra owner YAML 物化 canonical NumPy Manager-Based runtime。 -- 3 个 task 为 **Adapted**(`target=compatibility`):`Go2ArmManipLoco`、 - `SharpaInhandRotation` 和 `SharpaInhandRotationGrasp` 各自把自定义 IK/history 或 - tactile/contact/cache 行为保留在一个冻结的兼容 factory 后面;只有当正式能力存在时 - 才迁移。 +- 2 个 task 为 **Adapted**(`target=compatibility`):`SharpaInhandRotation` 和 + `SharpaInhandRotationGrasp` 各自把 tactile/contact/cache 行为保留在一个冻结的 + 兼容 factory 后面;只有当正式能力存在时才迁移。 ## 仓库证据 diff --git a/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/2-from_legged_gym.md b/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/2-from_legged_gym.md index 0280ed0dd..2b5d3e84b 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/2-from_legged_gym.md +++ b/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/2-from_legged_gym.md @@ -8,10 +8,10 @@ Legged Gym 曾是那套 GPU 常驻的 PPO 模板,教会了整个领域如何 | Legged Gym | UniLab | |---|---| -| `LeggedRobot` env 类 | `unilab.tasks.locomotion.common.base` | -| `compute_observations()` | env 侧 obs 构建器 + `unilab.base.observations` | -| `_reward_*` 方法 | env 的 `compute_reward()` + reward 项 registry | -| `command_ranges` | 任务 owner YAML 的 `commands` 块 | +| `LeggedRobot` env 类 | `unilab.envs.manager_based_rl_env.ManagerBasedRlEnv` | +| `compute_observations()` | owner 的 `env.observations` term + `unilab.managers.observation_manager` | +| `_reward_*` 方法 | owner 的 `reward` term + `unilab.managers.reward_manager` | +| `command_ranges` | 任务 owner YAML 的 `env.commands` 块 | | 地形课程 | {doc}`../../2-user_guide/6-terrain/1-procedural` | | RSL-RL PPO | `uni_rl.algos.rsl_rl_ppo` | @@ -31,8 +31,8 @@ Legged Gym 曾是那套 GPU 常驻的 PPO 模板,教会了整个领域如何 1. 把你的 URDF / MJCF asset 复制到 `src/unilab/assets/robots//` 下。 2. 在 `src/unilab/tasks/locomotion//` 下创建一个任务模块。 3. 镜像你的 reward 项;保持名称相同,以便 reward 一致性可被 diff。 -4. 翻译命令采样 —— Legged Gym 的 `_resample_commands` 在 UniLab 中变成一个 - curriculum provider。 +4. 翻译命令采样 —— 在 owner YAML 的 `env.commands` 下配置 + `UniformVelocityCommandCfg`,参考 Go1 flat owner。 5. 翻译地形 —— Legged Gym 的高度场生成器在 UniLab 中有一个对应物,位于 `unilab.terrains.heightfield_terrains`。 diff --git a/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/6-reward_porting.md b/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/6-reward_porting.md index cbc4fe732..304e4819f 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/6-reward_porting.md +++ b/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/6-reward_porting.md @@ -1,78 +1,59 @@ # Reward 移植 -reward 项是大多数移植 bug 藏身之处。本食谱记录了常见的 reward 项及其 UniLab -惯用写法。 - -## 模式:线性 / 二次跟踪误差 - -```python -# Legged Gym -def _reward_tracking_lin_vel(self): - err = torch.sum(torch.square(self.commands[:, :2] - self.base_lin_vel[:, :2]), dim=1) - return torch.exp(-err / self.cfg.rewards.tracking_sigma) - -# UniLab -def reward_tracking_lin_vel(self, state): - err = np.sum((state.commands[:, :2] - state.base_lin_vel[:, :2]) ** 2, axis=1) - return np.exp(-err / self.cfg.tracking_sigma) +把 Legged Gym 的 `_reward_*` 方法映射到 owner YAML 的 `reward` term。 +Manager-Based reward 接收 env,通过 entity facade 和各 manager 读取批量状态, +返回形状为 `(num_envs,)` 的 NumPy 数组。 + +## 跟踪、平滑与关节限制 + +以下示例的 `twist` command 必须由 owner 的 `env.commands` 定义。 +跟踪与动作平滑项参考 `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml`; +关节限制与终止项展示现有 helper 的配置方式,权重应按任务评估。 + +```yaml +reward: + tracking_lin_vel: + func: unilab.tasks.locomotion.common.manager_terms.track_lin_vel_xy_exp + weight: 1.0 + params: + std: 0.5 + command_name: twist + action_rate: + func: unilab.envs.mdp.action_rate_l2 + weight: -0.005 + joint_limits: + func: unilab.envs.mdp.joint_pos_limits + weight: -1.0 + termination: + func: unilab.envs.mdp.is_terminated + weight: -1.0 ``` -注意: - -- UniLab 的 reward 项在一个 `state` **批次**上运算(CPU 上的 NumPy);没有 - 逐 env 循环,也没有 `torch`。 -- 返回逐 env 的标量 reward(形状为 `(n_envs,)`)。 +`track_lin_vel_xy_exp` 使用底盘坐标系的 xy 速度误差,公式为 +`exp(-error_squared / std**2)`。若源实现分母为 `tracking_sigma`, +应令 `std = sqrt(tracking_sigma)`,并保持坐标系与 command 维度一致。 -## 模式:接触条件奖励 +`action_rate_l2` 从 action manager 读取当前与前一步动作。 +`joint_pos_limits` 从 entity facade 读取 soft joint limits。 +两者返回非负代价,惩罚符号由负的 `weight` 提供,不要重复取负。 -```python -def reward_feet_air_time(self, state): - contact = state.foot_contact # bool, (n_envs, n_feet) - air_time = state.last_air_time # float, (n_envs, n_feet) - first_contact = contact & ~state.prev_contact - reward = (air_time - self.cfg.air_time_threshold) * first_contact - return reward.sum(axis=1) -``` - -注意: +## 接触条件奖励 -- UniLab 的 `state` 携带了 `prev_contact`,因此你无需自己管理边沿检测。参见 - `unilab.tasks.locomotion.common.rewards`。 - -## 模式:动作平滑惩罚 - -```python -def reward_action_rate(self, state): - return -np.sum((state.action - state.prev_action) ** 2, axis=1) -``` - -它已经是 `unilab.tasks.locomotion.common.rewards` 中的现成辅助函数。 - -## 模式:姿态惩罚 - -```python -def reward_dof_pos_limits(self, state): - lower = self.cfg.dof_pos_lower - upper = self.cfg.dof_pos_upper - deviation = ( - np.maximum(0, lower - state.dof_pos) + - np.maximum(0, state.dof_pos - upper) - ) - return -np.sum(deviation, axis=1) -``` +接触历史不是通用 `state.prev_contact` 字段。需要计时或边沿检测的 reward +使用有状态的 manager term,并在局部 reset 时重置对应 env 的缓存。 +现有示例位于 `unilab.tasks.locomotion.common.gait_terms`: +`feet_air_time` 是时间窗口奖励,`foot_air_time` 提供当前腾空计时。 +它们不等同于 Legged Gym 的首次接触奖励;迁移时核对触发时刻、计时单位和 +command gating,再通过固定轨迹比较逐项输出。 ## 终止处理 -UniLab 把**终止信号**与**终止惩罚**分离开。env 的 `terminations()` 返回一个 -布尔掩码;reward registry 可以包含一个消费它的 `termination_penalty` 项。 - -```python -def reward_termination(self, state): - return -state.termination.astype(np.float32) * self.cfg.termination_penalty -``` +`unilab.envs.mdp.is_terminated` 读取 termination manager 的非 timeout +终止掩码。通过负权重构成终止惩罚,并单独核对 timeout 是否应参与源任务惩罚。 -## 另请参阅 +## 参考 - {doc}`5-task_config_translation` -- `unilab.utils.reward` -- `unilab.tasks.locomotion.common.rewards` +- `src/unilab/envs/mdp/rewards.py` +- `src/unilab/tasks/locomotion/common/manager_terms.py` +- `src/unilab/tasks/locomotion/common/gait_terms.py` diff --git a/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/3-task_owner.md b/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/3-task_owner.md index 5d8d3b310..6782cb94a 100644 --- a/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/3-task_owner.md +++ b/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/3-task_owner.md @@ -9,8 +9,7 @@ `src/unilab/conf/{ppo,appo}/task//.yaml`。 - Off-policy 算法(SAC / TD3 / FlashSAC)各自有独立的配置树: `src/unilab/conf//task//.yaml`。 -- 其他已有的 config 根目录,例如 `src/unilab/conf/ppo_him/` 与 `src/unilab/conf/hora_distill/`,对其 - 所支持的任务遵循相同的 owner YAML 身份规则。 +- `src/unilab/conf/hora_distill/` 对其支持的任务遵循相同的 owner YAML identity 规则。 ## 必需语义 diff --git a/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md b/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md index 6572c4f8b..205df9727 100644 --- a/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md +++ b/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md @@ -123,6 +123,4 @@ actuator 的机制泄漏到共享 payload 里。 `src/unilab/dr/__init__.py` 再导出 - DR manager:`src/unilab/dr/manager.py` - Backend 接口:`unisim.backend.base` -- 示例 provider:`src/unilab/tasks/locomotion/common/dr_provider.py`、 - `src/unilab/tasks/locomotion/go2_arm/manip_loco.py`、 - `src/unilab/tasks/manipulation/sharpa_inhand/rotation.py` +- 示例 provider:`src/unilab/tasks/manipulation/sharpa_inhand/rotation.py` diff --git a/docs/sphinx/source/zh_CN/4-developer_guide/3-extending/3-new_algorithm.md b/docs/sphinx/source/zh_CN/4-developer_guide/3-extending/3-new_algorithm.md index bbdc20581..c179841b3 100644 --- a/docs/sphinx/source/zh_CN/4-developer_guide/3-extending/3-new_algorithm.md +++ b/docs/sphinx/source/zh_CN/4-developer_guide/3-extending/3-new_algorithm.md @@ -68,7 +68,7 @@ fail-closed,报错信息列出全部可用 algo(内置 + 约定发现的) 注意: -- 只有 conf 目录而没有入口脚本的 config 树(如 `hora_distill`、`ppo_him`) +- 只有 conf 目录而没有入口脚本的 config 树(如 `hora_distill`) 不可路由——它们不是独立的 CLI algo。 - 内置算法的特殊脚本名映射保留不变:`ppo` → `train_rsl_rl.py`、 `appo` → `train_appo.py`。 diff --git a/docs/sphinx/source/zh_CN/4-developer_guide/6-agent_quick_reference.md b/docs/sphinx/source/zh_CN/4-developer_guide/6-agent_quick_reference.md index bed58cf73..56ab753c2 100644 --- a/docs/sphinx/source/zh_CN/4-developer_guide/6-agent_quick_reference.md +++ b/docs/sphinx/source/zh_CN/4-developer_guide/6-agent_quick_reference.md @@ -12,7 +12,6 @@ - APPO 入口:`src/unilab/scripts/train_appo.py` - SAC / TD3 / FlashSAC 入口:`src/unilab/scripts/train_sac.py` / `src/unilab/scripts/train_td3.py` / `src/unilab/scripts/train_flashsac.py` -- HIM-PPO 入口:`scripts/train_him_ppo.py` - HORA 蒸馏入口:`scripts/train_hora_distill.py` ## 需要记住的契约 diff --git a/docs/sphinx/source/zh_CN/4-developer_guide/7-motion_assets.md b/docs/sphinx/source/zh_CN/4-developer_guide/7-motion_assets.md index 96b87a52d..76a51e4ed 100644 --- a/docs/sphinx/source/zh_CN/4-developer_guide/7-motion_assets.md +++ b/docs/sphinx/source/zh_CN/4-developer_guide/7-motion_assets.md @@ -80,7 +80,7 @@ env: 机器人二进制网格和纹理(例如 `.STL`、`.obj`、`.png`)采用相同方式外置, 托管在 Hugging Face 数据集仓库 [unilabsim/unilab-robots](https://huggingface.co/datasets/unilabsim/unilab-robots)。 -已注册的机器人为 a2、allegro_hand、g1、go2、go2_arm、sharpa_wave、 +已注册的机器人为 a2、allegro_hand、g1、go2、sharpa_wave、 x2(见 `src/unilab/assets/hub.py` 的 `ROBOT_ASSET_SPECS`)。它们的 mesh/纹理目录在首次使用时按需下载,落盘到原始路径(例如 G1 的 `src/unilab/assets/robots/g1/assets/` 与 `robots/g1/textures/`),因此 XML 中的 diff --git a/docs/sphinx/source/zh_CN/4-developer_guide/9-sim2sim_contract_status.md b/docs/sphinx/source/zh_CN/4-developer_guide/9-sim2sim_contract_status.md index db8c4a218..23383cecc 100644 --- a/docs/sphinx/source/zh_CN/4-developer_guide/9-sim2sim_contract_status.md +++ b/docs/sphinx/source/zh_CN/4-developer_guide/9-sim2sim_contract_status.md @@ -28,7 +28,7 @@ uv run scripts/audit_sim2sim_contracts.py | Task | 判定 | 分歧 | |---|---|---| -| allegro_inhand · allegro_inhand_grasp · g1_climb_tracking · g1_motion_tracking · g1_wall_flip_tracking · go1_joystick_rough · go2_arm_manip_loco · go2_footstand · go2_handstand · go2_joystick_flat · go2_joystick_rough · go2w_joystick_flat · go2w_joystick_rough · sharpa_inhand · sharpa_inhand_grasp | ✅ | 无 | +| allegro_inhand · allegro_inhand_grasp · g1_climb_tracking · g1_motion_tracking · g1_wall_flip_tracking · go1_joystick_rough · go2_footstand · go2_handstand · go2_joystick_flat · go2_joystick_rough · go2w_joystick_flat · go2w_joystick_rough · sharpa_inhand · sharpa_inhand_grasp | ✅ | 无 | | g1_box_tracking | ❌ | `empirical_normalization` false↔true;`obs_groups` critic 组差异 | | g1_flip_tracking | ❌ | `empirical_normalization` true↔false;`obs_groups`;`action_scale` 29 维↔默认 0.25;`sampling_mode` 两后端运行时同为 `start`(无害) | | g1_walk_flat | ❌ | `env.actions.joint_pos.scale` 0.25↔0.5;`empirical_normalization` false↔true;`obs_groups` | @@ -46,7 +46,7 @@ uv run scripts/audit_sim2sim_contracts.py ## 其它配置树 -`src/unilab/conf/ppo_him/task`、`src/unilab/conf/sac/task`、`src/unilab/conf/td3/task`、`src/unilab/conf/flashsac/task`、 +`src/unilab/conf/sac/task`、`src/unilab/conf/td3/task`、`src/unilab/conf/flashsac/task`、 `src/unilab/conf/hora_distill/task` 均无 mujoco↔motrix 配对,sim2sim 不适用。 diff --git a/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md b/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md index c03e09b25..301fdb91d 100644 --- a/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md +++ b/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md @@ -86,7 +86,6 @@ uv run scripts/generate_support_matrix.py --write | PPO (torch) | `g1_climb_tracking` (g1 climb tracking) | Tested | - | Tested | - | - | - | - | | PPO (torch) | `g1_motion_tracking_deploy` (g1 motion tracking deploy) | Tested | - | Tested | - | - | - | - | | PPO (torch) | `go1_joystick_rough` (go1 joystick rough) | Tested | - | Tested | - | - | - | - | -| PPO (torch) | `go2_arm_manip_loco` (go2 arm manip loco) | Tested | - | Tested | - | - | - | - | | PPO (torch) | `go2_footstand` (go2 footstand) | Tested | - | Tested | - | - | - | - | | PPO (torch) | `go2w_joystick_flat` (go2w joystick flat) | Tested | - | Tested | - | - | - | - | | PPO (torch) | `go2w_joystick_rough` (go2w joystick rough) | Tested | - | Tested | - | - | - | - | diff --git a/pyproject.toml b/pyproject.toml index 6e1715327..0fc275b61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ source-exclude = [ "/src/unilab/assets/robots/g1/textures", "/src/unilab/assets/robots/go1/assets", "/src/unilab/assets/robots/go2/assets", - "/src/unilab/assets/robots/go2_arm/assets", "/src/unilab/assets/robots/sharpa_wave/meshes", "/src/unilab/assets/robots/x2/meshes", ] @@ -48,7 +47,7 @@ dependencies = [ # collectors, IPC, logging) live in the independently released uni-rl # package (distribution name ``unilab-rl``), consumed via the injected # env contract (uni_rl.env_contract.EnvFactory). Published on PyPI. - "unilab-rl==1.0.0", + "unilab-rl==1.1.0", "numba>=0.67", "prettytable>=3.10", # torch is a range (not an exact pin) so that published PyPI metadata lets diff --git a/scripts/manip_loco/benchmark_site_jacobian.py b/scripts/manip_loco/benchmark_site_jacobian.py deleted file mode 100644 index 8c5e25486..000000000 --- a/scripts/manip_loco/benchmark_site_jacobian.py +++ /dev/null @@ -1,335 +0,0 @@ -from __future__ import annotations - -import statistics -import sys -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Sequence - -import hydra -import mujoco -import numpy as np -from omegaconf import DictConfig, OmegaConf - -ROOT_DIR = Path(__file__).parent.parent -SRC_DIR = ROOT_DIR / "src" -if str(SRC_DIR) not in sys.path: - sys.path.insert(0, str(SRC_DIR)) -if str(ROOT_DIR) not in sys.path: - sys.path.insert(0, str(ROOT_DIR)) - -from unisim.backend.mujoco.xml import materialize_scene_visual_override - -from unilab.base.config_adapter import ( - BackendAdapter, - create_env, -) -from unilab.training import ensure_registries - - -def _coerce_str_list(value: Any, *, name: str) -> list[str]: - if OmegaConf.is_config(value): - value = OmegaConf.to_container(value, resolve=True) - if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): - raise ValueError(f"{name} must be a non-empty list.") - out = [str(v) for v in value] - if len(out) == 0: - raise ValueError(f"{name} must be a non-empty list.") - return out - - -def _coerce_int_list(value: Any, *, name: str) -> list[int]: - if OmegaConf.is_config(value): - value = OmegaConf.to_container(value, resolve=True) - if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): - raise ValueError(f"{name} must be a non-empty list.") - out = [int(v) for v in value] - if len(out) == 0: - raise ValueError(f"{name} must be a non-empty list.") - return out - - -def _env_cfg_override(cfg: DictConfig) -> dict[str, Any]: - return BackendAdapter( - cfg, - root_dir=ROOT_DIR, - algo_name="ppo", - scene_materializer=materialize_scene_visual_override, - ).build_task_env_cfg_override() - - -def _bench_ms(fn, *, warmup: int, repeat: int) -> list[float]: - for _ in range(warmup): - fn() - out: list[float] = [] - for _ in range(repeat): - t0 = time.perf_counter() - fn() - out.append((time.perf_counter() - t0) * 1000.0) - return out - - -def _pct(values: Sequence[float], q: float) -> float: - return float(np.percentile(np.asarray(values, dtype=np.float64), q)) - - -def _serial_site_jacobian( - backend: Any, site_id: int, dof_indices: np.ndarray -) -> tuple[np.ndarray, np.ndarray]: - dof_indices = np.asarray(dof_indices, dtype=np.int32).reshape(-1) - num_envs = int(backend._num_envs) - jacp_out = np.zeros((num_envs, 3, len(dof_indices)), dtype=np.float64) - jacr_out = np.zeros((num_envs, 3, len(dof_indices)), dtype=np.float64) - state_snapshot = np.asarray(backend._physics_state, dtype=np.float64).copy() - - for env_idx in range(num_envs): - variant_idx = int(backend._model_assignments[env_idx]) - model = backend._model_variants[variant_idx] - data = mujoco.MjData(model) - mujoco.mj_setState( - model, - data, - state_snapshot[env_idx], - int(mujoco.mjtState.mjSTATE_FULLPHYSICS), - ) - mujoco.mj_forward(model, data) - jacp_full = np.zeros((3, model.nv), dtype=np.float64) - jacr_full = np.zeros((3, model.nv), dtype=np.float64) - mujoco.mj_jacSite(model, data, jacp_full, jacr_full, int(site_id)) - jacp_out[env_idx] = jacp_full[:, dof_indices] - jacr_out[env_idx] = jacr_full[:, dof_indices] - - return jacp_out, jacr_out - - -def _print_stats(tag: str, ms_values: Sequence[float]) -> None: - values = list(ms_values) - print( - f"{tag:>18}: " - f"mean={statistics.mean(values):.3f} ms, " - f"median={statistics.median(values):.3f} ms, " - f"p95={_pct(values, 95):.3f} ms" - ) - - -@dataclass -class BenchResult: - num_envs: int - backend_threads: int - correctness_ok: bool - parallel_median_ms: float - parallel_p95_ms: float - serial_median_ms: float - serial_p95_ms: float - speedup: float - step_median_ms: float | None - jacobian_step_ratio_pct: float | None - - -def _format_markdown_table(results: Sequence[BenchResult]) -> str: - lines = [ - "| num_envs | backend_threads | correctness | par_median_ms | par_p95_ms | ser_median_ms | ser_p95_ms | speedup(serial/par) | step_median_ms | jacobian_step_ratio_pct |", - "|---:|---:|:---:|---:|---:|---:|---:|---:|---:|---:|", - ] - for r in results: - step_med = "-" if r.step_median_ms is None else f"{r.step_median_ms:.3f}" - jac_ratio = "-" if r.jacobian_step_ratio_pct is None else f"{r.jacobian_step_ratio_pct:.2f}" - lines.append( - "| " - f"{r.num_envs} | {r.backend_threads} | {'PASS' if r.correctness_ok else 'FAIL'} | " - f"{r.parallel_median_ms:.3f} | {r.parallel_p95_ms:.3f} | " - f"{r.serial_median_ms:.3f} | {r.serial_p95_ms:.3f} | " - f"{r.speedup:.3f}x | {step_med} | {jac_ratio} |" - ) - return "\n".join(lines) - - -def _run_one_case( - cfg: DictConfig, - *, - num_envs: int, - site_name: str, - joint_names: list[str], - jac_warmup: int, - jac_repeat: int, - step_warmup: int, - step_repeat: int, - atol: float, - run_step_bench: bool, -) -> BenchResult: - env = create_env( - cfg, - num_envs=num_envs, - env_cfg_override=_env_cfg_override(cfg), - ) - try: - backend = getattr(env, "_backend", None) - if backend is None: - raise RuntimeError("Cannot access env backend from benchmark script.") - if not hasattr(backend, "get_site_jacobian_w"): - raise RuntimeError("Backend does not expose get_site_jacobian_w.") - - site_id = int(backend.get_site_ids([site_name])[0]) - dof_indices = backend.get_joint_dof_indices(joint_names) - - jacp_par, jacr_par = backend.get_site_jacobian_w(site_id, dof_indices) - jacp_ser, jacr_ser = _serial_site_jacobian(backend, site_id, dof_indices) - np.testing.assert_allclose(jacp_par, jacp_ser, atol=atol) - np.testing.assert_allclose(jacr_par, jacr_ser, atol=atol) - - par_ms = _bench_ms( - lambda: backend.get_site_jacobian_w(site_id, dof_indices), - warmup=jac_warmup, - repeat=jac_repeat, - ) - ser_ms = _bench_ms( - lambda: _serial_site_jacobian(backend, site_id, dof_indices), - warmup=jac_warmup, - repeat=jac_repeat, - ) - - par_median = statistics.median(par_ms) - ser_median = statistics.median(ser_ms) - speedup = ser_median / max(par_median, 1.0e-12) - - step_median_ms: float | None = None - jacobian_step_ratio_pct: float | None = None - if run_step_bench: - action_space = getattr(env, "action_space") - action_dim = int(action_space.shape[0]) - actions = np.random.uniform(-1.0, 1.0, size=(int(env.num_envs), action_dim)).astype( - np.float64 - ) - - env.init_state() - for _ in range(step_warmup): - env.step(actions) - - step_total_ms: list[float] = [] - for _ in range(step_repeat): - state = env.step(actions) - timing = state.info.get("timing", {}) - value = float(timing.get("env_step_total_ms", np.nan)) - if np.isfinite(value): - step_total_ms.append(value) - - if step_total_ms: - step_median_ms = statistics.median(step_total_ms) - jacobian_step_ratio_pct = 100.0 * par_median / max(step_median_ms, 1.0e-12) - - return BenchResult( - num_envs=num_envs, - backend_threads=int(getattr(backend, "_n_threads", -1)), - correctness_ok=True, - parallel_median_ms=par_median, - parallel_p95_ms=_pct(par_ms, 95), - serial_median_ms=ser_median, - serial_p95_ms=_pct(ser_ms, 95), - speedup=speedup, - step_median_ms=step_median_ms, - jacobian_step_ratio_pct=jacobian_step_ratio_pct, - ) - finally: - env.close() - - -@hydra.main(version_base="1.3", config_path="../../src/unilab/conf/ppo", config_name="config") -def main(cfg: DictConfig) -> None: - ensure_registries() - if str(cfg.training.sim_backend) != "mujoco": - raise ValueError("This benchmark requires task=.../mujoco.") - - # Probe once to infer default site/joint names. - probe_env = create_env( - cfg, - num_envs=1, - env_cfg_override=_env_cfg_override(cfg), - ) - try: - env_cfg = getattr(probe_env, "_cfg", None) - asset_cfg = getattr(env_cfg, "asset", None) - default_site_name = getattr(asset_cfg, "ee_site_name", None) - default_joint_names = getattr(asset_cfg, "arm_joint_names", None) - finally: - probe_env.close() - - if default_site_name is None or default_joint_names is None: - raise ValueError( - "Cannot infer ee_site_name/arm_joint_names from env cfg. " - "Please provide +bench.site_name and +bench.joint_names." - ) - - site_name = str(OmegaConf.select(cfg, "bench.site_name", default=default_site_name)) - joint_names = _coerce_str_list( - OmegaConf.select(cfg, "bench.joint_names", default=list(default_joint_names)), - name="bench.joint_names", - ) - - jac_warmup = int(OmegaConf.select(cfg, "bench.jac_warmup", default=20)) - jac_repeat = int(OmegaConf.select(cfg, "bench.jac_repeat", default=100)) - step_warmup = int(OmegaConf.select(cfg, "bench.step_warmup", default=10)) - step_repeat = int(OmegaConf.select(cfg, "bench.step_repeat", default=100)) - atol = float(OmegaConf.select(cfg, "bench.atol", default=1.0e-6)) - run_step_bench = bool(OmegaConf.select(cfg, "bench.run_step_bench", default=True)) - - env_sweep_list = _coerce_int_list( - OmegaConf.select(cfg, "bench.env_sweep", default=[4, 32, 128]), - name="bench.env_sweep", - ) - - markdown_path_raw = OmegaConf.select(cfg, "bench.markdown_path", default="") - markdown_path = str(markdown_path_raw).strip() if markdown_path_raw is not None else "" - - print("=== MuJoCo Site Jacobian Benchmark Sweep ===") - print(f"task={cfg.training.task_name} backend={cfg.training.sim_backend}") - print(f"site={site_name} joints={joint_names}") - print( - f"env_sweep={env_sweep_list} jac_warmup={jac_warmup} jac_repeat={jac_repeat} " - f"step_warmup={step_warmup} step_repeat={step_repeat} atol={atol}" - ) - - results: list[BenchResult] = [] - for nenv in env_sweep_list: - print(f"\n--- Running num_envs={nenv} ---") - result = _run_one_case( - cfg, - num_envs=nenv, - site_name=site_name, - joint_names=joint_names, - jac_warmup=jac_warmup, - jac_repeat=jac_repeat, - step_warmup=step_warmup, - step_repeat=step_repeat, - atol=atol, - run_step_bench=run_step_bench, - ) - results.append(result) - print( - "result: " - f"threads={result.backend_threads}, " - f"par_median={result.parallel_median_ms:.3f} ms, " - f"ser_median={result.serial_median_ms:.3f} ms, " - f"speedup={result.speedup:.3f}x" - ) - if result.step_median_ms is not None and result.jacobian_step_ratio_pct is not None: - print( - f" step_median={result.step_median_ms:.3f} ms, " - f"jacobian/step={result.jacobian_step_ratio_pct:.2f}%" - ) - - markdown = _format_markdown_table(results) - print("\n=== Markdown Table ===") - print(markdown) - - if markdown_path: - path = Path(markdown_path) - if not path.is_absolute(): - path = ROOT_DIR / path - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(markdown + "\n", encoding="utf-8") - print(f"\nSaved markdown table to: {path}") - - -if __name__ == "__main__": - main() diff --git a/scripts/manip_loco/calibrate_go2_arm_ee_orientation.py b/scripts/manip_loco/calibrate_go2_arm_ee_orientation.py deleted file mode 100644 index 086f65a81..000000000 --- a/scripts/manip_loco/calibrate_go2_arm_ee_orientation.py +++ /dev/null @@ -1,348 +0,0 @@ -from __future__ import annotations - -import argparse -import contextlib -import csv -import importlib.util -import io -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import mujoco -import numpy as np - -ROOT_DIR = Path(__file__).resolve().parents[1] -SIM2SIM_PATH = ROOT_DIR / "scripts/play_go2_arm_onnx_sim2sim.py" - -DEFAULT_TARGETS = np.asarray( - [ - [0.15, 0.0, 0.25], - [0.20, 0.0, 0.25], - [0.25, 0.0, 0.25], - [0.30, 0.0, 0.25], - [0.25, 0.08, 0.25], - [0.25, -0.08, 0.25], - [0.25, 0.0, 0.15], - [0.25, 0.0, 0.35], - ], - dtype=np.float64, -) - - -@dataclass -class CandidateResult: - roll: float - pitch_offset: float - mean_pos_err: float - max_pos_err: float - mean_orn_err: float - max_orn_err: float - min_margin: float - score: float - feasible: bool - - -def _load_sim2sim_module() -> Any: - spec = importlib.util.spec_from_file_location("play_go2_arm_onnx_sim2sim", SIM2SIM_PATH) - if spec is None or spec.loader is None: - raise ImportError(f"Could not load {SIM2SIM_PATH}") - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -def _parse_target(raw: list[float]) -> np.ndarray: - arr = np.asarray(raw, dtype=np.float64) - if arr.shape != (3,): - raise ValueError(f"--target must have 3 values, got {raw}") - return arr - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Sweep default_orn_roll and arm_induced_pitch for Go2Arm EE orientation." - ) - parser.add_argument( - "--onnx-path", - type=Path, - required=True, - help="Exported policy.onnx used by the sim2sim script.", - ) - parser.add_argument( - "--model-file", - type=Path, - default=ROOT_DIR / "src/unilab/assets/robots/go2_arm/scene_flat.xml", - ) - parser.add_argument( - "--target", - type=float, - nargs=3, - action="append", - help="Target positions in arm-base local frame. Repeatable.", - ) - parser.add_argument("--roll-min", type=float, default=-np.pi) - parser.add_argument("--roll-max", type=float, default=np.pi) - parser.add_argument("--roll-steps", type=int, default=9) - parser.add_argument("--pitch-min", type=float, default=0.0) - parser.add_argument("--pitch-max", type=float, default=1.2) - parser.add_argument("--pitch-steps", type=int, default=13) - parser.add_argument("--steps-per-target", type=int, default=120) - parser.add_argument("--sim-dt", type=float, default=0.01) - parser.add_argument("--ctrl-dt", type=float, default=0.02) - parser.add_argument("--limit-margin-threshold", type=float, default=0.05) - parser.add_argument("--pos-threshold", type=float, default=0.05) - parser.add_argument("--top-k", type=int, default=10) - parser.add_argument("--csv-out", type=Path, default=None) - return parser - - -def _build_sim2sim_args( - module: Any, args: argparse.Namespace, target: np.ndarray -) -> argparse.Namespace: - base_argv = [ - "--onnx-path", - str(args.onnx_path), - "--model-file", - str(args.model_file), - "--command-source", - "zero", - "--target-mode", - "fixed", - "--target-orientation-mode", - "auto", - "--initial-goal", - *(str(x) for x in target.tolist()), - "--arm-action-scale", - "0.0", - "--zero-arm-action", - "--lock-legs-at-zero-command", - "--sim-dt", - str(args.sim_dt), - "--ctrl-dt", - str(args.ctrl_dt), - "--headless-steps", - "0", - ] - return module.parse_args(base_argv) - - -def _reset_candidate( - module: Any, - ctx: dict[str, Any], - module_args: argparse.Namespace, - target_local: np.ndarray, - *, - roll: float, - pitch_offset: float, -) -> None: - model: mujoco.MjModel = ctx["model"] - data: mujoco.MjData = ctx["data"] - state = ctx["state"] - - mujoco.mj_resetDataKeyframe(model, data, ctx["home_id"]) - mujoco.mj_forward(model, data) - - state.command[:] = 0.0 - state.phase = 0.0 - state.control_step = 0 - state.last_action[:] = 0.0 - state.target_local_pos[:] = target_local - state.target_local_quat[:] = module._target_quat_from_goal( - target_local, - default_orn_roll=roll, - arm_induced_pitch=pitch_offset, - ) - - target_world = module._local_to_world(data, ctx["armbase_site_id"], state.target_local_pos) - target_world_quat = module._local_quat_to_world( - data, - ctx["armbase_site_id"], - state.target_local_quat, - ) - data.mocap_pos[ctx["mocap_id"]] = target_world - data.mocap_quat[ctx["mocap_id"]] = target_world_quat - state.last_target_world[:] = target_world - state.last_target_world_quat[:] = target_world_quat - mujoco.mj_forward(model, data) - - module_args.default_orn_roll = float(roll) - module_args.arm_induced_pitch = float(pitch_offset) - module_args.initial_goal = target_local.copy() - - -def _evaluate_candidate( - module: Any, - ctx: dict[str, Any], - module_args: argparse.Namespace, - target_local: np.ndarray, - *, - roll: float, - pitch_offset: float, - steps_per_target: int, - margin_threshold: float, - pos_threshold: float, -) -> dict[str, float | bool]: - _reset_candidate( - module, - ctx, - module_args, - target_local, - roll=roll, - pitch_offset=pitch_offset, - ) - - model: mujoco.MjModel = ctx["model"] - data: mujoco.MjData = ctx["data"] - qpos_ids = ctx["qpos_ids"] - arm_qpos_ids = qpos_ids[12:] - low = model.actuator_ctrlrange[12:, 0] - high = model.actuator_ctrlrange[12:, 1] - - pos_hist: list[float] = [] - orn_hist: list[float] = [] - margin_hist: list[float] = [] - - for _ in range(steps_per_target): - status = module._run_control_step(ctx, module_args) - pos_hist.append(float(status["ee_err"])) - orn_hist.append(float(status["orn_err"])) - arm_qpos = data.qpos[arm_qpos_ids].copy() - margin = float(np.min(np.minimum(arm_qpos - low, high - arm_qpos))) - margin_hist.append(margin) - - tail_len = max(10, steps_per_target // 4) - tail_pos = np.asarray(pos_hist[-tail_len:], dtype=np.float64) - tail_orn = np.asarray(orn_hist[-tail_len:], dtype=np.float64) - min_margin = float(np.min(margin_hist)) - mean_pos = float(np.mean(tail_pos)) - max_pos = float(np.max(tail_pos)) - mean_orn = float(np.mean(tail_orn)) - max_orn = float(np.max(tail_orn)) - - feasible = mean_pos <= pos_threshold and min_margin >= margin_threshold - score = ( - mean_pos - + 0.25 * mean_orn - + 8.0 * max(0.0, margin_threshold - min_margin) - + 4.0 * max(0.0, mean_pos - pos_threshold) - ) - return { - "mean_pos_err": mean_pos, - "max_pos_err": max_pos, - "mean_orn_err": mean_orn, - "max_orn_err": max_orn, - "min_margin": min_margin, - "score": score, - "feasible": feasible, - } - - -def main(argv: list[str] | None = None) -> int: - args = _build_parser().parse_args(argv) - targets = np.asarray(args.target, dtype=np.float64) if args.target else DEFAULT_TARGETS - if args.roll_steps <= 0 or args.pitch_steps <= 0: - raise ValueError("--roll-steps and --pitch-steps must be positive") - if args.steps_per_target <= 0: - raise ValueError("--steps-per-target must be positive") - - module = _load_sim2sim_module() - base_target = targets[0] - module_args = _build_sim2sim_args(module, args, base_target) - with contextlib.redirect_stdout(io.StringIO()): - ctx = module._build_context(module_args) - ctx["home_id"] = module._named_id(ctx["model"], mujoco.mjtObj.mjOBJ_KEY, "home") - - rolls = np.linspace(args.roll_min, args.roll_max, args.roll_steps, dtype=np.float64) - pitch_offsets = np.linspace(args.pitch_min, args.pitch_max, args.pitch_steps, dtype=np.float64) - - rows: list[CandidateResult] = [] - for roll in rolls: - for pitch_offset in pitch_offsets: - per_target = [] - for target in targets: - metrics = _evaluate_candidate( - module, - ctx, - module_args, - target, - roll=float(roll), - pitch_offset=float(pitch_offset), - steps_per_target=args.steps_per_target, - margin_threshold=args.limit_margin_threshold, - pos_threshold=args.pos_threshold, - ) - per_target.append(metrics) - - mean_pos = float(np.mean([item["mean_pos_err"] for item in per_target])) - max_pos = float(np.max([item["max_pos_err"] for item in per_target])) - mean_orn = float(np.mean([item["mean_orn_err"] for item in per_target])) - max_orn = float(np.max([item["max_orn_err"] for item in per_target])) - min_margin = float(np.min([item["min_margin"] for item in per_target])) - feasible = all(bool(item["feasible"]) for item in per_target) - score = ( - mean_pos - + 0.25 * mean_orn - + 8.0 * max(0.0, args.limit_margin_threshold - min_margin) - + 4.0 * max(0.0, mean_pos - args.pos_threshold) - ) - rows.append( - CandidateResult( - roll=float(roll), - pitch_offset=float(pitch_offset), - mean_pos_err=mean_pos, - max_pos_err=max_pos, - mean_orn_err=mean_orn, - max_orn_err=max_orn, - min_margin=min_margin, - score=score, - feasible=feasible, - ) - ) - - rows.sort(key=lambda item: (not item.feasible, item.score, item.mean_pos_err, item.min_margin)) - - print("Top candidates:") - for row in rows[: max(1, args.top_k)]: - print( - f"roll={row.roll:+.3f} pitch_offset={row.pitch_offset:+.3f} " - f"mean_pos={row.mean_pos_err:.4f} max_pos={row.max_pos_err:.4f} " - f"mean_orn={row.mean_orn_err:.4f} max_orn={row.max_orn_err:.4f} " - f"min_margin={row.min_margin:.4f} score={row.score:.4f} feasible={row.feasible}" - ) - - best = rows[0] - print( - "\nRecommended start point: " - f"default_orn_roll={best.roll:+.3f}, arm_induced_pitch={best.pitch_offset:+.3f}" - ) - - if args.csv_out is not None: - args.csv_out.parent.mkdir(parents=True, exist_ok=True) - with args.csv_out.open("w", newline="", encoding="utf-8") as f: - writer = csv.DictWriter( - f, - fieldnames=[ - "roll", - "pitch_offset", - "mean_pos_err", - "max_pos_err", - "mean_orn_err", - "max_orn_err", - "min_margin", - "score", - "feasible", - ], - ) - writer.writeheader() - for row in rows: - writer.writerow(row.__dict__) - print(f"\nSaved CSV to {args.csv_out}") - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/manip_loco/diagnose_go2_arm_ik.py b/scripts/manip_loco/diagnose_go2_arm_ik.py deleted file mode 100644 index d859fba07..000000000 --- a/scripts/manip_loco/diagnose_go2_arm_ik.py +++ /dev/null @@ -1,363 +0,0 @@ -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Any - -import numpy as np - -ROOT_DIR = Path(__file__).parent.parent -SRC_DIR = ROOT_DIR / "src" -if str(SRC_DIR) not in sys.path: - sys.path.insert(0, str(SRC_DIR)) -if str(ROOT_DIR) not in sys.path: - sys.path.insert(0, str(ROOT_DIR)) - -from unilab.base import registry -from unilab.base.registry import ensure_registries -from unilab.tasks.locomotion.go2_arm.manip_loco import RewardConfig -from unilab.utils.rotation import np_matrix_from_quat - - -def _parse_vec3(raw: list[float], *, name: str) -> list[float]: - if len(raw) != 3: - raise ValueError(f"{name} must contain exactly 3 floats, got {raw}") - return [float(v) for v in raw] - - -def _reward_cfg() -> RewardConfig: - return RewardConfig( - scales={ - "tracking_lin_vel": 1.0, - "tracking_ang_vel": 0.2, - "lin_vel_z": -5.0, - "object_distance": 2.0, - }, - tracking_sigma=0.25, - base_height_target=0.3, - object_sigma=0.1, - ) - - -def _make_env(args: argparse.Namespace): - env_cfg_override: dict[str, Any] = { - "reward_config": _reward_cfg(), - "arm_stage": { - "freeze_arm_joints": False, - "disable_ee_goal_trajectory": True, - "fixed_ee_goal_cart": args.fixed_goal, - }, - "ik": { - "damping": args.damping, - "gain": args.gain, - "dq_clip": args.dq_clip, - "use_orientation": False, - }, - } - if args.disable_gain_randomization: - env_cfg_override["domain_rand"] = { - "randomize_kp": False, - "randomize_kd": False, - } - - ensure_registries() - return registry.make( - "Go2ArmManipLoco", - sim_backend="mujoco", - num_envs=1, - env_cfg_override=env_cfg_override, - ) - - -def _state_qpos_qvel(backend: Any) -> tuple[np.ndarray, np.ndarray]: - state = np.asarray(backend.get_physics_state()[0], dtype=np.float64) - qpos = state[backend._idx_qpos : backend._idx_qpos + backend.nq].copy() - qvel = state[backend._idx_qvel : backend._idx_qvel + backend.nv].copy() - return qpos, qvel - - -def _restore_state(env: Any, qpos: np.ndarray, qvel: np.ndarray) -> None: - env._backend.set_state( - np.array([0], dtype=np.int32), - qpos[None, :], - qvel[None, :], - ) - - -def _force_go2_home(env: Any, home_qpos: np.ndarray) -> None: - """Clamp floating base and leg joints to home while preserving arm state.""" - backend = env._backend - qpos, qvel = _state_qpos_qvel(backend) - first_arm_qpos = backend._root_qpos_dim + int(env.arm_dof_pos_indices[0]) - first_arm_qvel = int(env.arm_jacobian_dof_indices[0]) - qpos[:first_arm_qpos] = home_qpos[:first_arm_qpos] - qvel[:first_arm_qvel] = 0.0 - _restore_state(env, qpos, qvel) - - -def _local_position_jacobian(env: Any) -> np.ndarray: - jacp_w, _ = env._backend.get_site_jacobian_w( - env._ee_site_id, - env.arm_jacobian_dof_indices, - ) - ref_rot_w = np_matrix_from_quat( - env._backend.get_sensor_data(env._cfg.sensor.arm_ref_world_quat) - ) - return np.matmul(np.swapaxes(ref_rot_w, 1, 2), jacp_w)[0] - - -def run_jacobian_fd_check(env: Any, eps: float) -> dict[str, Any]: - backend = env._backend - qpos0, qvel0 = _state_qpos_qvel(backend) - ee0 = env.get_ee_local_pose()[0].copy()[0] - jac = _local_position_jacobian(env) - fd = np.zeros_like(jac) - - for col, qpos_idx_rel in enumerate(env.arm_dof_pos_indices): - qpos = qpos0.copy() - qpos[backend._root_qpos_dim + qpos_idx_rel] += eps - _restore_state(env, qpos, qvel0) - ee_pos = env.get_ee_local_pose()[0].copy()[0] - fd[:, col] = (ee_pos - ee0) / eps - - _restore_state(env, qpos0, qvel0) - diff = jac - fd - return { - "jac": jac, - "fd": fd, - "max_abs_err": float(np.max(np.abs(diff))), - "col_abs_err": np.max(np.abs(diff), axis=0), - } - - -def run_direction_check(env: Any, delta: list[float]) -> dict[str, Any]: - ee_pos = env.get_ee_local_pose()[0].copy() - desired_delta = np.asarray(delta, dtype=ee_pos.dtype)[None, :] - goal = ee_pos + desired_delta - dq = env.compute_arm_ik_delta(goal, ee_pos) - predicted_delta = np.matmul(_local_position_jacobian(env), dq[0]) - target = desired_delta[0] - denom = np.linalg.norm(predicted_delta) * np.linalg.norm(target) - cosine = float(np.dot(predicted_delta, target) / max(denom, 1e-12)) - return { - "ee_pos": ee_pos[0], - "goal": goal[0], - "dq": dq[0], - "predicted_delta": predicted_delta, - "target_delta": target, - "cosine": cosine, - } - - -def run_closed_loop_check(env: Any, steps: int, *, go2_home_mode: str) -> dict[str, Any]: - zero_actions = np.zeros((1, 18), dtype=np.float32) - errors: list[float] = [] - dq_norms: list[float] = [] - arm_action_norms: list[float] = [] - rows: list[dict[str, Any]] = [] - if go2_home_mode not in {"none", "once", "reset-each-step"}: - raise ValueError(f"Unsupported go2_home_mode: {go2_home_mode}") - - home_qpos = env._backend.get_keyframe_qpos("home") if go2_home_mode != "none" else None - if go2_home_mode in {"once", "reset-each-step"}: - assert home_qpos is not None - _force_go2_home(env, home_qpos) - - for step in range(steps): - if go2_home_mode == "reset-each-step": - assert home_qpos is not None - _force_go2_home(env, home_qpos) - ee_pos = env.get_ee_local_pose()[0].copy() - goal = env.curr_ee_goal_cart.copy() - dq = env.compute_arm_ik_delta(goal, ee_pos) - arm_qpos = env.get_arm_dof_pos().copy() - ctrl = env.apply_action(zero_actions, env._state) - arm_ctrl = ctrl[:, 12:18].copy() - err = float(np.linalg.norm(goal - ee_pos, axis=1)[0]) - errors.append(err) - dq_norms.append(float(np.linalg.norm(dq[0]))) - arm_action_norms.append(float(np.linalg.norm(arm_ctrl[0] - arm_qpos[0]))) - - if step < 5 or step % 10 == 0 or step == steps - 1: - rows.append( - { - "step": step, - "err": err, - "ee": ee_pos[0].copy(), - "goal": goal[0].copy(), - "dq": dq[0].copy(), - "arm_qpos": arm_qpos[0].copy(), - "arm_ctrl": arm_ctrl[0].copy(), - } - ) - - state = env.step(zero_actions) - if go2_home_mode == "reset-each-step": - assert home_qpos is not None - _force_go2_home(env, home_qpos) - if rows and rows[-1]["step"] == step: - rows[-1]["terminated"] = bool(state.terminated[0]) - rows[-1]["truncated"] = bool(state.truncated[0]) - rows[-1]["gravity_z"] = float(env._backend.get_sensor_data("upvector")[0, 2]) - - return { - "go2_home_mode": go2_home_mode, - "errors": np.asarray(errors, dtype=np.float64), - "dq_norms": np.asarray(dq_norms, dtype=np.float64), - "arm_action_norms": np.asarray(arm_action_norms, dtype=np.float64), - "rows": rows, - } - - -def _fmt_vec(v: np.ndarray) -> str: - return np.array2string(np.asarray(v), precision=5, suppress_small=False) - - -def _print_jacobian_report(result: dict[str, Any], atol: float) -> bool: - ok = result["max_abs_err"] <= atol - print("\n[1] Jacobian finite-difference check") - print( - f" max_abs_err: {result['max_abs_err']:.6g} " - f"threshold: {atol:.6g} status: {'PASS' if ok else 'FAIL'}" - ) - print(f" col_abs_err: {_fmt_vec(result['col_abs_err'])}") - print(" analytic J:") - print(_fmt_vec(result["jac"])) - print(" finite-diff J:") - print(_fmt_vec(result["fd"])) - return ok - - -def _print_direction_report(result: dict[str, Any], min_cosine: float) -> bool: - ok = result["cosine"] >= min_cosine - print("\n[2] One-step IK direction check") - print(f" ee: {_fmt_vec(result['ee_pos'])}") - print(f" goal: {_fmt_vec(result['goal'])}") - print(f" target_delta: {_fmt_vec(result['target_delta'])}") - print(f" dq: {_fmt_vec(result['dq'])}") - print(f" J @ dq: {_fmt_vec(result['predicted_delta'])}") - print( - f" cosine: {result['cosine']:.6f} " - f"threshold: {min_cosine:.6f} status: {'PASS' if ok else 'FAIL'}" - ) - return ok - - -def _print_closed_loop_report(result: dict[str, Any]) -> None: - errors = result["errors"] - mode = result["go2_home_mode"] - print(f"\n[3] Fixed-goal zero-action closed-loop check ({mode})") - print( - " error summary: " - f"initial={errors[0]:.6f}, min={np.min(errors):.6f}, " - f"final={errors[-1]:.6f}, argmin={int(np.argmin(errors))}" - ) - print( - " norm summary: " - f"dq_mean={np.mean(result['dq_norms']):.6f}, " - f"ctrl_minus_qpos_mean={np.mean(result['arm_action_norms']):.6f}" - ) - print(" sampled rows:") - for row in result["rows"]: - print( - f" step={row['step']:>4} err={row['err']:.6f} " - f"ee={_fmt_vec(row['ee'])} goal={_fmt_vec(row['goal'])} " - f"done={row.get('terminated', False) or row.get('truncated', False)} " - f"gravity_z={row.get('gravity_z', float('nan')):.3f}" - ) - print(f" dq={_fmt_vec(row['dq'])}") - print(f" arm_qpos={_fmt_vec(row['arm_qpos'])}") - print(f" arm_ctrl={_fmt_vec(row['arm_ctrl'])}") - - if errors[-1] > errors[0]: - print( - " note: final error is larger than initial error. " - "This points to closed-loop tuning, force limits, or action/IK interaction rather than Jacobian sign alone." - ) - if mode == "reset-each-step": - print( - " note: reset-each-step calls backend.set_state()/BatchEnvPool.reset inside the " - "control loop; use go2-home-mode=once to test IK without that reset disturbance." - ) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Diagnose Go2ArmManipLoco end-effector IK without a trained policy." - ) - parser.add_argument("--fixed-goal", type=float, nargs="+", default=[0.30, 0.0, 0.25]) - parser.add_argument("--direction-delta", type=float, nargs="+", default=[0.02, 0.0, 0.0]) - parser.add_argument("--steps", type=int, default=80) - parser.add_argument("--damping", type=float, default=0.05) - parser.add_argument("--gain", type=float, default=1.0) - parser.add_argument("--dq-clip", type=float, default=0.2) - parser.add_argument("--fd-eps", type=float, default=1e-5) - parser.add_argument("--jacobian-atol", type=float, default=2e-3) - parser.add_argument("--min-direction-cosine", type=float, default=0.9) - parser.add_argument("--closed-loop-only", action="store_true") - parser.add_argument( - "--go2-home-mode", - choices=["none", "once", "reset-each-step"], - default="once", - help=( - "How to put Go2 at the home pose for closed-loop IK. " - "'once' is the clean IK diagnostic; 'reset-each-step' is a reset-disturbance check." - ), - ) - parser.add_argument( - "--force-go2-home", - action="store_true", - help=( - "Backward-compatible alias for --go2-home-mode reset-each-step. " - "This intentionally exercises the reset disturbance path." - ), - ) - parser.add_argument( - "--disable-gain-randomization", - action="store_true", - help="Disable reset-time Kp/Kd randomization to isolate IK/controller behavior.", - ) - args = parser.parse_args() - - args.fixed_goal = _parse_vec3(args.fixed_goal, name="--fixed-goal") - args.direction_delta = _parse_vec3(args.direction_delta, name="--direction-delta") - if args.steps <= 0: - raise ValueError("--steps must be positive") - if args.fd_eps <= 0.0: - raise ValueError("--fd-eps must be positive") - if args.force_go2_home: - args.go2_home_mode = "reset-each-step" - - np.set_printoptions(precision=5, suppress=False, linewidth=180) - env = _make_env(args) - env.init_state() - - print("Go2ArmManipLoco IK diagnostic") - print(f" fixed_goal: {args.fixed_goal}") - print(f" ik: damping={args.damping}, gain={args.gain}, dq_clip={args.dq_clip}") - print(f" go2_home_mode: {args.go2_home_mode}") - print(f" disable_gain_randomization: {args.disable_gain_randomization}") - print(f" arm_dof_pos_indices: {env.arm_dof_pos_indices.tolist()}") - print(f" arm_jacobian_dof_indices: {env.arm_jacobian_dof_indices.tolist()}") - print(f" default_arm_qpos: {_fmt_vec(env.default_angles[12:18])}") - - checks_ok = True - if not args.closed_loop_only: - checks_ok &= _print_jacobian_report( - run_jacobian_fd_check(env, args.fd_eps), - args.jacobian_atol, - ) - checks_ok &= _print_direction_report( - run_direction_check(env, args.direction_delta), - args.min_direction_cosine, - ) - - _print_closed_loop_report( - run_closed_loop_check(env, args.steps, go2_home_mode=args.go2_home_mode) - ) - return 0 if checks_ok else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/manip_loco/play_go2_arm_ik_only.py b/scripts/manip_loco/play_go2_arm_ik_only.py deleted file mode 100644 index c1090ec84..000000000 --- a/scripts/manip_loco/play_go2_arm_ik_only.py +++ /dev/null @@ -1,503 +0,0 @@ -from __future__ import annotations - -import argparse -import os -import sys -import tempfile -import time -from pathlib import Path -from typing import Any - -import mujoco -import numpy as np - -ROOT_DIR = Path(__file__).parent.parent -SRC_DIR = ROOT_DIR / "src" -if str(SRC_DIR) not in sys.path: - sys.path.insert(0, str(SRC_DIR)) -if str(ROOT_DIR) not in sys.path: - sys.path.insert(0, str(ROOT_DIR)) - -from unilab.tasks.locomotion.go2_arm.base import build_go2_arm_position_gains -from unilab.tasks.locomotion.go2_arm.manip_loco import Go2ArmManipLocoCfg - -TARGET_BODY = "ik_mocap_target" - - -def _parse_vec3(raw: list[float], *, name: str) -> np.ndarray: - if len(raw) != 3: - raise ValueError(f"{name} must contain exactly 3 floats, got {raw}") - return np.asarray(raw, dtype=np.float64) - - -def _write_scene_with_mocap(source: Path) -> Path: - raw = source.read_text() - marker = " " - if marker not in raw: - raise ValueError(f"Could not find scene worldbody closing tag in {source}") - mocap_xml = f""" - - - - -""" - xml = raw.replace(marker, mocap_xml + marker, 1) - fd, tmp_name = tempfile.mkstemp( - prefix=".tmp_go2_arm_ik_only_", - suffix=".xml", - dir=str(source.parent), - ) - os.close(fd) - tmp_path = Path(tmp_name) - tmp_path.write_text(xml) - return tmp_path - - -def _load_model(args: argparse.Namespace) -> tuple[mujoco.MjModel, Go2ArmManipLocoCfg]: - cfg = Go2ArmManipLocoCfg() - source = Path(cfg.model_file) - tmp_xml = _write_scene_with_mocap(source) - try: - model = mujoco.MjModel.from_xml_path(str(tmp_xml)) - finally: - tmp_xml.unlink(missing_ok=True) - - model.opt.timestep = float(args.sim_dt) - gains = build_go2_arm_position_gains(cfg.control_config) - kp = np.asarray(gains["kp"], dtype=np.float64) - kd = np.asarray(gains["kd"], dtype=np.float64) - if kp.shape != (model.nu,) or kd.shape != (model.nu,): - raise ValueError(f"Expected gain shape ({model.nu},), got kp={kp.shape}, kd={kd.shape}") - model.actuator_gainprm[:, 0] = kp - model.actuator_biasprm[:, 1] = -kp - model.actuator_biasprm[:, 2] = -kd - return model, cfg - - -def _named_id(model: mujoco.MjModel, obj_type: mujoco.mjtObj, name: str) -> int: - obj_id = mujoco.mj_name2id(model, obj_type, name) - if obj_id < 0: - raise ValueError(f"{name!r} not found in MuJoCo model") - return int(obj_id) - - -def _home_state(model: mujoco.MjModel) -> tuple[np.ndarray, np.ndarray]: - key_id = _named_id(model, mujoco.mjtObj.mjOBJ_KEY, "home") - return model.key_qpos[key_id].copy(), model.key_ctrl[key_id].copy() - - -def _joint_qpos_indices(model: mujoco.MjModel, names: tuple[str, ...]) -> np.ndarray: - indices = [] - for name in names: - jid = _named_id(model, mujoco.mjtObj.mjOBJ_JOINT, name) - indices.append(int(model.jnt_qposadr[jid])) - return np.asarray(indices, dtype=np.int32) - - -def _joint_qvel_indices(model: mujoco.MjModel, names: tuple[str, ...]) -> np.ndarray: - indices = [] - for name in names: - jid = _named_id(model, mujoco.mjtObj.mjOBJ_JOINT, name) - indices.append(int(model.jnt_dofadr[jid])) - return np.asarray(indices, dtype=np.int32) - - -def _arm_actuator_ids(model: mujoco.MjModel, joint_names: tuple[str, ...]) -> np.ndarray: - ids = np.arange(model.nu - len(joint_names), model.nu, dtype=np.int32) - joint_ids = [_named_id(model, mujoco.mjtObj.mjOBJ_JOINT, name) for name in joint_names] - mapped = [int(model.actuator_trnid[aid, 0]) for aid in ids] - if mapped != joint_ids: - print( - "warning: last actuator joint mapping does not match arm joint names; " - f"using last {len(joint_names)} actuators anyway. mapped={mapped}, expected={joint_ids}" - ) - return ids - - -def _reset_home(model: mujoco.MjModel, data: mujoco.MjData, home_qpos: np.ndarray) -> None: - data.qpos[:] = home_qpos - data.qvel[:] = 0.0 - mujoco.mj_forward(model, data) - - -def _hold_go2_default( - data: mujoco.MjData, - *, - home_qpos: np.ndarray, - home_ctrl: np.ndarray, -) -> None: - data.qpos[:19] = home_qpos[:19] - data.qvel[:18] = 0.0 - data.ctrl[:12] = home_ctrl[:12] - - -def _site_pos_rot(data: mujoco.MjData, site_id: int) -> tuple[np.ndarray, np.ndarray]: - return data.site_xpos[site_id].copy(), data.site_xmat[site_id].reshape(3, 3).copy() - - -def _local_to_world(data: mujoco.MjData, site_id: int, local: np.ndarray) -> np.ndarray: - pos, rot = _site_pos_rot(data, site_id) - return pos + rot @ np.asarray(local, dtype=np.float64) - - -def _world_to_local(data: mujoco.MjData, site_id: int, world: np.ndarray) -> np.ndarray: - pos, rot = _site_pos_rot(data, site_id) - return rot.T @ (np.asarray(world, dtype=np.float64) - pos) - - -def _fullphysics_state(model: mujoco.MjModel, data: mujoco.MjData) -> np.ndarray: - state = np.zeros( - (mujoco.mj_stateSize(model, mujoco.mjtState.mjSTATE_FULLPHYSICS),), - dtype=np.float64, - ) - mujoco.mj_getState(model, data, state, int(mujoco.mjtState.mjSTATE_FULLPHYSICS)) - return state - - -def _direct_site_jacobian( - model: mujoco.MjModel, - data: mujoco.MjData, - *, - ee_site_id: int, - arm_qvel_ids: np.ndarray, -) -> np.ndarray: - jacp = np.zeros((3, model.nv), dtype=np.float64) - jacr = np.zeros((3, model.nv), dtype=np.float64) - mujoco.mj_jacSite(model, data, jacp, jacr, ee_site_id) - return jacp[:, arm_qvel_ids] - - -def _batch_site_jacobian( - model: mujoco.MjModel, - data: mujoco.MjData, - *, - batch_pool: Any, - ee_site_id: int, - arm_qvel_ids: np.ndarray, -) -> np.ndarray: - jp, _ = batch_pool.compute_site_jacobians( - _fullphysics_state(model, data)[None, :], - int(ee_site_id), - jacp=True, - jacr=True, - ) - if jp.ndim == 4: - jp = jp[:, 0] - jac = jp[0] - if jac.shape == (3, model.nv): - return jac[:, arm_qvel_ids] - if jac.shape == (model.nv, 3): - return jac[arm_qvel_ids, :].T - raise ValueError(f"Unexpected batch Jacobian shape: {jac.shape}") - - -def _site_jacobian_world( - model: mujoco.MjModel, - data: mujoco.MjData, - *, - jacobian_source: str, - batch_pool: Any, - ee_site_id: int, - arm_qvel_ids: np.ndarray, -) -> np.ndarray: - if jacobian_source == "direct": - return _direct_site_jacobian( - model, - data, - ee_site_id=ee_site_id, - arm_qvel_ids=arm_qvel_ids, - ) - if jacobian_source == "batch": - return _batch_site_jacobian( - model, - data, - batch_pool=batch_pool, - ee_site_id=ee_site_id, - arm_qvel_ids=arm_qvel_ids, - ) - raise ValueError(f"Unsupported jacobian source: {jacobian_source}") - - -def _compute_ik_delta( - model: mujoco.MjModel, - data: mujoco.MjData, - *, - batch_pool: Any, - jacobian_source: str, - compare_jacobians: bool, - ee_site_id: int, - ref_site_id: int, - arm_qvel_ids: np.ndarray, - goal_local_pos: np.ndarray, - curr_local_pos: np.ndarray, - damping: float, - dq_clip: float, -) -> tuple[np.ndarray, np.ndarray, float | None]: - jacp_world = _site_jacobian_world( - model, - data, - jacobian_source=jacobian_source, - batch_pool=batch_pool, - ee_site_id=ee_site_id, - arm_qvel_ids=arm_qvel_ids, - ) - compare_abs_max = None - if compare_jacobians: - jacp_direct = _direct_site_jacobian( - model, - data, - ee_site_id=ee_site_id, - arm_qvel_ids=arm_qvel_ids, - ) - jacp_batch = _batch_site_jacobian( - model, - data, - batch_pool=batch_pool, - ee_site_id=ee_site_id, - arm_qvel_ids=arm_qvel_ids, - ) - compare_abs_max = float(np.max(np.abs(jacp_direct - jacp_batch))) - - _, ref_rot = _site_pos_rot(data, ref_site_id) - jac_local = ref_rot.T @ jacp_world - pos_err = goal_local_pos - curr_local_pos - lhs = jac_local @ jac_local.T + np.eye(3, dtype=np.float64) * (damping**2) - dq = jac_local.T @ np.linalg.solve(lhs, pos_err) - if dq_clip > 0.0: - dq = np.clip(dq, -dq_clip, dq_clip) - return dq, jac_local, compare_abs_max - - -def _step_controller(ctx: dict[str, Any]) -> dict[str, Any]: - model: mujoco.MjModel = ctx["model"] - data: mujoco.MjData = ctx["data"] - goal_local = _world_to_local( - data, - ctx["armbase_site_id"], - data.mocap_pos[ctx["target_mocap_id"]], - ) - ee_local = data.sensor("endpoint_pos").data.copy() - dq, jac_local, jacobian_compare_abs_max = _compute_ik_delta( - model, - data, - batch_pool=ctx["batch_pool"], - jacobian_source=ctx["args"].jacobian_source, - compare_jacobians=ctx["args"].compare_jacobians, - ee_site_id=ctx["ee_site_id"], - ref_site_id=ctx["armbase_site_id"], - arm_qvel_ids=ctx["arm_qvel_ids"], - goal_local_pos=goal_local, - curr_local_pos=ee_local, - damping=ctx["args"].damping, - dq_clip=ctx["args"].dq_clip, - ) - arm_qpos = data.qpos[ctx["arm_qpos_ids"]].copy() - arm_target = arm_qpos + ctx["args"].gain * dq - arm_ctrl_ids = ctx["arm_actuator_ids"] - ctrl_low = model.actuator_ctrlrange[arm_ctrl_ids, 0] - ctrl_high = model.actuator_ctrlrange[arm_ctrl_ids, 1] - data.ctrl[arm_ctrl_ids] = np.clip(arm_target, ctrl_low, ctrl_high) - return { - "goal_local": goal_local, - "ee_local": ee_local, - "dq": dq, - "jac_local": jac_local, - "jacobian_compare_abs_max": jacobian_compare_abs_max, - "arm_qpos": arm_qpos, - "arm_ctrl": data.ctrl[arm_ctrl_ids].copy(), - "err": float(np.linalg.norm(goal_local - ee_local)), - } - - -def _init_target( - model: mujoco.MjModel, - data: mujoco.MjData, - *, - armbase_site_id: int, - initial_goal_local: np.ndarray, -) -> int: - target_body_id = _named_id(model, mujoco.mjtObj.mjOBJ_BODY, TARGET_BODY) - mocap_id = int(model.body_mocapid[target_body_id]) - if mocap_id < 0: - raise ValueError(f"{TARGET_BODY!r} is not a mocap body") - data.mocap_pos[mocap_id] = _local_to_world(data, armbase_site_id, initial_goal_local) - data.mocap_quat[mocap_id] = np.array([1.0, 0.0, 0.0, 0.0]) - mujoco.mj_forward(model, data) - return mocap_id - - -def _print_status(step: int, status: dict[str, Any]) -> None: - compare = status.get("jacobian_compare_abs_max") - compare_text = "" if compare is None else f" jac_direct_batch_maxdiff={compare:.3e}" - print( - f"step={step:>6} err={status['err']:.5f} " - f"goal_local={np.array2string(status['goal_local'], precision=4)} " - f"ee_local={np.array2string(status['ee_local'], precision=4)} " - f"dq={np.array2string(status['dq'], precision=4)}" - f"{compare_text}" - ) - - -def _draw_debug_geoms(viewer: Any, data: mujoco.MjData, ee_site_id: int) -> None: - viewer.user_scn.ngeom = 1 - mujoco.mjv_initGeom( - viewer.user_scn.geoms[0], - type=mujoco.mjtGeom.mjGEOM_SPHERE, - size=[0.018, 0.0, 0.0], - pos=data.site_xpos[ee_site_id].copy(), - mat=np.eye(3).reshape(-1), - rgba=np.array([0.1, 0.35, 1.0, 0.85]), - ) - - -def _build_context(args: argparse.Namespace) -> dict[str, Any]: - model, cfg = _load_model(args) - data = mujoco.MjData(model) - home_qpos, home_ctrl = _home_state(model) - _reset_home(model, data, home_qpos) - - armbase_site_id = _named_id(model, mujoco.mjtObj.mjOBJ_SITE, "armbasepoint") - ee_site_id = _named_id(model, mujoco.mjtObj.mjOBJ_SITE, cfg.asset.ee_site_name) - target_mocap_id = _init_target( - model, - data, - armbase_site_id=armbase_site_id, - initial_goal_local=args.initial_goal, - ) - joint_names = tuple(cfg.asset.arm_joint_names) - ctx = { - "args": args, - "model": model, - "data": data, - "home_qpos": home_qpos, - "home_ctrl": home_ctrl, - "armbase_site_id": armbase_site_id, - "ee_site_id": ee_site_id, - "target_mocap_id": target_mocap_id, - "arm_qpos_ids": _joint_qpos_indices(model, joint_names), - "arm_qvel_ids": _joint_qvel_indices(model, joint_names), - "arm_actuator_ids": _arm_actuator_ids(model, joint_names), - "batch_pool": None, - } - if args.jacobian_source == "batch" or args.compare_jacobians: - from mujoco_uni.batch_env import BatchEnvPool - - ctx["batch_pool"] = BatchEnvPool(model, nbatch=1, nthread=1) - return ctx - - -def _close_context(ctx: dict[str, Any]) -> None: - batch_pool = ctx.get("batch_pool") - if batch_pool is not None: - batch_pool.close() - - -def _run_one_sim_step(ctx: dict[str, Any], step: int) -> dict[str, Any]: - model: mujoco.MjModel = ctx["model"] - data: mujoco.MjData = ctx["data"] - _hold_go2_default(data, home_qpos=ctx["home_qpos"], home_ctrl=ctx["home_ctrl"]) - mujoco.mj_forward(model, data) - if step % ctx["control_every"] == 0: - ctx["last_status"] = _step_controller(ctx) - mujoco.mj_step(model, data) - _hold_go2_default(data, home_qpos=ctx["home_qpos"], home_ctrl=ctx["home_ctrl"]) - return ctx["last_status"] - - -def run_headless(ctx: dict[str, Any], steps: int) -> None: - ctx["control_every"] = max(1, int(round(ctx["args"].ctrl_dt / ctx["args"].sim_dt))) - ctx["last_status"] = _step_controller(ctx) - for step in range(steps): - status = _run_one_sim_step(ctx, step) - if step < 5 or step % ctx["args"].print_every == 0 or step == steps - 1: - _print_status(step, status) - - -def run_viewer(ctx: dict[str, Any]) -> None: - import mujoco.viewer - - args = ctx["args"] - model: mujoco.MjModel = ctx["model"] - data: mujoco.MjData = ctx["data"] - ctx["control_every"] = max(1, int(round(args.ctrl_dt / args.sim_dt))) - ctx["last_status"] = _step_controller(ctx) - - print("MuJoCo viewer controls:") - print(" Drag the yellow mocap sphere to move the IK target.") - print(" Blue sphere marks the current endpoint site.") - print(" Go2 base and legs are forced back to the home keyframe each step.") - - step = 0 - with mujoco.viewer.launch_passive(model, data) as viewer: - while viewer.is_running(): - start = time.perf_counter() - status = _run_one_sim_step(ctx, step) - _draw_debug_geoms(viewer, data, ctx["ee_site_id"]) - if step % args.print_every == 0: - _print_status(step, status) - viewer.sync() - step += 1 - sleep_s = args.sim_dt - (time.perf_counter() - start) - if sleep_s > 0.0: - time.sleep(sleep_s) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Interactively test Go2 arm Jacobian IK with UniLab XML and no policy." - ) - parser.add_argument("--initial-goal", type=float, nargs="+", default=[0.30, 0.0, 0.25]) - parser.add_argument("--sim-dt", type=float, default=0.004) - parser.add_argument("--ctrl-dt", type=float, default=0.02) - parser.add_argument("--damping", type=float, default=0.05) - parser.add_argument("--gain", type=float, default=1.0) - parser.add_argument("--dq-clip", type=float, default=0.2) - parser.add_argument("--print-every", type=int, default=100) - parser.add_argument( - "--jacobian-source", - choices=["batch", "direct"], - default="batch", - help="Use UniLab training path BatchEnvPool.compute_site_jacobians or direct mj_jacSite.", - ) - parser.add_argument( - "--compare-jacobians", - action="store_true", - help="Print max abs difference between direct mj_jacSite and batch Jacobian.", - ) - parser.add_argument( - "--headless-steps", - type=int, - default=0, - help="Run without launching the viewer for this many simulation steps.", - ) - args = parser.parse_args() - args.initial_goal = _parse_vec3(args.initial_goal, name="--initial-goal") - if args.sim_dt <= 0.0 or args.ctrl_dt <= 0.0: - raise ValueError("--sim-dt and --ctrl-dt must be positive") - if args.print_every <= 0: - raise ValueError("--print-every must be positive") - - np.set_printoptions(precision=5, suppress=False, linewidth=160) - ctx = _build_context(args) - print("Go2 arm IK-only play") - print(f" initial_goal local(armbasepoint): {np.array2string(args.initial_goal, precision=4)}") - print(f" ik: damping={args.damping}, gain={args.gain}, dq_clip={args.dq_clip}") - print(f" sim_dt={args.sim_dt}, ctrl_dt={args.ctrl_dt}") - print(f" jacobian_source={args.jacobian_source}, compare_jacobians={args.compare_jacobians}") - print(f" arm_qpos_ids={ctx['arm_qpos_ids'].tolist()}") - print(f" arm_qvel_ids={ctx['arm_qvel_ids'].tolist()}") - print(f" arm_actuator_ids={ctx['arm_actuator_ids'].tolist()}") - - try: - if args.headless_steps > 0: - run_headless(ctx, args.headless_steps) - else: - run_viewer(ctx) - finally: - _close_context(ctx) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/train_him_ppo.py b/scripts/train_him_ppo.py deleted file mode 100644 index 5bc836d34..000000000 --- a/scripts/train_him_ppo.py +++ /dev/null @@ -1,273 +0,0 @@ -import datetime -import statistics -import sys -import time -from pathlib import Path -from typing import Any, cast - -import hydra -import torch -from omegaconf import DictConfig - -EXPORT_POLICY = False # set to True in __main__ block - -ROOT_DIR = Path(__file__).parent.parent -SRC_DIR = ROOT_DIR / "src" -if str(SRC_DIR) not in sys.path: - sys.path.insert(0, str(SRC_DIR)) -if str(ROOT_DIR) not in sys.path: - sys.path.insert(0, str(ROOT_DIR)) - -from uni_rl.algos.him_ppo.runner import HIMOnPolicyRunner -from uni_rl.algos.rsl_rl import RslRlVecEnvWrapper, get_policy_obs_dims -from unisim.backend.mujoco.xml import materialize_scene_visual_override - -from unilab.base.config_adapter import ( - BackendAdapter, - create_env, -) -from unilab.training import ( - algo_config_dict, - apply_env_nan_guard, - build_run_dir_name, - ensure_registries, - format_play_checkpoint_error, - get_log_root, - parse_checkpoint_path, -) -from unilab.training.experiment import ExperimentTracker -from unilab.utils.checkpoint import get_entrypoint_log_root -from unilab.visualization import render_play_mode -from unilab.visualization.interactive_playback import ( - RslRlPlaybackConfig, - create_rsl_rl_playback_session, - infer_checkpoint_actor_input_dim, - make_sim2sim_preflight, - normalize_checkpoint_value, -) - - -def _backend_adapter(cfg: DictConfig) -> BackendAdapter: - return BackendAdapter( - cfg, - root_dir=ROOT_DIR, - algo_name="ppo_him", - scene_materializer=materialize_scene_visual_override, - ) - - -def _get_log_root(cfg: DictConfig) -> str: - return str(get_log_root(ROOT_DIR, cfg)) - - -def play_him_ppo(cfg: DictConfig, device: str) -> str | None: - """Play mode for HIM-PPO.""" - rl_cfg = algo_config_dict(cfg) - - task_log_root = get_log_root(ROOT_DIR, cfg) / str(cfg.training.task_name) - load_path, load_path_dir = parse_checkpoint_path(cfg, root_dir=ROOT_DIR) - if load_path is None or load_path_dir is None or not load_path.exists(): - print( - format_play_checkpoint_error( - cfg, - task_log_root=task_log_root, - load_path=load_path, - load_path_dir=load_path_dir, - ) - ) - return None - - print(f"Loading latest model: {load_path}") - _ckpt_keys = set(torch.load(load_path, map_location="cpu", weights_only=True).keys()) - if "actor_state_dict" not in _ckpt_keys: - print( - f"Checkpoint at {load_path} is not a HIM-PPO checkpoint " - f"(found keys: {_ckpt_keys}). Aborting play." - ) - return None - - def _create_env(num_envs: int): - env_cfg_override = cast(dict[str, Any], _backend_adapter(cfg).build_play_env_cfg_override()) - return create_env(cfg, num_envs=num_envs, env_cfg_override=env_cfg_override) - - session, _policy_obs_mode, _checkpoint_path = create_rsl_rl_playback_session( - playback_cfg=RslRlPlaybackConfig( - task=str(cfg.training.task_name), - load_run=str(getattr(cfg.algo, "load_run", "-1")), - checkpoint=normalize_checkpoint_value(getattr(cfg.algo, "checkpoint", None)), - action_mode="policy", - policy_obs_mode="flat", - algo_log_name=str(cfg.algo.algo_log_name), - log_root=getattr(cfg.training, "log_root", None), - num_envs=int(cfg.training.play_env_num), - ), - env_factory=_create_env, - algo_config=rl_cfg, - root_dir=ROOT_DIR, - device=device, - # The checkpoint was already resolved above for the friendly early exit. - checkpoint_resolver=lambda *_args: str(load_path), - checkpoint_input_dim_reader=infer_checkpoint_actor_input_dim, - entrypoint_log_root=get_entrypoint_log_root, - wrapper_cls=RslRlVecEnvWrapper, - runner_cls=HIMOnPolicyRunner, - # HIMOnPolicyRunner.load does not accept a load_cfg argument. - runner_loader=lambda runner, path: runner.load(path), - policy_obs_dims_getter=get_policy_obs_dims, - train_cfg_normalizer=lambda train_cfg: train_cfg, - sim2sim_preflight=make_sim2sim_preflight(cfg, algo_name="ppo"), - guard_algo_name="him_ppo", - ) - env = session.env - assert session.runner is not None and session.policy is not None - - # HIM's inference policy consumes the flat actor tensor, not the full obs - # TensorDict the session hands to ``policy``. - him_policy = session.policy - session.policy = lambda obs: him_policy(obs["actor"]) - - if EXPORT_POLICY: - session.runner.export_policy_to_onnx(path=str(load_path_dir)) - session.runner.export_policy_to_jit(path=str(load_path_dir)) - - output_video = Path(load_path_dir) / "play_video.mp4" - print(f"Rendering video to {output_video}...") - print("Collecting physics states...") - with torch.inference_mode(): - render_play_mode( - env, - sim_backend=cfg.training.sim_backend, - render_spacing=float( - getattr(cfg.training, "render_spacing", getattr(env.cfg, "render_spacing", 1.0)) - ), - num_steps=cfg.training.play_steps, - output_video=output_video, - initialize=lambda: session.reset()["actor"], - step=lambda _obs: session.step_once()["actor"], - camera_kwargs={ - "cam_distance": cfg.training.cam_distance, - "cam_elevation": cfg.training.cam_elevation, - "cam_azimuth": cfg.training.cam_azimuth, - "cam_lookat": getattr(cfg.training, "cam_lookat", None), - "cam_tracking": getattr(cfg.training, "cam_tracking", False), - "cam_tracking_env_idx": getattr(cfg.training, "cam_tracking_env_idx", 0), - "cam_tracking_extra_envs": getattr(cfg.training, "cam_tracking_extra_envs", 2), - }, - extra_data_getter=( - (lambda: getattr(env, "curr_ee_goal_world", None)) - if hasattr(env, "curr_ee_goal_world") - else None - ), - ) - print("Done.") - return str(output_video) - - -@hydra.main(version_base="1.3", config_path="../src/unilab/conf/ppo_him", config_name="config") -def main(cfg: DictConfig) -> None: - ensure_registries() - - env_cfg_override = cast(dict[str, Any], _backend_adapter(cfg).build_task_env_cfg_override()) - - if torch.cuda.is_available(): - device = "cuda" - elif torch.backends.mps.is_available(): - device = "mps" - else: - device = "cpu" - print(f"Using device: {device}") - - # Compute effective max_iterations - max_iterations = cfg.algo.max_iterations - if cfg.training.num_timesteps: - n_steps_per_iter = cfg.algo.num_steps_per_env * cfg.algo.num_envs - max_iterations = max(1, int(cfg.training.num_timesteps / n_steps_per_iter)) - print( - f"Overriding max_iterations to {max_iterations} based on " - f"num_timesteps {cfg.training.num_timesteps}" - ) - - if not cfg.training.play_only: - timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - log_root = _get_log_root(cfg) - log_dir = str( - Path(log_root) - / cfg.training.task_name - / build_run_dir_name(timestamp, str(cfg.training.sim_backend)) - ) - else: - log_dir = None - - tracker = None - if not cfg.training.play_only and log_dir is not None: - tracker = ExperimentTracker( - root_dir=ROOT_DIR, - log_dir=log_dir, - algo_name="ppo_him", - task_name=cfg.training.task_name, - sim_backend=cfg.training.sim_backend, - training_cfg=cfg.training, - full_cfg=cfg, - device=device, - ) - tracker.start() - - try: - if not cfg.training.play_only: - env = create_env(cfg, num_envs=cfg.algo.num_envs, env_cfg_override=env_cfg_override) - - apply_env_nan_guard(env, cfg.training) - - wrapped_env = RslRlVecEnvWrapper(env, device=device) - rl_cfg = algo_config_dict(cfg) - runner = HIMOnPolicyRunner(wrapped_env, rl_cfg, log_dir=log_dir, device=device) - - if cfg.algo.load_run != "-1": - resume_path, _ = parse_checkpoint_path(cfg, root_dir=ROOT_DIR) - if resume_path: - print(f"Resuming from {resume_path}") - runner.load(str(resume_path)) - - train_start_wall = time.time() - runner.learn(num_learning_iterations=max_iterations, init_at_random_ep_len=True) - assert log_dir is not None - train_summary = { - "status": "completed", - "completed_iterations": int(runner.current_learning_iteration), - "total_env_steps": int(runner.logger.tot_timesteps), - "final_mean_reward": ( - float(statistics.mean(runner.logger.rewbuffer)) - if len(runner.logger.rewbuffer) > 0 - else None - ), - "best_mean_reward": ( - float(max(runner.logger.rewbuffer)) - if len(runner.logger.rewbuffer) > 0 - else None - ), - "mean_episode_length": ( - float(statistics.mean(runner.logger.lenbuffer)) - if len(runner.logger.lenbuffer) > 0 - else None - ), - "last_checkpoint": str( - Path(log_dir) / f"model_{int(runner.current_learning_iteration)}.pt" - ), - "training_wall_time_sec": time.time() - train_start_wall, - } - if tracker is not None: - tracker.update_summary(train_summary) - env.close() - - if cfg.training.play_only or not cfg.training.no_play: - play_video_path = play_him_ppo(cfg, device) - if tracker is not None: - tracker.log_video(play_video_path) - finally: - if tracker is not None: - tracker.finish() - - -if __name__ == "__main__": - EXPORT_POLICY = True - main() diff --git a/src/unilab/assets/hub.py b/src/unilab/assets/hub.py index 48fd2a3c0..5cd89264b 100644 --- a/src/unilab/assets/hub.py +++ b/src/unilab/assets/hub.py @@ -46,11 +46,6 @@ ), "go1": (("robots/go1/assets", "trunk.stl", "*.stl", "STL"),), "go2": (("robots/go2/assets", "base_0.obj", "**/*", "asset"),), - # go2_arm reuses the Go2 base meshes via ``../go2/assets`` references. - "go2_arm": ( - ("robots/go2_arm/assets", "arm_base_0.obj", "**/*", "asset"), - ("robots/go2/assets", "base_0.obj", "**/*", "asset"), - ), # go2w points its meshdir at ``../go2/assets``. "go2w": (("robots/go2/assets", "base_0.obj", "**/*", "asset"),), "sharpa_wave": (("robots/sharpa_wave/meshes", "DP_HB1_4F.STL", "*.STL", "STL"),), diff --git a/src/unilab/assets/robots/go2_arm/go2_with_arm_mjx_full_collision.xml b/src/unilab/assets/robots/go2_arm/go2_with_arm_mjx_full_collision.xml deleted file mode 100644 index afb476ae1..000000000 --- a/src/unilab/assets/robots/go2_arm/go2_with_arm_mjx_full_collision.xml +++ /dev/null @@ -1,498 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/unilab/assets/robots/go2_arm/scene_flat.xml b/src/unilab/assets/robots/go2_arm/scene_flat.xml deleted file mode 100644 index 9ee8aa756..000000000 --- a/src/unilab/assets/robots/go2_arm/scene_flat.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/unilab/conf/ppo/task/go2_arm_manip_loco/motrix.yaml b/src/unilab/conf/ppo/task/go2_arm_manip_loco/motrix.yaml deleted file mode 100644 index 1b7047bbd..000000000 --- a/src/unilab/conf/ppo/task/go2_arm_manip_loco/motrix.yaml +++ /dev/null @@ -1,117 +0,0 @@ -# @package _global_ -training: - task_name: Go2ArmManipLoco - sim_backend: motrix -algo: - num_envs: 4096 - max_iterations: 3000 - empirical_normalization: true - obs_groups: - actor: - - actor - policy: - init_noise_std: 0.5 - algorithm: - learning_rate: 3.0e-4 - entropy_coef: 1.0e-3 - num_mini_batches: 4 -play_profile: - enabled: true - scene: - enabled: true - source_model_file: src/unilab/assets/robots/go2_arm/scene_flat.xml - ground_texture_file: src/unilab/assets/robots/g1/textures/floor.png - skybox_rgb1: [0.90, 0.90, 0.91] - skybox_rgb2: [0.68, 0.68, 0.70] - ground_texrepeat: [0.25, 0.25] -reward: - scales: - # locomotion: tracking - tracking_lin_vel: 2.0 #1.0 - tracking_ang_vel: 0.5 #0.2 - # locomotion: velocity / orientation penalties - lin_vel_z: -5.0 - ang_vel_xy: -0.1 - roll: -5.0 #-2.0 - # locomotion: height / pose - base_height: -100 #-20.0 # Go2+arm body oscillates more with arm mass; -100 conflicts with gait. - similar_to_default: -0.0 # Align with Go2 Joystick (L1). - leg_pose: -0.1 # Kept but unused; replaced by similar_to_default. - dof_pos_limits: 0.0 # Kept but unused; soft limits are not configured. - # locomotion: effort penalties - action_rate: -0.005 - torques: 0.0 # Kept but unused; backend does not expose torques. - energy: 0.0 # Kept but unused. - dof_vel: 0.0 # Kept but unused. - dof_acc: 0.0 # Kept but unused. - # locomotion: gait, aligned with Go2 Joystick - stand_still: -0.5 - contact: 0.24 - swing_feet_z: 4.0 - foot_drag: -0.1 - # manipulation rewards - object_distance: 2.0 - object_distance_l2: -0.5 - # arm collision penalty - arm_collision: -1.0 - tracking_sigma: 0.25 - base_height_target: 0.3 - object_sigma: 0.1 -env: - goal_ee: - sphere_l_range: [0.3, 0.6] - sphere_phi_range: [-1.2566, 1.0472] - sphere_theta_range: [-2.3562, 2.3562] - traj_time_range: [1.0, 3.0] - hold_time_range: [0.5, 2.0] - collision_upper_limits: [0.3, 0.15, -0.115] - collision_lower_limits: [-0.2, -0.15, -0.515] - underground_limit: -0.57 - num_collision_check_samples: 10 - num_resample_attempts: 10 - default_orn_roll: 0.0 - arm_induced_pitch: 0.78 - delta_orn_r: [-0.5, 0.5] - delta_orn_p: [-0.5, 0.5] - delta_orn_y: [-0.5, 0.5] - init_ee_cart: [0.25, 0.0, 0.25] - control_config: - arm_action_scale: 0.0 - ik: - damping: 0.05 - gain: 1.0 - dq_clip: 0.2 - use_orientation: true - orientation_mode: target # target: track sampled orientation; zero_error: constrain orientation change through Jrot without tracking orientation. - commands: - vel_limit: - - [-0.6, -0.4, -0.8] # Align with the default Go2 Joystick range. - - [1.0, 0.4, 0.8] - zero_command_prob: 0.15 # Explicitly sample command=0 for stable standing. - resample_time_s: 4.0 # Omit for null, which disables mid-episode resampling and matches Joystick. - curriculum: - enable: false # Align with Joystick; no curriculum. - domain_rand: - randomize_base_mass: false - added_mass_range: [-1.0, 1.0] - randomize_body_mass: true - body_mass_multiplier_range: [0.9, 1.1] - random_com: true - com_offset_x: [-0.03, 0.03] - randomize_gravity: false - randomize_ground_friction: true - ground_friction_multiplier_range: [0.8, 1.2] - randomize_dof_armature: false - dof_armature_multiplier_range: [0.8, 1.2] - push_robots: true - push_interval: 500 - max_force: [1.2, 1.2, 0.6] - push_body_name: base - randomize_kp: false - kp_multiplier_range: [0.9, 1.1] - randomize_kd: false - kd_multiplier_range: [0.9, 1.1] - arm_stage: - freeze_arm_joints: false - disable_ee_goal_trajectory: false - fixed_ee_goal_cart: [0.15, 0.0, 0.25] diff --git a/src/unilab/conf/ppo/task/go2_arm_manip_loco/mujoco.yaml b/src/unilab/conf/ppo/task/go2_arm_manip_loco/mujoco.yaml deleted file mode 100644 index 162e0a256..000000000 --- a/src/unilab/conf/ppo/task/go2_arm_manip_loco/mujoco.yaml +++ /dev/null @@ -1,112 +0,0 @@ -# @package _global_ -training: - task_name: Go2ArmManipLoco - sim_backend: mujoco -algo: - num_envs: 4096 - max_iterations: 151 - empirical_normalization: true - obs_groups: - actor: - - actor - policy: - init_noise_std: 0.5 - algorithm: - learning_rate: 3.0e-4 - entropy_coef: 1.0e-3 - num_mini_batches: 4 -reward: - scales: - # locomotion: tracking - tracking_lin_vel: 2.0 #1.0 - tracking_ang_vel: 0.5 #0.2 - # locomotion: velocity / orientation penalties - lin_vel_z: -5.0 - ang_vel_xy: -0.1 - roll: -5.0 #-2.0 - # locomotion: height / pose - base_height: -100 #-20.0 # Go2+arm body oscillates more with arm mass; -100 conflicts with gait. - similar_to_default: -0.0 # Align with Go2 Joystick (L1). - leg_pose: -0.1 # Kept but unused; replaced by similar_to_default. - dof_pos_limits: 0.0 # Kept but unused; soft limits are not configured. - # locomotion: effort penalties - action_rate: -0.005 - torques: 0.0 # Kept but unused; backend does not expose torques. - energy: 0.0 # Kept but unused. - dof_vel: 0.0 # Kept but unused. - dof_acc: 0.0 # Kept but unused. - # locomotion: gait, aligned with Go2 Joystick - stand_still: -0.5 - contact: 0.24 - swing_feet_z: 4.0 - foot_drag: -0.1 - # manipulation rewards - object_distance: 2.0 - object_distance_l2: -0.5 - # arm collision penalty - arm_collision: -1.0 - tracking_sigma: 0.25 - base_height_target: 0.3 - object_sigma: 0.1 -env: - goal_ee: - sphere_l_range: [0.3, 0.6] - sphere_phi_range: [-1.2566, 1.0472] - sphere_theta_range: [-2.3562, 2.3562] - traj_time_range: [1.0, 3.0] - hold_time_range: [0.5, 2.0] - collision_upper_limits: [0.3, 0.15, -0.115] - collision_lower_limits: [-0.2, -0.15, -0.515] - underground_limit: -0.57 - num_collision_check_samples: 10 - num_resample_attempts: 10 - default_orn_roll: 0.0 - arm_induced_pitch: 0.78 - delta_orn_r: [-0.5, 0.5] - delta_orn_p: [-0.5, 0.5] - delta_orn_y: [-0.5, 0.5] - init_ee_cart: [0.25, 0.0, 0.25] - control_config: - arm_action_scale: 0.0 - ik: - damping: 0.05 - gain: 1.0 - dq_clip: 0.2 - use_orientation: true - orientation_mode: target # target: track sampled orientation; zero_error: constrain orientation change through Jrot without tracking orientation. - commands: - vel_limit: - - [-0.6, -0.4, -0.8] # Align with the default Go2 Joystick range. - - [1.0, 0.4, 0.8] - zero_command_prob: 0.15 # Explicitly sample command=0 for stable standing. - resample_time_s: 4.0 # Omit for null, which disables mid-episode resampling and matches Joystick. - curriculum: - enable: false # Align with Joystick; no curriculum. - domain_rand: - randomize_base_mass: false - added_mass_range: [-1.0, 1.0] - randomize_body_mass: true - body_mass_multiplier_range: [0.9, 1.1] - random_com: true - com_offset_x: [-0.03, 0.03] - randomize_gravity: false - randomize_ground_friction: true - ground_friction_multiplier_range: [0.8, 1.2] - randomize_dof_armature: true - dof_armature_multiplier_range: [0.8, 1.2] - push_robots: true - push_interval: 500 - max_force: [1.2, 1.2, 0.6] - push_body_name: base - randomize_kp: true - kp_multiplier_range: [0.9, 1.1] - randomize_kd: true - kd_multiplier_range: [0.9, 1.1] - arm_stage: - freeze_arm_joints: false - disable_ee_goal_trajectory: false - fixed_ee_goal_cart: [0.15, 0.0, 0.25] -play_profile: - enabled: true - env: - render_spacing: 2.0 diff --git a/src/unilab/conf/ppo_him/config.yaml b/src/unilab/conf/ppo_him/config.yaml deleted file mode 100644 index c2ca97f95..000000000 --- a/src/unilab/conf/ppo_him/config.yaml +++ /dev/null @@ -1,92 +0,0 @@ -defaults: - - _self_ - - task: go2_arm_manip_loco/mujoco - -algo: - algo_log_name: ppo_him - seed: 1 - num_envs: 128 - num_steps_per_env: 24 - max_iterations: 3000 - save_interval: 300 - empirical_normalization: false - load_run: "-1" - checkpoint: -1 - # ── HIM-PPO specific ──────────────────────────────────────────────── - num_one_step_obs: ??? # REQUIRED: single-step obs dim (e.g. 75) - num_actor_history: 5 # actor sees H * num_one_step_obs history - num_critic_history: 1 # critic sees 1-step obs (privileged) - policy: - init_noise_std: 1.0 - actor_hidden_dims: [512, 256, 128] - critic_hidden_dims: [512, 256, 128] - activation: elu - estimator: - enc_hidden_dims: [128, 64, 16] - tar_hidden_dims: [128, 64] - learning_rate: 1.0e-3 - num_prototype: 32 - temperature: 3.0 - velocity_target_start: 0 # index of linvel (3D) in critic obs - target_obs_start: 3 # start index of one-step obs in critic obs - algorithm: - value_loss_coef: 1.0 - use_clipped_value_loss: true - clip_param: 0.2 - entropy_coef: 0.01 - num_learning_epochs: 5 - num_mini_batches: 4 - learning_rate: 1.0e-3 - schedule: adaptive - desired_kl: 0.01 - gamma: 0.99 - lam: 0.95 - max_grad_norm: 1.0 - -training: - task_name: ??? - device: null - logger: tensorboard - wandb_project: unilab - wandb_entity: null - wandb_group: null - wandb_job_type: null - wandb_name: null - wandb_tags: [] - wandb_notes: null - wandb_mode: null - sim_backend: mujoco - play_only: false - no_play: false - sim2sim_strict: true - play_env_num: 4 - render_spacing: 1.0 - play_steps: 200 - cam_distance: 6.0 - cam_elevation: -20.0 - cam_azimuth: 90.0 - cam_lookat: null - cam_tracking: false - cam_tracking_env_idx: 0 - cam_tracking_extra_envs: 2 - log_root: null - num_timesteps: null - log_dir: null - nan_guard: - enabled: true - buffer_size: 100 - max_envs_to_dump: 5 - output_dir: null - -env: - post_step_forward_sensor: false - -hydra: - run: - dir: . - output_subdir: null - job: - chdir: false - job_logging: - root: - handlers: [console] diff --git a/src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml b/src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml deleted file mode 100644 index c3022068b..000000000 --- a/src/unilab/conf/ppo_him/task/go2_arm_manip_loco/mujoco.yaml +++ /dev/null @@ -1,99 +0,0 @@ -# @package _global_ -training: - task_name: Go2ArmManipLoco - sim_backend: mujoco -algo: - num_envs: 128 #1024 - max_iterations: 3000 - num_one_step_obs: 76 # actor single-step obs dim (linvel excluded) - num_actor_history: 5 # actor sees 5 steps → 380-dim obs - num_critic_history: 1 # critic sees 1-step privileged obs → 79-dim - empirical_normalization: true - policy: - init_noise_std: 0.5 - algorithm: - learning_rate: 3.0e-4 - entropy_coef: 1.0e-3 - estimator: - velocity_target_start: 0 # linvel at critic_obs[0:3] - target_obs_start: 3 # 76-dim obs without linvel starts at critic_obs[3] -reward: - scales: - # locomotion: tracking - tracking_lin_vel: 1.0 - tracking_ang_vel: 0.2 - # locomotion: velocity / orientation penalties - lin_vel_z: -5.0 - ang_vel_xy: -0.1 - roll: -2.0 - # locomotion: height / pose - base_height: -100.0 - leg_pose: -0.1 - dof_pos_limits: -10.0 - # locomotion: effort penalties - action_rate: -0.005 - torques: -0.0002 - energy: -0.0002 - dof_vel: -0.0001 - dof_acc: -2.5e-7 - # locomotion: gait - stand_still: -0.1 - contact: 0.24 - swing_feet_z: 4.0 - # manipulation rewards - object_distance: 2.0 - object_distance_l2: -0.5 - # arm collision penalty - arm_collision: -1.0 - tracking_sigma: 0.25 - base_height_target: 0.3 - object_sigma: 0.1 -env: - goal_ee: - sphere_l_range: [0.3, 0.6] - sphere_phi_range: [-1.2566, 1.0472] - sphere_theta_range: [-2.3562, 2.3562] - traj_time_range: [1.0, 3.0] - hold_time_range: [0.5, 2.0] - collision_upper_limits: [0.3, 0.15, -0.115] - collision_lower_limits: [-0.2, -0.15, -0.515] - underground_limit: -0.57 - num_collision_check_samples: 10 - num_resample_attempts: 10 - init_ee_cart: [0.30, 0.0, 0.25] - ik: - damping: 0.05 - gain: 1.0 - dq_clip: 0.2 - use_orientation: false - commands: - vel_limit: - - [-0.3, -0.2, -0.4] # Smaller initial range for curriculum learning. - - [0.5, 0.2, 0.4] - resample_time_s: 5.0 - curriculum: - enable: true - threshold: 0.8 - step_size: [0.1, 0.05, 0.1] - max_vel_limit: [1.0, 0.4, 0.8] - history: - num_actor_history: 5 - num_critic_history: 1 - domain_rand: - randomize_base_mass: true - added_mass_range: [-1.0, 1.0] - random_com: true - com_offset_x: [-0.03, 0.03] - randomize_gravity: false - push_robots: true - push_interval: 500 - max_force: [1.2, 1.2, 0.6] - push_body_name: base - randomize_kp: true - kp_multiplier_range: [0.9, 1.1] - randomize_kd: true - kd_multiplier_range: [0.9, 1.1] - arm_stage: - freeze_arm_joints: true - disable_ee_goal_trajectory: true - fixed_ee_goal_cart: [0.30, 0.0, 0.25] diff --git a/src/unilab/demo.py b/src/unilab/demo.py index a083187f6..608126398 100644 --- a/src/unilab/demo.py +++ b/src/unilab/demo.py @@ -28,9 +28,6 @@ class DemoSpec: "dance": DemoSpec(algo="ppo", task="g1_motion_tracking", sim="motrix", entry="eval"), "wallflip": DemoSpec(algo="ppo", task="g1_wall_flip_tracking", sim="motrix", entry="eval"), "boxtracking": DemoSpec(algo="ppo", task="g1_box_tracking", sim="motrix", entry="eval"), - "locomani": DemoSpec( - algo="ppo", task="go2_arm_manip_loco", sim="mujoco", entry="play_interactive" - ), "inhandgrasp": DemoSpec( algo="hora_distill", task="sharpa_inhand", @@ -48,7 +45,6 @@ class DemoSpec: _LOCAL_ONLY_CHECKPOINT_DEMOS = {"sharpa_appo_student"} _DEMO_PLAY_INTERACTIVE_OVERRIDES: dict[str, tuple[str, ...]] = { - "locomani": ("interactive.camera_follow_body=false",), "inhandgrasp": ("interactive.camera_follow_body=false",), } diff --git a/src/unilab/dr/dr_utils.py b/src/unilab/dr/dr_utils.py index 58da49503..504c60e09 100644 --- a/src/unilab/dr/dr_utils.py +++ b/src/unilab/dr/dr_utils.py @@ -14,10 +14,6 @@ from unilab.dtype_config import get_global_dtype -def zero_actions(num_reset: int, num_action: int) -> np.ndarray: - return np.zeros((num_reset, num_action), dtype=get_global_dtype()) - - def _coerce_range(name: str, values: Any) -> tuple[float, float]: bounds = np.asarray(values, dtype=np.float64) if bounds.shape != (2,): @@ -191,27 +187,3 @@ def validate_common_reset_randomization( if payload is None: return frozenset() return cast(frozenset[str], capabilities.get_unsupported_reset_terms(payload.requested_terms())) - - -def build_interval_push_plan(env: Any, step_counter: int) -> IntervalRandomizationPlan | None: - domain_rand = getattr(env.cfg, "domain_rand", None) - if domain_rand is None or not getattr(domain_rand, "push_robots", False): - return None - if step_counter % domain_rand.push_interval != 0: - return None - return IntervalRandomizationPlan( - ops=(IntervalTermOp(INTERVAL_TERM_PUSH, np.asarray(domain_rand.max_force)),) - ) - - -def validate_interval_push_support(env: Any, capabilities: DomainRandomizationCapabilities) -> None: - domain_rand = getattr(env.cfg, "domain_rand", None) - if domain_rand is None or not getattr(domain_rand, "push_robots", False): - return - if not capabilities.supports_interval_term(INTERVAL_TERM_PUSH): - raise NotImplementedError( - f"{env._backend.backend_type} backend does not support interval push" - ) - force_limit = np.asarray(domain_rand.max_force, dtype=np.float64) - if force_limit.shape != (3,): - raise ValueError(f"domain_rand.max_force must have shape (3,), got {force_limit.shape}") diff --git a/src/unilab/scripts/train_rsl_rl.py b/src/unilab/scripts/train_rsl_rl.py index 77e1aba39..490089966 100644 --- a/src/unilab/scripts/train_rsl_rl.py +++ b/src/unilab/scripts/train_rsl_rl.py @@ -68,6 +68,9 @@ normalize_checkpoint_value, ) +# Also defined for consumers that invoke main with a composed configuration. +EXPORT_POLICY = False + try: from rsl_rl.runners import OnPolicyRunner except ImportError: diff --git a/src/unilab/tasks/__init__.py b/src/unilab/tasks/__init__.py index d9c58aa9f..6d59d3958 100644 --- a/src/unilab/tasks/__init__.py +++ b/src/unilab/tasks/__init__.py @@ -10,7 +10,6 @@ "unilab.tasks.locomotion.go2", "unilab.tasks.locomotion.go2w", "unilab.tasks.locomotion.g1", - "unilab.tasks.locomotion.go2_arm", "unilab.tasks.locomotion.a2", "unilab.tasks.manipulation.allegro_inhand", "unilab.tasks.manipulation.sharpa_inhand", diff --git a/src/unilab/tasks/locomotion/common/__init__.py b/src/unilab/tasks/locomotion/common/__init__.py index 67b9c7141..a6fc9e3a6 100644 --- a/src/unilab/tasks/locomotion/common/__init__.py +++ b/src/unilab/tasks/locomotion/common/__init__.py @@ -1,33 +1,13 @@ """Shared task-specific locomotion components.""" -from .base import ( - BaseNoiseConfig, - ControlConfigBase, - LocomotionBaseCfg, - LocomotionBaseEnv, - Sensor, -) -from .commands import Commands -from .domain_rand import DomainRandConfig -from .dr_provider import LocomotionDRProvider from .height_scan import ( DEFAULT_SCAN_POINTS_X, DEFAULT_SCAN_POINTS_Y, HeightScanConfig, ) -from .rewards import RewardContext __all__ = [ - "BaseNoiseConfig", - "Commands", - "ControlConfigBase", "DEFAULT_SCAN_POINTS_X", "DEFAULT_SCAN_POINTS_Y", - "DomainRandConfig", "HeightScanConfig", - "LocomotionDRProvider", - "LocomotionBaseCfg", - "LocomotionBaseEnv", - "RewardContext", - "Sensor", ] diff --git a/src/unilab/tasks/locomotion/common/base.py b/src/unilab/tasks/locomotion/common/base.py deleted file mode 100644 index f82ad17a6..000000000 --- a/src/unilab/tasks/locomotion/common/base.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Shared base environment and configuration for legacy locomotion tasks.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import ClassVar - -import gymnasium as gym -import numpy as np -from unisim import SimBackend - -from unilab.base.base import EnvCfg -from unilab.base.np_env import NpEnv, NpEnvState -from unilab.dtype_config import get_global_dtype -from unilab.tasks.locomotion.common.terrain_spawn import BaseSpawnManager - - -@dataclass -class Sensor: - local_linvel = "local_linvel" - gyro = "gyro" - - -@dataclass -class ControlConfigBase: - action_scale: float = 0.25 - simulate_action_latency: bool = False - - -@dataclass -class BaseNoiseConfig: - level: float = 0.0 - scale_joint_angle: float = 0.03 - scale_joint_vel: float = 0.5 - scale_gyro: float = 0.2 - scale_gravity: float = 0.05 - scale_linvel: float = 0.1 - seed: int | None = None - - -@dataclass -class LocomotionBaseCfg(EnvCfg): - control_config: ControlConfigBase = field(default_factory=ControlConfigBase) - noise_config: BaseNoiseConfig = field(default_factory=BaseNoiseConfig) - sensor: Sensor = field(default_factory=Sensor) - sim_dt: float = 0.01 - ctrl_dt: float = 0.02 - - -class LocomotionBaseEnv(NpEnv): - """Common base environment for locomotion tasks (G1, Go1, Go2, etc.).""" - - _cfg: LocomotionBaseCfg - - _keyframe_name: ClassVar[str] = "home" - _use_global_dtype: ClassVar[bool] = True - - def __init__(self, cfg: LocomotionBaseCfg, backend: SimBackend, num_envs: int = 1): - super().__init__(cfg, backend, num_envs) - self._init_action_space() - self._num_action = self._action_space.shape[0] - self._obs_noise_rng: np.random.Generator | None = None - self._obs_noise_seed_override: int | None = None - self._init_buffers() - self._spawn: BaseSpawnManager = BaseSpawnManager() - - def _init_action_space(self) -> None: - ctrl_range = self._backend.get_actuator_ctrl_range() - nu = self._backend.num_actuators - self._action_space = gym.spaces.Box(ctrl_range[:, 0], ctrl_range[:, 1], (nu,), dtype=float) # type: ignore[assignment, arg-type] - - @property - def action_space(self) -> gym.spaces.Box: - return self._action_space # type: ignore[no-any-return] - - def _init_buffers(self) -> None: - dtype = get_global_dtype() if self._use_global_dtype else np.float32 - raw_qpos = self._backend.get_keyframe_qpos(self._keyframe_name) - self._init_qpos = ( - np.asarray(raw_qpos, dtype=dtype) if self._use_global_dtype else np.asarray(raw_qpos) - ) - self.default_angles = np.asarray(self._init_qpos[-self._num_action :], dtype=dtype) - raw_qvel = self._backend.get_init_qvel() - self._init_qvel = ( - np.asarray(raw_qvel, dtype=dtype) if self._use_global_dtype else np.asarray(raw_qvel) - ) - - def apply_action(self, actions: np.ndarray, state: NpEnvState) -> np.ndarray: - state.info["last_actions"] = state.info.get("current_actions", np.zeros_like(actions)) - state.info["current_actions"] = actions - exec_actions = ( - state.info["last_actions"] - if self._cfg.control_config.simulate_action_latency - else actions - ) - ctrl: np.ndarray = ( - exec_actions * self._cfg.control_config.action_scale + self.default_angles - ) - return ctrl - - def seed_observation_noise(self, seed: int | None) -> None: - """Reset env-owned observation-noise RNG streams.""" - if seed is not None and int(seed) < 0: - raise ValueError(f"observation noise seed must be non-negative, got {seed}") - self._obs_noise_seed_override = int(seed) if seed is not None else None - self._obs_noise_rng = None - - def _configured_obs_noise_seed(self) -> int | None: - seed = getattr(self, "_obs_noise_seed_override", None) - if seed is None: - seed = getattr(self._cfg.noise_config, "seed", None) - if seed is None: - return None - seed = int(seed) - if seed < 0: - raise ValueError(f"observation noise seed must be non-negative, got {seed}") - return seed - - def _obs_noise(self, data: np.ndarray, scale: float) -> np.ndarray: - """Apply per-step uniform observation noise scaled by ``noise_config.level``.""" - level = float(self._cfg.noise_config.level) - if level <= 0.0: - return data - seed = self._configured_obs_noise_seed() - if seed is None: - noise = np.random.uniform(-1.0, 1.0, data.shape).astype(data.dtype) - else: - rng = getattr(self, "_obs_noise_rng", None) - if rng is None: - rng = np.random.default_rng(seed) - self._obs_noise_rng = rng - noise = rng.uniform(-1.0, 1.0, data.shape).astype(data.dtype) - return data + noise * level * scale - - def get_local_linvel(self) -> np.ndarray: - local_linvel: np.ndarray = self._backend.get_sensor_data(self._cfg.sensor.local_linvel) - return local_linvel - - def get_gyro(self) -> np.ndarray: - gyro: np.ndarray = self._backend.get_sensor_data(self._cfg.sensor.gyro) - return gyro - - def get_dof_pos(self) -> np.ndarray: - dof_pos: np.ndarray = self._backend.get_dof_pos() - return dof_pos - - def get_dof_vel(self) -> np.ndarray: - dof_vel: np.ndarray = self._backend.get_dof_vel() - return dof_vel diff --git a/src/unilab/tasks/locomotion/common/commands.py b/src/unilab/tasks/locomotion/common/commands.py deleted file mode 100644 index 92a63ff41..000000000 --- a/src/unilab/tasks/locomotion/common/commands.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Shared command helpers for locomotion tasks.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - - -@dataclass -class Commands: - vel_limit: list[list[float]] = field( - default_factory=lambda: [ - [-0.6, -0.4, -0.8], # [vx_min, vy_min, vyaw_min] - [1.0, 0.4, 0.8], # [vx_max, vy_max, vyaw_max] - ] - ) - resampling_time: float = 0.0 - heading_command: bool = False - heading_range: list[float] = field(default_factory=lambda: [-3.14, 3.14]) - heading_control_stiffness: float = 0.5 - rel_standing_envs: float = 0.0 diff --git a/src/unilab/tasks/locomotion/common/domain_rand.py b/src/unilab/tasks/locomotion/common/domain_rand.py deleted file mode 100644 index 968d9a894..000000000 --- a/src/unilab/tasks/locomotion/common/domain_rand.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Shared domain-randomization config for locomotion tasks.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - - -@dataclass -class DomainRandConfig: - randomize_base_mass: bool = False - added_mass_range: list[float] = field(default_factory=lambda: [-1.5, 1.5]) - - randomize_body_mass: bool = False - body_mass_multiplier_range: list[float] = field(default_factory=lambda: [0.9, 1.1]) - - random_com: bool = False - com_offset_x: list[float] = field(default_factory=lambda: [-0.05, 0.05]) - - randomize_gravity: bool = False - gravity_range: list[list[float]] = field( - default_factory=lambda: [[0.0, 0.0, -9.81], [0.0, 0.0, -9.81]] - ) - - randomize_ground_friction: bool = False - ground_friction_multiplier_range: list[float] = field(default_factory=lambda: [0.8, 1.2]) - - randomize_dof_armature: bool = False - dof_armature_multiplier_range: list[float] = field(default_factory=lambda: [0.8, 1.2]) - - push_robots: bool = False - push_interval: int = 750 # step - max_force: list[float] = field(default_factory=lambda: [1.0, 1.0, 0.5]) - push_body_name: str | None = None diff --git a/src/unilab/tasks/locomotion/common/dr_provider.py b/src/unilab/tasks/locomotion/common/dr_provider.py deleted file mode 100644 index 182274e4d..000000000 --- a/src/unilab/tasks/locomotion/common/dr_provider.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Shared DomainRandomizationProvider for locomotion tasks. - -Implements the common reset/interval randomization logic shared by -G1, Go1, and Go2 joystick environments. Subclasses override hooks -to provide robot-specific behaviour. -""" - -from __future__ import annotations - -import time -from typing import Any, cast - -import numpy as np - -from unilab.dr import ( - DomainRandomizationCapabilities, - DomainRandomizationProvider, - IntervalRandomizationPlan, - ResetPlan, -) -from unilab.dr.dr_utils import ( - build_common_reset_randomization, - build_interval_push_plan, - validate_common_reset_randomization, - validate_interval_push_support, - zero_actions, -) -from unilab.dtype_config import get_global_dtype -from unilab.utils.rotation import np_quat_mul, np_yaw_to_quat - - -class LocomotionDRProvider(DomainRandomizationProvider): - """Base DR provider for locomotion joystick environments. - - Shared logic: - - ``validate``, ``build_interval_randomization_plan``, ``_sample_commands`` - - ``build_reset_plan`` (template with hooks) - - ``build_reset_observation`` (template with hook) - - Override these hooks in subclasses: - - ``_get_qvel_limit`` — default ``0.5`` - - ``_build_extra_info_updates`` — default empty dict - - ``_compute_reset_obs`` — must be implemented per robot - """ - - # ── shared methods ─────────────────────────────────────────────── - - @property - def last_reset_observation_timing_ms(self) -> dict[str, float]: - return dict(getattr(self, "_last_reset_observation_timing_ms", {})) - - def _get_base_actuator_gains(self, env: Any) -> tuple[np.ndarray | None, np.ndarray | None]: - """Return (base_kp, base_kd) for per-joint kp/kd domain randomization. - - Override to provide per-joint gains cached at init time. - Returns ``(None, None)`` by default, which falls back to scalar - ``ControlConfig.Kp`` / ``ControlConfig.Kd``. - """ - return None, None - - def _get_reset_randomization_baselines( - self, env: Any - ) -> tuple[np.ndarray | None, np.ndarray | None, int | None, np.ndarray | None]: - """Return cached model tables used for reset-time randomization.""" - return None, None, None, None - - def validate(self, env: Any, capabilities: DomainRandomizationCapabilities) -> None: - base_kp, base_kd = self._get_base_actuator_gains(env) - base_body_mass, base_geom_friction, ground_geom_id, base_dof_armature = ( - self._get_reset_randomization_baselines(env) - ) - validate_common_reset_randomization( - env, - capabilities, - base_kp=base_kp, - base_kd=base_kd, - base_body_mass=base_body_mass, - base_geom_friction=base_geom_friction, - ground_geom_id=ground_geom_id, - base_dof_armature=base_dof_armature, - ) - validate_interval_push_support(env, capabilities) - - def build_interval_randomization_plan( - self, env: Any, step_counter: int - ) -> IntervalRandomizationPlan | None: - return build_interval_push_plan(env, step_counter) - - def _sample_commands(self, env: Any, num_reset: int) -> np.ndarray: - low = np.asarray(env.cfg.commands.vel_limit[0], dtype=get_global_dtype()) - high = np.asarray(env.cfg.commands.vel_limit[1], dtype=get_global_dtype()) - return np.asarray( - np.random.uniform(low=low, high=high, size=(num_reset, 3)), dtype=get_global_dtype() - ) - - # ── template: build_reset_plan ─────────────────────────────────── - - def _get_qvel_limit(self, env: Any) -> float: - """Return the base qvel reset limit. Override for configurable limits.""" - return 0.5 - - def _build_extra_info_updates(self, env: Any, num_reset: int) -> dict[str, np.ndarray]: - """Return additional info_updates entries (e.g. gait_phase for G1).""" - return {} - - def build_reset_plan(self, env: Any, env_ids: np.ndarray) -> ResetPlan: - num_reset = len(env_ids) - qpos = np.tile(env._init_qpos, (num_reset, 1)) - qvel = np.tile(env._init_qvel, (num_reset, 1)) - qpos[:, 0:2] += np.random.uniform(-0.5, 0.5, (num_reset, 2)) - yaw = np.random.uniform(-np.pi, np.pi, (num_reset,)) - qpos[:, 3:7] = np_quat_mul(qpos[:, 3:7], np_yaw_to_quat(yaw)) - qpos[:, 0:3] = env._spawn.apply_spawn(env_ids, qpos[:, 0:3], yaw=yaw) - limit = self._get_qvel_limit(env) - qvel[:, 0:6] = np.asarray( - np.random.uniform(-limit, limit, size=(num_reset, 6)), dtype=get_global_dtype() - ) - info_updates: dict[str, Any] = { - "commands": self._sample_commands(env, num_reset), - "current_actions": zero_actions(num_reset, env._num_action), - "last_actions": zero_actions(num_reset, env._num_action), - } - info_updates.update(self._build_extra_info_updates(env, num_reset)) - base_kp, base_kd = self._get_base_actuator_gains(env) - base_body_mass, base_geom_friction, ground_geom_id, base_dof_armature = ( - self._get_reset_randomization_baselines(env) - ) - env._spawn.record_episode_start(env_ids, qpos[:, 0:3]) - return ResetPlan( - env_ids=env_ids, - qpos=qpos, - qvel=qvel, - info_updates=info_updates, - randomization=build_common_reset_randomization( - env, - num_reset, - base_kp=base_kp, - base_kd=base_kd, - base_body_mass=base_body_mass, - base_geom_friction=base_geom_friction, - ground_geom_id=ground_geom_id, - base_dof_armature=base_dof_armature, - ), - ) - - # ── template: build_reset_observation ──────────────────────────── - - def _compute_reset_obs( - self, - env: Any, - env_ids: np.ndarray, - info_updates: dict[str, Any], - linvel: np.ndarray, - gyro: np.ndarray, - gravity: np.ndarray, - dof_pos: np.ndarray, - dof_vel: np.ndarray, - ) -> dict[str, np.ndarray]: - """Compute reset observations. Override per robot to pass correct args to _compute_obs.""" - raise NotImplementedError - - def build_reset_observation( - self, env: Any, env_ids: np.ndarray, info_updates: dict[str, Any] - ) -> dict[str, np.ndarray]: - obs_t0 = time.perf_counter() - - t0 = time.perf_counter() - linvel = env.get_local_linvel()[env_ids] - linvel_ms = (time.perf_counter() - t0) * 1000.0 - - t0 = time.perf_counter() - gyro = env.get_gyro()[env_ids] - gyro_ms = (time.perf_counter() - t0) * 1000.0 - - t0 = time.perf_counter() - gravity_sensor = getattr(getattr(env.cfg, "sensor", None), "upvector", "upvector") - gravity = env._backend.get_sensor_data(gravity_sensor)[env_ids] - gravity_ms = (time.perf_counter() - t0) * 1000.0 - - t0 = time.perf_counter() - dof_pos = env.get_dof_pos()[env_ids] - dof_pos_ms = (time.perf_counter() - t0) * 1000.0 - - t0 = time.perf_counter() - dof_vel = env.get_dof_vel()[env_ids] - dof_vel_ms = (time.perf_counter() - t0) * 1000.0 - - t0 = time.perf_counter() - obs = cast( - dict[str, np.ndarray], - self._compute_reset_obs( - env, env_ids, info_updates, linvel, gyro, gravity, dof_pos, dof_vel - ), - ) - compute_obs_ms = (time.perf_counter() - t0) * 1000.0 - - total_ms = (time.perf_counter() - obs_t0) * 1000.0 - getters_ms = linvel_ms + gyro_ms + gravity_ms + dof_pos_ms + dof_vel_ms - self._last_reset_observation_timing_ms = { - "dr_reset_observation_getters_ms": getters_ms, - "dr_reset_obs_get_local_linvel_ms": linvel_ms, - "dr_reset_obs_get_gyro_ms": gyro_ms, - "dr_reset_obs_get_gravity_ms": gravity_ms, - "dr_reset_obs_get_dof_pos_ms": dof_pos_ms, - "dr_reset_obs_get_dof_vel_ms": dof_vel_ms, - "dr_reset_observation_compute_obs_ms": compute_obs_ms, - "dr_reset_observation_internal_gap_ms": total_ms - getters_ms - compute_obs_ms, - } - return obs diff --git a/src/unilab/tasks/locomotion/common/rewards.py b/src/unilab/tasks/locomotion/common/rewards.py deleted file mode 100644 index bd127ed66..000000000 --- a/src/unilab/tasks/locomotion/common/rewards.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Shared reward functions for locomotion tasks. - -Introduces ``RewardContext`` — a dataclass that bundles all state any -reward function might need. Shared reward functions are plain -module-level callables ``fn(ctx) -> np.ndarray`` so that each -joystick environment can reference them **directly** in its -``_reward_fns`` dispatch table without per-class wrapper methods. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -import numpy as np - -from unilab.dtype_config import get_global_dtype - - -@dataclass -class RewardContext: - """Immutable snapshot of everything reward functions may read. - - Built once per ``_compute_reward`` call. Shared functions access - only the fields they need; robot-specific methods that still live - on the environment class receive the same context via ``self``. - """ - - # ── always populated ──────────────────────────────────────────── - info: dict - linvel: np.ndarray # (N, 3) - gyro: np.ndarray # (N, 3) - dof_pos: np.ndarray # (N, num_action) - num_envs: int = 0 - default_angles: np.ndarray = field(default_factory=lambda: np.empty(0)) - tracking_sigma: float = 0.25 - base_height_target: float = 0.0 - base_height: np.ndarray = field(default_factory=lambda: np.empty(0)) # pre-fetched - - # ── G1-only (None for quadrupeds) ─────────────────────────────── - gravity: np.ndarray | None = None - dof_vel: np.ndarray | None = None - - # ── optional weights (G1 pose rewards) ────────────────────────── - pose_weights: np.ndarray | None = None - - # ── optional state populated for rough / biped tasks ──────────── - joint_range: np.ndarray | None = None # (num_action, 2) — [lower, upper] - linvel_yaw: np.ndarray | None = None # (N, 3) — base linvel in yaw frame - - -# ── tracking rewards ───────────────────────────────────────────────── - - -def tracking_lin_vel(ctx: RewardContext) -> np.ndarray: - """Exponential reward for tracking commanded xy linear velocity.""" - commands = ctx.info["commands"] - lin_vel_error = np.sum(np.square(commands[:, :2] - ctx.linvel[:, :2]), axis=1) - return np.exp(-lin_vel_error / ctx.tracking_sigma) # type: ignore[no-any-return] - - -def tracking_ang_vel(ctx: RewardContext) -> np.ndarray: - """Exponential reward for tracking commanded yaw angular velocity.""" - commands = ctx.info["commands"] - ang_vel_error = np.square(commands[:, 2] - ctx.gyro[:, 2]) - return np.exp(-ang_vel_error / ctx.tracking_sigma) # type: ignore[no-any-return] - - -# ── velocity / orientation penalties ───────────────────────────────── - - -def lin_vel_z(ctx: RewardContext) -> np.ndarray: - """Penalty for vertical (z) linear velocity.""" - return np.square(ctx.linvel[:, 2]) # type: ignore[no-any-return] - - -def ang_vel_xy(ctx: RewardContext) -> np.ndarray: - """Penalty for roll/pitch angular velocity.""" - return np.sum(np.square(ctx.gyro[:, :2]), axis=1) # type: ignore[no-any-return] - - -def roll(ctx: RewardContext) -> np.ndarray: - """Penalty for deviation from roll orientation.""" - g = ctx.gravity - assert g is not None - return np.square(g[:, 0]) # type: ignore[no-any-return] - - -# ── height / pose penalties ────────────────────────────────────────── - - -def base_height(ctx: RewardContext) -> np.ndarray: - """Penalty for base height deviation from target.""" - return np.square(ctx.base_height - ctx.base_height_target) # type: ignore[no-any-return] - - -def similar_to_default(ctx: RewardContext) -> np.ndarray: - """Penalty for joint position deviation from default (L1 norm).""" - return np.sum(np.abs(ctx.dof_pos - ctx.default_angles), axis=1) # type: ignore[no-any-return] - - -def weighted_pose(ctx: RewardContext) -> np.ndarray: - """Weighted L2 penalty for joint position deviation.""" - assert ctx.pose_weights is not None - diff = ctx.dof_pos - ctx.default_angles - return np.asarray(np.sum(ctx.pose_weights * np.square(diff), axis=1), dtype=get_global_dtype()) - - -# ── action penalties ───────────────────────────────────────────────── - - -def action_rate(ctx: RewardContext) -> np.ndarray: - """Penalty for change in actions between timesteps.""" - current = ctx.info["current_actions"] - last = ctx.info["last_actions"] - return np.sum(np.square(current - last), axis=1) # type: ignore[no-any-return] - - -# ── effort penalties ───────────────────────────────────────────────── - - -def _get_torques(ctx: RewardContext) -> np.ndarray: - fallback = np.zeros((ctx.num_envs, ctx.dof_pos.shape[1]), dtype=get_global_dtype()) - return ctx.info.get("torques", fallback) # type: ignore[no-any-return] - - -def torques(ctx: RewardContext) -> np.ndarray: - """Penalty for total torque magnitude (L1 norm).""" - return np.sum(np.abs(_get_torques(ctx)), axis=1) # type: ignore[no-any-return] - - -def energy(ctx: RewardContext) -> np.ndarray: - """Penalty for mechanical energy consumption.""" - assert ctx.dof_vel is not None - t = _get_torques(ctx) - return np.sum(np.abs(ctx.dof_vel) * np.abs(t), axis=1) # type: ignore[no-any-return] - - -def dof_acc(ctx: RewardContext) -> np.ndarray: - """Penalty for joint acceleration magnitude.""" - fallback = np.zeros((ctx.num_envs, ctx.dof_pos.shape[1]), dtype=get_global_dtype()) - qacc = ctx.info.get("qacc", fallback) - return np.sum(np.square(qacc), axis=1) # type: ignore[no-any-return] - - -# ── survival ───────────────────────────────────────────────────────── - - -def alive(ctx: RewardContext) -> np.ndarray: - """Constant reward for staying alive.""" - return np.ones((ctx.num_envs,), dtype=get_global_dtype()) diff --git a/src/unilab/tasks/locomotion/go2_arm/__init__.py b/src/unilab/tasks/locomotion/go2_arm/__init__.py deleted file mode 100644 index 090283ecc..000000000 --- a/src/unilab/tasks/locomotion/go2_arm/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .manip_loco import Go2ArmManipLocoCfg, Go2ArmManipLocoEnv - -__all__ = ["Go2ArmManipLocoCfg", "Go2ArmManipLocoEnv"] diff --git a/src/unilab/tasks/locomotion/go2_arm/base.py b/src/unilab/tasks/locomotion/go2_arm/base.py deleted file mode 100644 index 1055ea9ec..000000000 --- a/src/unilab/tasks/locomotion/go2_arm/base.py +++ /dev/null @@ -1,238 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field - -import numpy as np -from unisim import SimBackend - -from unilab.tasks.locomotion.common.base import ( - ControlConfigBase, - LocomotionBaseCfg, - LocomotionBaseEnv, - Sensor, -) -from unilab.utils.geometry import np_quat_orientation_error_local -from unilab.utils.rotation import np_matrix_from_quat - - -@dataclass -class NoiseConfig: - level: float = 0.0 - scale_joint_angle: float = 0.03 - scale_joint_vel: float = 0.5 - scale_gyro: float = 0.2 - scale_gravity: float = 0.05 - scale_linvel: float = 0.1 - scale_ee_pos: float = 0.02 - - -@dataclass -class ControlConfig(ControlConfigBase): - Kp: float = 35.0 - Kd: float = 0.5 - leg_kp: float | list[float] | None = 60.0 - leg_kd: float | list[float] | None = 2.0 - arm_kp: float | list[float] | None = field( - default_factory=lambda: [95.0, 115.0, 100.0, 52.0, 54.0, 55.0] - ) - arm_kd: float | list[float] | None = field( - default_factory=lambda: [3.5, 3.8, 2.5, 1.5, 1.5, 1.5] - ) - # Arm residual action scale, independent from leg action_scale. IK provides - # the primary control, so policy output stays small early in training. - arm_action_scale: float = 0.0 - - -@dataclass -class IKConfig: - damping: float = 0.05 - gain: float = 1.0 - dq_clip: float = 0.2 - use_orientation: bool = False - # Used only when use_orientation is true: - # - target: track goal_local_quat against curr_local_quat. - # - zero_error: include the rotational Jacobian with zero orientation error, - # matching go2_arx's position IK with orientation-change regularization. - orientation_mode: str = "target" - - -@dataclass -class Asset: - base_name: str = "base" - foot_name: str = "foot" - ground: str = "floor" - ee_site_name: str = "endpoint" - ee_body_name: str = "link6" - arm_joint_names: tuple[str, ...] = ( - "joint1", - "joint2", - "joint3", - "joint4", - "joint5", - "joint6", - ) - - -@dataclass -class Go2ArmSensor(Sensor): - feet_force: list[str] = field( - default_factory=lambda: [ - "FL_foot_contact", - "FR_foot_contact", - "RL_foot_contact", - "RR_foot_contact", - ] - ) - feet_pos: list[str] = field(default_factory=lambda: ["FL_pos", "FR_pos", "RL_pos", "RR_pos"]) - ee_local_pos: str = "endpoint_pos" - ee_local_quat: str = "endpoint_quat" - ee_local_vel: str = "endpoint_vel" - ee_relative_pos: str = "endpoint_relative_pos" - ee_relative_quat: str = "endpoint_relative_quat" - arm_ref_world_quat: str = "armbasepoint_world_quat" - - -@dataclass -class Go2ArmBaseCfg(LocomotionBaseCfg): - noise_config: NoiseConfig = field(default_factory=NoiseConfig) # type: ignore[assignment] - control_config: ControlConfig = field(default_factory=ControlConfig) # type: ignore[assignment] - ik: IKConfig = field(default_factory=IKConfig) - asset: Asset = field(default_factory=Asset) - sensor: Go2ArmSensor = field(default_factory=Go2ArmSensor) # type: ignore[assignment] - iterations: int | None = None - sim_dt: float = 0.01 - ctrl_dt: float = 0.02 - - -def _expand_gain( - name: str, value: float | list[float] | None, fallback: float, size: int -) -> np.ndarray: - raw_value = fallback if value is None else value - gain = np.asarray(raw_value, dtype=np.float64) - if gain.ndim == 0: - return np.full((size,), float(gain), dtype=np.float64) - if gain.shape != (size,): - raise ValueError(f"{name} must be a scalar or have shape ({size},), got {gain.shape}") - return gain - - -def build_go2_arm_position_gains(cfg: ControlConfig) -> dict[str, np.ndarray]: - leg_kp = _expand_gain("control_config.leg_kp", cfg.leg_kp, cfg.Kp, 12) - leg_kd = _expand_gain("control_config.leg_kd", cfg.leg_kd, cfg.Kd, 12) - arm_kp = _expand_gain("control_config.arm_kp", cfg.arm_kp, cfg.Kp, 6) - arm_kd = _expand_gain("control_config.arm_kd", cfg.arm_kd, cfg.Kd, 6) - return { - "kp": np.concatenate([leg_kp, arm_kp]), - "kd": np.concatenate([leg_kd, arm_kd]), - } - - -class Go2ArmBaseEnv(LocomotionBaseEnv): - _cfg: Go2ArmBaseCfg # pyright: ignore[reportIncompatibleVariableOverride] - - def __init__(self, cfg: Go2ArmBaseCfg, backend: SimBackend, num_envs: int = 1): - super().__init__(cfg, backend, num_envs) - self._ee_site_id = int(self._backend.get_site_ids([cfg.asset.ee_site_name])[0]) - self._arm_jacobian_dof_indices = self._backend.get_joint_dof_indices( - cfg.asset.arm_joint_names - ) - self._arm_dof_pos_indices = self._backend.get_joint_dof_pos_indices( - cfg.asset.arm_joint_names - ) - - @property - def arm_dof_pos_indices(self) -> np.ndarray: - return self._arm_dof_pos_indices - - @property - def arm_jacobian_dof_indices(self) -> np.ndarray: - return self._arm_jacobian_dof_indices - - def _obs_noise(self, data: np.ndarray, scale: float) -> np.ndarray: - """Apply per-step uniform observation noise scaled by ``noise_config.level``.""" - noise_cfg = self._cfg.noise_config - if noise_cfg.level > 0.0: - return data + ( - np.random.uniform(-1.0, 1.0, data.shape).astype(data.dtype) - * noise_cfg.level - * scale - ) - return data - - def get_foot_pos(self) -> np.ndarray: - """Get foot positions. Returns shape (num_envs, 4, 3).""" - foot_pos = [self._backend.get_sensor_data(name) for name in self._cfg.sensor.feet_pos] - return np.stack(foot_pos, axis=1) - - def get_foot_contact(self) -> np.ndarray: - """Get foot contact values. Returns shape (num_envs, 4).""" - contacts = [ - self._backend.get_sensor_data(name)[:, 0] for name in self._cfg.sensor.feet_force - ] - return np.stack(contacts, axis=1) - - def get_ee_local_pose(self) -> tuple[np.ndarray, np.ndarray]: - """Return end-effector pose expressed in the arm reference frame.""" - pos = self._backend.get_sensor_data(self._cfg.sensor.ee_local_pos) - quat = self._backend.get_sensor_data(self._cfg.sensor.ee_local_quat) - return pos, quat - - def get_arm_dof_pos(self) -> np.ndarray: - return self.get_dof_pos()[:, self._arm_dof_pos_indices] - - def compute_arm_ik_delta( - self, - goal_local_pos: np.ndarray, - curr_local_pos: np.ndarray, - goal_local_quat: np.ndarray | None = None, - curr_local_quat: np.ndarray | None = None, - ) -> np.ndarray: - """Compute damped least-squares IK delta for the 6 arm joints. - - Position and optional orientation errors are expressed in the arm - reference frame (``armbasepoint``). MuJoCo provides world-frame site - Jacobians, so both translational and rotational blocks are rotated into - that same frame before solving. - """ - cfg = self._cfg.ik - pos_err = np.asarray(goal_local_pos - curr_local_pos) - jacp_w, jacr_w = self._backend.get_site_jacobian_w( - self._ee_site_id, - self._arm_jacobian_dof_indices, - ) - ref_rot_w = np_matrix_from_quat( - self._backend.get_sensor_data(self._cfg.sensor.arm_ref_world_quat) - ) - rot_w_to_b = np.swapaxes(ref_rot_w, 1, 2) - jacp_b = np.matmul(rot_w_to_b, jacp_w) - - if cfg.use_orientation: - if cfg.orientation_mode == "target": - if goal_local_quat is None or curr_local_quat is None: - raise ValueError( - "goal_local_quat and curr_local_quat are required when " - "ik.use_orientation=true and ik.orientation_mode='target'" - ) - orn_err = np_quat_orientation_error_local(goal_local_quat, curr_local_quat) - elif cfg.orientation_mode == "zero_error": - orn_err = np.zeros_like(pos_err) - else: - raise ValueError( - "ik.orientation_mode must be one of {'target', 'zero_error'}, " - f"got {cfg.orientation_mode!r}" - ) - jacr_b = np.matmul(rot_w_to_b, jacr_w) - jac = np.concatenate([jacp_b, jacr_b], axis=1) - dpose = np.concatenate([pos_err, orn_err], axis=1) - else: - jac = jacp_b - dpose = pos_err - - identity = np.eye(jac.shape[1], dtype=jac.dtype)[None, :, :] - lhs = np.matmul(jac, np.swapaxes(jac, 1, 2)) + identity * (cfg.damping**2) - rhs = dpose[:, :, None] - solved = np.linalg.solve(lhs, rhs) - dq = np.matmul(np.swapaxes(jac, 1, 2), solved)[:, :, 0] - if cfg.dq_clip > 0.0: - dq = np.clip(dq, -cfg.dq_clip, cfg.dq_clip) - return dq diff --git a/src/unilab/tasks/locomotion/go2_arm/manip_loco.py b/src/unilab/tasks/locomotion/go2_arm/manip_loco.py deleted file mode 100644 index 6c3501592..000000000 --- a/src/unilab/tasks/locomotion/go2_arm/manip_loco.py +++ /dev/null @@ -1,1039 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -import numpy as np -from unisim.dr.types import ResetPlan - -from unilab.assets import ASSETS_ROOT_PATH -from unilab.base import registry -from unilab.base.backend_factory import create_backend, env_backend_kwargs -from unilab.base.np_env import NpEnvState -from unilab.base.scene import SceneCfg -from unilab.dtype_config import get_global_dtype -from unilab.tasks.compatibility import adapt_legacy_factory -from unilab.tasks.locomotion.common import rewards -from unilab.tasks.locomotion.common.commands import Commands -from unilab.tasks.locomotion.common.domain_rand import DomainRandConfig -from unilab.tasks.locomotion.common.dr_provider import LocomotionDRProvider -from unilab.tasks.locomotion.common.rewards import RewardContext -from unilab.tasks.locomotion.go2_arm.base import ( - Go2ArmBaseCfg, - Go2ArmBaseEnv, - Go2ArmSensor, - build_go2_arm_position_gains, -) -from unilab.utils.geometry import ( - np_cartesian_to_spherical as _cart2sphere, -) -from unilab.utils.geometry import ( - np_spherical_to_cartesian as _sphere2cart, -) -from unilab.utils.rotation import np_matrix_from_quat, np_quat_from_euler_xyz - - -def _default_go2_arm_model_file() -> str: - return str(ASSETS_ROOT_PATH / "robots" / "go2_arm" / "scene_flat.xml") - - -def _default_go2_arm_scene() -> SceneCfg: - return SceneCfg(model_file=_default_go2_arm_model_file()) - - -def _resolve_go2_arm_scene(cfg: "Go2ArmManipLocoCfg") -> SceneCfg: - scene = cfg.scene - default_model_file = _default_go2_arm_model_file() - if scene is None: - scene = SceneCfg(model_file=cfg.model_file) - elif cfg.model_file != default_model_file and scene.model_file == default_model_file: - scene = SceneCfg( - model_file=cfg.model_file, - fragment_files=list(scene.fragment_files), - terrain=scene.terrain, - ) - cfg.scene = scene - return scene - - -@dataclass -class InitState: - pos: list[float] = field(default_factory=lambda: [0.0, 0.0, 0.42]) - - -@dataclass -class Go2ArmDomainRandConfig(DomainRandConfig): - randomize_kp: bool = True - kp_multiplier_range: list[float] = field(default_factory=lambda: [0.9, 1.1]) - - randomize_kd: bool = True - kd_multiplier_range: list[float] = field(default_factory=lambda: [0.9, 1.1]) - - -@dataclass -class EEGoalConfig: - """End-effector goal config in spherical coordinates.""" - - # Spherical sampling ranges. - sphere_l_range: list[float] = field(default_factory=lambda: [0.3, 0.6]) - sphere_phi_range: list[float] = field(default_factory=lambda: [-1.2566, 1.0472]) - sphere_theta_range: list[float] = field(default_factory=lambda: [-2.3562, 2.3562]) - # Trajectory timing. - traj_time_range: list[float] = field(default_factory=lambda: [1.0, 3.0]) - hold_time_range: list[float] = field(default_factory=lambda: [0.5, 2.0]) - # Collision checks. - collision_upper_limits: list[float] = field(default_factory=lambda: [0.3, 0.15, 0.05 - 0.165]) - collision_lower_limits: list[float] = field( - default_factory=lambda: [-0.2, -0.15, -0.35 - 0.165] - ) - underground_limit: float = -0.57 - num_collision_check_samples: int = 10 - num_resample_attempts: int = 10 - # End-effector target orientation sampling (XYZ Euler to wxyz quaternion). - default_orn_roll: float = float(np.pi / 2.0) - arm_induced_pitch: float = 0.78 - delta_orn_r: list[float] = field(default_factory=lambda: [-0.5, 0.5]) - delta_orn_p: list[float] = field(default_factory=lambda: [-0.5, 0.5]) - delta_orn_y: list[float] = field(default_factory=lambda: [-0.5, 0.5]) - # Initial goal used as the reset-time start point. - init_ee_cart: list[float] = field(default_factory=lambda: [0.30, 0.0, 0.25]) - - -@dataclass -class CommandsConfig(Commands): - # Periodic command resampling time in seconds. None disables mid-episode resampling. - resample_time_s: float | None = None - # Probability of explicitly sampling a zero-velocity command for stable standing. - zero_command_prob: float = 0.2 - - -@dataclass -class CurriculumConfig: - """Expand velocity command ranges when mean tracking_lin_vel exceeds a threshold.""" - - enable: bool = False - # Expansion threshold: per-step episode mean tracking_lin_vel must exceed this value. - threshold: float = 0.8 - # Expansion step applied on each trigger: [vx, vy, vyaw]. - step_size: list[float] = field(default_factory=lambda: [0.1, 0.05, 0.1]) - # Absolute velocity-range limits to prevent unbounded expansion. - max_vel_limit: list[float] = field(default_factory=lambda: [1.0, 0.4, 0.8]) - - -@dataclass -class RewardConfig: - scales: dict[str, float] - tracking_sigma: float - base_height_target: float - target_foot_height: float = 0.1 - object_sigma: float = 0.1 - # Soft limits for 12 leg joints in radians. Empty lists disable the reward term. - leg_dof_upper_limits: list[float] = field(default_factory=list) - leg_dof_lower_limits: list[float] = field(default_factory=list) - dof_pos_limit_margin: float = 0.01 - - -@dataclass -class HistoryConfig: - """Actor/critic observation history lengths. A value of 1 disables history.""" - - num_actor_history: int = 1 - num_critic_history: int = 1 - - -@dataclass -class ArmStageConfig: - freeze_arm_joints: bool = False - disable_ee_goal_trajectory: bool = False - fixed_ee_goal_cart: list[float] = field(default_factory=lambda: [0.30, 0.0, 0.25]) - - -@registry.envcfg("Go2ArmManipLoco") -@dataclass -class Go2ArmManipLocoCfg(Go2ArmBaseCfg): - scene: SceneCfg = field( # pyright: ignore[reportIncompatibleVariableOverride] - default_factory=_default_go2_arm_scene - ) - model_file: str = field(default_factory=_default_go2_arm_model_file) - max_episode_seconds: float = 20.0 # pyright: ignore[reportIncompatibleVariableOverride] - init_state: InitState = field(default_factory=InitState) - commands: CommandsConfig = field(default_factory=CommandsConfig) # type: ignore[assignment] - reward_config: RewardConfig | None = None - sensor: Go2ArmSensor = field(default_factory=Go2ArmSensor) # type: ignore[assignment] - domain_rand: Go2ArmDomainRandConfig = field(default_factory=Go2ArmDomainRandConfig) - goal_ee: EEGoalConfig = field(default_factory=EEGoalConfig) - history: HistoryConfig = field(default_factory=HistoryConfig) - arm_stage: ArmStageConfig = field(default_factory=ArmStageConfig) - curriculum: CurriculumConfig = field(default_factory=CurriculumConfig) - - -class Go2ArmManipLocoDRProvider(LocomotionDRProvider): - def __init__( - self, - *, - base_kp: np.ndarray | None = None, - base_kd: np.ndarray | None = None, - base_body_mass: np.ndarray | None = None, - base_geom_friction: np.ndarray | None = None, - ground_geom_id: int | None = None, - base_dof_armature: np.ndarray | None = None, - ): - self._base_kp = base_kp - self._base_kd = base_kd - self._base_body_mass = base_body_mass - self._base_geom_friction = base_geom_friction - self._ground_geom_id = ground_geom_id - self._base_dof_armature = base_dof_armature - - def _sample_commands(self, env: Any, num_reset: int) -> np.ndarray: - commands = super()._sample_commands(env, num_reset) - return env._postprocess_velocity_commands(commands) - - def _get_base_actuator_gains(self, env: Any) -> tuple[np.ndarray | None, np.ndarray | None]: - return self._base_kp, self._base_kd - - def _get_reset_randomization_baselines( - self, env: Any - ) -> tuple[np.ndarray | None, np.ndarray | None, int | None, np.ndarray | None]: - return ( - self._base_body_mass, - self._base_geom_friction, - self._ground_geom_id, - self._base_dof_armature, - ) - - def build_reset_plan(self, env: Any, env_ids: np.ndarray) -> ResetPlan: - plan = super().build_reset_plan(env, env_ids) - env.reset_ee_goals(env_ids) - # Update command curriculum at episode end before resetting timers. - env._update_command_curriculum(env_ids) - # Reset command timers. reset_ee_goals already clears _arm_goal_timer. - env._cmd_timer[env_ids] = 0 - env._arm_goal_timer[env_ids] = 0 - # Clear history buffers for reset environments. - env._history_obs_buf[env_ids] = 0.0 - env._history_critic_buf[env_ids] = 0.0 - env.phase[env_ids] = 0.0 - env._write_feet_phase(env_ids, env._command_is_moving(plan.info_updates["commands"])) - return plan - - def _compute_reset_obs( - self, - env: Any, - env_ids: np.ndarray, - info_updates: dict[str, Any], - linvel: np.ndarray, - gyro: np.ndarray, - gravity: np.ndarray, - dof_pos: np.ndarray, - dof_vel: np.ndarray, - ) -> dict[str, np.ndarray]: - ee_local_pos, _ = env.get_ee_local_pose() - # info_updates may contain global info arrays; slice entries for env_ids. - n = len(env_ids) - sliced_info: dict[str, Any] = {} - for k, v in info_updates.items(): - if isinstance(v, np.ndarray) and v.ndim >= 1 and v.shape[0] == env._num_envs: - sliced_info[k] = v[env_ids] - else: - sliced_info[k] = v - actor_raw = env._compute_raw_obs( # type: ignore[no-any-return] - sliced_info, - linvel, - gyro, - gravity, - dof_pos, - dof_vel, - ee_local_pos[env_ids], - env.curr_ee_goal_cart[env_ids], - env.feet_phase[env_ids], - add_noise=True, - ) - critic_raw = env._compute_raw_obs( # type: ignore[no-any-return] - sliced_info, - linvel, - gyro, - gravity, - dof_pos, - dof_vel, - ee_local_pos[env_ids], - env.curr_ee_goal_cart[env_ids], - env.feet_phase[env_ids], - add_noise=False, - ) - del n - return env._update_history(actor_raw, env_ids=env_ids, critic_raw_obs=critic_raw) # type: ignore[no-any-return] - - -class Go2ArmManipLocoEnv(Go2ArmBaseEnv): - _cfg: Go2ArmManipLocoCfg # pyright: ignore[reportIncompatibleVariableOverride] - - def __init__(self, cfg: Go2ArmManipLocoCfg, num_envs=1, backend_type="mujoco"): - if cfg.reward_config is None: - raise ValueError("reward_config must be provided via Hydra configuration") - if backend_type not in {"drake", "mujoco", "motrix"}: - raise ValueError( - "Go2ArmManipLoco supports only the drake, mujoco and motrix backends, " - f"got {backend_type!r}" - ) - - scene = _resolve_go2_arm_scene(cfg) - # Single assembly point: every entry is routed (or dropped) by - # create_backend/env_backend_kwargs per backend, so the env never - # branches on backend_type. Go2Arm's single ``iterations`` knob feeds - # both MuJoCo (``iterations``) and Motrix (``max_iterations``). - backend_kwargs: dict[str, Any] = { - "base_name": cfg.asset.base_name, - "push_body_name": cfg.domain_rand.push_body_name, - "position_actuator_gains": build_go2_arm_position_gains(cfg.control_config), - "iterations": cfg.iterations, - **env_backend_kwargs(cfg), - "motrix_max_iterations": cfg.iterations, - } - backend = create_backend( - backend_type, - scene, - num_envs, - cfg.sim_dt, - **backend_kwargs, - ) - super().__init__(cfg, backend, num_envs) - if self._num_action != 18: - raise ValueError(f"Go2ArmManipLoco expects 18 actuators, got {self._num_action}") - if not 0.0 <= cfg.commands.zero_command_prob <= 1.0: - raise ValueError( - "env.commands.zero_command_prob must be in [0, 1], " - f"got {cfg.commands.zero_command_prob}" - ) - - self._enable_reward_log = True - self._reward_cfg = cfg.reward_config - self._leg_pose_weights = np.array([1.0, 1.0, 0.1] * 4 + [0.0] * 6, dtype=get_global_dtype()) - self._init_reward_functions() - self._init_ee_goal_buffers(num_envs) - self._current_ee_local_pos = np.zeros((num_envs, 3), dtype=get_global_dtype()) - self.phase = np.zeros((num_envs,), dtype=np.float32) - self.feet_phase = np.zeros((num_envs, len(cfg.sensor.feet_force)), dtype=np.float32) - self.gait_frequency = 2.0 - self.feet_force = np.zeros((num_envs, len(cfg.sensor.feet_force), 3), dtype=np.float32) - self.feet_pos = np.zeros((num_envs, len(cfg.sensor.feet_pos), 3), dtype=np.float32) - - # Mid-episode command resampling. None disables periodic resampling. - if cfg.commands.resample_time_s is not None: - self._cmd_resample_steps: int | None = max( - 1, int(cfg.commands.resample_time_s / cfg.ctrl_dt) - ) - self._cmd_timer = np.random.randint( - 0, self._cmd_resample_steps, size=(num_envs,), dtype=np.int32 - ) - else: - self._cmd_resample_steps = None - self._cmd_timer = np.zeros((num_envs,), dtype=np.int32) - - # Per-env episode tracking_lin_vel accumulator for command curriculum. - self._episode_sum_tracking_vel = np.zeros(num_envs, dtype=np.float64) - self._episode_steps = np.zeros(num_envs, dtype=np.int32) - - # History buffers. - # Actor obs excludes linvel (first 3 dims) to avoid bypassing the estimator. - # raw_obs layout: linvel(3)+gyro(3)+(-gravity)(3)+command(3)+feet_phase(4)+ - # diff(18)+dof_vel(18)+ee_local_pos(3)+ee_goal_cart(3)+ - # ee_error(3)+last_actions(18) = 79 dims. - _CRITIC_ONE = 79 # Single-step critic obs dim, including privileged linvel. - _ACTOR_ONE = 76 # Single-step actor obs dim after removing linvel[0:3]. - H_a = cfg.history.num_actor_history - H_c = cfg.history.num_critic_history - self._actor_one_step_dim = _ACTOR_ONE - self._critic_one_step_dim = _CRITIC_ONE - self._history_obs_buf = np.zeros((num_envs, H_a * _ACTOR_ONE), dtype=get_global_dtype()) - self._history_critic_buf = np.zeros((num_envs, H_c * _CRITIC_ONE), dtype=get_global_dtype()) - - base_kp: np.ndarray | None = None - base_kd: np.ndarray | None = None - if cfg.domain_rand.randomize_kp or cfg.domain_rand.randomize_kd: - base_kp, base_kd = backend.get_actuator_gains() - - base_body_mass: np.ndarray | None = None - if cfg.domain_rand.randomize_body_mass: - base_body_mass = backend.get_body_mass() - - base_geom_friction: np.ndarray | None = None - ground_geom_id: int | None = None - if cfg.domain_rand.randomize_ground_friction: - base_geom_friction = backend.get_geom_friction() - ground_geom_id = backend.get_geom_id(cfg.asset.ground) - - base_dof_armature: np.ndarray | None = None - if cfg.domain_rand.randomize_dof_armature: - base_dof_armature = backend.get_dof_armature() - - dr_provider = Go2ArmManipLocoDRProvider( - base_kp=base_kp, - base_kd=base_kd, - base_body_mass=base_body_mass, - base_geom_friction=base_geom_friction, - ground_geom_id=ground_geom_id, - base_dof_armature=base_dof_armature, - ) - self._init_domain_randomization(dr_provider) - - @property - def obs_groups_spec(self) -> dict[str, int]: - H_a = self._cfg.history.num_actor_history - H_c = self._cfg.history.num_critic_history - return {"obs": H_a * self._actor_one_step_dim, "critic": H_c * self._critic_one_step_dim} - - def _init_ee_goal_buffers(self, num_envs: int) -> None: - dtype = get_global_dtype() - self.curr_ee_goal_cart = np.zeros((num_envs, 3), dtype=dtype) - self.curr_ee_goal_sphere = np.zeros((num_envs, 3), dtype=dtype) - self.ee_goal_orn_euler = np.zeros((num_envs, 3), dtype=dtype) - self.ee_goal_orn_quat = np.tile( - np.asarray([1.0, 0.0, 0.0, 0.0], dtype=dtype), - (num_envs, 1), - ) - self.ee_goal_orn_delta_rpy = np.zeros((num_envs, 3), dtype=dtype) - # Goal position in world coordinates, used for render-time visualization. - self.curr_ee_goal_world = np.zeros((num_envs, 3), dtype=dtype) - self._ee_start_sphere = np.zeros((num_envs, 3), dtype=dtype) - self._ee_goal_sphere = np.zeros((num_envs, 3), dtype=dtype) - self._arm_goal_timer = np.zeros((num_envs,), dtype=np.int32) - self._traj_steps = np.ones((num_envs,), dtype=np.int32) - self._traj_total_steps = np.ones((num_envs,), dtype=np.int32) - - def _sample_timing(self, env_ids: np.ndarray) -> None: - """Sample movement and hold durations for env_ids.""" - cfg = self._cfg.goal_ee - dt = self._cfg.ctrl_dt - traj_t = np.random.uniform(*cfg.traj_time_range, size=len(env_ids)) - hold_t = np.random.uniform(*cfg.hold_time_range, size=len(env_ids)) - traj_s = np.maximum(1, np.round(traj_t / dt).astype(np.int32)) - hold_s = np.maximum(0, np.round(hold_t / dt).astype(np.int32)) - self._traj_steps[env_ids] = traj_s - self._traj_total_steps[env_ids] = traj_s + hold_s - - def _collision_check_sphere(self, starts: np.ndarray, goals: np.ndarray) -> np.ndarray: - """Check spherical lerp paths for collisions after Cartesian conversion.""" - cfg = self._cfg.goal_ee - dtype = get_global_dtype() - n = max(2, cfg.num_collision_check_samples) - t = np.linspace(0.0, 1.0, n, dtype=dtype) # (n,) - path_sphere = ( - starts[:, None, :] + (goals - starts)[:, None, :] * t[None, :, None] - ) # (N, n, 3) - path_cart = _sphere2cart(path_sphere.reshape(-1, 3)).reshape(len(starts), n, 3) - upper = np.asarray(cfg.collision_upper_limits, dtype=dtype) - lower = np.asarray(cfg.collision_lower_limits, dtype=dtype) - inside_collision_box = np.all(path_cart < upper, axis=2) & np.all(path_cart > lower, axis=2) - collision_mask = np.any(inside_collision_box, axis=1) - underground_mask = np.any(path_cart[..., 2] < float(cfg.underground_limit), axis=1) - return collision_mask | underground_mask - - def _sample_goal_spheres(self, env_ids: np.ndarray, start_spheres: np.ndarray) -> None: - """Sample goal spheres and write them into _ee_goal_sphere[env_ids].""" - cfg = self._cfg.goal_ee - dtype = get_global_dtype() - init_sphere = _cart2sphere(np.asarray(cfg.init_ee_cart, dtype=dtype)[None, :])[0] - candidates = np.broadcast_to(init_sphere, (len(env_ids), 3)).copy() - remaining = np.arange(len(env_ids), dtype=np.int32) - for _ in range(max(1, cfg.num_resample_attempts)): - l = np.random.uniform(*cfg.sphere_l_range, size=len(remaining)).astype(dtype) - phi = np.random.uniform(*cfg.sphere_phi_range, size=len(remaining)).astype(dtype) - theta = np.random.uniform(*cfg.sphere_theta_range, size=len(remaining)).astype(dtype) - new_goals = np.stack([l, phi, theta], axis=1) - candidates[remaining] = new_goals - unsafe = self._collision_check_sphere(start_spheres[remaining], new_goals) - remaining = remaining[unsafe] - if len(remaining) == 0: - break - self._ee_goal_sphere[env_ids] = candidates - - def _sample_ee_goal_orn_delta(self, env_ids: np.ndarray, *, is_init: bool) -> None: - if len(env_ids) == 0: - return - if is_init: - self.ee_goal_orn_delta_rpy[env_ids] = 0.0 - return - - dtype = get_global_dtype() - ranges = ( - self._cfg.goal_ee.delta_orn_r, - self._cfg.goal_ee.delta_orn_p, - self._cfg.goal_ee.delta_orn_y, - ) - for axis, bounds in enumerate(ranges): - low_high = np.asarray(bounds, dtype=dtype) - if low_high.shape != (2,): - raise ValueError("goal_ee delta orientation ranges must have shape (2,)") - if low_high[1] < low_high[0]: - raise ValueError("goal_ee delta orientation range high must be >= low") - self.ee_goal_orn_delta_rpy[env_ids, axis] = np.random.uniform( - low=low_high[0], - high=low_high[1], - size=(len(env_ids),), - ).astype(dtype) - - def _update_curr_ee_goal_orientation(self, env_ids: np.ndarray) -> None: - if len(env_ids) == 0: - return - dtype = get_global_dtype() - goal_cfg = self._cfg.goal_ee - goal_local = self.curr_ee_goal_cart[env_ids] - goal_sphere = self.curr_ee_goal_sphere[env_ids] - delta = self.ee_goal_orn_delta_rpy[env_ids] - - default_yaw = np.arctan2(goal_local[:, 1], goal_local[:, 0]) - default_pitch = -goal_sphere[:, 1] + float(goal_cfg.arm_induced_pitch) - roll = float(goal_cfg.default_orn_roll) + delta[:, 0] - pitch = default_pitch + delta[:, 1] - yaw = default_yaw + delta[:, 2] - - self.ee_goal_orn_euler[env_ids] = np.stack([roll, pitch, yaw], axis=1).astype(dtype) - self.ee_goal_orn_quat[env_ids] = np.atleast_2d( - np_quat_from_euler_xyz(roll, pitch, yaw) - ).astype(dtype) - - def reset_ee_goals(self, env_ids: np.ndarray) -> None: - """Reset EE goals by sampling the first segment from init_ee_cart.""" - env_ids = np.asarray(env_ids, dtype=np.int32).reshape(-1) - if len(env_ids) == 0: - return - stage_cfg = self._cfg.arm_stage - if stage_cfg.disable_ee_goal_trajectory: - fixed_goal = np.asarray(stage_cfg.fixed_ee_goal_cart, dtype=get_global_dtype()) - if fixed_goal.shape != (3,): - raise ValueError( - f"env.arm_stage.fixed_ee_goal_cart must have shape (3,), got {fixed_goal.shape}" - ) - fixed_sphere = _cart2sphere(fixed_goal[None, :])[0] - self._ee_start_sphere[env_ids] = fixed_sphere - self._ee_goal_sphere[env_ids] = fixed_sphere - self._traj_steps[env_ids] = 1 - self._traj_total_steps[env_ids] = 1 - self._arm_goal_timer[env_ids] = 0 - self.curr_ee_goal_cart[env_ids] = fixed_goal - self.curr_ee_goal_sphere[env_ids] = fixed_sphere - self._sample_ee_goal_orn_delta(env_ids, is_init=True) - self._update_curr_ee_goal_orientation(env_ids) - return - dtype = get_global_dtype() - init_sphere = _cart2sphere( - np.asarray(self._cfg.goal_ee.init_ee_cart, dtype=dtype)[None, :] - )[0] - self._ee_start_sphere[env_ids] = init_sphere - self._sample_goal_spheres( - env_ids, - np.broadcast_to(init_sphere, (len(env_ids), 3)).copy(), - ) - self._sample_timing(env_ids) - self._arm_goal_timer[env_ids] = 0 - self.curr_ee_goal_sphere[env_ids] = init_sphere - self.curr_ee_goal_cart[env_ids] = _sphere2cart( - np.broadcast_to(init_sphere, (len(env_ids), 3)) - ) - self._sample_ee_goal_orn_delta(env_ids, is_init=True) - self._update_curr_ee_goal_orientation(env_ids) - - def _update_command_curriculum(self, env_ids: np.ndarray) -> None: - """Update velocity command ranges at episode end from tracking_lin_vel. - - This follows the go2_arx_robot.py rule: - mean(episode_sum[env_ids] / episode_steps[env_ids]) > threshold - The unweighted tracking_lin_vel maximum is 1.0 per step. - """ - cur = self._cfg.curriculum - if not cur.enable: - return - ep_steps = np.maximum(self._episode_steps[env_ids], 1) - mean_per_step = float(np.mean(self._episode_sum_tracking_vel[env_ids] / ep_steps)) - if mean_per_step > cur.threshold: - step = np.asarray(cur.step_size, dtype=np.float64) - max_limit = np.asarray(cur.max_vel_limit, dtype=np.float64) - low = np.asarray(self._cfg.commands.vel_limit[0], dtype=np.float64) - high = np.asarray(self._cfg.commands.vel_limit[1], dtype=np.float64) - low = np.clip(low - step, -max_limit, 0.0) - high = np.clip(high + step, 0.0, max_limit) - self._cfg.commands.vel_limit = [low.tolist(), high.tolist()] - # Clear episode statistics for reset environments. - self._episode_sum_tracking_vel[env_ids] = 0.0 - self._episode_steps[env_ids] = 0 - - # Command clipping threshold: small vx/vy/vyaw commands are zeroed out. - _CMD_CLIP: float = 0.1 - - def _command_is_moving(self, commands: np.ndarray) -> np.ndarray: - command_arr = np.asarray(commands) - return np.any(np.abs(command_arr[:, :3]) > self._CMD_CLIP, axis=1) - - def _normalize_velocity_commands(self, commands: np.ndarray) -> np.ndarray: - normalized = np.asarray(commands, dtype=get_global_dtype()).copy() - normalized[~self._command_is_moving(normalized)] = 0.0 - return normalized - - def _postprocess_velocity_commands(self, commands: np.ndarray) -> np.ndarray: - processed = self._normalize_velocity_commands(commands) - prob = float(self._cfg.commands.zero_command_prob) - if prob <= 0.0 or processed.shape[0] == 0: - return processed - zero_mask = np.random.random(size=(processed.shape[0],)) < prob - processed[zero_mask] = 0.0 - return processed - - def _write_feet_phase(self, env_ids: np.ndarray | slice, is_moving: np.ndarray) -> None: - phase = self.phase[env_ids] - feet_phase = self.feet_phase[env_ids].copy() - feet_phase[:, 0] = phase - feet_phase[:, 3] = phase - feet_phase[:, 1] = (phase + 0.5) % 1.0 - feet_phase[:, 2] = (phase + 0.5) % 1.0 - feet_phase[~is_moving] = 0.0 - self.feet_phase[env_ids] = feet_phase - - def _resample_commands(self, env_ids: np.ndarray, info: dict) -> None: - """Resample velocity commands and zero out small commands.""" - if len(env_ids) == 0: - return - low = np.asarray(self._cfg.commands.vel_limit[0], dtype=get_global_dtype()) - high = np.asarray(self._cfg.commands.vel_limit[1], dtype=get_global_dtype()) - new_cmds = np.random.uniform(low=low, high=high, size=(len(env_ids), 3)).astype( - get_global_dtype() - ) - new_cmds = self._postprocess_velocity_commands(new_cmds) - if "commands" in info: - info["commands"][env_ids] = new_cmds - - def apply_action(self, actions: np.ndarray, state: NpEnvState) -> np.ndarray: - state.info["last_actions"] = state.info.get("current_actions", np.zeros_like(actions)) - stage_cfg = self._cfg.arm_stage - if stage_cfg.freeze_arm_joints: - effective_actions = actions.copy() - effective_actions[:, 12:18] = 0.0 - else: - effective_actions = actions - state.info["current_actions"] = effective_actions - exec_actions = ( - state.info["last_actions"] - if self._cfg.control_config.simulate_action_latency - else effective_actions - ) - - leg_ctrl = ( - exec_actions[:, :12] * self._cfg.control_config.action_scale + self.default_angles[:12] - ) - if stage_cfg.freeze_arm_joints: - arm_ctrl = np.broadcast_to(self.default_angles[12:18], (self._num_envs, 6)).astype( - get_global_dtype(), - copy=False, - ) - else: - ee_local_pos, ee_local_quat = self.get_ee_local_pose() - dq_ik = self.compute_arm_ik_delta( - self.curr_ee_goal_cart, - ee_local_pos, - self.ee_goal_orn_quat, - ee_local_quat, - ) - arm_ctrl = ( - self.get_arm_dof_pos() - + exec_actions[:, 12:18] * self._cfg.control_config.arm_action_scale - + self._cfg.ik.gain * dq_ik - ) - ctrl = np.concatenate([leg_ctrl, arm_ctrl], axis=1, dtype=get_global_dtype()) - return np.clip(ctrl, self.action_space.low, self.action_space.high) - - def _init_reward_functions(self) -> None: - self._reward_fns: dict[str, Any] = { - # Tracking rewards. - "tracking_lin_vel": rewards.tracking_lin_vel, - "tracking_ang_vel": rewards.tracking_ang_vel, - # Velocity and orientation penalties. - "lin_vel_z": rewards.lin_vel_z, - "ang_vel_xy": rewards.ang_vel_xy, - "roll": rewards.roll, # Requires ctx.gravity. - # Height and joint-pose terms. - "base_height": rewards.base_height, - "similar_to_default": rewards.similar_to_default, # Aligns with Go2 Joystick. - "leg_pose": rewards.weighted_pose, # Weighted leg L2 term. - "dof_pos_limits": self._reward_dof_pos_limits, # Leg soft limits. - # Action and effort penalties. - "action_rate": rewards.action_rate, - "torques": rewards.torques, # L1 torque over all 18 DOFs. - "energy": rewards.energy, # Requires ctx.dof_vel and info["torques"]. - "dof_vel": self._reward_dof_vel, # L2 velocity over all 18 DOFs. - "dof_acc": rewards.dof_acc, # Requires info["qacc"]. - # Standing penalty. - "stand_still": self._reward_stand_still, # Penalizes leg pose at zero command. - # Survival. - "alive": rewards.alive, - # Gait terms. - "swing_feet_z": self._reward_swing_feet_z, - "foot_drag": self._reward_foot_drag, - "contact": self._reward_contact, - # Manipulation rewards. - "object_distance": self._reward_object_distance, - "object_distance_l2": self._reward_object_distance_l2, - # Arm collision penalty. - "arm_collision": self._reward_arm_collision, - } - - def update_state(self, state: NpEnvState) -> NpEnvState: - # Mid-episode command resampling, enabled only when resample_time_s is set. - if self._cmd_resample_steps is not None: - self._cmd_timer += 1 - resample_ids = np.where(self._cmd_timer >= self._cmd_resample_steps)[0].astype(np.int32) - if len(resample_ids) > 0: - self._resample_commands(resample_ids, state.info) - self._cmd_timer[resample_ids] = 0 - - # Gait phase update: zero commands reset the phase to a full-stance pattern. - # This gives contact a four-feet contact target and naturally disables swing_feet_z. - cmd = state.info.get("commands", np.zeros((self._num_envs, 3), dtype=np.float32)) - is_moving = self._command_is_moving(cmd) - advanced = np.fmod(self.phase + self._cfg.ctrl_dt * self.gait_frequency, 1.0) - self.phase = np.where(is_moving, advanced, 0.0) - self._write_feet_phase(slice(None), is_moving) - - # EE goal trajectory update. - stage_cfg = self._cfg.arm_stage - if stage_cfg.disable_ee_goal_trajectory: - fixed_goal = np.asarray(stage_cfg.fixed_ee_goal_cart, dtype=get_global_dtype()) - if fixed_goal.shape != (3,): - raise ValueError( - f"env.arm_stage.fixed_ee_goal_cart must have shape (3,), got {fixed_goal.shape}" - ) - self.curr_ee_goal_cart[:] = fixed_goal - self.curr_ee_goal_sphere[:] = _cart2sphere(fixed_goal[None, :])[0] - else: - self._arm_goal_timer += 1 - expired = np.where(self._arm_goal_timer >= self._traj_total_steps)[0].astype(np.int32) - if len(expired) > 0: - self._ee_start_sphere[expired] = self._ee_goal_sphere[expired].copy() - self._sample_goal_spheres(expired, self._ee_start_sphere[expired]) - self._sample_ee_goal_orn_delta(expired, is_init=False) - self._sample_timing(expired) - self._arm_goal_timer[expired] = 0 - # Spherical interpolation, updated every step. - t_frac = np.clip(self._arm_goal_timer / self._traj_steps, 0.0, 1.0).astype( - get_global_dtype() - )[:, None] # (num_envs, 1) - curr_sphere = ( - self._ee_start_sphere + (self._ee_goal_sphere - self._ee_start_sphere) * t_frac - ) - self.curr_ee_goal_sphere[:] = curr_sphere - self.curr_ee_goal_cart[:] = _sphere2cart(curr_sphere) - self._update_curr_ee_goal_orientation(np.arange(self._num_envs, dtype=np.int32)) - # Compute the world-space goal position for render-time visualization. - ab_pos = self._backend.get_sensor_data("armbasepoint_world_pos") # (N, 3) - ab_quat = self._backend.get_sensor_data("armbasepoint_world_quat") # (N, 4) - R = np_matrix_from_quat(ab_quat) # (N, 3, 3) - self.curr_ee_goal_world[:] = ab_pos + np.einsum("nij,nj->ni", R, self.curr_ee_goal_cart) - - linvel = self.get_local_linvel() - gyro = self.get_gyro() - gravity = self._backend.get_sensor_data("upvector") - dof_pos = self.get_dof_pos() - dof_vel = self.get_dof_vel() - ee_local_pos, _ = self.get_ee_local_pose() - self._current_ee_local_pos = ee_local_pos - - self.feet_force[:, :, :] = 0 - for i, sensor_name in enumerate(self._cfg.sensor.feet_force): - self.feet_force[:, i, :] = self._backend.get_sensor_data(sensor_name) - for i, sensor_name in enumerate(self._cfg.sensor.feet_pos): - self.feet_pos[:, i, :] = self._backend.get_sensor_data(sensor_name) - - terminated = gravity[:, 2] <= 0.5 - reward = self._compute_reward( - state.info, linvel, gyro, gravity, dof_pos, dof_vel, ee_local_pos - ) - obs = self._compute_obs( - state.info, - linvel, - gyro, - gravity, - dof_pos, - dof_vel, - ee_local_pos, - self.curr_ee_goal_cart, - self.feet_phase, - ) - return state.replace(obs=obs, reward=reward, terminated=terminated) - - def _compute_raw_obs( - self, - info: dict, - linvel: np.ndarray, - gyro: np.ndarray, - gravity: np.ndarray, - dof_pos: np.ndarray, - dof_vel: np.ndarray, - ee_local_pos: np.ndarray, - ee_goal_cart: np.ndarray, - feet_phase: np.ndarray, - *, - add_noise: bool = True, - ) -> np.ndarray: - """Compute single-step 79-dim obs (no history). - - Layout: linvel(3)+gyro(3)+(-gravity)(3)+command(3)+feet_phase(4)+ - diff(18)+dof_vel(18)+ee_local_pos(3)+ee_goal_cart(3)+ee_error(3)+ - last_actions(18) = 79 - """ - diff = dof_pos - self.default_angles - if add_noise: - noise_cfg = self._cfg.noise_config - linvel = self._obs_noise(linvel, noise_cfg.scale_linvel) - gyro = self._obs_noise(gyro, noise_cfg.scale_gyro) - gravity = self._obs_noise(gravity, noise_cfg.scale_gravity) - diff = self._obs_noise(diff, noise_cfg.scale_joint_angle) - dof_vel = self._obs_noise(dof_vel, noise_cfg.scale_joint_vel) - ee_local_pos = self._obs_noise(ee_local_pos, noise_cfg.scale_ee_pos) - n = len(dof_pos) - command = info["commands"] if info["commands"].shape[0] == n else info["commands"][:n] - last_actions = info.get( - "current_actions", np.zeros((n, self._num_action), dtype=get_global_dtype()) - ) - ee_error = ee_local_pos - ee_goal_cart - return np.concatenate( - [ - linvel, # 3 - gyro, # 3 - -gravity, # 3 - command, # 3 - feet_phase, # 4 - diff, # 18 - dof_vel, # 18 - ee_local_pos, # 3 - ee_goal_cart, # 3 - ee_error, # 3 - last_actions, # 18 - ], - axis=1, - dtype=get_global_dtype(), - ) - - def _update_history( - self, - raw_obs: np.ndarray, - env_ids: np.ndarray | None = None, - *, - critic_raw_obs: np.ndarray | None = None, - ) -> dict[str, np.ndarray]: - """Update history buffers and return obs dict (with or without env_ids slice). - - Actor buffer stores obs WITHOUT linvel (raw_obs[:, 3:], 76-dim) so the actor - cannot shortcut the estimator. Critic buffer stores clean full 79-dim obs - when a separate critic_raw_obs is provided. - """ - A = self._actor_one_step_dim # 76 - C = self._critic_one_step_dim # 79 - H_a = self._cfg.history.num_actor_history - H_c = self._cfg.history.num_critic_history - actor_step = raw_obs[:, 3:] if raw_obs.ndim == 2 else raw_obs[3:] - critic_step = raw_obs if critic_raw_obs is None else critic_raw_obs - if env_ids is None: - if H_a > 1: - self._history_obs_buf = np.roll(self._history_obs_buf, -A, axis=1) - self._history_obs_buf[:, -A:] = actor_step - if H_c > 1: - self._history_critic_buf = np.roll(self._history_critic_buf, -C, axis=1) - self._history_critic_buf[:, -C:] = critic_step - return { - "obs": self._history_obs_buf.copy(), - "critic": self._history_critic_buf.copy(), - } - else: - if H_a > 1: - self._history_obs_buf[env_ids] = np.roll(self._history_obs_buf[env_ids], -A, axis=1) - self._history_obs_buf[env_ids, -A:] = actor_step - if H_c > 1: - self._history_critic_buf[env_ids] = np.roll( - self._history_critic_buf[env_ids], -C, axis=1 - ) - self._history_critic_buf[env_ids, -C:] = critic_step - return { - "obs": self._history_obs_buf[env_ids].copy(), - "critic": self._history_critic_buf[env_ids].copy(), - } - - def _compute_obs( - self, - info: dict, - linvel: np.ndarray, - gyro: np.ndarray, - gravity: np.ndarray, - dof_pos: np.ndarray, - dof_vel: np.ndarray, - ee_local_pos: np.ndarray, - ee_goal_cart: np.ndarray, - feet_phase: np.ndarray, - ) -> dict[str, np.ndarray]: - actor_raw = self._compute_raw_obs( - info, - linvel, - gyro, - gravity, - dof_pos, - dof_vel, - ee_local_pos, - ee_goal_cart, - feet_phase, - add_noise=True, - ) - critic_raw = self._compute_raw_obs( - info, - linvel, - gyro, - gravity, - dof_pos, - dof_vel, - ee_local_pos, - ee_goal_cart, - feet_phase, - add_noise=False, - ) - return self._update_history(actor_raw, critic_raw_obs=critic_raw) - - def _compute_reward( - self, - info: dict, - linvel: np.ndarray, - gyro: np.ndarray, - gravity: np.ndarray, - dof_pos: np.ndarray, - dof_vel: np.ndarray, - ee_local_pos: np.ndarray, - ) -> np.ndarray: - dtype = get_global_dtype() - reward = np.zeros((self._num_envs,), dtype=dtype) - cfg = self._reward_cfg - self._current_ee_local_pos = ee_local_pos - ctx = RewardContext( - info=info, - linvel=linvel, - gyro=gyro, - gravity=gravity, - dof_pos=dof_pos, - dof_vel=dof_vel, - num_envs=self._num_envs, - default_angles=self.default_angles, - tracking_sigma=cfg.tracking_sigma, - base_height_target=cfg.base_height_target, - base_height=self._backend.get_base_pos()[:, 2], - pose_weights=self._leg_pose_weights, - ) - - step_count = info.get("steps", np.zeros((self._num_envs,), dtype=np.uint32)) - should_log = self._enable_reward_log and (int(step_count[0]) % 4 == 0) - log = {} if should_log else info.get("log", {}) - - for name, scale in cfg.scales.items(): - if scale == 0 or name not in self._reward_fns: - continue - rew = self._reward_fns[name](ctx) - weighted_rew = rew * scale - reward += weighted_rew - if name == "tracking_lin_vel": - self._episode_sum_tracking_vel += rew.astype(np.float64) - if should_log: - log[f"reward/{name}"] = float(np.mean(weighted_rew)) - - self._episode_steps += 1 - info["log"] = log - return reward * self._cfg.ctrl_dt - - def _reward_swing_feet_z(self, _ctx: RewardContext) -> np.ndarray: - is_swing = self.feet_phase >= 0.6 - height_error = np.square(self.feet_pos[:, :, 2] - self._reward_cfg.target_foot_height) - swing_rew = np.exp(-height_error / 0.01) * is_swing - reward: np.ndarray = np.sum(swing_rew, axis=1) / len(self._cfg.sensor.feet_pos) - return reward - - def _reward_foot_drag(self, _ctx: RewardContext) -> np.ndarray: - foot_heights = self.feet_pos[..., 2] - foot_contact = self.get_foot_contact() - is_swing = foot_contact < 0.5 - safe_height = self._reward_cfg.target_foot_height / 2.0 - height_error = np.clip(safe_height - foot_heights, 0.0, None) - error = np.square(height_error) * is_swing - drag_penalty: np.ndarray = np.sum(error, axis=1) - return drag_penalty - - def _reward_contact(self, _ctx: RewardContext) -> np.ndarray: - contact = self.feet_force[:, :, 2] > 0.1 - res = np.zeros(self._num_envs, dtype=np.float32) - for i in range(len(self._cfg.sensor.feet_force)): - is_contact = (self.feet_phase[:, i] < 0.6) | (self.gait_frequency < 1.0e-8) - res += (contact[:, i] == is_contact).astype(np.float32) - return res / len(self._cfg.sensor.feet_force) - - def _reward_object_distance(self, _ctx: RewardContext) -> np.ndarray: - dis_err = np.sum( - np.square(self._current_ee_local_pos - self.curr_ee_goal_cart), - axis=1, - ) - return np.exp(-dis_err / self._reward_cfg.object_sigma) # type: ignore[no-any-return] - - def _reward_object_distance_l2(self, _ctx: RewardContext) -> np.ndarray: - return np.sum( - np.square(self._current_ee_local_pos - self.curr_ee_goal_cart), - axis=1, - ) - - def _reward_stand_still(self, ctx: RewardContext) -> np.ndarray: - """Penalize leg deviation from the default pose when command is near zero.""" - commands = ctx.info["commands"] - is_still = (~self._command_is_moving(commands)).astype(get_global_dtype()) - assert ctx.dof_pos is not None - dof_error = np.sum(np.abs(ctx.dof_pos[:, :12] - ctx.default_angles[:12]), axis=1) - return is_still * dof_error - - def _reward_dof_vel(self, ctx: RewardContext) -> np.ndarray: - """L2 velocity penalty over all 18 joints.""" - assert ctx.dof_vel is not None - return np.sum(np.square(ctx.dof_vel), axis=1) # type: ignore[no-any-return] - - def _reward_dof_pos_limits(self, ctx: RewardContext) -> np.ndarray: - """Leg soft-limit penalty configured through reward_config limits.""" - cfg = self._reward_cfg - if not cfg.leg_dof_upper_limits or not cfg.leg_dof_lower_limits: - return np.zeros(self._num_envs, dtype=get_global_dtype()) - dtype = get_global_dtype() - upper = np.asarray(cfg.leg_dof_upper_limits, dtype=dtype) - lower = np.asarray(cfg.leg_dof_lower_limits, dtype=dtype) - m = cfg.dof_pos_limit_margin - leg_pos = ctx.dof_pos[:, :12] - over = np.square(np.maximum(leg_pos - upper + m, 0.0)) - under = np.square(np.maximum(lower + m - leg_pos, 0.0)) - return np.sum(over + under, axis=1) # type: ignore[no-any-return] - - _ARM_TOUCH_SENSORS = ( - "arm_touch_base", - "arm_touch_link1", - "arm_touch_link2", - "arm_touch_link3", - "arm_touch_link4", - "arm_touch_link5", - "arm_touch_link6", - "arm_touch_eef", - "arm_touch_g2base", - ) - - def _reward_arm_collision(self, _ctx: RewardContext) -> np.ndarray: - """Sum arm-link contact forces. The scale should be negative.""" - total = np.zeros(self._num_envs, dtype=get_global_dtype()) - for name in self._ARM_TOUCH_SENSORS: - total += self._backend.get_sensor_data(name)[:, 0] - return total - - -_GO2_ARM_COMPAT_FACTORY = adapt_legacy_factory( - Go2ArmManipLocoEnv, - task_family="Go2ArmManipLoco", - reason=( - "custom IK/Jacobian, end-effector goals, and observation history remain " - "task-owned until formal Manager-Based terms exist" - ), -) -for _backend_type in ("mujoco", "motrix", "drake"): - registry.register_env("Go2ArmManipLoco", _GO2_ARM_COMPAT_FACTORY, _backend_type) diff --git a/src/unilab/tasks/migration_matrix.py b/src/unilab/tasks/migration_matrix.py index 2238f4ecc..ef0af5569 100644 --- a/src/unilab/tasks/migration_matrix.py +++ b/src/unilab/tasks/migration_matrix.py @@ -56,7 +56,6 @@ class TaskMigrationRecord: _CUSTOM_COMPAT_TASKS = frozenset( { - "Go2ArmManipLoco", "SharpaInhandRotation", "SharpaInhandRotationGrasp", } @@ -144,13 +143,12 @@ def migration_record(task_name: str) -> TaskMigrationRecord: "Keep the manager contract and regression evidence current.", ) if task_name in _CUSTOM_COMPAT_TASKS: - family = "go2_arm" if task_name == "Go2ArmManipLoco" else "sharpa" return TaskMigrationRecord( task_name, - family, + "sharpa", "Adapted", "compatibility", - "Custom IK/history or tactile/contact/cache behavior is retained behind one frozen adapter.", + "Custom tactile/contact/cache behavior is retained behind one frozen adapter.", "Keep Hydra/Registry ownership single; migrate only when the formal capability exists.", ) if task_name in _MOTION_CORE_TASKS: diff --git a/src/unilab/utils/geometry.py b/src/unilab/utils/geometry.py index 5358f4f6d..4c0779390 100644 --- a/src/unilab/utils/geometry.py +++ b/src/unilab/utils/geometry.py @@ -30,16 +30,6 @@ def np_sample_uniform( return np.random.uniform(lower, upper, size).astype(dtype) -def np_quat_normalize(q: np.ndarray) -> np.ndarray: - """L2-normalize quaternion(s), clamping tiny norms to 1e-8 to avoid divide-by-zero.""" - q = np.asarray(q) - if q.ndim == 1: - norm = float(np.linalg.norm(q)) - return q / max(norm, 1.0e-8) - norm = np.linalg.norm(q, axis=-1, keepdims=True) - return q / np.clip(norm, 1.0e-8, None) - - def np_normalize_axis(axis: np.ndarray | tuple[float, ...] | list[float]) -> np.ndarray: """Return a unit-length copy of a rotation axis vector. Raises on zero norm.""" axis = np.asarray(axis) @@ -76,25 +66,6 @@ def np_gravity_z_in_body_from_quat(quat_w: np.ndarray) -> np.ndarray: return 2.0 * (quat_w[..., 1] * quat_w[..., 1] + quat_w[..., 2] * quat_w[..., 2]) - 1.0 -def np_quat_orientation_error_local(goal_quat: np.ndarray, curr_quat: np.ndarray) -> np.ndarray: - """Local-frame orientation error as the signed xyz of the relative quaternion. - - Both inputs are re-normalized to unit length. Returns the imaginary - part of ``goal * inv(curr)`` after canonicalization, giving a - signed-axis-scaled error suitable for PD-style feedback terms. - """ - goal = np_quat_normalize(goal_quat) - curr = np_quat_normalize(curr_quat) - if goal.ndim == 1: - goal = goal[None, :] - if curr.ndim == 1: - curr = curr[None, :] - rel = np_quat_mul(goal, np_quat_inv(curr)) - rel = np_quat_canonicalize(rel) - sign = np.where(rel[:, 0:1] < 0.0, -1.0, 1.0) - return rel[:, 1:] * sign - - def np_quat_angular_velocity_from_pair( quat: np.ndarray, prev_quat: np.ndarray, dt: float ) -> np.ndarray: @@ -121,30 +92,3 @@ def np_sample_uniform_quaternion(num_samples: int) -> np.ndarray: q4 = r2 * np.cos(u3) return np.stack([q4, q1, q2, q3], axis=1) - - -def np_spherical_to_cartesian(sphere: np.ndarray) -> np.ndarray: - """Convert ``(..., 3)[l, phi, theta]`` to ``(..., 3)[x, y, z]``. - - Uses the go2-arm spherical convention: ``phi`` sweeps in the x-z plane - from the positive x axis, and ``theta`` measures elevation toward - positive y. - """ - length = sphere[..., 0] - phi = sphere[..., 1] - theta = sphere[..., 2] - x = length * np.cos(phi) * np.cos(theta) - y = length * np.sin(theta) - z = length * np.sin(phi) * np.cos(theta) - return np.stack([x, y, z], axis=-1) - - -def np_cartesian_to_spherical(cart: np.ndarray) -> np.ndarray: - """Convert ``(..., 3)[x, y, z]`` to ``(..., 3)[l, phi, theta]`` (inverse of - :func:`np_spherical_to_cartesian`).""" - cart = np.asarray(cart) - l_sq = np.sum(cart**2, axis=-1, keepdims=True) - length = np.sqrt(np.maximum(l_sq, 1e-12)) - phi = np.arctan2(cart[..., 2:3], cart[..., 0:1]) - theta = np.arcsin(np.clip(cart[..., 1:2] / length, -1.0, 1.0)) - return np.concatenate([length, phi, theta], axis=-1) diff --git a/tests/assets/test_hub.py b/tests/assets/test_hub.py index 5429a5668..78de7517a 100644 --- a/tests/assets/test_hub.py +++ b/tests/assets/test_hub.py @@ -350,7 +350,6 @@ def test_robot_asset_specs_cover_hf_hosted_robots(): "g1", "go1", "go2", - "go2_arm", "go2w", "sharpa_wave", "x2", @@ -359,7 +358,7 @@ def test_robot_asset_specs_cover_hf_hosted_robots(): for robot, specs in ROBOT_ASSET_SPECS.items(): assert specs, robot for directory, marker, pattern, label in specs: - # go2_arm / go2w additionally reference the shared go2 mesh dir. + # go2w additionally reference the shared go2 mesh dir. assert directory.startswith("robots/") assert marker and pattern and label @@ -455,24 +454,3 @@ def fake_ensure(paths): "src/unilab/assets/robots/g1/locomotion_task.xml", ] ] - - -def test_ensure_robot_assets_go2_arm_pulls_shared_go2_meshes( - monkeypatch: pytest.MonkeyPatch, -): - """go2_arm XMLs reference ``../go2/assets``; both dirs must resolve.""" - from unilab.assets import hub - - calls: list[tuple[str, str]] = [] - monkeypatch.setattr( - hub, - "resolve_robot_asset_dir", - lambda directory, *, marker: calls.append((directory, marker)) or Path(directory), - ) - - hub.ensure_robot_assets_for_paths(["src/unilab/assets/robots/go2_arm/scene_flat.xml"]) - - assert calls == [ - ("robots/go2_arm/assets", "arm_base_0.obj"), - ("robots/go2/assets", "base_0.obj"), - ] diff --git a/tests/base/backend/test_mujoco_chunk_size_wiring.py b/tests/base/backend/test_mujoco_chunk_size_wiring.py index 3edf71d29..c88540d63 100644 --- a/tests/base/backend/test_mujoco_chunk_size_wiring.py +++ b/tests/base/backend/test_mujoco_chunk_size_wiring.py @@ -1,3 +1,5 @@ +from pathlib import Path + import numpy as np import pytest @@ -16,10 +18,9 @@ import mujoco from unisim.backend.mujoco.backend import MuJoCoBackend -from unilab.assets import ASSETS_ROOT_PATH from unilab.base.scene import SceneCfg -_MODEL_FILE = str(ASSETS_ROOT_PATH / "robots" / "go2_arm" / "scene_flat.xml") +_MODEL_FILE = str(Path(__file__).resolve().parents[2] / "fixtures/free_chain.xml") _NUM_ENVS = 4 diff --git a/tests/base/backend/test_mujoco_site_jacobian.py b/tests/base/backend/test_mujoco_site_jacobian.py index c702d553b..2e20412fb 100644 --- a/tests/base/backend/test_mujoco_site_jacobian.py +++ b/tests/base/backend/test_mujoco_site_jacobian.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + import numpy as np import pytest @@ -16,10 +18,9 @@ from unisim.backend.mujoco.backend import MuJoCoBackend -from unilab.assets import ASSETS_ROOT_PATH from unilab.base.scene import SceneCfg -MODEL_FILE = str(ASSETS_ROOT_PATH / "robots" / "go2_arm" / "scene_flat.xml") +MODEL_FILE = str(Path(__file__).resolve().parents[2] / "fixtures/free_chain.xml") NUM_ENVS = 4 ARM_JOINT_NAMES = ("joint1", "joint2", "joint3", "joint4", "joint5", "joint6") EE_SITE_NAME = "endpoint" diff --git a/tests/base/test_backend_imports.py b/tests/base/test_backend_imports.py index 87a35451b..33ed47ed9 100644 --- a/tests/base/test_backend_imports.py +++ b/tests/base/test_backend_imports.py @@ -11,9 +11,7 @@ _MATERIALIZER_CONSUMERS = ( "src/unilab/base/config_adapter.py", "src/unilab/scripts/train_rsl_rl.py", - "scripts/train_him_ppo.py", "scripts/train_hora_distill.py", - "scripts/manip_loco/benchmark_site_jacobian.py", ) @@ -44,40 +42,6 @@ def test_materializer_consumers_use_unisim_owner_module() -> None: assert offenders == [] -def test_site_jacobian_benchmark_imports_with_mujoco_stub() -> None: - code = textwrap.dedent( - """ - import importlib.util - import sys - import types - from pathlib import Path - - sys.modules["mujoco"] = types.ModuleType("mujoco") - path = Path(sys.argv[1]) - spec = importlib.util.spec_from_file_location("benchmark_site_jacobian", path) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - - print(module.materialize_scene_visual_override.__module__) - print("mujoco_backend", "unisim.backend.mujoco.backend" in sys.modules) - """ - ) - script = _REPO_ROOT / "scripts" / "manip_loco" / "benchmark_site_jacobian.py" - result = subprocess.run( - [sys.executable, "-c", code, str(script)], - check=True, - capture_output=True, - text=True, - ) - - assert result.stdout.splitlines() == [ - "unisim.backend.mujoco.xml", - "mujoco_backend False", - ] - - def test_mujoco_backend_import_path_does_not_eagerly_import_motrix() -> None: code = textwrap.dedent( """ diff --git a/tests/config/test_config_system.py b/tests/config/test_config_system.py index 7f791b174..234ea5e77 100644 --- a/tests/config/test_config_system.py +++ b/tests/config/test_config_system.py @@ -177,19 +177,6 @@ def test_supported_task_composes( _assert_reward_populated(cfg, task_file) -def test_ppo_go2_arm_manip_loco_motrix_preserves_backend_overrides(): - cfg = _compose("ppo", overrides=["task=go2_arm_manip_loco/motrix"]) - - assert cfg.training.task_name == "Go2ArmManipLoco" - assert cfg.training.sim_backend == "motrix" - assert cfg.algo.num_envs == 4096 - assert cfg.algo.max_iterations == 3000 - assert cfg.reward.scales.tracking_lin_vel == pytest.approx(2.0) - assert cfg.env.domain_rand.randomize_dof_armature is False - assert cfg.env.domain_rand.randomize_kp is False - assert cfg.env.domain_rand.randomize_kd is False - - def test_offpolicy_g1_walk_flat_motrix_sac_preserves_backend_overrides(): cfg = _compose("sac", overrides=["task=g1_walk_flat/motrix"]) @@ -305,7 +292,6 @@ def test_ppo_g1_backend_specific_hyperparams_remain_separate(): ("algo_dir", "overrides"), [ ("ppo", ["task=g1_walk_flat/mujoco"]), - ("ppo_him", ["task=go2_arm_manip_loco/mujoco"]), ("appo", ["task=g1_walk_flat/mujoco"]), ("sac", ["task=g1_walk_flat/mujoco"]), ("flashsac", ["task=g1_walk_flat/mujoco"]), diff --git a/tests/envs/locomotion/go2_arm/__init__.py b/tests/envs/locomotion/go2_arm/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/envs/locomotion/go2_arm/test_base_ik.py b/tests/envs/locomotion/go2_arm/test_base_ik.py deleted file mode 100644 index 044f9f129..000000000 --- a/tests/envs/locomotion/go2_arm/test_base_ik.py +++ /dev/null @@ -1,93 +0,0 @@ -"""IK unit tests for Go2Arm base environment.""" - -from __future__ import annotations - -import numpy as np - -from unilab.tasks.locomotion.go2_arm.base import Go2ArmBaseCfg, Go2ArmBaseEnv - - -class _IkHarness(Go2ArmBaseEnv): - def apply_action(self, actions, state): - raise NotImplementedError - - def update_state(self, state): - raise NotImplementedError - - -class _FakeBackend: - def __init__(self, jacp: np.ndarray, jacr: np.ndarray): - self._jacp = jacp - self._jacr = jacr - - def get_site_jacobian_w(self, site_id: int, dof_indices: np.ndarray): - del site_id, dof_indices - return self._jacp, self._jacr - - def get_sensor_data(self, name: str) -> np.ndarray: - del name - return np.asarray([[1.0, 0.0, 0.0, 0.0]], dtype=np.float64) - - -def _ik_env(*, use_orientation: bool, orientation_mode: str) -> Go2ArmBaseEnv: - jacp = np.asarray( - [ - [ - [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 1.0, 0.0, 0.0, 1.0], - ] - ], - dtype=np.float64, - ) - jacr = np.asarray( - [ - [ - [0.0, 0.0, 0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 0.0, 0.0, 1.0], - ] - ], - dtype=np.float64, - ) - env = object.__new__(_IkHarness) - cfg = Go2ArmBaseCfg() - cfg.ik.use_orientation = use_orientation - cfg.ik.orientation_mode = orientation_mode - cfg.ik.damping = 0.0 - cfg.ik.dq_clip = 0.0 - env._cfg = cfg - env._backend = _FakeBackend(jacp, jacr) - env._ee_site_id = 0 - env._arm_jacobian_dof_indices = np.arange(6, dtype=np.int32) - return env - - -def test_go2_arm_ik_zero_error_orientation_regularizes_rotation_nullspace(): - goal = np.asarray([[1.0, 2.0, 3.0]], dtype=np.float64) - curr = np.zeros((1, 3), dtype=np.float64) - - position_only = _ik_env(use_orientation=False, orientation_mode="target").compute_arm_ik_delta( - goal, - curr, - ) - zero_error = _ik_env( - use_orientation=True, - orientation_mode="zero_error", - ).compute_arm_ik_delta(goal, curr) - - np.testing.assert_allclose(position_only, [[0.5, 1.0, 1.5, 0.5, 1.0, 1.5]]) - np.testing.assert_allclose(zero_error, [[1.0, 2.0, 3.0, 0.0, 0.0, 0.0]]) - - -def test_go2_arm_ik_rejects_unknown_orientation_mode(): - env = _ik_env(use_orientation=True, orientation_mode="invalid") - goal = np.asarray([[1.0, 0.0, 0.0]], dtype=np.float64) - curr = np.zeros((1, 3), dtype=np.float64) - - try: - env.compute_arm_ik_delta(goal, curr) - except ValueError as exc: - assert "ik.orientation_mode" in str(exc) - else: - raise AssertionError("expected invalid ik.orientation_mode to raise ValueError") diff --git a/tests/envs/locomotion/go2_arm/test_manip_loco_contract.py b/tests/envs/locomotion/go2_arm/test_manip_loco_contract.py deleted file mode 100644 index b36b35bb5..000000000 --- a/tests/envs/locomotion/go2_arm/test_manip_loco_contract.py +++ /dev/null @@ -1,377 +0,0 @@ -"""Contract tests for Go2ArmManipLoco environment.""" - -from __future__ import annotations - -import importlib -import sys - -import numpy as np -import pytest -from gymnasium import spaces - -from unilab.base.np_env import NpEnvState - -_GO2_ARM_MANIP_LOCO_MODULE = "unilab.tasks.locomotion.go2_arm.manip_loco" -_REGISTRY_MODULE = "unilab.base.registry" - - -def _skip_if_no_mujoco(): - pytest.importorskip("mujoco", reason="mujoco not installed") - try: - from mujoco_uni.batch_env import BatchEnvPool # noqa: F401 - except Exception: - pytest.skip("mujoco_uni.batch_env not available") - - -def _default_reward_cfg(): - from unilab.tasks.locomotion.go2_arm.manip_loco import RewardConfig - - return RewardConfig( - scales={ - "tracking_lin_vel": 1.0, - "tracking_ang_vel": 0.2, - "lin_vel_z": -5.0, - "object_distance": 2.0, - }, - tracking_sigma=0.25, - base_height_target=0.3, - ) - - -def _make_env(num_envs: int = 2, env_cfg_override: dict | None = None): - _ensure_go2_arm_manip_loco_registered() - registry = _registry_module() - override = {"reward_config": _default_reward_cfg()} - if env_cfg_override: - override.update(env_cfg_override) - return registry.make( - "Go2ArmManipLoco", - sim_backend="mujoco", - num_envs=num_envs, - env_cfg_override=override, - ) - - -def _registry_module(): - return importlib.import_module(_REGISTRY_MODULE) - - -def _ensure_go2_arm_manip_loco_registered() -> None: - registry = _registry_module() - registry.ensure_registries() - if registry.contains("Go2ArmManipLoco"): - return - module = sys.modules.get(_GO2_ARM_MANIP_LOCO_MODULE) - if module is None: - importlib.import_module(_GO2_ARM_MANIP_LOCO_MODULE) - else: - importlib.reload(module) - - -def test_go2_arm_manip_loco_cfg_registered(): - """Go2ArmManipLoco config should be registered.""" - _ensure_go2_arm_manip_loco_registered() - registry = _registry_module() - assert registry.contains("Go2ArmManipLoco") - - -def test_go2_arm_manip_loco_registers_motrix_backend(): - """Go2ArmManipLoco should route through both MuJoCo and Motrix backends.""" - _ensure_go2_arm_manip_loco_registered() - registry = _registry_module() - meta = registry._envs["Go2ArmManipLoco"] - - assert meta.support_sim_backend("mujoco") - assert meta.support_sim_backend("motrix") - - -def test_go2_arm_manip_loco_cfg_declares_scene_for_playback(): - """MuJoCo video playback needs the original visual scene, not only legacy model_file.""" - from unilab.base.scene import SceneCfg - from unilab.tasks.locomotion.go2_arm.manip_loco import ( - Go2ArmManipLocoCfg, - _resolve_go2_arm_scene, - ) - - cfg = Go2ArmManipLocoCfg(reward_config=_default_reward_cfg()) - - assert isinstance(cfg.scene, SceneCfg) - assert cfg.scene.model_file == cfg.model_file - assert cfg.scene.model_file.replace("\\", "/").endswith("robots/go2_arm/scene_flat.xml") - - cfg.model_file = "custom_scene.xml" - scene = _resolve_go2_arm_scene(cfg) - assert scene.model_file == "custom_scene.xml" - assert cfg.scene is scene - - -def test_go2_arm_ee_goal_collision_check_matches_reference_semantics(): - """Any EE goal path sample inside the collision box or below ground is unsafe.""" - from unilab.tasks.locomotion.go2_arm.manip_loco import ( - EEGoalConfig, - Go2ArmManipLocoCfg, - Go2ArmManipLocoEnv, - _cart2sphere, - ) - - env = object.__new__(Go2ArmManipLocoEnv) - cfg = Go2ArmManipLocoCfg(reward_config=_default_reward_cfg()) - cfg.goal_ee = EEGoalConfig(num_collision_check_samples=3) - env._cfg = cfg - - starts = _cart2sphere(np.asarray([[0.4, 0.2, 0.0]], dtype=np.float32)) - through_collision_box = _cart2sphere(np.asarray([[0.0, 0.0, -0.3]], dtype=np.float32)) - below_ground = _cart2sphere(np.asarray([[0.4, 0.2, -0.8]], dtype=np.float32)) - clear_path = _cart2sphere(np.asarray([[0.4, 0.2, 0.2]], dtype=np.float32)) - - assert env._collision_check_sphere(starts, through_collision_box).tolist() == [True] - assert env._collision_check_sphere(starts, below_ground).tolist() == [True] - assert env._collision_check_sphere(starts, clear_path).tolist() == [False] - - -def test_go2_arm_command_moving_mask_includes_all_velocity_axes(): - """A command is moving when vx, vy, or vyaw exceeds the motion threshold.""" - from unilab.tasks.locomotion.go2_arm.manip_loco import Go2ArmManipLocoEnv - - env = object.__new__(Go2ArmManipLocoEnv) - clip = env._CMD_CLIP - commands = np.asarray( - [ - [0.0, 0.0, 0.0], - [0.0, 1.5 * clip, 0.0], - [0.0, 0.0, 1.5 * clip], - [1.5 * clip, 0.0, 0.0], - [0.5 * clip, -0.5 * clip, 0.5 * clip], - ], - dtype=np.float32, - ) - - assert env._command_is_moving(commands).tolist() == [False, True, True, True, False] - - normalized = env._normalize_velocity_commands(commands) - np.testing.assert_allclose(normalized[0], np.zeros(3, dtype=np.float32)) - np.testing.assert_allclose(normalized[4], np.zeros(3, dtype=np.float32)) - np.testing.assert_allclose(normalized[1:4], commands[1:4]) - - -def test_go2_arm_command_postprocess_can_force_zero_commands(): - """zero_command_prob should inject exact zero commands after small-command zeroing.""" - from unilab.tasks.locomotion.go2_arm.manip_loco import ( - Go2ArmManipLocoCfg, - Go2ArmManipLocoEnv, - ) - - env = object.__new__(Go2ArmManipLocoEnv) - cfg = Go2ArmManipLocoCfg(reward_config=_default_reward_cfg()) - cfg.commands.zero_command_prob = 1.0 - env._cfg = cfg - commands = np.asarray( - [ - [0.5, 0.2, 0.3], - [-0.5, -0.2, -0.3], - ], - dtype=np.float32, - ) - - np.testing.assert_allclose( - env._postprocess_velocity_commands(commands), np.zeros_like(commands) - ) - - cfg.commands.zero_command_prob = 0.0 - np.testing.assert_allclose(env._postprocess_velocity_commands(commands), commands) - - -def test_go2_arm_stand_still_reward_uses_same_command_mask(): - """stand_still should not penalize leg pose under lateral, yaw, or forward commands.""" - from unilab.tasks.locomotion.common.rewards import RewardContext - from unilab.tasks.locomotion.go2_arm.manip_loco import Go2ArmManipLocoEnv - - env = object.__new__(Go2ArmManipLocoEnv) - clip = env._CMD_CLIP - commands = np.asarray( - [ - [0.0, 0.0, 0.0], - [0.0, 1.5 * clip, 0.0], - [0.0, 0.0, 1.5 * clip], - [1.5 * clip, 0.0, 0.0], - ], - dtype=np.float32, - ) - dof_pos = np.ones((4, 18), dtype=np.float32) - ctx = RewardContext( - info={"commands": commands}, - linvel=np.zeros((4, 3), dtype=np.float32), - gyro=np.zeros((4, 3), dtype=np.float32), - dof_pos=dof_pos, - dof_vel=np.zeros((4, 18), dtype=np.float32), - default_angles=np.zeros(18, dtype=np.float32), - ) - - np.testing.assert_allclose(env._reward_stand_still(ctx), np.asarray([12.0, 0.0, 0.0, 0.0])) - - -def test_go2_arm_write_feet_phase_updates_indexed_envs(): - """Resetting env subsets must write back feet_phase instead of losing fancy-index copies.""" - from unilab.tasks.locomotion.go2_arm.manip_loco import Go2ArmManipLocoEnv - - env = object.__new__(Go2ArmManipLocoEnv) - env.phase = np.asarray([0.2, 0.4, 0.6], dtype=np.float32) - env.feet_phase = np.ones((3, 4), dtype=np.float32) - - env._write_feet_phase(np.asarray([0, 2], dtype=np.int32), np.asarray([False, True])) - - np.testing.assert_allclose(env.feet_phase[0], np.zeros(4, dtype=np.float32)) - np.testing.assert_allclose(env.feet_phase[1], np.ones(4, dtype=np.float32)) - np.testing.assert_allclose( - env.feet_phase[2], np.asarray([0.6, 0.1, 0.1, 0.6], dtype=np.float32), atol=1e-6 - ) - - -def test_go2_arm_apply_action_uses_arm_action_scale_for_arm_residual(): - """Leg residuals use action_scale while arm residuals use arm_action_scale.""" - from unilab.tasks.locomotion.go2_arm.manip_loco import ( - Go2ArmManipLocoCfg, - Go2ArmManipLocoEnv, - ) - - env = object.__new__(Go2ArmManipLocoEnv) - cfg = Go2ArmManipLocoCfg(reward_config=_default_reward_cfg()) - cfg.control_config.action_scale = 0.25 - cfg.control_config.arm_action_scale = 0.05 - cfg.ik.gain = 0.0 - env._cfg = cfg - env._num_envs = 1 - env.default_angles = np.zeros(18, dtype=np.float64) - env._action_space = spaces.Box(-np.inf, np.inf, shape=(18,), dtype=np.float64) - env.curr_ee_goal_cart = np.zeros((1, 3), dtype=np.float64) - env.ee_goal_orn_quat = np.zeros((1, 4), dtype=np.float64) - env.get_ee_local_pose = lambda: ( # type: ignore[method-assign] - np.zeros((1, 3), dtype=np.float64), - np.zeros((1, 4), dtype=np.float64), - ) - env.compute_arm_ik_delta = lambda *_args, **_kwargs: np.zeros( # type: ignore[method-assign] - (1, 6), dtype=np.float64 - ) - env.get_arm_dof_pos = lambda: np.ones((1, 6), dtype=np.float64) # type: ignore[method-assign] - - state = NpEnvState( - obs={}, - reward=np.zeros(1, dtype=np.float64), - terminated=np.zeros(1, dtype=bool), - truncated=np.zeros(1, dtype=bool), - info={}, - ) - ctrl = env.apply_action(np.ones((1, 18), dtype=np.float64), state) - - np.testing.assert_allclose(ctrl[0, :12], np.full(12, 0.25, dtype=np.float64)) - np.testing.assert_allclose(ctrl[0, 12:18], np.full(6, 1.05, dtype=np.float64)) - - -@pytest.mark.slow -def test_go2_arm_playback_resolves_visual_scene_model(tmp_path): - """Offline video export should re-materialize the visual XML for Go2Arm.""" - _skip_if_no_mujoco() - import mujoco - - from unilab.visualization.playback import _resolve_render_play_model_files - - env = _make_env(num_envs=2) - try: - assert env.cfg.scene is not None - model_file = _resolve_render_play_model_files(env, num_envs=2, tmp_dir=tmp_path) - assert isinstance(model_file, str) - - model = mujoco.MjModel.from_binary_path(model_file) - assert mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "floor") >= 0 - assert mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "base_link_visual") >= 0 - finally: - env.close() - - -@pytest.mark.slow -def test_go2_arm_obs_groups_spec(): - """obs_groups_spec should expose the expected actor and critic dimensions.""" - _skip_if_no_mujoco() - env = _make_env(num_envs=1) - assert env.obs_groups_spec == {"obs": 76, "critic": 79} - - -@pytest.mark.slow -def test_go2_arm_reset_step_contract(): - """init_state and step should return the expected shapes.""" - _skip_if_no_mujoco() - env = _make_env(num_envs=2) - state = env.init_state() - assert state.obs["obs"].shape == (2, 76) - assert state.obs["critic"].shape == (2, 79) - - actions = np.zeros((2, 18)) - state = env.step(actions) - assert state.reward.shape == (2,) - assert state.obs["obs"].shape == (2, 76) - - -@pytest.mark.slow -def test_go2_arm_ee_goal_valid_after_reset(): - """EE goals should have the expected shape and finite values after reset.""" - _skip_if_no_mujoco() - env = _make_env(num_envs=2) - env.init_state() - assert env.curr_ee_goal_cart.shape == (2, 3) - assert np.all(np.isfinite(env.curr_ee_goal_cart)) - - -@pytest.mark.slow -def test_go2_arm_ee_goal_resampling(): - """EE goal should change when the arm goal timer expires.""" - _skip_if_no_mujoco() - env = _make_env(num_envs=4) - env.init_state() - - # Force timer expiry by setting it to total_steps - 1 before step triggers >=. - env._arm_goal_timer[:] = env._traj_total_steps - 1 - goal_before = env.curr_ee_goal_cart.copy() - - actions = np.zeros((4, 18)) - env.step(actions) - - changed = not np.allclose(env.curr_ee_goal_cart, goal_before) - assert changed, "ee goal should have changed after arm_goal_timer expiry" - - -@pytest.mark.slow -def test_go2_arm_ee_goal_interpolation(): - """curr_ee_goal_cart should change during the movement phase via spherical interpolation.""" - _skip_if_no_mujoco() - env = _make_env(num_envs=2) - env.init_state() - - # Put the timer in the middle of the movement phase so expiry is not triggered. - env._arm_goal_timer[:] = 0 - env._traj_steps[:] = 100 - env._traj_total_steps[:] = 150 - - pos0 = env.curr_ee_goal_cart.copy() - env.step(np.zeros((2, 18))) - pos1 = env.curr_ee_goal_cart.copy() - - assert not np.allclose(pos0, pos1), "EE goal should interpolate each step" - - -@pytest.mark.slow -def test_go2_arm_command_resampling(): - """Command should change when the command timer expires.""" - _skip_if_no_mujoco() - env = _make_env(num_envs=4, env_cfg_override={"commands": {"resample_time_s": 0.02}}) - env.init_state() - - # Force timer expiry. - env._cmd_timer[:] = env._cmd_resample_steps - 1 - cmd_before = env._state.info["commands"].copy() - - actions = np.zeros((4, 18)) - env.step(actions) - - # At least some env commands should change. - changed = not np.allclose(env._state.info["commands"], cmd_before) - assert changed, "commands should have been resampled after timer expiry" diff --git a/tests/fixtures/free_chain.xml b/tests/fixtures/free_chain.xml new file mode 100644 index 000000000..7551495ea --- /dev/null +++ b/tests/fixtures/free_chain.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ipc/test_dp_launcher.py b/tests/ipc/test_dp_launcher.py index 41940a233..507bcaed1 100644 --- a/tests/ipc/test_dp_launcher.py +++ b/tests/ipc/test_dp_launcher.py @@ -346,7 +346,7 @@ def test_supervisor_spawn_argv_and_env(fake_popen, monkeypatch: pytest.MonkeyPat assert len(fake_popen.instances) == 2 for rank, child in enumerate(fake_popen.instances, start=1): assert child.argv[0] == sys.executable - assert child.argv[1].endswith("scripts/train_sac.py") + assert child.argv[1] == sys.argv[0] assert child.argv[2:] == ["training.devices=[0,1,2]"] assert child.start_new_session is (os.name == "posix") assert child.env[UNILAB_DP_RANK] == str(rank) diff --git a/tests/scripts/test_train_scripts.py b/tests/scripts/test_train_scripts.py index e03cc37d6..3201aab92 100644 --- a/tests/scripts/test_train_scripts.py +++ b/tests/scripts/test_train_scripts.py @@ -1400,35 +1400,6 @@ def test_build_ppo_play_env_cfg_override_keeps_task_owned_manager_scene( assert env_cfg_override["scene"]["entities"]["robot"]["joint_names"] -def test_go2_arm_manip_loco_motrix_eval_uses_visual_floor( - monkeypatch: pytest.MonkeyPatch, -): - mod = _train_rsl_rl(monkeypatch) - cfg = _ppo_cfg(["task=go2_arm_manip_loco/motrix", "training.play_only=true"]) - - captured = {} - - def _fake_materialize(source_model_file, **kwargs): - captured["source_model_file"] = source_model_file - captured.update(kwargs) - return "/tmp/go2_arm_manip_loco_play_scene.xml" - - monkeypatch.setattr(mod, "materialize_scene_visual_override", _fake_materialize) - - env_cfg_override = mod.build_ppo_play_env_cfg_override(cfg) - - assert captured["source_model_file"] == str( - (Path.cwd() / "src" / "unilab" / "assets" / "robots/go2_arm/scene_flat.xml").resolve() - ) - assert captured["ground_texture_file"] == str( - (Path.cwd() / "src" / "unilab" / "assets" / "robots/g1/textures/floor.png").resolve() - ) - assert captured["skybox_rgb1"] == [0.90, 0.90, 0.91] - assert captured["skybox_rgb2"] == [0.68, 0.68, 0.70] - assert captured["ground_texrepeat"] == [0.25, 0.25] - assert env_cfg_override["scene"].model_file == "/tmp/go2_arm_manip_loco_play_scene.xml" - - def test_run_motrix_rsl_play_loop_uses_render_spacing_and_offset_mode( monkeypatch: pytest.MonkeyPatch, ): @@ -3409,19 +3380,6 @@ def _fail_bootstrap() -> None: # --------------------------------------------------------------------------- -def _him_ppo_cfg(overrides=None): - GlobalHydra.instance().clear() - with initialize_config_dir(config_dir=str(_CONF_DIR / "ppo_him"), version_base="1.3"): - return compose( - "config", - overrides=["task=go2_arm_manip_loco/mujoco", *(overrides or [])], - ) - - -def _train_him_ppo(): - return _load_script("train_him_ppo") - - class _FakePlaybackEnv: """Env stand-in driving one initialize/step cycle through run_playback_mode.""" @@ -3509,94 +3467,6 @@ def fake_create_session(**kwargs: Any): assert env_captured["next_obs"] == "obs_1" -def test_train_him_ppo_play_missing_checkpoint_returns_none_without_env( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] -): - mod = _train_him_ppo() - cfg = _him_ppo_cfg(["training.play_only=true"]) - - monkeypatch.setattr(mod, "parse_checkpoint_path", lambda *args, **kwargs: (None, None)) - monkeypatch.setattr( - mod, - "create_env", - lambda *args, **kwargs: (_ for _ in ()).throw( - AssertionError("play_him_ppo should not create an env before checkpoint resolution") - ), - ) - - result = mod.play_him_ppo(cfg, device="cpu") - - assert result is None - assert "Could not resolve a checkpoint for play mode." in capsys.readouterr().out - - -def test_train_him_ppo_play_uses_shared_playback_session_factory( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -): - mod = _train_him_ppo() - cfg = _him_ppo_cfg(["training.play_only=true"]) - run_dir = tmp_path / "run" - run_dir.mkdir() - checkpoint = run_dir / "model_37.pt" - mod.torch.save({"actor_state_dict": {}}, checkpoint) - captured: dict[str, Any] = {} - - class FakeSession: - def __init__(self): - self.env = types.SimpleNamespace( - cfg=types.SimpleNamespace(render_spacing=1.0), - ) - self.runner = object() - self.policy = lambda obs: obs - self.reset_calls = 0 - self.step_calls = 0 - - def reset(self): - self.reset_calls += 1 - return {"actor": "obs_0"} - - def step_once(self): - self.step_calls += 1 - return {"actor": "obs_1"} - - fake_session = FakeSession() - - def fake_create_session(**kwargs: Any): - captured["factory_kwargs"] = kwargs - return fake_session, "actor", str(checkpoint) - - def fake_render_play_mode(env, **kwargs: Any): - captured["render_kwargs"] = kwargs - captured["init_obs"] = kwargs["initialize"]() - captured["next_obs"] = kwargs["step"](captured["init_obs"]) - - monkeypatch.setattr(mod, "EXPORT_POLICY", False, raising=False) - monkeypatch.setattr(mod, "parse_checkpoint_path", lambda *args, **kwargs: (checkpoint, run_dir)) - monkeypatch.setattr(mod, "create_rsl_rl_playback_session", fake_create_session) - monkeypatch.setattr(mod, "render_play_mode", fake_render_play_mode) - - result = mod.play_him_ppo(cfg, device="cpu") - - assert result == str(run_dir / "play_video.mp4") - factory_kwargs = captured["factory_kwargs"] - playback_cfg = factory_kwargs["playback_cfg"] - assert playback_cfg.task == cfg.training.task_name - assert playback_cfg.action_mode == "policy" - assert playback_cfg.num_envs == cfg.training.play_env_num - assert factory_kwargs["device"] == "cpu" - assert factory_kwargs["wrapper_cls"] is mod.RslRlVecEnvWrapper - assert factory_kwargs["runner_cls"] is mod.HIMOnPolicyRunner - assert factory_kwargs["guard_algo_name"] == "him_ppo" - assert callable(factory_kwargs["runner_loader"]) - assert factory_kwargs["checkpoint_resolver"]() == str(checkpoint) - assert callable(factory_kwargs["sim2sim_preflight"]) - assert fake_session.reset_calls == 1 - assert fake_session.step_calls == 1 - assert captured["init_obs"] == "obs_0" - assert captured["next_obs"] == "obs_1" - assert captured["render_kwargs"]["output_video"] == run_dir / "play_video.mp4" - - def test_play_appo_missing_checkpoint_returns_none_without_env( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ): diff --git a/tests/scripts/test_visualization_entrypoints.py b/tests/scripts/test_visualization_entrypoints.py index e85121204..c7d02fd26 100644 --- a/tests/scripts/test_visualization_entrypoints.py +++ b/tests/scripts/test_visualization_entrypoints.py @@ -210,10 +210,10 @@ def test_velocity_arrows_require_velocity_command_task_and_policy_obs(): module="unilab.tasks.locomotion.go2.joystick", obs_contains_command=True, ) - manip_loco_env = _keyboard_env( - env_cls_name="Go2ArmManipLocoEnv", - cfg_cls_name="Go2ArmManipLocoCfg", - module="unilab.tasks.locomotion.go2_arm.manip_loco", + custom_env = _keyboard_env( + env_cls_name="CustomTaskEnv", + cfg_cls_name="CustomTaskCfg", + module="custom_tasks.example", obs_contains_command=True, ) missing_obs_command_env = _keyboard_env( @@ -224,7 +224,7 @@ def test_velocity_arrows_require_velocity_command_task_and_policy_obs(): ) assert mod._should_render_velocity_arrows(joystick_env) is True - assert mod._should_render_velocity_arrows(manip_loco_env) is False + assert mod._should_render_velocity_arrows(custom_env) is False assert mod._should_render_velocity_arrows(missing_obs_command_env) is False diff --git a/tests/tasks/test_legacy_task_compatibility.py b/tests/tasks/test_legacy_task_compatibility.py index eebabe275..68e2e0803 100644 --- a/tests/tasks/test_legacy_task_compatibility.py +++ b/tests/tasks/test_legacy_task_compatibility.py @@ -89,14 +89,14 @@ def factory(cfg: EnvCfg, *, num_envs: int, backend_type: str) -> ABEnv: adapter = adapt_legacy_factory( factory, - task_family="Go2ArmManipLoco", + task_family="CustomLegacyTask", reason="existing task owner already constructs an NpEnv", ) cfg = _Cfg() assert adapter(cfg, num_envs=4, backend_type="motrix") is expected assert received == [(cfg, 4, "motrix")] - assert adapter.compatibility.task_family == "Go2ArmManipLoco" + assert adapter.compatibility.task_family == "CustomLegacyTask" assert adapter.compatibility.status is CompatibilityStatus.ADAPTED assert adapter.compatibility.reason == "existing task owner already constructs an NpEnv" @@ -120,8 +120,8 @@ def factory(cfg: EnvCfg, *, num_envs: int, backend_type: str) -> ABEnv: @pytest.mark.parametrize( ("result", "match"), ( - (object(), r"Go2ArmManipLoco.*object.*expected ABEnv"), - (_PlainABEnv(), r"Go2ArmManipLoco.*Unsupported.*_PlainABEnv.*NpEnv"), + (object(), r"CustomLegacyTask.*object.*expected ABEnv"), + (_PlainABEnv(), r"CustomLegacyTask.*Unsupported.*_PlainABEnv.*NpEnv"), ), ) def test_adapter_rejects_factories_outside_the_np_env_lifecycle( @@ -133,7 +133,7 @@ def factory(cfg: EnvCfg, *, num_envs: int, backend_type: str) -> object: adapter = adapt_legacy_factory( factory, # type: ignore[arg-type] - task_family="Go2ArmManipLoco", + task_family="CustomLegacyTask", reason="migration seam", ) @@ -180,7 +180,6 @@ def test_compatibility_metadata_requires_stable_family_and_reason( @pytest.mark.parametrize( ("task_name", "family", "backends"), ( - ("Go2ArmManipLoco", "Go2ArmManipLoco", {"mujoco", "motrix", "drake"}), ("SharpaInhandRotation", "Sharpa", {"mujoco", "motrix", "drake"}), ("SharpaInhandRotationGrasp", "Sharpa", {"mujoco", "motrix"}), ), diff --git a/tests/tasks/test_migration_matrix.py b/tests/tasks/test_migration_matrix.py index fb577f3a4..849af3f45 100644 --- a/tests/tasks/test_migration_matrix.py +++ b/tests/tasks/test_migration_matrix.py @@ -22,7 +22,6 @@ def test_registered_tasks_have_explicit_migration_records() -> None: @pytest.mark.parametrize( ("task_name", "family", "target", "status"), [ - ("Go2ArmManipLoco", "go2_arm", "compatibility", "Adapted"), ("SharpaInhandRotation", "sharpa", "compatibility", "Adapted"), ("G1MotionTracking", "motion_tracking", "complete", "Compatible"), ("G1WBTObs", "motion_tracking", "complete", "Compatible"), diff --git a/tests/tasks/test_package_boundary.py b/tests/tasks/test_package_boundary.py index 38cfb78b1..34d7f5056 100644 --- a/tests/tasks/test_package_boundary.py +++ b/tests/tasks/test_package_boundary.py @@ -17,7 +17,6 @@ "unilab.tasks.locomotion.go2", "unilab.tasks.locomotion.go2w", "unilab.tasks.locomotion.g1", - "unilab.tasks.locomotion.go2_arm", "unilab.tasks.locomotion.a2", "unilab.tasks.manipulation.allegro_inhand", "unilab.tasks.manipulation.sharpa_inhand", diff --git a/tests/test_cli.py b/tests/test_cli.py index 3ff387795..dae9f2ccb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -131,17 +131,17 @@ def test_train_profile_routes_to_owner_variant(tmp_path: Path) -> None: ] -def test_go2_arm_manip_loco_motrix_train_and_eval_route_to_owner_config( +def test_go2_joystick_flat_motrix_train_and_eval_route_to_owner_config( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - _make_minimal_checkout(tmp_path, task="go2_arm_manip_loco") + _make_minimal_checkout(tmp_path, task="go2_joystick_flat") _pretend_motrix_is_installed(monkeypatch) monkeypatch.setattr(cli.platform, "system", lambda: "Linux") train_command = cli.build_command( mode="train", algo="ppo", - task="go2_arm_manip_loco", + task="go2_joystick_flat", sim="motrix", overrides=[], root=tmp_path, @@ -149,7 +149,7 @@ def test_go2_arm_manip_loco_motrix_train_and_eval_route_to_owner_config( eval_command = cli.build_command( mode="eval", algo="ppo", - task="go2_arm_manip_loco", + task="go2_joystick_flat", sim="motrix", overrides=[], load_run="-1", @@ -158,11 +158,11 @@ def test_go2_arm_manip_loco_motrix_train_and_eval_route_to_owner_config( assert train_command[1:] == [ str(tmp_path / "scripts" / "train_rsl_rl.py"), - "task=go2_arm_manip_loco/motrix", + "task=go2_joystick_flat/motrix", ] assert eval_command[1:3] == [ str(tmp_path / "scripts" / "train_rsl_rl.py"), - "task=go2_arm_manip_loco/motrix", + "task=go2_joystick_flat/motrix", ] assert "training.play_only=true" in eval_command assert "algo.load_run=-1" in eval_command @@ -657,13 +657,10 @@ def test_demo_registry_contains_expected_entries() -> None: "dance", "wallflip", "boxtracking", - "locomani", "sharpa_appo_student", "inhandgrasp", "teaser", } - assert demo.DEMO_REGISTRY["locomani"].entry == "play_interactive" - assert demo.DEMO_REGISTRY["locomani"].sim == "mujoco" assert demo.DEMO_REGISTRY["inhandgrasp"] == demo.DemoSpec( algo="hora_distill", task="sharpa_inhand", @@ -701,25 +698,6 @@ def test_demo_eval_entry_passes_checkpoint_as_load_run_override( assert f"algo.load_run={abs_pt}" in command -def test_demo_play_interactive_entry_assembles_locomani_command( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - _make_demo_checkout(tmp_path, demo_name="locomani") - monkeypatch.setattr(demo.platform, "system", lambda: "Linux") - abs_pt = str(tmp_path / "fake" / "model_0.pt") - command = demo.build_demo_command( - demo_name="locomani", checkpoint_path=abs_pt, device="cpu", root=tmp_path - ) - - assert command[0] == sys.executable - assert command[1] == str(tmp_path / "scripts" / "play_interactive.py") - assert command[2:4] == ["--algo", "ppo"] - assert command[4:8] == ["--task", "go2_arm_manip_loco", "--sim", "mujoco"] - assert f"algo.load_run={abs_pt}" in command - assert "training.device=cpu" in command - assert "interactive.camera_follow_body=false" in command - - def test_demo_play_interactive_entry_assembles_inhandgrasp_command( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -801,7 +779,7 @@ def test_demo_play_interactive_sac_owner_path_uses_sac_tree(tmp_path: Path) -> N def test_demo_play_interactive_linux_does_not_materialize_mjpython_app( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - _make_demo_checkout(tmp_path, demo_name="locomani") + _make_demo_checkout(tmp_path, demo_name="inhandgrasp") monkeypatch.setattr(demo.platform, "system", lambda: "Linux") def fail_materialize() -> None: @@ -810,7 +788,7 @@ def fail_materialize() -> None: monkeypatch.setattr(demo, "_ensure_mujoco_mjpython_app", fail_materialize) command = demo.build_demo_command( - demo_name="locomani", + demo_name="inhandgrasp", checkpoint_path="/tmp/fake/model_0.pt", root=tmp_path, ) @@ -821,7 +799,7 @@ def fail_materialize() -> None: def test_demo_play_interactive_uses_mjpython_on_macos( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - _make_demo_checkout(tmp_path, demo_name="locomani") + _make_demo_checkout(tmp_path, demo_name="inhandgrasp") venv_bin = tmp_path / ".venv" / "bin" venv_bin.mkdir(parents=True) fake_python = venv_bin / "python" @@ -833,7 +811,7 @@ def test_demo_play_interactive_uses_mjpython_on_macos( monkeypatch.setattr(demo, "_ensure_mujoco_mjpython_app", lambda: None) command = demo.build_demo_command( - demo_name="locomani", + demo_name="inhandgrasp", checkpoint_path="/tmp/fake/model_0.pt", root=tmp_path, ) @@ -846,13 +824,13 @@ def test_demo_play_interactive_checks_mujoco_mjpython_app_on_macos( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: calls: list[str] = [] - _make_demo_checkout(tmp_path, demo_name="locomani") + _make_demo_checkout(tmp_path, demo_name="inhandgrasp") monkeypatch.setattr(demo.platform, "system", lambda: "Darwin") monkeypatch.setattr(demo, "_ensure_mujoco_mjpython_app", lambda: calls.append("checked")) monkeypatch.setattr(demo, "_current_env_mjpython", lambda: "/tmp/mjpython") command = demo.build_demo_command( - demo_name="locomani", + demo_name="inhandgrasp", checkpoint_path="/tmp/fake/model_0.pt", root=tmp_path, ) @@ -867,21 +845,21 @@ def test_demo_play_interactive_requires_owner_yaml(tmp_path: Path) -> None: with pytest.raises(SystemExit, match="owner config"): demo.build_demo_command( - demo_name="locomani", + demo_name="inhandgrasp", checkpoint_path="/tmp/fake/model_0.pt", root=tmp_path, ) def test_demo_play_interactive_requires_script(tmp_path: Path) -> None: - spec = demo.DEMO_REGISTRY["locomani"] + spec = demo.DEMO_REGISTRY["inhandgrasp"] owner_dir = tmp_path / "conf" / spec.algo / "task" / spec.task owner_dir.mkdir(parents=True) (owner_dir / f"{spec.sim}.yaml").write_text("training:\n", encoding="utf-8") with pytest.raises(SystemExit, match="play_interactive.py"): demo.build_demo_command( - demo_name="locomani", + demo_name="inhandgrasp", checkpoint_path="/tmp/fake/model_0.pt", root=tmp_path, ) diff --git a/tests/test_completion.py b/tests/test_completion.py index 38963646d..bc342a938 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -308,7 +308,6 @@ def test_demo_positional_completes_all_demo_names(tmp_path: Path) -> None: "boxtracking", "dance", "inhandgrasp", - "locomani", "sharpa_appo_student", "teaser", "wallflip", diff --git a/tests/visualization/test_interactive_playback.py b/tests/visualization/test_interactive_playback.py index 45b89ebfa..30a784de9 100644 --- a/tests/visualization/test_interactive_playback.py +++ b/tests/visualization/test_interactive_playback.py @@ -1283,14 +1283,14 @@ def fake_dim_guard(**kwargs): kwargs = _rsl_rl_session_kwargs(tmp_path) kwargs["checkpoint_resolver"] = lambda *args: str(checkpoint) kwargs["runner_cls"] = Runner - kwargs["guard_algo_name"] = "him_ppo" + kwargs["guard_algo_name"] = "custom_algo" create_rsl_rl_playback_session(**kwargs) assert captured["dim_guard"] == { "env_obs_dim": 5, "env_action_dim": 2, - "algo_name": "him_ppo", + "algo_name": "custom_algo", } diff --git a/uv.lock b/uv.lock index 51df0b47f..f70ac92c7 100644 --- a/uv.lock +++ b/uv.lock @@ -5118,7 +5118,7 @@ requires-dist = [ { name = "trimesh", marker = "extra == 'newton'", specifier = ">=3.21.7" }, { name = "trimesh", marker = "extra == 'viser'", specifier = ">=3.21.7" }, { name = "typing-extensions" }, - { name = "unilab-rl", specifier = "==1.0.0" }, + { name = "unilab-rl", specifier = "==1.1.0" }, { name = "unisim-core", specifier = ">=1.1.3" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, @@ -5139,7 +5139,7 @@ dev = [ [[package]] name = "unilab-rl" -version = "1.0.0" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hydra-core" }, @@ -5156,9 +5156,9 @@ dependencies = [ { name = "torch", version = "2.9.0+cu130", source = { registry = "https://download-r2.pytorch.org/whl/cu130" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, { name = "wandb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/ba/d924223d8bccd9714bbe28152a51a12f0ea7f6390584d15fee43f4087a5a/unilab_rl-1.0.0.tar.gz", hash = "sha256:b2ab788a99054b2a75183b08c76c9cb5e70af5a6ed749316106a082ded9fbe16", size = 176442, upload-time = "2026-09-04T12:11:26.591Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d2/93615e9770553ac4c3f67b27203c28509767f994484c2c1237ac1fdce731/unilab_rl-1.1.0.tar.gz", hash = "sha256:1051ef15e0b809fd9a7197458987421fb11052b34ffa49a90a92f76fb5d38e0d", size = 176655, upload-time = "2026-09-06T07:01:21.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/b9/f6a5e7772ca8f17c458627657eb1c05564b9abeefdce67afea6a9f044e53/unilab_rl-1.0.0-py3-none-any.whl", hash = "sha256:6405df72e66c0e3f2d20bf92020ef343f0047e63235088ec36151a4a8e0963f9", size = 221834, upload-time = "2026-09-04T12:11:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/0e/94/4300a8b866d275226e78d2d4dbb92d82d857cb328e14ab02cf5bd20b1d90/unilab_rl-1.1.0-py3-none-any.whl", hash = "sha256:c35e76b4a138a9196f835c5166c1f32c16df82df74a8fcdf356393be53ee5952", size = 222098, upload-time = "2026-09-06T07:01:20.069Z" }, ] [[package]]