diff --git a/.gitignore b/.gitignore index 7386f0a..29a3009 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ logs/ checkpoints/ wandb/ sim2sim_log_*.txt +**/sim2sim_temp.xml # IDE and operating system files .idea/ diff --git a/01_doc/version_history.md b/01_doc/version_history.md new file mode 100644 index 0000000..2607850 --- /dev/null +++ b/01_doc/version_history.md @@ -0,0 +1,19 @@ +# 版本演进 + +本项目使用同一条 `16dof` 主线和里程碑 Tag 保存线性演进,不在源码目录中复制历史版本。 + +| Tag | 阶段 | 核心内容 | +| --- | --- | --- | +| `v0.1.0` | 8DOF 中期检查 | 8DOF 串联足机械与大疆 A 板实机版本 | +| `v0.2.0` | 16DOF 机械 | 16DOF 串联轮足机械 CAD 与 STEP | +| `v0.3.0` | 第一代软件闭环 | 早期训练、MJCF、MuJoCo、Sim2Sim、IK 与 Python Sim2Real | +| `v0.3.1` | 实机记录 | 补充第一代 Sim2Real 实机视频 | +| `v0.4.0` | 新训练基线 | 第一份完整的新 MJCF、新 mjlab 框架和 Rough 策略工程 | + +## `v0.4.0` 的模型变化 + +- 机械 CAD 不变。 +- MJCF 更新整机质量和惯性参数,旧、新 `wheelleg.xml` 的 SHA-256 不同。 +- mjlab 上游基准从 `00409797` 更新到 `40f8d93e`。 +- 保留轮腿分组执行器随机化所需的本地补丁。 +- 本阶段归档 `model_rough.pt`,不将生成日志、缓存和临时 XML 纳入版本库。 diff --git a/05_software/README.md b/05_software/README.md index 98c93e4..b0bc2b3 100644 --- a/05_software/README.md +++ b/05_software/README.md @@ -1,11 +1,11 @@ # 软件 -本目录当前保存 16DOF 轮足机器人的第一代完整软件闭环。 +本目录保存 16DOF 轮足机器人的训练、仿真和真机软件演进。 ```text 05_software/ ├─ train/ -│ └─ rc_mjlab/ # 训练、MJCF、MuJoCo、Sim2Sim 和本地 mjlab 依赖 +│ └─ rc_mjlab/ # 训练、MJCF、Sim2Sim 和本地 mjlab 依赖 └─ real/ ├─ ik_real/ # IK 轨迹与早期真机控制 └─ sim2real/ # 第一代 Python 策略真机部署 @@ -18,8 +18,6 @@ MJCF + mjlab task | v PPO 训练策略 - | - +----> MuJoCo 独立模型调试 | +----> Sim2Sim 策略验证 | @@ -28,7 +26,7 @@ MJCF + mjlab task IK real --------------------------------> 电机 ``` -`rc_mjlab` 在早期版本中是自包含工程。训练、MJCF、独立 MuJoCo、Sim2Sim 和策略权重通过相对路径绑定,因此本次保留其原始内部布局,没有为了目录外观拆散。 +`rc_mjlab` 是自包含工程。训练、MJCF、Sim2Sim 和策略权重通过相对路径绑定,因此保留其内部布局,没有为了目录外观拆散。第一代完整闭环见 `v0.3.0`,第一份新版 MJCF 与训练框架见 `v0.4.0`。 详细说明见: diff --git a/05_software/train/README.md b/05_software/train/README.md index 92d2fab..cc6c4d9 100644 --- a/05_software/train/README.md +++ b/05_software/train/README.md @@ -1,15 +1,18 @@ -# 第一代强化学习与仿真工程 +# 强化学习与仿真工程 -`rc_mjlab/` 是 16DOF 轮足机器人的第一代自包含训练与仿真工程。 +`rc_mjlab/` 保存 16DOF 轮足机器人的当前训练与 Sim2Sim 工程。历史快照由 Git Tag 保留,不在目录中复制 `old`、`new` 或 `final` 版本。 + +当前内容对应 `v0.4.0`,是第一份完整采用新版 MJCF 和新版 mjlab 框架的训练工程。 ## 内容 - `src/robot`:Flat、Rough、Crawl 训练任务和自定义 MDP - `mjcf`:轮足机器人 MuJoCo 模型和网格 -- `mujoco_sim`:不依赖策略的独立 MuJoCo/MPC 调试工具 - `sim2sim`:策略加载、交互控制和比赛地形验证 - `mjlab`:固定版本的本地训练框架依赖 -- `model_rough.pt`、`model_crawl.pt`:对应的早期策略权重 +- `model_rough.pt`:本阶段 Rough 策略权重 - `pyproject.toml`、`uv.lock`:Python 环境与依赖锁定 +与 `v0.3.0` 相比,本版本更新了 MJCF 质量和惯性参数,并将 mjlab 上游基准从 `00409797` 更新到 `40f8d93e`。机械 CAD 未发生变化。 + 工程命令和任务说明见 [`rc_mjlab/README.md`](rc_mjlab/README.md),本地依赖来源见 [`rc_mjlab/DEPENDENCIES.md`](rc_mjlab/DEPENDENCIES.md)。 diff --git a/05_software/train/rc_mjlab/DEPENDENCIES.md b/05_software/train/rc_mjlab/DEPENDENCIES.md index 7e22564..b3fb65e 100644 --- a/05_software/train/rc_mjlab/DEPENDENCIES.md +++ b/05_software/train/rc_mjlab/DEPENDENCIES.md @@ -2,14 +2,14 @@ ## Python 环境 -- Python `>=3.10` +- Python `>=3.10,<3.14` - `uv` 依赖管理 -- MuJoCo development wheel +- MuJoCo `3.8` 系列 - `mjlab[cu128]` - PyTorch CUDA 12.8 环境 - `pynput` -精确解析结果保存在 `uv.lock`。项目使用本地可编辑 `mjlab`: +精确解析结果保存在 `uv.lock`。项目使用本地可编辑的 `mjlab`: ```toml [tool.uv.sources] @@ -19,17 +19,15 @@ mjlab = { path = "mjlab", editable = true } ## mjlab 来源 - 上游仓库:`https://github.com/mujocolab/mjlab.git` -- 基准提交:`0040979763ab43bc1220812c9de4bc74e2631f42` -- 基准日期:`2026-04-28` +- 基准提交:`40f8d93e31b589dccae78ba6aadfc4b74cd1e3fd` +- 基准日期:`2026-06-02` - 上游许可证:Apache-2.0,许可证文件保留在 `mjlab/LICENSE` -早期工程在该基准上保留了 3 处本地修改: +本版本在该基准上保留 1 处本地修改: -1. `mjlab/pyproject.toml`:增加清华 PyPI 镜像。 -2. `mjlab/src/mjlab/envs/mdp/dr/actuator.py`:让 effort limit 随机化支持轮子使用的 velocity/motor actuator。 -3. `mjlab/src/mjlab/scene/scene.py`:通过 XML 字符串加载场景,以适配当时的场景组合方式。 +1. `mjlab/src/mjlab/envs/mdp/dr/actuator.py`:为分组执行器补充名称到运行时执行器对象的解析,使 PD 增益和力矩限制随机化能够正确作用于轮腿机器人的执行器组。 -本次归档保留修改后的完整工作树,但不包含上游 `.git`、本地 `.venv`、缓存和生成日志。 +本次归档保留修改后的完整工作树,但不包含上游 `.git`、本地缓存、生成日志和运行时临时文件。 ## 基本入口 diff --git a/05_software/train/rc_mjlab/README.md b/05_software/train/rc_mjlab/README.md index 87d90da..b690c7a 100644 --- a/05_software/train/rc_mjlab/README.md +++ b/05_software/train/rc_mjlab/README.md @@ -2,6 +2,8 @@ 基于 [mjlab](https://github.com/google-deepmind/mjlab) 框架的四轮腿混合机器人强化学习训练与部署部署项目,面向机器人竞赛场景(如越障、匍匐、斜坡、台阶等复合任务)。 +> 本目录对应 `v0.4.0`:第一份完整的新 MJCF 与新框架训练工程。该快照包含 `model_rough.pt`;未包含独立 `mujoco_sim` 工具和单独的 Crawl 策略权重,相关早期内容仍可通过 `v0.3.0` 查看。 + --- ## 🛠️ 项目简介 @@ -51,8 +53,7 @@ rc_mjlab/ │ ├── wheelleg.xml # 机器人 MuJoCo 模型(含网格引用) │ ├── scene.xml # mjlab 场景入口文件 │ └── meshes/ # STL/OBJ 碰撞与外观网格 -├── mujoco_sim/ # 独立 MPC 仿真调试工具(不依赖 RL 训练) -├── logs/ # 训练日志(rsl_rl 格式,按任务名/日期/checkpoint 归档) +├── model_rough.pt # 本阶段用于回放和 Sim2Sim 的 Rough 策略 ├── pyproject.toml # 项目依赖(uv 管理,含清华镜像源加速) └── uv.lock # 精确依赖锁定文件 ``` diff --git a/05_software/train/rc_mjlab/mjcf/sim2sim_temp.xml b/05_software/train/rc_mjlab/mjcf/sim2sim_temp.xml deleted file mode 100644 index 80306ee..0000000 --- a/05_software/train/rc_mjlab/mjcf/sim2sim_temp.xml +++ /dev/null @@ -1,327 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/05_software/train/rc_mjlab/mjcf/wheelleg.xml b/05_software/train/rc_mjlab/mjcf/wheelleg.xml index 960a3cb..b4d50c6 100644 --- a/05_software/train/rc_mjlab/mjcf/wheelleg.xml +++ b/05_software/train/rc_mjlab/mjcf/wheelleg.xml @@ -26,28 +26,28 @@ - + - + - + - + - + @@ -56,23 +56,23 @@ - + - + - + - + @@ -81,23 +81,23 @@ - + - + - + - + @@ -106,23 +106,23 @@ - + - + - + - + diff --git a/05_software/train/rc_mjlab/mjlab/.github/workflows/ci.yml b/05_software/train/rc_mjlab/mjlab/.github/workflows/ci.yml index 06fa858..50b8633 100644 --- a/05_software/train/rc_mjlab/mjlab/.github/workflows/ci.yml +++ b/05_software/train/rc_mjlab/mjlab/.github/workflows/ci.yml @@ -28,9 +28,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: true version: "0.9.27" @@ -45,9 +45,9 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} enable-cache: true @@ -68,9 +68,9 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} enable-cache: true @@ -84,9 +84,9 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} enable-cache: true diff --git a/05_software/train/rc_mjlab/mjlab/.github/workflows/claude-code-review.yml b/05_software/train/rc_mjlab/mjlab/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..b5e8cfd --- /dev/null +++ b/05_software/train/rc_mjlab/mjlab/.github/workflows/claude-code-review.yml @@ -0,0 +1,44 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/05_software/train/rc_mjlab/mjlab/.github/workflows/claude.yml b/05_software/train/rc_mjlab/mjlab/.github/workflows/claude.yml new file mode 100644 index 0000000..6b15fac --- /dev/null +++ b/05_software/train/rc_mjlab/mjlab/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr *)' + diff --git a/05_software/train/rc_mjlab/mjlab/.github/workflows/docs.yml b/05_software/train/rc_mjlab/mjlab/.github/workflows/docs.yml index cd140b0..ce75536 100644 --- a/05_software/train/rc_mjlab/mjlab/.github/workflows/docs.yml +++ b/05_software/train/rc_mjlab/mjlab/.github/workflows/docs.yml @@ -17,7 +17,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -26,7 +26,7 @@ jobs: python-version: '3.13' - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@v7 - name: Build Sphinx Documentation run: uv run --group docs sphinx-multiversion docs docs/_build diff --git a/05_software/train/rc_mjlab/mjlab/.github/workflows/release.yml b/05_software/train/rc_mjlab/mjlab/.github/workflows/release.yml index 7b6da9b..900e535 100644 --- a/05_software/train/rc_mjlab/mjlab/.github/workflows/release.yml +++ b/05_software/train/rc_mjlab/mjlab/.github/workflows/release.yml @@ -15,9 +15,9 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Install Python 3.13 run: uv python install 3.13 - name: Build diff --git a/05_software/train/rc_mjlab/mjlab/CITATION.cff b/05_software/train/rc_mjlab/mjlab/CITATION.cff index f506d30..26ab2f8 100644 --- a/05_software/train/rc_mjlab/mjlab/CITATION.cff +++ b/05_software/train/rc_mjlab/mjlab/CITATION.cff @@ -33,9 +33,9 @@ keywords: - reinforcement-learning - robotics license: Apache-2.0 -commit: e2f33c6fb49caa26ec11f7b2de3c0c9aba71e9fd -version: 1.3.0 -date-released: '2026-04-14' +commit: 3cc461cd15e7155a8998b75ad767fae6dd448072 +version: 1.4.0 +date-released: '2026-05-26' preferred-citation: type: article title: >- diff --git a/05_software/train/rc_mjlab/mjlab/Makefile b/05_software/train/rc_mjlab/mjlab/Makefile index 7640c58..7a1b096 100644 --- a/05_software/train/rc_mjlab/mjlab/Makefile +++ b/05_software/train/rc_mjlab/mjlab/Makefile @@ -1,6 +1,10 @@ .PHONY: sync sync: - uv sync --all-extras --all-packages --group dev + uv sync --all-packages --extra cu128 --group dev + +.PHONY: sync-cpu +sync-cpu: + uv sync --all-packages --extra cpu --group dev .PHONY: format format: diff --git a/05_software/train/rc_mjlab/mjlab/docs/source/actuators.rst b/05_software/train/rc_mjlab/mjlab/docs/source/actuators.rst index bb4ff6e..4227837 100644 --- a/05_software/train/rc_mjlab/mjlab/docs/source/actuators.rst +++ b/05_software/train/rc_mjlab/mjlab/docs/source/actuators.rst @@ -61,6 +61,7 @@ MuJoCo's integrator handles velocity-dependent forces. **Built-in actuators** (``BuiltinPositionActuator``, ``BuiltinVelocityActuator``, ``BuiltinMotorActuator``, +``BuiltinPdActuator``, ``BuiltinDcMotorActuator``, ``BuiltinMuscleActuator``) create native MuJoCo actuator elements in the MjSpec. The physics engine computes the control law and integrates velocity-dependent damping forces implicitly. This provides the best @@ -119,6 +120,31 @@ control. **BuiltinMotorActuator**: Creates ```` actuators for direct torque control. +**BuiltinPdActuator**: Native PD that closes on both a position and a +velocity target, implemented as paired ```` + ```` +actuators summing to ``kp * (p_target - q) + kd * (v_target - qdot)``. +``BuiltinPositionActuator`` puts kd on the ```` element and +implicitly assumes a zero velocity reference; use this when the policy +emits a non-zero velocity target. Native delivery lets +``implicit`` / ``implicitfast`` see the kd term in their velocity update, +unlike ``IdealPdActuator`` which forwards Python-computed torque through +an opaque ````. + +**BuiltinDcMotorActuator**: Wraps MuJoCo's native +` `_ +element. Torque is ``tau = K * (V - K * omega) / R``; the back-EMF runs +through the native bias path, so ``implicit`` / ``implicitfast`` pick up +its velocity derivative as effective damping. Three input modes pick what +``ctrl`` carries: VOLTAGE drives the motor directly; POSITION / VELOCITY +close an internal PID (with anti-windup and slew limiting) against a +single setpoint, whose Vmax-clamped output becomes torque. POSITION mode +pins v_target = 0 (the kd term acts on raw velocity). Optional physics: +inductance, +thermal model with I^2R heating, cogging ripple, LuGre friction. +``DcMotorActuator`` (the explicit version) is a software PD with a +velocity-dependent torque clamp on top of a ````; this is the real +electrical model. + **BuiltinMuscleActuator**: Creates ```` actuators for biologically-inspired muscle dynamics with force-length-velocity characteristics. diff --git a/05_software/train/rc_mjlab/mjlab/docs/source/api/actuator.rst b/05_software/train/rc_mjlab/mjlab/docs/source/api/actuator.rst index 80b1456..f89063b 100644 --- a/05_software/train/rc_mjlab/mjlab/docs/source/api/actuator.rst +++ b/05_software/train/rc_mjlab/mjlab/docs/source/api/actuator.rst @@ -18,6 +18,13 @@ mjlab.actuator - :class:`BuiltinPositionActuatorCfg` - :class:`BuiltinVelocityActuator` - :class:`BuiltinVelocityActuatorCfg` + - :class:`BuiltinPdActuator` + - :class:`BuiltinPdActuatorCfg` + - :class:`BuiltinDcMotorActuator` + - :class:`BuiltinDcMotorActuatorCfg` + - :class:`DcMotorInputMode` + - :class:`DcMotorDatasheetParams` + - :class:`DcMotorPhysicalParams` - :class:`BuiltinMuscleActuator` - :class:`BuiltinMuscleActuatorCfg` - :class:`XmlActuator` @@ -84,6 +91,40 @@ Builtin Actuators :undoc-members: +.. autoclass:: BuiltinPdActuator + :members: + :show-inheritance: + +.. autoclass:: BuiltinPdActuatorCfg + :members: + :exclude-members: __init__ + :undoc-members: + + +.. autoclass:: BuiltinDcMotorActuator + :members: + :show-inheritance: + +.. autoclass:: BuiltinDcMotorActuatorCfg + :members: + :exclude-members: __init__ + :undoc-members: + +.. autoclass:: DcMotorInputMode + :members: + :show-inheritance: + +.. autoclass:: DcMotorDatasheetParams + :members: + :exclude-members: __init__ + :undoc-members: + +.. autoclass:: DcMotorPhysicalParams + :members: + :exclude-members: __init__ + :undoc-members: + + .. autoclass:: BuiltinMuscleActuator :members: :show-inheritance: diff --git a/05_software/train/rc_mjlab/mjlab/docs/source/changelog.rst b/05_software/train/rc_mjlab/mjlab/docs/source/changelog.rst index f769a1d..c7b363a 100644 --- a/05_software/train/rc_mjlab/mjlab/docs/source/changelog.rst +++ b/05_software/train/rc_mjlab/mjlab/docs/source/changelog.rst @@ -8,6 +8,89 @@ Upcoming version (not yet released) Added ^^^^^ +- Added ``BuiltinDcMotorActuator``, a native MuJoCo ```` wrapper. + Supports voltage / position / velocity input modes with back-EMF, + configurable motor constants, and optional integral, slew, inductance, + thermal, LuGre, and cogging extensions. +- Added ``scale_with_difficulty`` to ``HfRandomUniformTerrainCfg``. When + enabled, the noise amplitude scales with difficulty (flat at 0, full + ``noise_range`` at 1) so the terrain progresses in a curriculum. Defaults to + ``False``, preserving the previous difficulty-independent behavior. + +Changed +^^^^^^^ + +- Bumped ``rsl-rl-lib`` from 5.2.0 to 5.4.0. +- Curriculum-mode terrain difficulty is now deterministic across rows + and reaches the configured ``difficulty_range`` endpoints + (:issue:`1027`). +- Heightfield terrains now color by absolute height with a diverging palette + (cool below the ground plane, green at ground level, warm above) on a fixed + scale, replacing the per-patch normalization. Color is now consistent across + terrains, and low-amplitude terrain such as ``random_rough`` reads as gently + tinted ground instead of high-contrast noise. +- ``BoxNestedRingsTerrainCfg`` now builds uniform-height concentric ridges + whose separating gaps widen with difficulty, replacing the random per-ring + heights. Rings are colored by height (like the other terrains) and the outer + border matches the ring height. +- Terrain generation no longer prints timing information to stdout. + +Fixed +^^^^^ + +- Fixed ``select_gpus`` crashing when ``CUDA_VISIBLE_DEVICES`` contains MIG UUIDs instead of numeric indices. +- Fixed pyramid-stairs terrains (``BoxPyramidStairsTerrainCfg``, + ``BoxInvertedPyramidStairsTerrainCfg``, and ``BoxOpenStairsTerrainCfg``) + leaving an empty, geometry-free border at difficulty 0, where the step + height collapses to zero. The flat border frame is now always generated as + solid geometry flush with the ground (:issue:`1033`). +- Fixed ``HfPerlinNoiseTerrainCfg`` failing to compile at difficulty 0, where + the target height collapses to zero and MuJoCo rejects the non-positive + heightfield size. +- Fixed ``BoxRandomGridTerrainCfg`` producing NaN colors (and failing to build) + at difficulty 0, where the grid height is zero and the color normalization + divided by zero. +- Fixed the center platform z-fighting with surrounding geometry in + ``BoxRandomGridTerrainCfg`` (grid cells were left underneath the platform) and + ``BoxRandomSpreadTerrainCfg`` (the platform duplicated the floor surface). +- Fixed ``BoxNarrowBeamsTerrainCfg`` square platform corners protruding between + the beams at high difficulty; the platform now shrinks to stay within the + beams' angular coverage. +- Fixed ``BoxSteppingStonesTerrainCfg`` reconfiguring abruptly at a difficulty + threshold, where the stone grid re-tiled as its spacing crossed an integer + boundary, and leaving an oversized gap around the center platform. The grid is + now difficulty-independent and the platform snaps to it as a clean island. +- Fixed ``train --video``, ``play``, and ``demo`` crashing with ``OpenGL + platform library not loaded`` on headless Linux hosts that don't pre-set + ``MUJOCO_GL``. The default is now applied in ``mjlab/__init__.py`` (Linux + only) so it takes effect before mujoco's GL backend selection runs. + +Version 1.4.0 (May 26, 2026) +---------------------------- + +Added +^^^^^ + +- Added ``BuiltinPdActuator``, the implicit-integration version of + ``IdealPdActuator``. Same interface (position + velocity targets, + kp/kd gains), but expresses the PD as native MuJoCo ```` + and ```` elements so the ``implicit`` / ``implicitfast`` + integrators include the kp/kd derivatives in their velocity update. + The actuator stays stable at gain/timestep combinations where + explicit Python PD would diverge, which matters when you want to + run a real motor's stiff on-board PD gains in sim. ``effort_limit`` + is enforced as a sum-clamp on the two PD terms via + ``jnt_actfrcrange`` (or ``tendon_actfrcrange``). Supported by + ``dr.pd_gains`` and ``dr.effort_limits``. +- Added ``mdp.projected_gravity_from_sensor``, an observation that derives + projected gravity from a ``framezaxis`` up-vector sensor (negated) rather + than from the root body orientation. Unlike ``mdp.projected_gravity``, it + reflects the sensor's site frame, so it can observe IMU mounting domain + randomization (e.g. via ``dr.site_quat``). Go1 and G1 ship an + ``imu_upvector`` sensor for this. +- Added ``DebugVisualizer.add_box`` for drawing an axis-oriented box + primitive, mirroring ``add_ellipsoid``. Supported by both the native + and Viser viewers. ``size`` is the box half-extents (:issue:`992`). - Added ``--log-root`` CLI option to ``train``, ``play``, and ``evaluate`` scripts for choosing where training logs are stored. Defaults to ``logs/rsl_rl`` (unchanged behavior). Useful for directing outputs to a @@ -21,22 +104,41 @@ Added primary names in the order they appear along the per-contact axis of the output tensors. This makes it possible to map a contact-data column back to the primary it belongs to (:issue:`914`). -- Added per-world mesh variant support via ``VariantEntityCfg`` and - ``VariantCfg``. Each world in a batched simulation can now use a - different mesh asset for the same logical entity (e.g. world 0 holds a - cube, world 1 a sphere), with weights controlling the proportion of - worlds assigned to each variant. Mesh-derived constants (collision - bounds, body inertials, subtree mass, inverse weights) are compiled - per-variant and stored as per-world arrays in the Warp model, so domain - randomization, the native viewer, the offscreen renderer, and the Viser - viewer all pick up the variant assignment automatically. Variants must - share the same kinematic structure (same bodies, joints, joint types); - only mesh geoms may differ. Assignment is fixed at simulation init. - See :ref:`per_world_mesh` for usage. With help from @XiangruiJiang. +- Added per-world mesh variant support via ``VariantEntityCfg``. Each + world in a batched simulation can now use a different mesh asset for + the same logical entity (e.g. world 0 holds a cube, world 1 a + sphere). Variants are passed as a ``dict[str, Callable]`` of named + spec callables; the optional ``assignment`` field controls how worlds + map to variants and accepts ``None`` (uniform), a ``dict[str, float]`` + of per-variant weights, or a custom ``Callable[[int], Sequence[int]]``. + Mesh-derived constants (collision bounds, body inertials, subtree + mass, inverse weights) are compiled per-variant and stored as + per-world arrays in the Warp model, so domain randomization, the + native viewer, the offscreen renderer, and the Viser viewer all pick + up the variant assignment automatically. Variants must share the + same kinematic structure (same bodies, joints, joint types); only + mesh geoms may differ. Assignment is fixed at simulation init. See + :ref:`heterogeneous_worlds` for usage. With help from @XiangruiJiang. +- Per-world mesh variants now support per-variant materials and textures. + Each variant can reference its own named material, which is automatically + prefixed and scattered via ``geom_matid`` alongside the existing + ``geom_dataid`` table. Variants without a material get ``matid = -1``. + Contribution by @omarrayyann. Changed ^^^^^^^ +- ``Entity`` now raises a clear error at construction when its spec contains + more than one freejoint. An entity models a single system rooted at one + body, so it has at most one freejoint; a second one was previously accepted + silently and only surfaced later as a cryptic shape mismatch when writing + root state. Model each detached floating body as its own entry in + ``SceneCfg.entities`` instead. +- Changed ``compute_root_relative_mpkpe`` to re-anchor the reference to the + robot's root each step, removing yaw drift as well as translation so it + measures intrinsic body pose error. +- Changed ``compute_joint_velocity_error`` from an L2 norm to a per-joint + RMS, so it no longer scales with the number of joints. - Bumped ``mujoco`` to 3.8 and ``mujoco-warp`` to 3.8.0. The ``multiccd`` enable flag was removed in mujoco 3.8 (it became default-on), so configs that listed ``"multiccd"`` in ``MujocoCfg.enableflags`` need to drop it. @@ -68,15 +170,63 @@ Changed air-time fields (``current_air_time``, ``last_air_time``, ``current_contact_time``, ``last_contact_time``) have shape ``[B, P]``, where ``P`` is the number of resolved primaries (:issue:`914`). +- Event functions now share a single ``resolve_env_ids`` helper to expand + ``env_ids=None`` to all environments, replacing five copies of the same + guard. ``push_by_setting_velocity`` and ``apply_external_force_torque`` + accept ``env_ids=None`` too, so they work as global-time interval terms. + Documented when to use ``apply_external_force_torque`` (a constant, + self-managed wrench) versus ``apply_body_impulse`` (transient, automatic + impulses) versus ``push_by_setting_velocity`` (an instantaneous velocity + kick). Fixed ^^^^^ -- Fixed ``ManagerBasedRlEnv`` initializing Warp on all visible CUDA devices - even when constructed with ``device="cpu"``. ``seed_rng`` now accepts a - ``device`` argument and skips ``wp.rand_init`` on CPU devices, so a - CPU-only env no longer claims a CUDA context on machines with a visible - GPU (:issue:`949`). +- Removed use of deprecated ``warp-lang`` symbols (``wp.context.runtime`` + and ``wp.context.Device``) that were dropped in newer ``warp-lang`` + releases, causing ``AttributeError: module 'warp' has no attribute + 'context'`` at import/runtime. mjlab now uses + ``wp.get_cuda_driver_version()`` and ``wp.Device`` instead + (:issue:`967`). Contribution by @rdeits. +- Fixed the tracking ``evaluate`` script scoring each metric against the + next motion frame; the reference is now snapshotted before each step to + match the reward. +- Fixed the tracking end-effector metrics silently scoring zero for an + unknown body name; they now raise ``ValueError``. +- Fixed ``compute_mpkpe`` measuring root-relative instead of global error; + it now uses the global reference ``body_pos_w`` (:issue:`1006`). +- Fixed heavy flicker in offscreen training videos on rough-terrain tasks. + The renderer recomputed its context "neighbor" robots every frame from + ``env_origins``, which the terrain curriculum mutates on reset, so the + neighbor set kept changing and robots popped in and out. The neighbor + set is now computed once and cached (:issue:`979`). +- Fixed command delay only applying to an actuator's position target. + ``IdealPdActuator`` and ``DcMotorActuator`` also use velocity and effort, which + arrived undelayed and out of sync; all command targets now share one delay. + Zero-reference setups are unaffected. +- Fixed duplicate random seeds across nodes in multi-node training. The + per-process seed offset in ``scripts/train.py`` now uses the global + ``RANK`` instead of ``LOCAL_RANK``. Contribution by @bd-pdomanico. +- Fixed ``apply_body_impulse`` firing an impulse on the very first step (and + the first step after every reset) instead of starting with a cooldown as + documented. The cooldown is now sampled lazily on the first call so impulse + timing is decorrelated from episode resets (:issue:`973`). +- Fixed ``dr.pd_gains`` and ``dr.effort_limits`` silently no-oping when + passed an ``Operation`` object (e.g. ``dr.scale``) instead of a string. + Both functions now accept ``Operation | str`` like every other DR event + and raise ``ValueError`` for unsupported operations (:issue:`971`). +- Fixed ``ContactSensor`` with ``global_frame=True`` and + ``reduce`` ∈ {``"none"``, ``"mindist"``, ``"maxforce"``} producing forces + rotated onto the wrong axis. The contact-frame→world rotation matrix had + its columns ordered ``[tangent, tangent2, normal]`` instead of + ``[normal, tangent, tangent2]``, projecting the normal-force component + onto a tangent direction. Contribution by @bd-pdomanico. +- Fixed ``extras["log"]`` entries written by reward terms (e.g. ``Metrics/*`` + values in velocity tasks) being silently discarded on any step where at + least one environment resets. ``_reset_idx`` was clearing the dict after + ``reward_manager.compute()`` had already populated it. The clear now + happens at the top of ``step()`` and ``reset()`` so that all entries + survive (:issue:`957`). - Fixed ``ContactSensor.compute_first_contact`` and ``compute_first_air`` occasionally missing events when a contact began or ended right at the last physics substep of a control step. ``current_contact_time`` / diff --git a/05_software/train/rc_mjlab/mjlab/docs/source/entity/index.rst b/05_software/train/rc_mjlab/mjlab/docs/source/entity/index.rst index 64fbd0d..4e9b714 100644 --- a/05_software/train/rc_mjlab/mjlab/docs/source/entity/index.rst +++ b/05_software/train/rc_mjlab/mjlab/docs/source/entity/index.rst @@ -197,8 +197,8 @@ example, a ``CollisionCfg`` with ``geom_names_expr=(".*_foot.*",)`` sets contact parameters only on foot geoms. See the asset zoo (``mjlab.asset_zoo.robots``) for complete examples. -Per-world mesh variants -^^^^^^^^^^^^^^^^^^^^^^^ +Heterogeneous worlds +^^^^^^^^^^^^^^^^^^^^ For scenes that need different mesh assets in different parallel worlds (for example, training a manipulation policy that generalizes across @@ -206,7 +206,7 @@ object shapes), use ``VariantEntityCfg`` instead of ``EntityCfg``. Each world is assigned a variant proportional to a configurable weight, and mesh-dependent compiled constants (collision bounds, body inertials, subtree mass) are stored as per-world arrays so domain randomization and -viewers stay consistent. See :ref:`per_world_mesh`. +viewers stay consistent. See :ref:`heterogeneous_worlds`. Subclassing Entity ^^^^^^^^^^^^^^^^^^ diff --git a/05_software/train/rc_mjlab/mjlab/docs/source/entity/per_world_mesh.rst b/05_software/train/rc_mjlab/mjlab/docs/source/entity/per_world_mesh.rst index 8741e00..0aa89c2 100644 --- a/05_software/train/rc_mjlab/mjlab/docs/source/entity/per_world_mesh.rst +++ b/05_software/train/rc_mjlab/mjlab/docs/source/entity/per_world_mesh.rst @@ -1,50 +1,35 @@ -.. _per_world_mesh: +.. _heterogeneous_worlds: -Mesh Variants -============= +Heterogeneous Worlds +==================== -Mesh variants let a single batched simulation run with different mesh -assets in different parallel worlds. World 0 may simulate a cube, world -1 a sphere, and world 2 a bowl, all sharing the same compiled scene -and the same kinematic structure. The result is a heterogeneous batch -in which the mesh and its derived constants vary across worlds while -everything else (the body tree, the joint structure, the contact and -solver setup) is fixed. - -Mesh variants are configured at the entity level through -``VariantEntityCfg`` and ``VariantCfg``. Once configured, -domain randomization, the native viewer, the offscreen renderer, and -the Viser viewer all pick up the variant assignment automatically. +mjlab can run a single batched simulation in which different parallel +worlds use different mesh assets for the same logical entity. World 0 +may simulate a cube, world 1 a sphere, world 2 a bowl. All worlds +share the same compiled scene and the same body and joint structure; +only the meshes and the per-geom attributes that travel with them +(friction, contact bits, mass, density, and a few more) differ across +worlds. Articulated props work too (you can have a hinge or slide +below the variant's root), as long as the joint topology matches +across variants. The feature is exposed through ``VariantEntityCfg``. +The full breakdown of what can and cannot vary across variants is in +the next section. -How it works ------------- +Quickstart +---------- -A standard ``EntityCfg`` provides a single ``spec_fn`` that returns one -``MjSpec``. A ``VariantEntityCfg`` provides a dictionary of named -variants, each with its own ``spec_fn`` and a weight controlling the -proportion of worlds that use it. - -**All variants must declare the same kinematic structure.** The batched -simulator assumes a single topology across worlds; per-world variation -is confined to mesh assets and the constants derived from them. mjlab -uses the first variant's body tree as the template and copies mesh -assets and explicit body inertials from the others. Geom-level -properties on later variants such as ``rgba``, friction, and material -assignments are not propagated; control per-world appearance through -domain randomization on ``geom_rgba`` or ``mat_rgba``. The structural -check is enforced at construction time and raises a ``ValueError`` -describing the first mismatch. Variants must also be floating-base -(declare a free joint on the root body); fixed-base variants are -rejected. - -A minimal two-variant config: +Say you want some parallel worlds to hold a sphere and others to hold +a cone, with a single shared scene running both at once. Define each +variant as a function that returns an ``MjSpec``, then group them +under one ``VariantEntityCfg``: .. code-block:: python import mujoco - from mjlab.entity import EntityCfg, VariantCfg, VariantEntityCfg + from mjlab.entity import EntityCfg, VariantEntityCfg + def make_sphere_spec() -> mujoco.MjSpec: spec = mujoco.MjSpec() @@ -56,40 +41,267 @@ A minimal two-variant config: body.add_geom(type=mujoco.mjtGeom.mjGEOM_MESH, meshname="visual") return spec - # ``make_cone_spec`` follows the same shape with - # ``mesh.make_cone(nedge=16, radius=0.04)`` in place of the sphere call. + + def make_cone_spec() -> mujoco.MjSpec: + spec = mujoco.MjSpec() + mesh = spec.add_mesh(name="visual") + mesh.make_cone(nedge=16, radius=0.04) + body = spec.worldbody.add_body(name="prop") + body.add_freejoint() + body.add_geom(type=mujoco.mjtGeom.mjGEOM_MESH, meshname="visual") + return spec + object_cfg = VariantEntityCfg( variants={ - "sphere": VariantCfg(spec_fn=make_sphere_spec, weight=1.0), - "cone": VariantCfg(spec_fn=make_cone_spec, weight=2.0), + "sphere": make_sphere_spec, + "cone": make_cone_spec, }, + assignment={"cone": 2.0}, # twice as many cones as spheres init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)), ) -During scene construction mjlab merges the per-variant specs into a -single ``MjSpec`` whose mesh slots are padded to the maximum count any -variant uses, then writes a per-world ``geom_dataid`` table that -selects the right mesh for each world. In the merged scene -``geom_dataid`` is no longer a flat ``(ngeom,)`` vector but a -``(num_envs, ngeom)`` table whose rows differ by variant. A value of -``-1`` marks a disabled mesh slot, used for variants with fewer mesh -geoms than the maximum. +Plug the variant entity into a :ref:`scene` exactly like a regular +``EntityCfg``: -Mesh choice is entangled with several other compiled-model constants: -geom collision bounds, geom local frames, body inertials, subtree mass, -and inverse weights. mjlab compiles each unique row of the -``geom_dataid`` table on the host and copies the relevant compiled -fields into per-world arrays on the GPU, so each world's compiled -constants stay consistent with that world's mesh selection. The full -list of fields handled this way is in -``mjlab.sim.mesh_variants.VARIANT_DEPENDENT_FIELDS``. +.. code-block:: python + + from mjlab.scene import SceneCfg + + scene_cfg = SceneCfg( + num_envs=4096, + entities={"object": object_cfg}, + ) + +Twice as many worlds will hold a cone as a sphere. Variants not listed +in the ``assignment`` dict default to weight 1.0; omit ``assignment`` +entirely for uniform allocation across all variants. + + +What variants can differ in +--------------------------- + +**Free to vary across variants:** the mesh asset assigned to each +slot, the number of mesh geoms per ``(body, role)`` bucket on the +variant body (one variant can have more collision meshes than +another), the per-mesh-geom attributes that travel with the mesh +(friction, contact bits, mass, density, ``condim``, and a handful of +others), and explicit body inertial values within whichever single +inertial mode the variants agree on per body. + +**Must match across variants:** the body tree, joint topology, +primitive (non-mesh) geoms, and any actuators / sensors / tendons / +equalities. Variants must also agree on the inertial representation +per body (mesh-derived, diagonal, or fullinertia), and may not use the +reserved ``mjlab/pad/`` name prefix on any element. Variant entities +must also be floating-base: the root body declares a freejoint. + +The validator runs at entity build time and raises ``ValueError`` +naming the offending variant and the exact mismatch. + + +How variants are assembled +-------------------------- + +mjlab merges every variant's mesh assets into a single ``MjSpec`` and +gives the variant body enough mesh-geom *slots* to cover the maximum +mesh count any variant uses for each ``(body, role)`` bucket. A slot +is identified by ``(body_path, role, ordinal)``. ``role`` is "visual" +or "collision", derived from ``contype``/``conaffinity``; +mujoco_warp's ``geom_contype``/``geom_conaffinity`` are 1D shared +(not per-world), so a slot's role is fixed across worlds by +construction. + +A worked example +~~~~~~~~~~~~~~~~ + +Say variant ``sphere`` has 1 visual mesh geom and 2 collision mesh +geoms on the prop body, and variant ``cone`` has 1 visual mesh geom +and 4 collision mesh geoms on the same body. + +.. code-block:: text + + sphere variant body cone variant body + ------------------- ------------------- + prop body prop body + [visual] sphere_vis [visual] cone_vis + [coll] sphere_col_0 [coll] cone_col_0 + [coll] sphere_col_1 [coll] cone_col_1 + [coll] cone_col_2 + [coll] cone_col_3 + +mjlab walks each variant's body tree, buckets mesh geoms by +``(body_path, role)``, and lays the union out as slots: + +.. list-table:: + :header-rows: 1 + :widths: 8 18 8 12 27 27 + + * - Slot + - body_path + - role + - ordinal + - sphere fills with + - cone fills with + * - 0 + - /prop + - visual + - 0 + - sphere_vis + - cone_vis + * - 1 + - /prop + - collision + - 0 + - sphere_col_0 + - cone_col_0 + * - 2 + - /prop + - collision + - 1 + - sphere_col_1 + - cone_col_1 + * - 3 + - /prop + - collision + - 2 + - *(unfilled)* + - cone_col_2 + * - 4 + - /prop + - collision + - 3 + - *(unfilled)* + - cone_col_3 + +Five slots total. The merged scene's prop body has five mesh geoms: +slot 0 plus four collision slots (the union of sphere's two and +cone's four). At merge time, every variant's mesh asset is added to +the merged spec under a unique name (e.g. +``sphere/sphere_vis``, ``cone/cone_col_2``). + +The merged scene compiles once into a single canonical ``MjModel`` +that every world in the batch agrees on layout-wise: same nbody, +ngeom, same body and geom IDs. mjlab's per-world overrides on top of +that one model are what make worlds heterogeneous. + +What each world sees at runtime +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Worlds where ``sphere`` is active see only its three meshes; the two +extra collision slots are disabled via per-world ``geom_dataid = -1``, +and mujoco_warp skips them. Worlds where ``cone`` is active see all +five meshes wired up. + +.. list-table:: + :header-rows: 1 + :widths: 14 14 12 12 12 12 12 + + * - World + - variant + - slot 0 + - slot 1 + - slot 2 + - slot 3 + - slot 4 + * - 0 + - sphere + - sphere_vis + - sphere_col_0 + - sphere_col_1 + - **off (-1)** + - **off (-1)** + * - 1 + - cone + - cone_vis + - cone_col_0 + - cone_col_1 + - cone_col_2 + - cone_col_3 + +Three categories of per-world override carry the variation: + +* **geom_dataid** is a ``(num_envs, ngeom)`` table. Its row for + world W picks which compiled mesh each slot points at. ``-1`` is + the "skip me" sentinel mujoco_warp already understands. +* **Mesh-derived fields** (``geom_size``, ``geom_rbound``, + ``geom_aabb``, ``geom_pos``, ``geom_quat``, ``body_mass``, + ``body_subtreemass``, ``body_inertia``, ``body_invweight0``, + ``body_ipos``, ``body_iquat``) are stored as ``(num_envs, ...)`` + arrays. The values for sphere worlds reflect a sphere-shaped + inertia tensor and sphere-sized AABBs; the values for cone worlds + reflect the cone. The full list is in + ``mjlab.entity.variants.VARIANT_DEPENDENT_FIELDS``. +* **Per-mesh-geom attributes** (contact bits, friction, mass, + density, condim, group, priority, rgba, solref, solimp, margin, + gap) are captured per variant in ``VariantGeomSpec`` at merge time + and restored verbatim on the slot geom during the per-variant + reference compile. So if sphere's collision geoms have + ``friction=0.5`` and cone's have ``friction=1.2``, world W's + per-step friction reflects the assigned variant's source value. + The one exception is ``material``, which is not propagated across + variants; if you need per-world appearance variation use DR on + ``geom_rgba`` / ``mat_rgba``. + +If ``sphere`` adds a body that ``cone`` lacks (or vice versa), the +validator rejects the configuration before any of the merge logic +runs. The slot mechanism only flexes mesh geom counts within +matching bodies; everything structural above the geom level must +agree. + +.. note:: + + **Doesn't compiling the merged scene ruin the prop body's + inertia?** + + No, but it's worth understanding why, because the naive intuition + says it should. If you stuck every variant's mesh geoms on the + prop body and called ``spec.compile()``, MuJoCo would sum each + geom's inertial contribution, and you would get a body whose mass + and inertia tensor are a meaningless mix of every variant's shape. + + mjlab avoids this in two layers: + + * **The merged scene does not stick every variant's geoms on the + body.** The prop body in the merged spec carries variant 0's + mesh geoms (with their original mass and density) plus, for any + slot variant 0 doesn't fill, a synthesized padding geom that has + ``mass = 0`` and ``density = 0``. Padding contributes nothing to + body inertia. Other variants' meshes are present in the merged + spec only as **mesh assets** (in the assets section, not as geoms + on any body). They get wired in at runtime via per-world + ``geom_dataid`` and never affect the host compile's inertial + sums. + * **Per-world overrides come from per-variant source compiles.** + Even with the above, the merged-scene compile's prop body inertia + is only correct for variant 0. For every other variant, mjlab + compiles that variant's original source spec in isolation (one + body, one variant's worth of meshes), reads the resulting + ``body_mass``, ``body_inertia``, ``body_ipos``, ``body_iquat``, + ``body_invweight0``, and ``body_subtreemass``, and writes them + into the per-world arrays at the prop body's index. + + Net result: world W's prop body inertia is byte-equal to what you + would get by compiling variant W's source spec on its own. There + is a regression test + (``test_visual_collision_split_inertia_matches_independent_compile`` + in ``tests/test_variants.py``) that asserts exactly this against + independent per-variant compiles. World assignment ---------------- -mjlab assigns variants to worlds proportionally by weight using the +How worlds get mapped to variants is controlled by the ``assignment`` +field on ``VariantEntityCfg``. It accepts three shapes: + +* ``None`` (default): uniform allocation across variants. +* ``dict[str, float]``: per-variant weights. Variants not listed + default to weight 1.0. +* ``Callable[[int], Sequence[int]]``: an explicit assignment function + called with ``num_envs`` at simulation init. + +Both the ``None`` and dict cases use the `largest remainder method `_. Each variant's quota is ``q_i = (w_i / sum(w)) * num_envs``; each variant @@ -98,14 +310,28 @@ first receives ``floor(q_i)`` worlds, and the remaining fractional remainders, with ties broken by declaration order. For ``num_envs = 10`` and weights ``(1.0, 2.0, 1.0)`` this gives ``(3, 5, 2)`` worlds per variant. Weights are normalized internally, -so ``(1, 2, 1)`` and ``(0.25, 0.5, 0.25)`` produce identical -assignments. A weight of zero is allowed and produces zero worlds for -that variant; at least one variant must have a positive weight. +so ``{"a": 1, "b": 2, "c": 1}`` and ``{"a": 0.25, "b": 0.5, "c": 0.25}`` +produce identical assignments. A weight of zero is allowed and +produces zero worlds for that variant; at least one variant must end +up with positive weight. -Variant assignment is fixed at simulation initialization and does not -resample on episode reset. The intended use is heterogeneous training -across the batch, not per-episode mesh randomization. To inspect the -assignment from user code, read ``env.sim.world_to_variant``: +The default and dict paths are purely deterministic given +``(assignment, num_envs)``. With ``assignment={"a": 1, "b": 1}`` and +``num_envs = 8`` you always get ``[0, 0, 0, 0, 1, 1, 1, 1]``. There is +no seed involved; rerunning the same config produces the same +partition every time. Note that the partition's *boundaries* depend +on ``num_envs``, so world W's variant is not necessarily stable when +you change ``num_envs``. If you need explicit per-world stability +across batch sizes (e.g. "world 0 is always variant 0, world 1 is +always variant 1, regardless of how many envs I launch"), use a +callable assignment as below. + +Variant assignment is fixed at ``Simulation`` initialization and does +not resample on episode reset. The intended use is heterogeneous +training across the batch, not per-episode mesh randomization. + +Read the resolved assignment from user code via +``env.sim.world_to_variant``: .. code-block:: python @@ -118,23 +344,53 @@ variants were declared in ``VariantEntityCfg.variants``. The dict is empty for non-variant scenes. +Custom assignment with a callable +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When the weighted default is not what you want, pass a callable to +``assignment``. The callable receives ``num_envs`` and must return a +length-``num_envs`` sequence of variant indices in +``[0, len(variants))``. The returned sequence's length and bounds are +validated at sim init; mismatches raise a ``ValueError`` naming the +offending entity. + +A few patterns: + +**Round-robin** - cycle through variants by world index. + +.. code-block:: python + + cfg = VariantEntityCfg( + variants={"a": make_a, "b": make_b, "c": make_c}, + assignment=lambda n: [w % 3 for w in range(n)], + ) + +**Stratified halves** - first half is variant 0, second half is +variant 1. + +.. code-block:: python + + cfg = VariantEntityCfg( + variants={"easy": make_easy, "hard": make_hard}, + assignment=lambda n: [0] * (n // 2) + [1] * (n - n // 2), + ) + Domain randomization -------------------- Domain randomization on variant scenes preserves per-variant baselines automatically. When the simulation initializes, mjlab snapshots the -variant-dependent fields (``body_mass``, ``body_inertia``, -``geom_size``, and others listed in ``VARIANT_DEPENDENT_FIELDS``) as -``(num_envs, ...)`` tensors and registers them in -``sim.per_world_default_fields``. Domain randomization operations that -read defaults (scale, additive offsets) detect this registration and -index the per-world default array by environment, so a 10% mass scale +variant-dependent fields as ``(num_envs, ...)`` tensors and registers +them in ``sim.per_world_default_fields``. DR operations that read +defaults (scale, additive offsets) detect this registration and index +the per-world default array by environment, so a 10% mass scale applied across a batch containing a 100 g sphere variant and a 1 kg -cube variant produces 10% perturbations around each variant's own -mass, not 10% of a shared template mass. Fields that are not -variant-dependent (``geom_friction``, ``dof_armature``, -``dof_damping``, and so on) behave identically on variant and -non-variant scenes. +cube variant produces 10% perturbations *around each variant's own +mass*, not 10% of a shared template mass. + +Fields that are not variant-dependent (``geom_friction``, +``dof_armature``, ``dof_damping``, and so on) behave identically on +variant and non-variant scenes. For inertial randomization the recommended path is ``dr.pseudo_inertia``, which jointly randomizes mass, COM offset, @@ -169,32 +425,66 @@ Convex hull visualization is computed per variant from the variant's mesh vertices. -Performance considerations --------------------------- +Performance +----------- -Mesh variants do not add per-step overhead in the GPU kernels. -Variant-dependent fields are stored as per-world arrays accessed by -world index in the existing kernels, with no branching or dispatch -on variant. +**Per-step cost is unaffected by variant count.** Variant-dependent +fields are stored as per-world arrays accessed by world index in the +existing kernels, with no branching or dispatch on variant. -Initialization is the main consideration. mjlab compiles each unique -row of the ``geom_dataid`` table by taking a fresh ``MjSpec.copy()``, -editing the mesh selection and (if applicable) the explicit body -inertials, and calling ``spec.compile()``. This work scales with the -number of unique variant combinations rather than with ``num_envs``. -For a scene with one variant entity declaring k variants, this is k -host compiles regardless of how many worlds use each variant. With -multiple variant entities the unique-row count is bounded by the -product of their variant counts in the worst case, so a scene with -two variant entities of 5 variants each could trigger up to 25 host -compiles at init. +**Construction cost is linear in the total variant count.** mjlab +compiles the merged scene once to produce the canonical ``MjModel``, +then compiles each variant's original (un-merged) source spec in +isolation to recover that variant's per-body and per-geom mesh-derived +fields. Each per-variant compile sees only that variant's single body +and mesh, so its cost is independent of the total number of variants +in the scene. -``MjSpec.copy()`` and ``spec.compile()`` are non-trivial operations, -and their cost grows with scene size. For a scene with many variant -entities or many variants per entity, the cumulative initialization -cost can be measured in seconds. This cost is paid once at startup -and does not affect training throughput. +For a scene with one variant entity declaring k variants, construction +runs ``1 + k`` compiles. With multiple variant entities, compiles +decouple across entities: two variant entities of 5 variants each cost +``1 + 5 + 5 = 11`` compiles, not ``1 + 5 * 5 = 26``. As an order of +magnitude on CPU with typical procedural meshes, each per-variant +compile takes around 1-2 ms, so a scene with 100 variants pays a few +hundred milliseconds at startup and a scene with 1000 variants pays +roughly two seconds. -The merged spec contains every variant's mesh assets simultaneously. -Memory footprint at scene-build time scales with the total number of -mesh vertices and faces across all declared variants. +The merged spec contains every variant's mesh assets simultaneously, +so memory at scene-build time scales with the total mesh vertex / +face count across all variants. This is paid once at startup and does +not affect training throughput. + + +Limitations +----------- + +**Floating-base only.** Each variant's root body must declare a free +joint. Fixed-base variants are rejected; mocap auto-wrapping that +applies to non-variant entities is not applied here. + +**Material assets are not propagated.** Each variant's ``contype``, +``conaffinity``, ``condim``, ``friction``, ``mass``, ``density``, +``group``, ``priority``, ``rgba``, ``solref``, ``solimp``, ``margin``, +and ``gap`` are restored per-world during compile, but the +``material`` reference on slot geoms inherits whichever material the +template variant set. Use DR on ``geom_rgba`` / ``mat_rgba`` for +per-world appearance variation. + +**Assignment is fixed at sim init.** There is no API to swap a world +to a different variant on episode reset. World W's mesh asset is +whatever it was assigned at init for the lifetime of the simulation. +Per-episode mesh randomization is not supported today; DR can vary +scalar properties (mass, friction, color, scale) on a fixed variant +but cannot swap one mesh for another. + +**No support for per-world differing kinematic topology.** Variants +must share the same body tree, joints, and actuator/sensor counts, +so you cannot configure things like: + +* a different number of objects per world (world 0 has two props on + the table, world 1 has three); +* different articulation per world (world 0's prop is an articulated + drawer with a slider joint, world 1's prop is a rigid block). + +True heterogeneous topology requires upstream support in mujoco_warp +that does not currently exist. diff --git a/05_software/train/rc_mjlab/mjlab/docs/source/faq.rst b/05_software/train/rc_mjlab/mjlab/docs/source/faq.rst index ef2475f..4240a70 100644 --- a/05_software/train/rc_mjlab/mjlab/docs/source/faq.rst +++ b/05_software/train/rc_mjlab/mjlab/docs/source/faq.rst @@ -42,6 +42,27 @@ Not all CUDA versions are supported by MuJoCo Warp. - **Recommended**: CUDA **12.4+** (for conditional execution support in CUDA graphs). +How do I run on CPU without touching the GPU? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Passing ``device="cpu"`` puts all mjlab computation on the CPU, but it does +**not** stop Warp from initializing the GPU. The first time Warp's runtime +comes up, it eagerly enumerates and creates a CUDA context on **every** +visible device, regardless of which device you requested. So on a machine +with a visible GPU, a ``device="cpu"`` run still claims VRAM. + +This happens inside Warp and cannot be prevented from Python once the +package is imported. To keep the process entirely off the GPU, hide the +devices from CUDA before launching: + +.. code-block:: bash + + CUDA_VISIBLE_DEVICES="" uv run train.py ... + +With no visible CUDA devices, Warp initializes CPU-only and never allocates +on the GPU. See `issue #949 +`_ for background. + Performance ----------- diff --git a/05_software/train/rc_mjlab/mjlab/docs/source/terrain.rst b/05_software/train/rc_mjlab/mjlab/docs/source/terrain.rst index 7f681cb..0e71851 100644 --- a/05_software/train/rc_mjlab/mjlab/docs/source/terrain.rst +++ b/05_software/train/rc_mjlab/mjlab/docs/source/terrain.rst @@ -49,7 +49,6 @@ the geometry and how it scales with difficulty. terrain_generator=TerrainGeneratorCfg( size=(8.0, 8.0), num_rows=10, - num_cols=20, border_width=20.0, curriculum=True, sub_terrains={ @@ -70,10 +69,12 @@ the geometry and how it scales with difficulty. max_init_terrain_level=5, ) -The generator creates a ``num_rows x num_cols`` grid of patches. The -``sub_terrains`` dictionary maps names to ``SubTerrainCfg`` instances, -and each sub-terrain's ``proportion`` weight controls how many columns -(curriculum mode) or sampling probability (random mode) it receives. +The generator creates a grid of patches sized ``num_rows`` by either +``num_cols`` (random mode) or ``len(sub_terrains)`` (curriculum mode, +where ``num_cols`` is ignored). The ``sub_terrains`` dictionary maps +names to ``SubTerrainCfg`` instances; each sub-terrain's ``proportion`` +controls robot spawning distribution across columns in curriculum mode, +or per-patch sampling probability in random mode. Grid layout @@ -82,30 +83,48 @@ Grid layout Two generation modes control how terrain types are distributed across the grid: -**Curriculum mode** (``curriculum=True``). Columns are deterministically -assigned to terrain types based on their ``proportion`` weights. A type -with proportion 0.4 in a 20-column grid gets 8 columns. All patches in -a column share the same terrain type, and difficulty increases from row 0 -(easiest) to row ``num_rows - 1`` (hardest). This structured layout is -what enables the curriculum system to advance environments to harder rows -as performance improves. +**Curriculum mode** (``curriculum=True``). Each terrain type gets exactly +one column; the generator uses ``len(sub_terrains)`` columns regardless of +``num_cols``. All patches in a column share the same terrain type, and +difficulty increases from row 0 (easiest) to row ``num_rows - 1`` +(hardest). The ``proportion`` field controls how robots are distributed +across columns at spawn time, not column count. This structured layout +is what enables the curriculum system to advance environments to harder +rows as performance improves. **Random mode** (``curriculum=False``). Every patch independently samples a terrain type weighted by ``proportion`` and a difficulty from -``difficulty_range``. This provides maximum variety but no structured -difficulty progression. +``difficulty_range``. ``num_cols`` is honored. This provides maximum +variety but no structured difficulty progression. The difficulty parameter ^^^^^^^^^^^^^^^^^^^^^^^^ Each sub-terrain's generation function receives a ``difficulty`` value -in ``[0, 1]``. This value linearly interpolates the terrain's -configurable ranges. For example, a ``BoxPyramidStairsTerrainCfg`` with +that linearly interpolates the terrain's configurable ranges. For +example, a ``BoxPyramidStairsTerrainCfg`` with ``step_height_range=(0.0, 0.2)`` produces flat ground at difficulty 0 -and 20 cm steps at difficulty 1. In curriculum mode, difficulty is -determined by the row: row 0 gets the minimum, row ``num_rows - 1`` gets -the maximum. +and 20 cm steps at difficulty 1. + +In curriculum mode, difficulty is determined by the row: +``difficulty = lower + (upper - lower) * row / max(num_rows - 1, 1)``, +where ``(lower, upper) = difficulty_range``. Row 0 is exactly +``lower``, row ``num_rows - 1`` is exactly ``upper``, and intermediate +rows are evenly spaced between them. All columns in a given row share +the same difficulty scalar; the visible variation across columns comes +from each sub-terrain type generating different geometry at the same +difficulty. + +.. note:: + + With ``num_rows=1`` and ``curriculum=True``, every patch is generated + at ``difficulty = lower`` (the easiest configured difficulty). Use + ``curriculum=False`` if you want a single grid of randomly sampled + difficulties instead. + +In random mode, difficulty is sampled uniformly from +``difficulty_range`` independently for every patch. Sub-terrain types @@ -244,17 +263,23 @@ and undulating ground that box geoms cannot represent. Preset configurations --------------------- -mjlab ships two ready-made ``TerrainGeneratorCfg`` presets in +mjlab ships three ready-made ``TerrainGeneratorCfg`` presets in ``mjlab.terrains.config``: ``ROUGH_TERRAINS_CFG`` - A 10x20 grid with seven terrain types (flat, stairs, inverted - stairs, slopes, inverted slopes, random rough, waves). Designed for - locomotion training with a moderate difficulty range. + A 10x20 random-mode grid with seven terrain types (flat, stairs, + inverted stairs, slopes, inverted slopes, random rough, waves). + Designed for locomotion training with a moderate difficulty range. + Set ``curriculum=True`` via ``dataclasses.replace`` to use it as a + curriculum grid (one column per terrain type). + +``STAIRS_TERRAINS_CFG`` + A 10-row curriculum grid focused on stair traversal: flat plus + three pyramid-stair variants of increasing difficulty. ``ALL_TERRAINS_CFG`` - A 10x16 grid with all sixteen terrain types at equal proportion. - Useful for training on maximum terrain variety. + A 10-row random-mode grid covering all available terrain types at + equal proportion. Useful for training on maximum terrain variety. Both can be used directly or customized with ``dataclasses.replace()``: @@ -285,9 +310,9 @@ The key concepts: - The built-in ``terrain_levels_vel`` curriculum term promotes environments that track commanded velocity well and demotes environments that fall or fail to make progress. -- When an environment reaches the maximum row, it is randomly reassigned - to a lower row to prevent the policy from collapsing to a single - difficulty level. +- When an environment is promoted past the hardest row, it is randomly + reassigned to any row in ``[0, num_rows)`` to prevent the policy from + collapsing to a single difficulty level. Flat patch detection diff --git a/05_software/train/rc_mjlab/mjlab/pyproject.toml b/05_software/train/rc_mjlab/mjlab/pyproject.toml index ec47b93..0892666 100644 --- a/05_software/train/rc_mjlab/mjlab/pyproject.toml +++ b/05_software/train/rc_mjlab/mjlab/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "mjlab" -version = "1.3.0" +version = "1.4.0" license = "Apache-2.0" license-files = ["LICENSE"] readme = { file = "README.md", content-type = "text/markdown" } @@ -37,15 +37,16 @@ dependencies = [ "torch>=2.7.0", "torchrunx>=0.3.4", "warp-lang>=1.12.0", - "mujoco-warp>=3.8.0", - "mujoco>=3.8.0", + "mujoco-warp>=3.8.0.3,~=3.8.0", + "mujoco~=3.8.0", "trimesh>=4.8.3", - "viser>=1.0.26", - "mjviser>=0.0.13", + "scipy>=1.15", + "viser>=1.0.27", + "mjviser>=0.0.14", "mediapy>=1.2.6", "imageio-ffmpeg", "tensordict", - "rsl-rl-lib==5.2.0", + "rsl-rl-lib==5.4.0", "tensorboard>=2.20.0", "onnxscript>=0.5.4", "wandb>=0.22.3", @@ -98,11 +99,11 @@ conflicts = [ [{extra = "cu128"}, {extra = "cpu"}], ] # The nightly index (py.mujoco.org) only has dev builds, and PEP 440 ranks -# 3.7.0.devN < 3.7.0, so the >=3.7.0 floor in [project.dependencies] would +# 3.8.0.devN < 3.8.0, so the ~=3.8.0 floor in [project.dependencies] would # reject them. This override loosens the constraint for uv resolution only. override-dependencies = ["mujoco>=3.8.0.dev0"] constraint-dependencies = [ - "GitPython>=3.1.47", + "GitPython>=3.1.49", "lxml>=6.1.0", ] required-environments = [ @@ -111,12 +112,6 @@ required-environments = [ ] [[tool.uv.index]] -name = "tsinghua" -url = "https://pypi.tuna.tsinghua.edu.cn/simple" -default = true - -[[tool.uv.index]] -name = "pypi" url = "https://pypi.org/simple" [[tool.uv.index]] @@ -146,8 +141,7 @@ torch = [ { index = "pytorch-cpu", extra = "cpu", marker = "sys_platform != 'darwin'" }, ] mujoco = { index = "mujoco" } -mujoco-warp = { git = "https://github.com/google-deepmind/mujoco_warp", rev = "6f235d4" } -mjviser = { git = "https://github.com/mujocolab/mjviser", rev = "1bdfd6fe79066b847a5f430000fcfbb53ec31a6f" } +mujoco-warp = { git = "https://github.com/google-deepmind/mujoco_warp", rev = "88b55fc2696960b927bc12584994bb8412b36558" } [tool.ruff] src = ["src"] # Helpful for recognizing first-party imports. diff --git a/05_software/train/rc_mjlab/mjlab/scripts/benchmarks/generate_report.py b/05_software/train/rc_mjlab/mjlab/scripts/benchmarks/generate_report.py index fb430b8..fcffab8 100644 --- a/05_software/train/rc_mjlab/mjlab/scripts/benchmarks/generate_report.py +++ b/05_software/train/rc_mjlab/mjlab/scripts/benchmarks/generate_report.py @@ -215,6 +215,27 @@ def generate_dashboard_html(runs: list[dict], throughput_data: list[dict]) -> st border-color: var(--accent); color: white; }} + .range-selector {{ + display: flex; + gap: 0.4rem; + margin-bottom: 1rem; + }} + .range-btn {{ + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.3rem 0.75rem; + cursor: pointer; + color: var(--text); + font-size: 0.8rem; + font-weight: 500; + }} + .range-btn:hover {{ border-color: var(--accent); }} + .range-btn.active {{ + background: var(--accent); + border-color: var(--accent); + color: white; + }} .tab-content {{ display: none; }} .tab-content.active {{ display: block; }} .tab-description {{ @@ -303,12 +324,24 @@ def generate_dashboard_html(runs: list[dict], throughput_data: list[dict]) -> st

Nightly motion imitation training and evaluation on Unitree G1 (1024 trials per run).

+
+ + + + +

Physics simulation throughput across tasks (4096 parallel envs, NVIDIA RTX 5090).

+
+ + + + +
@@ -393,6 +426,8 @@ def generate_dashboard_html(runs: list[dict], throughput_data: list[dict]) -> st }}; let charts = []; + let trackingCharts = []; + let throughputCharts = []; function updateChartColors() {{ const style = getComputedStyle(root); @@ -449,7 +484,7 @@ def generate_dashboard_html(runs: list[dict], throughput_data: list[dict]) -> st `; chartsContainer.appendChild(card); - charts.push(new Chart(card.querySelector('canvas'), {{ + const chart = new Chart(card.querySelector('canvas'), {{ type: 'line', data: {{ datasets: [ @@ -459,7 +494,8 @@ def generate_dashboard_html(runs: list[dict], throughput_data: list[dict]) -> st borderColor: color, backgroundColor: color + '20', borderWidth: 2, - pointRadius: 4, + pointRadius: 2, + pointHoverRadius: 5, tension: 0.1, fill: true }}, @@ -531,7 +567,9 @@ def generate_dashboard_html(runs: list[dict], throughput_data: list[dict]) -> st }} }} }} - }})); + }}); + charts.push(chart); + trackingCharts.push(chart); }}); // Tab switching @@ -621,7 +659,8 @@ def generate_dashboard_html(runs: list[dict], throughput_data: list[dict]) -> st borderColor: '#58a6ff', backgroundColor: '#58a6ff20', borderWidth: 2, - pointRadius: 4, + pointRadius: 2, + pointHoverRadius: 5, tension: 0.1, fill: true }}, @@ -631,7 +670,8 @@ def generate_dashboard_html(runs: list[dict], throughput_data: list[dict]) -> st borderColor: '#3fb950', backgroundColor: '#3fb95020', borderWidth: 2, - pointRadius: 4, + pointRadius: 2, + pointHoverRadius: 5, tension: 0.1, fill: true }} @@ -697,6 +737,7 @@ def generate_dashboard_html(runs: list[dict], throughput_data: list[dict]) -> st }} }}); charts.push(chart); + throughputCharts.push(chart); throughputChartInstances[task] = {{ chart, panelId: `task-panel-${{i}}` }}; // Card click handler @@ -712,6 +753,23 @@ def generate_dashboard_html(runs: list[dict], throughput_data: list[dict]) -> st }} else {{ taskGrid.innerHTML = '

No throughput data available. Run measure_throughput.py to generate data.

'; }} + + // Date-range windowing across both tracking and throughput charts. + // Setting min and clearing max also resets any zoom/pan. + function setRange(days) {{ + const min = days > 0 ? Date.now() - days * 86400000 : undefined; + [...trackingCharts, ...throughputCharts].forEach(c => {{ + c.options.scales.x.min = min; + c.options.scales.x.max = undefined; + c.update(); + }}); + document.querySelectorAll('.range-btn').forEach(b => + b.classList.toggle('active', parseInt(b.dataset.days) === days)); + }} + document.querySelectorAll('.range-btn').forEach(btn => {{ + btn.addEventListener('click', () => setRange(parseInt(btn.dataset.days))); + }}); + setRange(90); @@ -764,7 +822,11 @@ def main( if run_id in eval_results_by_id: print(f"Using cached result for {run_id}") else: - result = evaluate_run(run_path, num_envs) + try: + result = evaluate_run(run_path, num_envs) + except RuntimeError as e: + print(f"Skipping {run_path}: {e}") + continue eval_results_by_id[run_id] = result new_evals += 1 else: @@ -783,7 +845,11 @@ def main( print(f"Reached eval limit ({eval_limit}), skipping remaining new runs") break run_path = f"{entity}/{project}/{run.id}" - result = evaluate_run(run_path, num_envs) + try: + result = evaluate_run(run_path, num_envs) + except RuntimeError as e: + print(f"Skipping {run.name} ({run.id}): {e}") + continue eval_results_by_id[run.id] = result new_evals += 1 diff --git a/05_software/train/rc_mjlab/mjlab/scripts/tools/terrain_explorer.py b/05_software/train/rc_mjlab/mjlab/scripts/tools/terrain_explorer.py new file mode 100644 index 0000000..f3b8cfe --- /dev/null +++ b/05_software/train/rc_mjlab/mjlab/scripts/tools/terrain_explorer.py @@ -0,0 +1,115 @@ +"""Interactive single-patch terrain explorer (Viser + MuJoCo MjSpec). + +Run with: + uv run python scripts/tools/terrain_explorer.py + uv run python scripts/tools/terrain_explorer.py --port 8081 + +Then open the printed URL (default http://localhost:8080). +""" + +from __future__ import annotations + +import argparse +import time + +import mujoco +import numpy as np +import viser +from mjviser.conversions import merge_geoms + +from mjlab.terrains.config import ALL_TERRAIN_PRESETS +from mjlab.terrains.terrain_generator import TerrainGenerator, TerrainGeneratorCfg + +PATCH_SIZE = (8.0, 8.0) + + +# Per-preset overrides applied when building in the explorer (e.g. to surface +# difficulty-driven behavior that is off by default). +_PRESET_OVERRIDES: dict[str, dict] = { + "random_rough": {"scale_with_difficulty": True}, +} + + +def _build_terrain_mesh(preset_name: str, difficulty: float, seed: int): + """Generate a single terrain patch and return a merged trimesh (or raise).""" + preset_fn = ALL_TERRAIN_PRESETS[preset_name] + overrides = _PRESET_OVERRIDES.get(preset_name, {}) + generator_cfg = TerrainGeneratorCfg( + seed=seed, + size=PATCH_SIZE, + num_rows=1, + num_cols=1, + border_width=0.0, + curriculum=False, + # A degenerate range pins the single patch to exactly this difficulty. + difficulty_range=(difficulty, difficulty), + color_scheme="height", + sub_terrains={preset_name: preset_fn(proportion=1.0, **overrides)}, + ) + generator = TerrainGenerator(generator_cfg) + spec = mujoco.MjSpec() + generator.compile(spec) + model = spec.compile() + + terrain_body_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "terrain") + geom_ids = [i for i in range(model.ngeom) if model.geom_bodyid[i] == terrain_body_id] + return merge_geoms(model, geom_ids) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--port", type=int, default=8080, help="Port for the viser server." + ) + args = parser.parse_args() + + server = viser.ViserServer(port=args.port) + preset_names = sorted(ALL_TERRAIN_PRESETS) + + terrain_dropdown = server.gui.add_dropdown( + "Terrain", options=preset_names, initial_value=preset_names[0] + ) + difficulty_slider = server.gui.add_slider( + "Difficulty", min=0.0, max=1.0, step=0.01, initial_value=0.0 + ) + seed_input = server.gui.add_number("Seed", initial_value=42, step=1) + status = server.gui.add_markdown("**Status:** ready") + + handle: viser.SceneNodeHandle | None = None + + def update() -> None: + nonlocal handle + name = terrain_dropdown.value + difficulty = float(difficulty_slider.value) + seed = int(seed_input.value) + status.content = f"**Status:** building `{name}` at difficulty {difficulty:.2f}..." + try: + mesh = _build_terrain_mesh(name, difficulty, seed) + except Exception as e: # noqa: BLE001 - surface any generation failure in the UI. + status.content = f"**Error:** {type(e).__name__}: {e}" + print(f"Failed to build {name} at difficulty {difficulty}: {e}") + return + if handle is not None: + handle.remove() + handle = server.scene.add_mesh_trimesh("/terrain", mesh) + status.content = ( + f"**Loaded** `{name}` at difficulty {difficulty:.2f} ({len(mesh.faces):,} faces)" + ) + + terrain_dropdown.on_update(lambda _: update()) + difficulty_slider.on_update(lambda _: update()) + seed_input.on_update(lambda _: update()) + + # Top-down-ish initial camera. + @server.on_client_connect + def _(client: viser.ClientHandle) -> None: + client.camera.position = np.array([10.0, 10.0, 8.0]) + client.camera.look_at = np.array([0.0, 0.0, 0.0]) + + update() + while True: + time.sleep(1.0) + + +if __name__ == "__main__": + main() diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/__init__.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/__init__.py index eeb970f..462b3f0 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/__init__.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/__init__.py @@ -1,5 +1,15 @@ import os import sys + +# Default to EGL for GPU-accelerated offscreen rendering on Linux. Must be set +# before any mujoco import: mujoco's gl_context module captures MUJOCO_GL once +# at load time. Override with e.g. MUJOCO_GL=osmesa on clusters without EGL. +# Linux-only because mujoco's gl_context rejects "egl" on macOS/Windows and +# raises at import. On those platforms we leave MUJOCO_GL alone so mujoco +# defaults to GLFW. +if sys.platform.startswith("linux"): + os.environ.setdefault("MUJOCO_GL", "egl") + import traceback from importlib.metadata import entry_points from pathlib import Path diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/__init__.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/__init__.py index c6db91a..153ee12 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/__init__.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/__init__.py @@ -4,6 +4,12 @@ from mjlab.actuator.actuator import Actuator as Actuator from mjlab.actuator.actuator import ActuatorCfg as ActuatorCfg from mjlab.actuator.actuator import ActuatorCmd as ActuatorCmd from mjlab.actuator.actuator import CommandField as CommandField +from mjlab.actuator.builtin_actuator import ( + BuiltinDcMotorActuator as BuiltinDcMotorActuator, +) +from mjlab.actuator.builtin_actuator import ( + BuiltinDcMotorActuatorCfg as BuiltinDcMotorActuatorCfg, +) from mjlab.actuator.builtin_actuator import ( BuiltinMotorActuator as BuiltinMotorActuator, ) @@ -16,6 +22,12 @@ from mjlab.actuator.builtin_actuator import ( from mjlab.actuator.builtin_actuator import ( BuiltinMuscleActuatorCfg as BuiltinMuscleActuatorCfg, ) +from mjlab.actuator.builtin_actuator import ( + BuiltinPdActuator as BuiltinPdActuator, +) +from mjlab.actuator.builtin_actuator import ( + BuiltinPdActuatorCfg as BuiltinPdActuatorCfg, +) from mjlab.actuator.builtin_actuator import ( BuiltinPositionActuator as BuiltinPositionActuator, ) @@ -28,6 +40,15 @@ from mjlab.actuator.builtin_actuator import ( from mjlab.actuator.builtin_actuator import ( BuiltinVelocityActuatorCfg as BuiltinVelocityActuatorCfg, ) +from mjlab.actuator.builtin_actuator import ( + DcMotorDatasheetParams as DcMotorDatasheetParams, +) +from mjlab.actuator.builtin_actuator import ( + DcMotorInputMode as DcMotorInputMode, +) +from mjlab.actuator.builtin_actuator import ( + DcMotorPhysicalParams as DcMotorPhysicalParams, +) from mjlab.actuator.builtin_group import BuiltinActuatorGroup as BuiltinActuatorGroup from mjlab.actuator.dc_actuator import DcMotorActuator as DcMotorActuator from mjlab.actuator.dc_actuator import DcMotorActuatorCfg as DcMotorActuatorCfg diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/actuator.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/actuator.py index cb9df5c..34b130c 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/actuator.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/actuator.py @@ -174,15 +174,6 @@ class Actuator(ABC, Generic[ActuatorCfgT]): """Whether this actuator has delay configured.""" return self.cfg.delay_max_lag > 0 - @property - def command_field(self) -> CommandField | None: - """The primary command field this actuator consumes. - - Returns None by default. Subclasses should override to return the - appropriate field. - """ - return None - @property def target_ids(self) -> torch.Tensor: """Local indices of targets controlled by this actuator.""" @@ -271,11 +262,6 @@ class Actuator(ABC, Generic[ActuatorCfgT]): """Create delay buffer. Called during initialize().""" if not self.has_delay: return - if self.command_field is None: - raise ValueError( - f"{self.__class__.__name__}: delay is configured (delay_max_lag=" - f"{self.cfg.delay_max_lag}) but command_field is not defined." - ) self._delay_buffer = DelayBuffer( min_lag=self.cfg.delay_min_lag, max_lag=self.cfg.delay_max_lag, @@ -287,19 +273,25 @@ class Actuator(ABC, Generic[ActuatorCfgT]): ) def apply_delay(self, cmd: ActuatorCmd) -> ActuatorCmd: - """Apply delay to the command_field target. No-op without delay.""" + """Delay all command targets with one shared lag. No-op without delay. + + Every target the policy issues (position, velocity, effort) travels the same + command channel and experiences the same latency, so they are stacked and + delayed together. Feedback fields (``pos``, ``vel``) are never delayed. + """ if self._delay_buffer is None: return cmd - cf = self.command_field - if cf == "position": - self._delay_buffer.append(cmd.position_target) - return dataclasses.replace(cmd, position_target=self._delay_buffer.compute()) - elif cf == "velocity": - self._delay_buffer.append(cmd.velocity_target) - return dataclasses.replace(cmd, velocity_target=self._delay_buffer.compute()) - else: - self._delay_buffer.append(cmd.effort_target) - return dataclasses.replace(cmd, effort_target=self._delay_buffer.compute()) + targets = torch.stack( + (cmd.position_target, cmd.velocity_target, cmd.effort_target), dim=-1 + ) + self._delay_buffer.append(targets) + delayed = self._delay_buffer.compute() + return dataclasses.replace( + cmd, + position_target=delayed[..., 0], + velocity_target=delayed[..., 1], + effort_target=delayed[..., 2], + ) def set_lags( self, diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/builtin_actuator.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/builtin_actuator.py index 151c429..2f7cfa4 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/builtin_actuator.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/builtin_actuator.py @@ -7,19 +7,21 @@ created programmatically via the MjSpec API. from __future__ import annotations from dataclasses import dataclass +from enum import IntEnum from typing import TYPE_CHECKING import mujoco +import numpy as np import torch from mjlab.actuator.actuator import ( Actuator, ActuatorCfg, ActuatorCmd, - CommandField, TransmissionType, ) from mjlab.utils.spec import ( + apply_target_overrides, create_motor_actuator, create_muscle_actuator, create_position_actuator, @@ -63,10 +65,6 @@ class BuiltinPositionActuatorCfg(ActuatorCfg): class BuiltinPositionActuator(Actuator[BuiltinPositionActuatorCfg]): """MuJoCo built-in position actuator.""" - @property - def command_field(self) -> CommandField: - return "position" - def __init__( self, cfg: BuiltinPositionActuatorCfg, @@ -96,6 +94,102 @@ class BuiltinPositionActuator(Actuator[BuiltinPositionActuatorCfg]): return cmd.position_target +@dataclass(kw_only=True) +class BuiltinPdActuatorCfg(ActuatorCfg): + """Implicit-integration version of IdealPdActuator. + + Both consume a position target and a velocity target with kp/kd gains. The + difference is in how the PD is delivered to MuJoCo: IdealPdActuator computes + the PD force in Python and feeds it to a ```` element, which MuJoCo + sees as an opaque external force. This actuator expresses the PD as native + MuJoCo elements (a ```` carrying kp, a ```` carrying kd), + so the implicit and implicitfast integrators include the kp/kd derivatives + in their velocity update. That makes the actuator numerically stable at + gain/timestep combinations where explicit Python PD would diverge, which + matters when you want to run a real motor's stiff on-board PD gains in sim. + """ + + stiffness: float + """Proportional gain (kp).""" + damping: float + """Derivative gain (kd).""" + effort_limit: float | None = None + """Maximum total torque applied to the joint or tendon. Enforced as a + sum-clamp on the two PD terms via jnt_actfrcrange (JOINT) or + tendon_actfrcrange (TENDON). None leaves the limit unset.""" + + def __post_init__(self) -> None: + super().__post_init__() + if self.transmission_type == TransmissionType.SITE: + raise ValueError( + "BuiltinPdActuatorCfg does not support SITE transmission. " + "Use BuiltinMotorActuatorCfg for site transmission." + ) + + def build( + self, entity: Entity, target_ids: list[int], target_names: list[str] + ) -> BuiltinPdActuator: + return BuiltinPdActuator(self, entity, target_ids, target_names) + + +class BuiltinPdActuator(Actuator[BuiltinPdActuatorCfg]): + """MuJoCo native PD: paired + elements per target.""" + + def __init__( + self, + cfg: BuiltinPdActuatorCfg, + entity: Entity, + target_ids: list[int], + target_names: list[str], + ) -> None: + super().__init__(cfg, entity, target_ids, target_names) + + @property + def num_targets(self) -> int: + """Number of targets. ``ctrl_ids`` is laid out as ``[pos..., vel...]``, + each block of length ``num_targets``.""" + return len(self._target_ids_list) + + def edit_spec(self, spec: mujoco.MjSpec, target_names: list[str]) -> None: + # Position elements first, then velocity elements, so ctrl_ids is laid out + # as [pos_0..pos_{N-1}, vel_0..vel_{N-1}]. + for target_name in target_names: + pos_act = create_position_actuator( + spec, + target_name, + actuator_name=f"{target_name}_pd_pos", + stiffness=self.cfg.stiffness, + damping=0.0, # damping lives on the element. + armature=self.cfg.armature, + frictionloss=self.cfg.frictionloss, + viscous_damping=self.cfg.viscous_damping, + transmission_type=self.cfg.transmission_type, + ) + self._mjs_actuators.append(pos_act) + for target_name in target_names: + vel_act = create_velocity_actuator( + spec, + target_name, + actuator_name=f"{target_name}_pd_vel", + damping=self.cfg.damping, + transmission_type=self.cfg.transmission_type, + ) + self._mjs_actuators.append(vel_act) + # Effort limit: sum-clamp on the joint/tendon, not on each element. + if self.cfg.effort_limit is not None: + lim = self.cfg.effort_limit + for target_name in target_names: + if self.cfg.transmission_type == TransmissionType.JOINT: + target = spec.joint(target_name) + else: + target = spec.tendon(target_name) + target.actfrclimited = mujoco.mjtLimited.mjLIMITED_TRUE + target.actfrcrange[:] = np.array([-lim, lim]) + + def compute(self, cmd: ActuatorCmd) -> torch.Tensor: + return torch.cat((cmd.position_target, cmd.velocity_target), dim=1) + + @dataclass(kw_only=True) class BuiltinMotorActuatorCfg(ActuatorCfg): """Configuration for MuJoCo built-in motor actuator. @@ -119,10 +213,6 @@ class BuiltinMotorActuatorCfg(ActuatorCfg): class BuiltinMotorActuator(Actuator[BuiltinMotorActuatorCfg]): """MuJoCo built-in motor actuator.""" - @property - def command_field(self) -> CommandField: - return "effort" - def __init__( self, cfg: BuiltinMotorActuatorCfg, @@ -151,6 +241,248 @@ class BuiltinMotorActuator(Actuator[BuiltinMotorActuatorCfg]): return cmd.effort_target +def _or_zeros(t: tuple[float, ...] | None, n: int) -> list[float]: + return list(t) if t is not None else [0.0] * n + + +class DcMotorInputMode(IntEnum): + """What the ``ctrl`` signal of a ```` represents. + + Values match MuJoCo's enum, consumed by mjs_setToDCMotor and read as gainprm[8]. + """ + + VOLTAGE = 0 + POSITION = 1 + VELOCITY = 2 + + +@dataclass(frozen=True) +class DcMotorDatasheetParams: + """Datasheet characterization of a DC motor.""" + + nominal_voltage: float + """Nominal (rated) voltage V_n [V].""" + stall_torque: float + """Stall torque tau_stall at V_n [N*m].""" + no_load_speed: float + """No-load angular velocity omega_no_load at V_n [rad/s].""" + + def _pack(self) -> tuple[list[float], float, list[float]]: + """Returns (motorconst, resistance, nominal) for set_to_dcmotor.""" + return ( + [0.0, 0.0], + 0.0, + [self.nominal_voltage, self.stall_torque, self.no_load_speed], + ) + + +@dataclass(frozen=True) +class DcMotorPhysicalParams: + """Physical characterization of a DC motor.""" + + kt: float + """Torque constant [N*m/A].""" + ke: float + """Back-EMF constant [V*s/rad].""" + resistance: float + """Terminal resistance R [Ohm].""" + + def _pack(self) -> tuple[list[float], float, list[float]]: + """Returns (motorconst, resistance, nominal) for set_to_dcmotor.""" + return [self.kt, self.ke], self.resistance, [0.0, 0.0, 0.0] + + +@dataclass(kw_only=True) +class BuiltinDcMotorActuatorCfg(ActuatorCfg): + """Native MuJoCo ```` wrapper. + + Models a DC motor: torque is derived from voltage via the motor constant K and + back-EMF, tau = K * (V - K * omega) / R. The back-EMF term lives in biasprm, so + MuJoCo's implicit / implicitfast integrators pick up its velocity derivative as + effective damping. + + Three input modes select what ctrl carries: + + * VOLTAGE: ctrl is the drive voltage. cmd.effort_target carries volts, not torque. + * POSITION / VELOCITY: an internal PID closes on the setpoint and the motor produces + torque from its (Vmax-clamped) voltage output. + + Motor characterization: pass either DcMotorDatasheetParams or DcMotorPhysicalParams + as motor_params. mjs_setToDCMotor derives K and R (including the viscous-damping + correction) and packs the generic gainprm / biasprm / dynprm slots. + + Optional extensions, off by default: integral_gain / integral_limit, slew_rate, + inductance / electrical_time_constant, thermal, lugre, cogging. + + dr.pd_gains randomizes only kp and kd; for DR over the extensions, write directly to + actuator_gainprm or actuator_dynprm. + """ + + motor_params: DcMotorDatasheetParams | DcMotorPhysicalParams + """Motor characterization. Datasheet form: (V_n, tau_stall, omega_no_load). + Physical form: (Kt, Ke, R).""" + + mode: DcMotorInputMode = DcMotorInputMode.POSITION + """ctrl input semantics. See class docstring.""" + + stiffness: float = 0.0 + """PID proportional gain kp. Required in POSITION / VELOCITY mode; must be + 0 in VOLTAGE mode.""" + + damping: float = 0.0 + """PID derivative gain kd. Used in POSITION / VELOCITY mode; must be 0 in + VOLTAGE mode.""" + + voltage_limit: float = 0.0 + """Max drive voltage Vmax. Required in POSITION / VELOCITY mode (clamps the + PID output). In VOLTAGE mode it is an optional clamp on ctrl; 0 disables.""" + + integral_gain: float = 0.0 + """PID integral gain ki. In position mode the integrator tracks + ki * integral(target - q); in velocity mode, ki * (integral(target) - q). + Must be 0 in VOLTAGE mode.""" + + integral_limit: float = 0.0 + """Anti-windup clamp Imax on the integrator state. 0 disables (the + integrator can run away).""" + + slew_rate: float = 0.0 + """Max rate of change of ctrl per second. 0 disables.""" + + effort_limit: float | None = None + """Continuous torque cap [N*m]. Sets actuator_forcerange. None leaves the + per-element forcerange unset.""" + + gear: float = 1.0 + """Mechanical gear ratio.""" + + inductance: float = 0.0 + """Winding inductance L [H]. Enables first-order electrical dynamics on the + motor current. MuJoCo internally uses te = L / R; pass + electrical_time_constant directly to skip the divide. 0 disables.""" + + electrical_time_constant: float = 0.0 + """Alternative to inductance: specify te [s] directly. Ignored if + inductance > 0. 0 disables.""" + + thermal: tuple[float, float, float, float, float, float] | None = None + """Thermal model (R_thermal, C_thermal, tau_thermal, alpha, T0, T_ambient). + See MuJoCo's ```` reference for units and which of the + first three may be underspecified. Effective resistance becomes + R * (1 + alpha * (T + T_ambient - T0)). None disables.""" + + cogging: tuple[float, float, float] | None = None + """Cogging torque (amplitude, periodicity, phase) in (N*m, cycles per unit + length, rad). Models magnetic torque ripple from rotor-stator interaction; + at joint angle q the contribution is amplitude * sin(periodicity * q + phase). + + Added *after* effort_limit is enforced, matching MuJoCo's physical model: + effort_limit bounds the electromagnetic torque (the current limit), not the + mechanical torque. Total joint torque can exceed effort_limit by up to + amplitude. None disables.""" + + lugre: tuple[float, float, float, float, float] | None = None + """LuGre friction (sigma0, sigma1, F_Coulomb, F_Stribeck, v_Stribeck). + Stick-slip friction with bristle-deflection state. Subtracted from joint + torque after the effort_limit clamp (mechanical, like cogging). None + disables.""" + + def __post_init__(self) -> None: + super().__post_init__() + if self.transmission_type == TransmissionType.SITE: + raise ValueError( + "BuiltinDcMotorActuatorCfg does not support SITE transmission. " + "Use BuiltinMotorActuatorCfg for site transmission." + ) + + if self.mode in (DcMotorInputMode.POSITION, DcMotorInputMode.VELOCITY): + if self.stiffness <= 0.0: + raise ValueError(f"{self.mode.name} mode requires stiffness > 0.") + if self.voltage_limit <= 0.0: + raise ValueError(f"{self.mode.name} mode requires voltage_limit > 0.") + else: + if self.stiffness != 0.0 or self.damping != 0.0 or self.integral_gain != 0.0: + raise ValueError( + "stiffness, damping, and integral_gain are unused in VOLTAGE mode." + ) + + for name in ( + "integral_gain", + "integral_limit", + "slew_rate", + "inductance", + "electrical_time_constant", + ): + if getattr(self, name) < 0.0: + raise ValueError(f"{name} must be non-negative.") + + def build( + self, entity: Entity, target_ids: list[int], target_names: list[str] + ) -> BuiltinDcMotorActuator: + return BuiltinDcMotorActuator(self, entity, target_ids, target_names) + + +class BuiltinDcMotorActuator(Actuator[BuiltinDcMotorActuatorCfg]): + """MuJoCo native ````: one actuator per target.""" + + def edit_spec(self, spec: mujoco.MjSpec, target_names: list[str]) -> None: + cfg = self.cfg + motorconst, resistance, nominal = cfg.motor_params._pack() + saturation = ( + [cfg.effort_limit, 0.0, 0.0] if cfg.effort_limit is not None else [0.0] * 3 + ) + controller = [ + cfg.stiffness, # kp + cfg.integral_gain, # ki + cfg.damping, # kd + cfg.slew_rate, # slewmax + cfg.integral_limit, # Imax (anti-windup) + cfg.voltage_limit, # v_max + ] + # SITE is rejected in __post_init__, so only JOINT and TENDON remain. + trntype = ( + mujoco.mjtTrn.mjTRN_JOINT + if cfg.transmission_type == TransmissionType.JOINT + else mujoco.mjtTrn.mjTRN_TENDON + ) + + for target_name in target_names: + actuator = spec.add_actuator(name=target_name, target=target_name) + actuator.trntype = trntype + actuator.gear[0] = cfg.gear + actuator.set_to_dcmotor( + motorconst=motorconst, + resistance=resistance, + nominal=nominal, + saturation=saturation, + controller=controller, + cogging=_or_zeros(cfg.cogging, 3), + inductance=[cfg.inductance, cfg.electrical_time_constant], + thermal=_or_zeros(cfg.thermal, 6), + lugre=_or_zeros(cfg.lugre, 5), + input_mode=cfg.mode, + ) + + apply_target_overrides( + spec, + target_name, + cfg.transmission_type, + armature=cfg.armature, + frictionloss=cfg.frictionloss, + viscous_damping=cfg.viscous_damping, + ) + + self._mjs_actuators.append(actuator) + + def compute(self, cmd: ActuatorCmd) -> torch.Tensor: + if self.cfg.mode == DcMotorInputMode.POSITION: + return cmd.position_target + if self.cfg.mode == DcMotorInputMode.VELOCITY: + return cmd.velocity_target + # voltage mode: ctrl is the drive voltage carried in effort_target. + return cmd.effort_target + + @dataclass(kw_only=True) class BuiltinVelocityActuatorCfg(ActuatorCfg): """Configuration for MuJoCo built-in velocity actuator. @@ -182,10 +514,6 @@ class BuiltinVelocityActuatorCfg(ActuatorCfg): class BuiltinVelocityActuator(Actuator[BuiltinVelocityActuatorCfg]): """MuJoCo built-in velocity actuator.""" - @property - def command_field(self) -> CommandField: - return "velocity" - def __init__( self, cfg: BuiltinVelocityActuatorCfg, @@ -260,10 +588,6 @@ class BuiltinMuscleActuatorCfg(ActuatorCfg): class BuiltinMuscleActuator(Actuator[BuiltinMuscleActuatorCfg]): """MuJoCo built-in muscle actuator.""" - @property - def command_field(self) -> CommandField: - return "effort" - def __init__( self, cfg: BuiltinMuscleActuatorCfg, diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/dc_actuator.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/dc_actuator.py index 615279a..58c81ac 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/dc_actuator.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/dc_actuator.py @@ -33,6 +33,9 @@ class DcMotorActuatorCfg(IdealPdActuatorCfg): Note: effort_limit should be explicitly set to a realistic value for proper motor modeling. Using the default (inf) will trigger a warning. Use IdealPdActuator if unlimited torque is desired. + + For a native MuJoCo ```` with back-EMF, voltage saturation, and + configurable ``Kt`` / ``Ke`` / ``R``, see ``BuiltinDcMotorActuator``. """ saturation_effort: float diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/pd_actuator.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/pd_actuator.py index b52f118..fad15ff 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/pd_actuator.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/pd_actuator.py @@ -9,7 +9,7 @@ import mujoco import mujoco_warp as mjwarp import torch -from mjlab.actuator.actuator import Actuator, ActuatorCfg, ActuatorCmd, CommandField +from mjlab.actuator.actuator import Actuator, ActuatorCfg, ActuatorCmd from mjlab.utils.spec import create_motor_actuator if TYPE_CHECKING: @@ -38,10 +38,6 @@ class IdealPdActuatorCfg(ActuatorCfg): class IdealPdActuator(Actuator, Generic[IdealPdCfgT]): """Ideal PD control actuator.""" - @property - def command_field(self) -> CommandField: - return "position" - def __init__( self, cfg: IdealPdCfgT, diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/asset_zoo/robots/unitree_g1/xmls/g1.xml b/05_software/train/rc_mjlab/mjlab/src/mjlab/asset_zoo/robots/unitree_g1/xmls/g1.xml index 0e190ad..dda4779 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/asset_zoo/robots/unitree_g1/xmls/g1.xml +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/asset_zoo/robots/unitree_g1/xmls/g1.xml @@ -102,11 +102,6 @@ diaginertia="0.00167218 0.0016161 0.000217621"/> - @@ -156,11 +151,6 @@ diaginertia="0.00167218 0.0016161 0.000217621"/> - @@ -312,6 +302,7 @@ + diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/asset_zoo/robots/unitree_go1/xmls/go1.xml b/05_software/train/rc_mjlab/mjlab/src/mjlab/asset_zoo/robots/unitree_go1/xmls/go1.xml index 9f392e6..cc6cacb 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/asset_zoo/robots/unitree_go1/xmls/go1.xml +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/asset_zoo/robots/unitree_go1/xmls/go1.xml @@ -165,6 +165,7 @@ + diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/entity/__init__.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/entity/__init__.py index 12f81fb..f6cffff 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/entity/__init__.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/entity/__init__.py @@ -3,6 +3,5 @@ from mjlab.entity.entity import Entity as Entity from mjlab.entity.entity import EntityArticulationInfoCfg as EntityArticulationInfoCfg from mjlab.entity.entity import EntityCfg as EntityCfg from mjlab.entity.entity import EntityIndexing as EntityIndexing -from mjlab.entity.entity import VariantCfg as VariantCfg -from mjlab.entity.entity import VariantEntityCfg as VariantEntityCfg -from mjlab.entity.entity import VariantMetadata as VariantMetadata +from mjlab.entity.variants import VariantEntityCfg as VariantEntityCfg +from mjlab.entity.variants import VariantMetadata as VariantMetadata diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/entity/entity.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/entity/entity.py index 1993317..7582a02 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/entity/entity.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/entity/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations import warnings from dataclasses import dataclass, field from pathlib import Path -from typing import Callable, Sequence +from typing import TYPE_CHECKING, Callable, Sequence import mujoco import mujoco_warp as mjwarp @@ -18,14 +18,13 @@ from mjlab.entity.data import EntityData from mjlab.utils import spec_config as spec_cfg from mjlab.utils.lab_api.string import resolve_matching_names from mjlab.utils.mujoco import dof_width, qpos_width -from mjlab.utils.spec import ( - auto_wrap_fixed_base_mocap, - copy_mesh_data, - validate_variant_structure, -) +from mjlab.utils.spec import auto_wrap_fixed_base_mocap from mjlab.utils.string import resolve_expr from mjlab.utils.xml import fix_spec_xml, strip_buffer_textures +if TYPE_CHECKING: + from mjlab.entity.variants import VariantMetadata + @dataclass(frozen=False) class EntityIndexing: @@ -67,79 +66,6 @@ class EntityIndexing: return self.bodies[0].id -@dataclass -class VariantCfg: - """One object variant for per-world mesh randomization. - - Each variant provides a ``spec_fn`` that returns an MjSpec for one object. - The ``weight`` controls what fraction of worlds use this variant. - """ - - spec_fn: Callable[[], mujoco.MjSpec] - weight: float = 1.0 - - -@dataclass(frozen=True) -class BodyInertialMetadata: - """Explicit inertial properties for one body in a mesh variant.""" - - body_name: str - mass: float - ipos: tuple[float, float, float] - inertia: tuple[float, float, float] - iquat: tuple[float, float, float, float] - - -@dataclass -class VariantMetadata: - """Bookkeeping produced by Entity when merging variant specs.""" - - variant_names: tuple[str, ...] - variant_weights: tuple[float, ...] - # Per-variant ordered mesh names for each geom slot. Shorter variants - # have None for padding slots that should be disabled (dataid = -1). - variant_mesh_names: tuple[tuple[str | None, ...], ...] - num_mesh_geoms: int # Max mesh geom count after padding. - # Per-variant explicit body inertials. Names are local to the variant spec; - # build_mesh_variant_model prefixes them with the scene entity name when - # applying them. - variant_body_inertials: tuple[tuple[BodyInertialMetadata, ...], ...] = () - - -def _iter_body_tree(body: mujoco.MjsBody): - yield body - for child in body.bodies: - yield from _iter_body_tree(child) - - -def _collect_explicit_body_inertials( - root_body: mujoco.MjsBody, -) -> tuple[BodyInertialMetadata, ...]: - inertials: list[BodyInertialMetadata] = [] - for body in _iter_body_tree(root_body): - if not body.name or not body.explicitinertial: - continue - inertials.append( - BodyInertialMetadata( - body_name=body.name, - mass=float(body.mass), - ipos=(float(body.ipos[0]), float(body.ipos[1]), float(body.ipos[2])), - inertia=( - float(body.inertia[0]), - float(body.inertia[1]), - float(body.inertia[2]), - ), - iquat=( - float(body.iquat[0]), - float(body.iquat[1]), - float(body.iquat[2]), - float(body.iquat[3]), - ), - ) - ) - return tuple(inertials) - - @dataclass class EntityCfg: @dataclass @@ -187,50 +113,6 @@ class EntityArticulationInfoCfg: soft_joint_pos_limit_factor: float = 1.0 -def _variant_spec_fn_unset() -> mujoco.MjSpec: - """Sentinel default for ``VariantEntityCfg.spec_fn``. - - ``VariantEntityCfg`` builds its spec from ``variants`` via - ``Entity._build_merged_spec``; the inherited ``spec_fn`` field is unused. - Identity comparison against this sentinel detects accidental user overrides. - """ - raise AssertionError( - "VariantEntityCfg.spec_fn should never be called; the merged spec is " - "built from `variants`." - ) - - -@dataclass -class VariantEntityCfg(EntityCfg): - """Entity config for per-world mesh variants. - - Instead of a single ``spec_fn``, provide a dict of named variants. - Each world gets a variant assigned proportionally by weight. The - merged spec (with all variant meshes and padded geoms) is built - automatically. - - All variants must share the same kinematic structure (same bodies, - joints, joint types). Only mesh geoms can differ. - - Variant assignment is fixed at ``Simulation`` initialization; it does - not resample on episode reset. Pass the per-variant spec via - :class:`VariantCfg` rather than setting ``spec_fn`` directly. - """ - - variants: dict[str, VariantCfg] = field(default_factory=dict) - """Named mesh variants with weights.""" - - spec_fn: Callable[[], mujoco.MjSpec] = field(default=_variant_spec_fn_unset) - """Unused on ``VariantEntityCfg``; the merged spec is built from ``variants``.""" - - def __post_init__(self) -> None: - if self.spec_fn is not _variant_spec_fn_unset: - raise ValueError( - "VariantEntityCfg.spec_fn cannot be set; pass per-variant specs via " - "VariantCfg(spec_fn=...) inside `variants` instead." - ) - - class Entity: """An entity represents a physical object in the simulation. @@ -272,130 +154,13 @@ class Entity: self._add_initial_state_keyframe() def _build_spec(self) -> None: + from mjlab.entity.variants import VariantEntityCfg, build_merged_variant_spec + if isinstance(self.cfg, VariantEntityCfg): - self._build_merged_spec() + self._spec, self._variant_metadata = build_merged_variant_spec(self.cfg) else: self._spec = auto_wrap_fixed_base_mocap(self.cfg.spec_fn)() - def _build_merged_spec(self) -> None: - """Build a merged spec from multiple variant specs. - - Validates that all variants share the same kinematic structure, - merges all mesh assets into a single spec, and pads the body to - the max mesh geom count across variants. - """ - assert isinstance(self.cfg, VariantEntityCfg) - variants = self.cfg.variants - if not variants: - raise ValueError("VariantEntityCfg.variants must contain at least one entry.") - - variant_names: list[str] = [] - variant_weights: list[float] = [] - variant_specs: list[mujoco.MjSpec] = [] - for name, vcfg in variants.items(): - variant_names.append(name) - variant_weights.append(vcfg.weight) - variant_specs.append(vcfg.spec_fn()) - - # Find root body in each variant. - variant_bodies: list[mujoco.MjsBody] = [] - for i, spec in enumerate(variant_specs): - children = list(spec.worldbody.bodies) - if len(children) != 1: - raise ValueError( - f"Variant '{variant_names[i]}' must have exactly one " - f"root body under worldbody, got {len(children)}." - ) - variant_bodies.append(children[0]) - - validate_variant_structure(variant_names, variant_bodies) - - # Variant entities must be floating-base. Mocap auto-wrap is not applied - # for variant entities, so fixed-base variants would silently stack at - # the world origin. Variants share joint structure (validated above), so - # checking the first is sufficient. - ref_joints = list(variant_bodies[0].joints) - if not ref_joints or ref_joints[0].type != mujoco.mjtJoint.mjJNT_FREE: - raise ValueError( - "VariantEntityCfg requires floating-base variants. Each variant's " - "root body must declare a free joint via body.add_freejoint(); " - "fixed-base variants are not supported." - ) - - variant_body_inertials = tuple( - _collect_explicit_body_inertials(body) for body in variant_bodies - ) - - # Collect original mesh names per variant BEFORE any renaming. - variant_orig_mesh_names: list[list[str]] = [] - variant_mesh_geom_counts: list[int] = [] - for body in variant_bodies: - orig_names = [ - g.meshname for g in body.geoms if g.type == mujoco.mjtGeom.mjGEOM_MESH - ] - variant_orig_mesh_names.append(orig_names) - variant_mesh_geom_counts.append(len(orig_names)) - - max_mesh_geoms = max(variant_mesh_geom_counts) - - # Use first variant as template. Prefix ALL mesh names with - # variant name to avoid collisions across variants. - template_spec = variant_specs[0] - template_body = variant_bodies[0] - - # Rename template meshes first. - template_prefix = f"{variant_names[0]}/" - old_to_new: dict[str, str] = {} - for mesh in template_spec.meshes: - new_name = f"{template_prefix}{mesh.name}" - old_to_new[mesh.name] = new_name - mesh.name = new_name - for g in template_body.geoms: - if g.meshname in old_to_new: - g.meshname = old_to_new[g.meshname] - - # Copy mesh assets from other variants. - for i in range(1, len(variant_specs)): - prefix = f"{variant_names[i]}/" - for mesh in variant_specs[i].meshes: - new_mesh = template_spec.add_mesh() - new_mesh.name = f"{prefix}{mesh.name}" - copy_mesh_data(mesh, new_mesh) - - # Pad body to max mesh geom count. - current_count = variant_mesh_geom_counts[0] - if max_mesh_geoms > current_count: - longest_idx = max( - range(len(variant_mesh_geom_counts)), - key=lambda j: variant_mesh_geom_counts[j], - ) - longest_prefix = f"{variant_names[longest_idx]}/" - longest_names = variant_orig_mesh_names[longest_idx] - for k in range(current_count, max_mesh_geoms): - geom = template_body.add_geom() - geom.type = mujoco.mjtGeom.mjGEOM_MESH - geom.meshname = f"{longest_prefix}{longest_names[k]}" - geom.contype = 1 - geom.conaffinity = 1 - - # Build variant_mesh_names: use original names with variant prefix. - variant_mesh_name_lists: list[tuple[str | None, ...]] = [] - for i, orig_names in enumerate(variant_orig_mesh_names): - prefix = f"{variant_names[i]}/" - names: list[str | None] = [f"{prefix}{n}" for n in orig_names] - while len(names) < max_mesh_geoms: - names.append(None) - variant_mesh_name_lists.append(tuple(names)) - - self._variant_metadata = VariantMetadata( - variant_names=tuple(variant_names), - variant_weights=tuple(variant_weights), - variant_mesh_names=tuple(variant_mesh_name_lists), - num_mesh_geoms=max_mesh_geoms, - variant_body_inertials=variant_body_inertials, - ) - self._spec = template_spec - @property def variant_metadata(self) -> VariantMetadata | None: return self._variant_metadata @@ -404,6 +169,16 @@ class Entity: self._all_joints = self._spec.joints self._free_joint = None self._non_free_joints = tuple(self._all_joints) + + free_joints = [j for j in self._all_joints if j.type == mujoco.mjtJoint.mjJNT_FREE] + if len(free_joints) > 1: + raise ValueError( + f"Entity spec has {len(free_joints)} freejoints. An Entity models a " + "single rigid- or articulated-body system with at most one freejoint, " + "which serves as its root. Model each detached floating body as its own " + "entry in SceneCfg.entities instead." + ) + if self._all_joints and self._all_joints[0].type == mujoco.mjtJoint.mjJNT_FREE: self._free_joint = self._all_joints[0] if not self._free_joint.name: diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/entity/variants.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/entity/variants.py new file mode 100644 index 0000000..01dfcbc --- /dev/null +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/entity/variants.py @@ -0,0 +1,1442 @@ +"""Per-world mesh variant support. + +A single batched simulation can run with different mesh assets in different +parallel worlds. World 0 may simulate a cube, world 1 a sphere, world 2 a +bowl, all sharing the same compiled scene and the same kinematic structure. +The result is a heterogeneous batch in which the mesh and its derived +constants vary across worlds while everything else (body tree, joint +structure, contact and solver setup) is fixed. + +The feature spans two phases, both of which live in this file: + +1. **Authoring** (entity-build time, MjSpec only). The user supplies a + ``VariantEntityCfg`` whose ``variants`` map names to spec callables + instances, each with its own ``spec_fn``. ``build_merged_variant_spec`` + validates that all source specs share kinematic topology, computes + ``(body_path, role, ordinal)`` slots so each variable mesh geom has a + structural identity, and merges the source specs into one padded + template spec. It returns ``(template_spec, VariantMetadata)``. +2. **Realization** (sim-init time, mujoco_warp). ``build_variant_model`` + takes the scene-attached padded spec plus the per-entity + ``VariantMetadata`` and produces a heterogeneous Warp model: a per-world + ``geom_dataid`` table plus per-world arrays for the geometry-dependent + fields listed in ``VARIANT_DEPENDENT_FIELDS``. Each unique variant is + compiled once on the host with its source-spec geom semantics restored, + and the resulting reference fields are scattered into per-world arrays + keyed by world-to-variant assignment. + +Slot identity is the throughline. ``SlotKey(body_path, role, ordinal)`` is +fixed across variants because mujoco_warp's ``geom_contype``/ +``geom_conaffinity`` are 1D shared, so each slot's role (visual vs +collision) cannot change per world. ``VariantGeomSpec`` records each +variant's source-geom attributes per slot so the per-variant reference +compile restores them verbatim instead of inheriting whatever the template +variant set. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any, Callable, Literal, Sequence, cast + +import mujoco +import mujoco_warp as mjwarp +import numpy as np +import warp as wp + +from mjlab.entity.entity import EntityCfg +from mjlab.utils.mujoco import dof_width, qpos_width +from mjlab.utils.spec import copy_material_data, copy_mesh_data, copy_texture_data + +# Reserved name prefixes for synthesized template entities. Source variant +# specs must not create geoms, bodies, meshes, or other named items under +# these prefixes; validation rejects them so the merge pass can introduce +# padding geoms without colliding with user names. +RESERVED_NAME_PREFIXES: tuple[str, ...] = ("mjlab/pad/",) + +# Fields that depend on mesh geometry and must be compiled per-variant. +VARIANT_DEPENDENT_FIELDS = ( + "geom_size", + "geom_rbound", + "geom_aabb", + "geom_pos", + "geom_quat", + "body_mass", + "body_subtreemass", + "body_inertia", + "body_invweight0", + "body_ipos", + "body_iquat", +) + +GeomRole = Literal["visual", "collision"] +InertialMode = Literal["mesh-derived", "diagonal", "fullinertia"] + + +## +# Input: what the user writes. +## + + +def _variant_spec_fn_unset() -> mujoco.MjSpec: + """Sentinel default for ``VariantEntityCfg.spec_fn``. + + ``VariantEntityCfg`` builds its spec from ``variants`` via + ``build_merged_variant_spec``; the inherited ``spec_fn`` field is unused. + Identity comparison against this sentinel detects accidental user overrides. + """ + raise AssertionError( + "VariantEntityCfg.spec_fn should never be called; the merged spec is " + "built from `variants`." + ) + + +@dataclass +class VariantEntityCfg(EntityCfg): + """Entity config for per-world mesh variants. + + Provide a dict of named variants (each value is a callable returning + an ``MjSpec``) and optionally an ``assignment`` describing how worlds + map to variants. The merged spec (with all variant meshes and padded + geoms) is built automatically. + + All variants must share the same kinematic structure (same bodies, + joints, joint types). Only mesh geoms can differ. + + Variant assignment is fixed at ``Simulation`` initialization; it does + not resample on episode reset. + """ + + variants: dict[str, Callable[[], mujoco.MjSpec]] = field(default_factory=dict) + """Named mesh variants. Each value is a callable returning an ``MjSpec``.""" + + assignment: dict[str, float] | Callable[[int], Sequence[int]] | None = None + """How worlds get mapped to variants. Three shapes: + + * ``None`` (default): uniform allocation across variants via + largest-remainder. + * ``dict[str, float]``: per-variant weights for largest-remainder + allocation. Variants not listed default to weight 1.0. + * ``Callable[[int], Sequence[int]]``: an explicit assignment + function, called with ``num_envs`` at simulation init and required + to return a length-``num_envs`` sequence of variant indices in + ``[0, len(variants))``.""" + + spec_fn: Callable[[], mujoco.MjSpec] = field(default=_variant_spec_fn_unset) + """Unused on ``VariantEntityCfg``; the merged spec is built from ``variants``.""" + + def __post_init__(self) -> None: + if self.spec_fn is not _variant_spec_fn_unset: + raise ValueError( + "VariantEntityCfg.spec_fn cannot be set; pass per-variant spec " + "callables in `variants` instead." + ) + if isinstance(self.assignment, dict): + extras = set(self.assignment) - set(self.variants) + if extras: + raise ValueError( + f"VariantEntityCfg.assignment has keys not present in variants: " + f"{sorted(extras)}." + ) + + +## +# Output: data records that flow to scene/sim. +## + + +@dataclass(frozen=True) +class SlotKey: + """Structural identity of a mesh slot in the merged template.""" + + body_path: str + role: GeomRole + ordinal: int + + +@dataclass(frozen=True) +class VariantGeomSpec: + """Per-variant per-slot geom attributes captured from the source spec. + + The slot-aware merge uses this to restore exact source semantics + (contact bits, friction, material, etc.) for each variant's reference + compilation, instead of inheriting whatever the template variant set. + """ + + mesh_name: str + geom_name: str | None + contype: int + conaffinity: int + condim: int + group: int + priority: int + material: str | None + rgba: tuple[float, float, float, float] + friction: tuple[float, float, float] + margin: float + gap: float + solref: tuple[float, float] + solimp: tuple[float, float, float, float, float] + # ``mass``/``density`` default to 0.0 in the source spec, which MuJoCo + # interprets as "infer from mesh volume". The slot-aware compile path + # passes the source value through verbatim. + mass: float + density: float + + +@dataclass(frozen=True) +class VariantSlot: + """One slot in the merged template body. + + ``template_geom_name`` is the synthesized name on the template body's + geom that backs this slot. It always uses the reserved + ``mjlab/pad///`` prefix so it cannot + collide with user-named source geoms (validation rejects user geoms + starting with ``mjlab/pad/``). + + ``source_geom_names`` records, per variant, the original source geom + name for diagnostics. ``None`` here can mean either "variant doesn't + fill the slot" or "variant fills the slot with an unnamed geom"; the + authoritative source for fill status is + ``VariantMetadata.variant_slot_specs[v][s] is None``. + """ + + key: SlotKey + template_geom_name: str + source_geom_names: tuple[str | None, ...] + + +@dataclass +class VariantMetadata: + """Bookkeeping produced when merging variant specs. + + Slots are ordered by ``(body_path, role, ordinal)`` and + ``variant_slot_specs[v]`` aligns positionally with ``slots`` so + ``variant_slot_specs[v][s]`` describes how variant ``v`` fills slot + ``s`` (``None`` if variant ``v`` leaves the slot unfilled). + + ``variant_source_specs[v]`` is the original ``MjSpec`` produced by + variant ``v``'s ``spec_fn`` (pre-merge). The sim-time build path + compiles each one in isolation rather than recompiling a copy of the + merged scene, which keeps construction cost linear in the number of + variants instead of quadratic. + + ``variant_mesh_names`` and ``num_mesh_geoms`` are kept as derived + ``@property`` views over ``variant_slot_specs`` for back-compat with + consumers that index by slot. + """ + + variant_names: tuple[str, ...] + assignment: tuple[float, ...] | Callable[[int], Sequence[int]] = () + slots: tuple[VariantSlot, ...] = () + variant_slot_specs: tuple[tuple[VariantGeomSpec | None, ...], ...] = () + variant_source_specs: tuple[mujoco.MjSpec, ...] = () + + @property + def variant_mesh_names(self) -> tuple[tuple[str | None, ...], ...]: + """Per-variant slot-aligned mesh names with variant prefix. + + ``None`` at position ``s`` means variant ``v`` does not fill slot + ``s``. Derived from ``variant_slot_specs``. + """ + return tuple( + tuple( + None if ss is None else f"{self.variant_names[v_idx]}/{ss.mesh_name}" + for ss in slot_specs + ) + for v_idx, slot_specs in enumerate(self.variant_slot_specs) + ) + + @property + def num_mesh_geoms(self) -> int: + """Max number of filled mesh slots across variants (i.e. the longest + variant's source mesh count). Derived from ``variant_slot_specs``.""" + if not self.variant_slot_specs: + return 0 + return max( + sum(1 for ss in slot_specs if ss is not None) + for slot_specs in self.variant_slot_specs + ) + + +@dataclass +class MeshVariantResult: + """Output of :func:`build_variant_model`.""" + + wp_model: mjwarp.Model + mj_model: mujoco.MjModel + # Maps entity prefix -> array of variant indices per world. + world_to_variant: dict[str, np.ndarray] + + +## +# Tree helpers (shared by validation and slot computation). +## + + +def _iter_body_paths( + body: mujoco.MjsBody, parent_path: str = "" +) -> list[tuple[str, mujoco.MjsBody]]: + """Return ``[(path, body), ...]`` for the recursive body tree. + + Path is the body's path relative to the variant root (e.g. ``/prop``, + ``/prop/lid``), built by joining body names with ``/``. + """ + body_name = body.name or "" + path = f"{parent_path}/{body_name}" + out: list[tuple[str, mujoco.MjsBody]] = [(path, body)] + for child in body.bodies: + out.extend(_iter_body_paths(child, path)) + return out + + +def _classify_geom_role(geom: mujoco.MjsGeom) -> GeomRole: + """Derive a geom's slot role from its contact bits. + + Visual: ``contype == 0`` and ``conaffinity == 0``. Collision: anything + else. Shared by validation and slot computation. + """ + if int(geom.contype) == 0 and int(geom.conaffinity) == 0: + return "visual" + return "collision" + + +def _iter_body_tree(body: mujoco.MjsBody): + yield body + for child in body.bodies: + yield from _iter_body_tree(child) + + +## +# Validation pass: cross-variant topology comparison. +## + + +@dataclass(frozen=True) +class JointSignature: + """Joint identity for cross-variant matching. + + Captures the structural attributes that must match across variants: + name, type, and qpos/qvel widths. Parameter attributes (axis, range, + stiffness, damping, etc.) are not part of the structural signature; + they may legitimately differ across variants once registered as + per-world fields. + """ + + name: str + type: int + qpos_width: int + qvel_width: int + + +@dataclass(frozen=True) +class GeomSignature: + """Geom identity for cross-variant matching. + + ``role`` is derived from ``contype``/``conaffinity``: a geom that + participates in any contact pair is "collision"; otherwise "visual". + A slot's role is fixed across variants by construction because + mujoco_warp's ``geom_contype``/``geom_conaffinity`` are 1D shared. + """ + + name: str | None + type: int + role: GeomRole + + +@dataclass(frozen=True) +class BodySignature: + """Recursive body identity for cross-variant matching. + + ``path`` is the body path relative to its variant's worldbody, before + any scene-attachment prefix. ``children`` are nested signatures in + source order. + """ + + path: str + name: str + joints: tuple[JointSignature, ...] + geoms: tuple[GeomSignature, ...] + children: tuple["BodySignature", ...] + + +def _format_variant_error( + variant_name: str, message: str, hint: str | None = None +) -> str: + prefix = f"mjlab.entity: VariantEntityCfg '{variant_name}': " + body = message + if hint: + body += f" Hint: {hint}" + return prefix + body + + +def _extract_body_signature( + body: mujoco.MjsBody, parent_path: str = "" +) -> BodySignature: + body_name = body.name or "" + path = f"{parent_path}/{body_name}" + joints: list[JointSignature] = [] + for j in body.joints: + jt = int(j.type) + joints.append( + JointSignature( + name=j.name or "", + type=jt, + qpos_width=qpos_width(jt), + qvel_width=dof_width(jt), + ) + ) + geoms = tuple( + GeomSignature( + name=g.name if g.name else None, + type=int(g.type), + role=_classify_geom_role(g), + ) + for g in body.geoms + ) + children = tuple(_extract_body_signature(child, path) for child in body.bodies) + return BodySignature( + path=path, + name=body_name, + joints=tuple(joints), + geoms=geoms, + children=children, + ) + + +def _detect_inertial_mode(body: mujoco.MjsBody) -> InertialMode: + # `body.fullinertia` defaults to [nan, 0, 0, 0, 0, 0]; a non-NaN first + # element means the user assigned fullinertia (any assignment, even + # zeros, flips the user-specified flag at compile time). + if not math.isnan(float(body.fullinertia[0])): + return "fullinertia" + if int(body.explicitinertial): + return "diagonal" + return "mesh-derived" + + +def _collect_inertial_modes( + body: mujoco.MjsBody, parent_path: str = "" +) -> dict[str, InertialMode]: + body_name = body.name or "" + path = f"{parent_path}/{body_name}" + modes: dict[str, InertialMode] = {path: _detect_inertial_mode(body)} + for child in body.bodies: + modes.update(_collect_inertial_modes(child, path)) + return modes + + +def _check_reserved_names(spec: mujoco.MjSpec, variant_name: str) -> None: + collections: tuple[tuple[str, str], ...] = ( + ("bodies", "body"), + ("geoms", "geom"), + ("meshes", "mesh"), + ("materials", "material"), + ("textures", "texture"), + ("joints", "joint"), + ("actuators", "actuator"), + ("tendons", "tendon"), + ("sensors", "sensor"), + ("cameras", "camera"), + ("lights", "light"), + ("sites", "site"), + ("equalities", "equality"), + ) + for attr, item_kind in collections: + items = getattr(spec, attr, None) + if items is None: + continue + for item in items: + name = getattr(item, "name", None) or "" + for prefix in RESERVED_NAME_PREFIXES: + if name.startswith(prefix): + raise ValueError( + _format_variant_error( + variant_name, + f"reserved name prefix '{prefix}' is used by source " + f"{item_kind} '{name}'.", + "rename user geoms/bodies/assets away from this prefix.", + ) + ) + + +def _compare_geoms_for_body( + ref_variant: str, + ref_geoms: tuple[GeomSignature, ...], + other_variant: str, + other_geoms: tuple[GeomSignature, ...], + body_path: str, +) -> None: + """Compare geoms within a matching body across two variants. + + Mesh geoms may differ in count per ``(body, role)``; they form padded + slots and are not part of the structural signature. Non-mesh + primitive geoms must match exactly in count, name, type, role, and + ordering relative to other primitives. + """ + mesh_type = int(mujoco.mjtGeom.mjGEOM_MESH) + ref_primitives = tuple(g for g in ref_geoms if g.type != mesh_type) + other_primitives = tuple(g for g in other_geoms if g.type != mesh_type) + if len(ref_primitives) != len(other_primitives): + raise ValueError( + _format_variant_error( + other_variant, + f"body '{body_path}' has {len(other_primitives)} non-mesh " + f"geoms, but '{ref_variant}' has {len(ref_primitives)}.", + "primitive geoms must match across all variants; only mesh " + "geom counts may differ.", + ) + ) + for i, (rp, op) in enumerate(zip(ref_primitives, other_primitives, strict=False)): + if rp != op: + raise ValueError( + _format_variant_error( + other_variant, + f"body '{body_path}' primitive geom #{i} differs from " + f"'{ref_variant}': got {op}, expected {rp}.", + "primitive geom name, type, and role must match across variants.", + ) + ) + + +def _compare_body_signatures( + ref_variant: str, + ref: BodySignature, + other_variant: str, + other: BodySignature, +) -> None: + if ref.path != other.path: + raise ValueError( + _format_variant_error( + other_variant, + f"body path '{other.path}' has no counterpart in " + f"'{ref_variant}' (expected '{ref.path}').", + "variants must share the same recursive body tree with matching body names.", + ) + ) + if len(ref.joints) != len(other.joints): + raise ValueError( + _format_variant_error( + other_variant, + f"body '{other.path}' has {len(other.joints)} joints, but " + f"'{ref_variant}' has {len(ref.joints)}.", + "variants may change mesh assets, not add/remove joints.", + ) + ) + for i, (rj, oj) in enumerate(zip(ref.joints, other.joints, strict=False)): + if rj != oj: + raise ValueError( + _format_variant_error( + other_variant, + f"body '{other.path}' joint #{i} differs from " + f"'{ref_variant}': got {oj}, expected {rj}.", + "joint name, type, and qpos/qvel width must match across variants.", + ) + ) + _compare_geoms_for_body( + ref_variant, ref.geoms, other_variant, other.geoms, other.path + ) + if len(ref.children) != len(other.children): + raise ValueError( + _format_variant_error( + other_variant, + f"body '{other.path}' has {len(other.children)} child bodies, " + f"but '{ref_variant}' has {len(ref.children)}.", + "variants must share the same recursive body tree.", + ) + ) + for ref_child, other_child in zip(ref.children, other.children, strict=False): + _compare_body_signatures(ref_variant, ref_child, other_variant, other_child) + + +def _compare_inertial_modes( + ref_variant: str, + ref_modes: dict[str, InertialMode], + other_variant: str, + other_modes: dict[str, InertialMode], +) -> None: + for path in sorted(set(ref_modes) | set(other_modes)): + rm = ref_modes.get(path) + om = other_modes.get(path) + if rm is None or om is None: + # Path mismatch is reported by the body-tree comparison. + continue + if rm != om: + raise ValueError( + _format_variant_error( + other_variant, + f"body '{path}' uses inertial representation '{om}', but " + f"'{ref_variant}' uses '{rm}'.", + "use the same inertial representation in every variant: " + "all diagonal, all fullinertia, or all mesh-derived.", + ) + ) + + +def _compare_collection_topology( + *, + item_kind: str, + ref_variant: str, + ref_items: list[Any], + other_variant: str, + other_items: list[Any], + key_fn: Callable[[Any], tuple], + count_hint: str, + item_hint: str, +) -> None: + """Compare a flat spec-level collection (actuators, sensors, ...). + + Length must match; per-element ``key_fn(item)`` tuples must match. + Per-collection error specificity is preserved through ``item_kind``, + ``count_hint``, and ``item_hint`` strings supplied by the caller. + """ + if len(ref_items) != len(other_items): + raise ValueError( + _format_variant_error( + other_variant, + f"{item_kind} count {len(other_items)} differs from " + f"'{ref_variant}' ({len(ref_items)}).", + count_hint, + ) + ) + for i, (r, o) in enumerate(zip(ref_items, other_items, strict=False)): + rk, ok = key_fn(r), key_fn(o) + if rk != ok: + raise ValueError( + _format_variant_error( + other_variant, + f"{item_kind} #{i} differs from '{ref_variant}': got {ok}, expected {rk}.", + item_hint, + ) + ) + + +def _actuator_key(a: mujoco.MjsActuator) -> tuple[str, int, str]: + return (a.name or "", int(a.trntype), a.target or "") + + +def _sensor_key(s: mujoco.MjsSensor) -> tuple[str, int, int, str]: + return (s.name or "", int(s.type), int(s.objtype), s.objname or "") + + +def _tendon_key(t: mujoco.MjsTendon) -> tuple[str]: + return (t.name or "",) + + +def _equality_key(e: mujoco.MjsEquality) -> tuple[str, int, str, str]: + return (e.name or "", int(e.type), e.name1 or "", e.name2 or "") + + +def validate_variant_specs( + names: list[str], + specs: list[mujoco.MjSpec], +) -> None: + """Validate that all variant specs share the same kinematic topology. + + Performs recursive body-tree, joint, and primitive-geom comparison; + spec-level actuator/sensor/tendon/equality topology comparison; + per-body inertial-mode consistency; and reserved-name-prefix + rejection. Raises ``ValueError`` with a message of the form: + + mjlab.entity: VariantEntityCfg '': . Hint: . + + Mesh geom counts may differ per ``(body, role)``; this is the + intended source of variant-to-variant variation. Primitive (non-mesh) + geom counts and structure must match. + + Args: + names: Variant names. Must be non-empty and align with ``specs``. + specs: Variant specs (pre-attachment). Each must have exactly one + root body under worldbody. + """ + if len(names) != len(specs): + raise ValueError("names and specs must have the same length.") + if not names: + raise ValueError("at least one variant is required.") + + for name, spec in zip(names, specs, strict=False): + _check_reserved_names(spec, name) + + root_bodies: list[mujoco.MjsBody] = [] + for name, spec in zip(names, specs, strict=False): + children = list(spec.worldbody.bodies) + if len(children) != 1: + raise ValueError( + _format_variant_error( + name, + f"variant must have exactly one root body under worldbody, " + f"got {len(children)}.", + "place the variant's root body directly under worldbody.", + ) + ) + root_bodies.append(children[0]) + + ref_name = names[0] + ref_signature = _extract_body_signature(root_bodies[0]) + ref_modes = _collect_inertial_modes(root_bodies[0]) + ref_spec = specs[0] + + for i in range(1, len(names)): + other_name = names[i] + other_signature = _extract_body_signature(root_bodies[i]) + other_modes = _collect_inertial_modes(root_bodies[i]) + other_spec = specs[i] + + _compare_body_signatures(ref_name, ref_signature, other_name, other_signature) + _compare_inertial_modes(ref_name, ref_modes, other_name, other_modes) + _compare_collection_topology( + item_kind="actuator", + ref_variant=ref_name, + ref_items=list(ref_spec.actuators), + other_variant=other_name, + other_items=list(other_spec.actuators), + key_fn=_actuator_key, + count_hint="keep control and observation dimensions fixed across variants.", + item_hint=( + "actuator topology (name, transmission type, target) must match " + "across variants." + ), + ) + _compare_collection_topology( + item_kind="sensor", + ref_variant=ref_name, + ref_items=list(ref_spec.sensors), + other_variant=other_name, + other_items=list(other_spec.sensors), + key_fn=_sensor_key, + count_hint="keep sensor topology identical across variants.", + item_hint="sensor topology (name, type, target object) must match.", + ) + _compare_collection_topology( + item_kind="tendon", + ref_variant=ref_name, + ref_items=list(ref_spec.tendons), + other_variant=other_name, + other_items=list(other_spec.tendons), + key_fn=_tendon_key, + count_hint="keep tendon topology identical across variants.", + item_hint="tendon names must match across variants.", + ) + _compare_collection_topology( + item_kind="equality", + ref_variant=ref_name, + ref_items=list(ref_spec.equalities), + other_variant=other_name, + other_items=list(other_spec.equalities), + key_fn=_equality_key, + count_hint="keep equality constraints identical across variants.", + item_hint="equality constraint topology must match across variants.", + ) + + +## +# Slot computation: assign (body_path, role, ordinal) keys to mesh geoms. +## + + +def _extract_variant_geom_spec(g: mujoco.MjsGeom) -> VariantGeomSpec: + """Snapshot a source mesh geom's attributes for slot-aware compile.""" + return VariantGeomSpec( + mesh_name=g.meshname, + geom_name=g.name if g.name else None, + contype=int(g.contype), + conaffinity=int(g.conaffinity), + condim=int(g.condim), + group=int(g.group), + priority=int(g.priority), + material=g.material if g.material else None, + rgba=( + float(g.rgba[0]), + float(g.rgba[1]), + float(g.rgba[2]), + float(g.rgba[3]), + ), + friction=( + float(g.friction[0]), + float(g.friction[1]), + float(g.friction[2]), + ), + margin=float(g.margin), + gap=float(g.gap), + solref=(float(g.solref[0]), float(g.solref[1])), + solimp=( + float(g.solimp[0]), + float(g.solimp[1]), + float(g.solimp[2]), + float(g.solimp[3]), + float(g.solimp[4]), + ), + mass=float(g.mass), + density=float(g.density), + ) + + +def _compute_slot_metadata( + variant_names: tuple[str, ...], + variant_specs: list[mujoco.MjSpec], +) -> tuple[ + tuple[VariantSlot, ...], + tuple[tuple[VariantGeomSpec | None, ...], ...], +]: + """Compute slot list and per-variant slot specs. + + Walks each variant's body tree, buckets mesh geoms by ``(body_path, + role)``, and assigns ordinals deterministically from source order. + Slot count per ``(body_path, role)`` equals the max across variants; + variants with fewer geoms in that bucket leave trailing slots + unfilled (``None`` in ``variant_slot_specs``). + """ + mesh_type = int(mujoco.mjtGeom.mjGEOM_MESH) + + # Per-variant {body_path -> {role -> [VariantGeomSpec]}}. + per_variant_buckets: list[dict[str, dict[GeomRole, list[VariantGeomSpec]]]] = [] + for spec in variant_specs: + root = list(spec.worldbody.bodies)[0] + buckets: dict[str, dict[GeomRole, list[VariantGeomSpec]]] = {} + for path, body in _iter_body_paths(root): + role_buckets = buckets.setdefault(path, {"visual": [], "collision": []}) + for g in body.geoms: + if int(g.type) != mesh_type: + continue + role_buckets[_classify_geom_role(g)].append(_extract_variant_geom_spec(g)) + per_variant_buckets.append(buckets) + + # All body paths present in any variant. (Validation ensures the path + # sets agree across variants, but use the union here for safety.) + all_paths: set[str] = set() + for buckets in per_variant_buckets: + all_paths.update(buckets.keys()) + + slots: list[VariantSlot] = [] + per_variant_slot_specs: list[list[VariantGeomSpec | None]] = [ + [] for _ in variant_names + ] + + # Deterministic order: sort body paths lexicographically, then visual + # before collision per body, then by ordinal. + for body_path in sorted(all_paths): + for role in ("visual", "collision"): + max_count = 0 + for buckets in per_variant_buckets: + count = len(buckets.get(body_path, {}).get(role, [])) + if count > max_count: + max_count = count + for ordinal in range(max_count): + source_names: list[str | None] = [] + slot_specs: list[VariantGeomSpec | None] = [] + for variant_buckets in per_variant_buckets: + variant_geoms = variant_buckets.get(body_path, {}).get(role, []) + if ordinal < len(variant_geoms): + geom_spec = variant_geoms[ordinal] + source_names.append(geom_spec.geom_name) + slot_specs.append(geom_spec) + else: + source_names.append(None) + slot_specs.append(None) + template_name = f"mjlab/pad{body_path}/{role}/{ordinal}" + slots.append( + VariantSlot( + key=SlotKey( + body_path=body_path, + role=role, + ordinal=ordinal, + ), + template_geom_name=template_name, + source_geom_names=tuple(source_names), + ) + ) + for variant_idx, slot_spec in enumerate(slot_specs): + per_variant_slot_specs[variant_idx].append(slot_spec) + + return tuple(slots), tuple(tuple(s) for s in per_variant_slot_specs) + + +## +# Build pipeline (entity-time): merge variant specs into one padded template. +## + + +def build_merged_variant_spec( + cfg: VariantEntityCfg, +) -> tuple[mujoco.MjSpec, VariantMetadata]: + """Merge a ``VariantEntityCfg``'s variants into a single padded template. + + Validates that all variants share the same kinematic structure, merges + every variant's mesh assets into the template namespace, slot-renames + variant 0's mesh geoms to ``mjlab/pad///``, and + synthesizes padding geoms (per-(body, role)) for slots that variant 0 + does not fill, so the template's geom count and bodyid layout cover + every variant's needs. + + Returns the merged template spec and a ``VariantMetadata`` describing + the slot layout, per-variant slot specs, and explicit body inertials. + The reference compile in :func:`build_variant_model` overrides every + per-variant attribute via ``VariantGeomSpec``, so variant 0 being "the + template" does not privilege its attributes. + """ + variants = cfg.variants + if not variants: + raise ValueError("VariantEntityCfg.variants must contain at least one entry.") + + variant_names: list[str] = [] + variant_specs: list[mujoco.MjSpec] = [] + for name, spec_fn in variants.items(): + variant_names.append(name) + variant_specs.append(spec_fn()) + + # Resolve cfg.assignment (None | dict | callable) into the metadata's + # unified ``assignment`` field. None and dict both produce a weights + # tuple keyed by variant declaration order; a callable passes through. + resolved_assignment: tuple[float, ...] | Callable[[int], Sequence[int]] + cfg_assignment = cfg.assignment + if cfg_assignment is None: + resolved_assignment = (1.0,) * len(variant_names) + elif isinstance(cfg_assignment, dict): + weights_dict = cast(dict[str, float], cfg_assignment) + resolved_assignment = tuple(weights_dict.get(n, 1.0) for n in variant_names) + else: + resolved_assignment = cfg_assignment + + # Validate cross-variant topology (recursive body tree, joints, + # primitive geoms, actuators, sensors, tendons, equalities, inertial + # mode consistency, and reserved-prefix collisions). The validator + # enforces "exactly one root body under worldbody" itself. + validate_variant_specs(variant_names, variant_specs) + + variant_bodies: list[mujoco.MjsBody] = [ + list(spec.worldbody.bodies)[0] for spec in variant_specs + ] + + # Variant entities must be floating-base. Mocap auto-wrap is not applied + # for variant entities, so fixed-base variants would silently stack at + # the world origin. Variants share joint structure (validated above), so + # checking the first is sufficient. + ref_joints = list(variant_bodies[0].joints) + if not ref_joints or ref_joints[0].type != mujoco.mjtJoint.mjJNT_FREE: + raise ValueError( + "VariantEntityCfg requires floating-base variants. Each variant's " + "root body must declare a free joint via body.add_freejoint(); " + "fixed-base variants are not supported." + ) + + # Compute slot-based metadata from source specs BEFORE any mesh + # renaming or merging. This captures (body_path, role, ordinal) keyed + # slots and per-variant geom attributes for the slot-aware build path. + slots, variant_slot_specs = _compute_slot_metadata( + tuple(variant_names), variant_specs + ) + + # Snapshot a clean copy of every source spec BEFORE the merge mutates + # variant 0's spec (mesh renames, slot-name geom renames, padding geom + # synthesis). The sim-time build compiles each of these in isolation + # to populate per-world fields, avoiding the O(N) cost of recompiling + # the merged scene per unique variant assignment. + variant_source_specs = tuple(s.copy() for s in variant_specs) + + # Use variant 0's spec as the template. The merge: + # 1. prefixes variant 0's mesh names with its variant name and + # copies every other variant's mesh assets into the template; + # 2. renames variant 0's mesh geoms to slot template names so + # every mesh geom in the merged template is addressable by + # ``slot.template_geom_name``; + # 3. synthesizes padding geoms (per-(body, role)) for slots that + # variant 0 does not fill, so the template's geom count and + # bodyid layout cover every variant's needs. + template_spec = variant_specs[0] + template_body = variant_bodies[0] + + # (1) Rename template meshes and update mesh references on the + # template body's geoms (recursive: child bodies may also reference + # the template variant's meshes). + template_prefix = f"{variant_names[0]}/" + old_to_new: dict[str, str] = {} + for mesh in template_spec.meshes: + new_name = f"{template_prefix}{mesh.name}" + old_to_new[mesh.name] = new_name + mesh.name = new_name + for _, body in _iter_body_paths(template_body): + for g in body.geoms: + if g.meshname in old_to_new: + g.meshname = old_to_new[g.meshname] + + # Copy mesh assets from other variants into the template namespace. + for i in range(1, len(variant_specs)): + prefix = f"{variant_names[i]}/" + for mesh in variant_specs[i].meshes: + new_mesh = template_spec.add_mesh() + new_mesh.name = f"{prefix}{mesh.name}" + copy_mesh_data(mesh, new_mesh) + + # Mirror of the mesh treatment above for textures and materials. + texture_old_to_new: dict[str, str] = {} + for tex in template_spec.textures: + new_name = f"{template_prefix}{tex.name}" + texture_old_to_new[tex.name] = new_name + tex.name = new_name + material_old_to_new: dict[str, str] = {} + for mat in template_spec.materials: + new_name = f"{template_prefix}{mat.name}" + material_old_to_new[mat.name] = new_name + mat.name = new_name + mat.textures = [texture_old_to_new.get(t, t) for t in mat.textures] + for _, body in _iter_body_paths(template_body): + for g in body.geoms: + if g.material in material_old_to_new: + g.material = material_old_to_new[g.material] + + # Copy texture/material assets from other variants into the template. + for i in range(1, len(variant_specs)): + prefix = f"{variant_names[i]}/" + src_to_dst_tex: dict[str, str] = {} + for tex in variant_specs[i].textures: + new_tex = template_spec.add_texture() + new_tex.name = f"{prefix}{tex.name}" + src_to_dst_tex[tex.name] = new_tex.name + copy_texture_data(tex, new_tex) + for mat in variant_specs[i].materials: + new_mat = template_spec.add_material() + new_mat.name = f"{prefix}{mat.name}" + copy_material_data(mat, new_mat) + new_mat.textures = [src_to_dst_tex.get(t, t) for t in new_mat.textures] + + # (2) Slot-driven rename of variant 0's existing mesh geoms. Walk + # the template body tree; within each (body, role) bucket, the + # n-th mesh geom (in source order) maps to slot ordinal n. + mesh_type = mujoco.mjtGeom.mjGEOM_MESH + slot_by_key: dict[SlotKey, VariantSlot] = {s.key: s for s in slots} + template_bodies_by_path: dict[str, mujoco.MjsBody] = {} + filled_template_slot_keys: set[SlotKey] = set() + for body_path, body in _iter_body_paths(template_body): + template_bodies_by_path[body_path] = body + role_ordinals: dict[GeomRole, int] = {"visual": 0, "collision": 0} + for g in body.geoms: + if g.type != mesh_type: + continue + role = _classify_geom_role(g) + ordinal = role_ordinals[role] + role_ordinals[role] += 1 + key = SlotKey(body_path=body_path, role=role, ordinal=ordinal) + slot = slot_by_key[key] + g.name = slot.template_geom_name + filled_template_slot_keys.add(key) + + # (3) Synthesize per-(body, role) padding geoms for slots variant 0 + # doesn't fill. Each padding geom uses the slot's reserved + # ``mjlab/pad/...`` template name, the role's union contact bits, + # and a placeholder mesh from the lowest-index variant that fills + # the slot. ``mass=0`` and ``density=0`` keep the placeholder from + # contributing to the template's body inertial inference; the + # per-variant reference compile handles further masking. + for s_idx, slot in enumerate(slots): + if slot.key in filled_template_slot_keys: + continue + target_body = template_bodies_by_path[slot.key.body_path] + pad_geom = target_body.add_geom() + pad_geom.type = mesh_type + pad_geom.name = slot.template_geom_name + if slot.key.role == "visual": + pad_geom.contype = 0 + pad_geom.conaffinity = 0 + else: + pad_geom.contype = 1 + pad_geom.conaffinity = 1 + placeholder_set = False + for v_idx, slot_specs in enumerate(variant_slot_specs): + gs = slot_specs[s_idx] + if gs is None: + continue + pad_geom.meshname = f"{variant_names[v_idx]}/{gs.mesh_name}" + placeholder_set = True + break + assert placeholder_set, ( + f"slot {slot.key} has no filling variant; " + "_compute_slot_metadata must not emit unfilled slots." + ) + pad_geom.mass = 0.0 + pad_geom.density = 0.0 + + metadata = VariantMetadata( + variant_names=tuple(variant_names), + assignment=resolved_assignment, + slots=slots, + variant_slot_specs=variant_slot_specs, + variant_source_specs=variant_source_specs, + ) + return template_spec, metadata + + +## +# Build pipeline (sim-time): produce a heterogeneous Warp model. +# +# The module-level entry point is ``build_variant_model`` at the bottom +# of this section. ``allocate_worlds`` is its world-assignment step and +# is also exposed for unit testing the largest-remainder math in +# isolation. Everything between is private to ``build_variant_model`` +# (or to ``_populate_dependent_fields``, which is itself private). +## + + +def _qualified_mesh_name(entity_prefix: str, variant_name: str, mesh_name: str) -> str: + """Mesh asset name in the merged template's namespace. + + Source mesh names are prefixed with the variant name during merge + (``/``); after scene attach the entity prefix is + added (``//``). + """ + return f"{entity_prefix}{variant_name}/{mesh_name}" + + +def _qualified_material_name( + entity_prefix: str, variant_name: str, material_name: str +) -> str: + """Material asset name in the merged template's namespace. + + Source materials are prefixed with the variant name during merge + (``/``); after scene attach the entity prefix is + added (``//``). + """ + return f"{entity_prefix}{variant_name}/{material_name}" + + +def _qualified_slot_geom_name(entity_prefix: str, template_geom_name: str) -> str: + """Slot geom name in the merged template's namespace after attach.""" + return f"{entity_prefix}{template_geom_name}" + + +def _populate_dependent_fields( + m: mjwarp.Model, + padded_model: mujoco.MjModel, + nworld: int, + variant_info: list[tuple[str, VariantMetadata]], + world_to_variant: dict[str, np.ndarray], +) -> None: + """Populate per-world Warp arrays for the geometry-dependent fields. + + Compiles each variant's *source* spec in isolation (small, single-entity + spec) and scatters per-body and per-geom fields into per-world arrays. + Total cost is the sum of per-source compiles across all variant + entities, i.e. linear in the number of variants. This avoids the + O(N^2) cost of recompiling the merged scene per unique variant + assignment. + + For non-variant bodies and non-slot geoms, values come from a single + base scene compile (``padded_model``) broadcast across worlds. For + variant entity bodies and slot geoms, the per-world value comes from + the corresponding variant's source compile. ``body_subtreemass`` for + ancestors of each variant entity's root body is updated per-world by + applying the per-variant subtreemass delta. + """ + ngeom = padded_model.ngeom + nbody = padded_model.nbody + + # Initialize per-world arrays from the base scene compile. Bodies and + # geoms not touched by any variant entity inherit these values. We + # broadcast then copy so writes to per-world rows don't alias. + def _broadcast(src: np.ndarray, shape: tuple[int, ...]) -> np.ndarray: + return np.broadcast_to(src.astype(np.float32), shape).copy() + + geom_size = _broadcast(padded_model.geom_size, (nworld, ngeom, 3)) + geom_rbound = _broadcast(padded_model.geom_rbound, (nworld, ngeom)) + geom_aabb = _broadcast( + padded_model.geom_aabb.reshape(ngeom, 2, 3), (nworld, ngeom, 2, 3) + ) + geom_pos = _broadcast(padded_model.geom_pos, (nworld, ngeom, 3)) + geom_quat = _broadcast(padded_model.geom_quat, (nworld, ngeom, 4)) + body_mass = _broadcast(padded_model.body_mass, (nworld, nbody)) + body_subtreemass = _broadcast(padded_model.body_subtreemass, (nworld, nbody)) + body_inertia = _broadcast(padded_model.body_inertia, (nworld, nbody, 3)) + body_invweight0 = _broadcast(padded_model.body_invweight0, (nworld, nbody, 2)) + body_ipos = _broadcast(padded_model.body_ipos, (nworld, nbody, 3)) + body_iquat = _broadcast(padded_model.body_iquat, (nworld, nbody, 4)) + + mesh_type = mujoco.mjtGeom.mjGEOM_MESH + + for entity_prefix, metadata in variant_info: + w2v = world_to_variant[entity_prefix] + + # Use any variant's source spec to enumerate body paths and the + # entity's slot geom names. Validation guarantees variants share + # the same body tree and slot keys, so the choice is arbitrary. + sample_spec = metadata.variant_source_specs[0] + sample_root = list(sample_spec.worldbody.bodies)[0] + + # Map each variant-entity body path to its scene body id. Body + # names in the scene are the bare body name prefixed by the + # entity's attach prefix. + scene_body_id_by_path: dict[str, int] = {} + body_name_by_path: dict[str, str] = {} + for body_path, body in _iter_body_paths(sample_root): + body_name_by_path[body_path] = body.name or "" + scene_name = f"{entity_prefix}{body.name or ''}" + bid = mujoco.mj_name2id(padded_model, mujoco.mjtObj.mjOBJ_BODY, scene_name) + if bid < 0: + raise ValueError( + f"variant entity body '{scene_name}' not found in compiled scene." + ) + scene_body_id_by_path[body_path] = bid + + # Map each slot key to its scene geom id. + scene_geom_id_by_slot: dict[SlotKey, int] = {} + for slot in metadata.slots: + full_geom_name = _qualified_slot_geom_name(entity_prefix, slot.template_geom_name) + gid = mujoco.mj_name2id(padded_model, mujoco.mjtObj.mjOBJ_GEOM, full_geom_name) + if gid < 0: + raise ValueError( + f"slot geom '{full_geom_name}' (entity '{entity_prefix}') not " + f"found in compiled scene." + ) + scene_geom_id_by_slot[slot.key] = gid + + # Variant entity's root body (shortest body path), used to compute + # the subtreemass delta to propagate to ancestors. The root path is + # always the lex-smallest among the entity's body paths (parents + # sort before children). + sorted_paths = sorted(scene_body_id_by_path.keys()) + root_body_path = sorted_paths[0] + root_scene_bid = scene_body_id_by_path[root_body_path] + + # Walk up the scene's parent chain from the variant root to + # worldbody (inclusive). The variant root's own subtreemass is + # written from the per-variant compile; each ancestor receives an + # additive delta equal to (variant_subtreemass - base_subtreemass). + ancestor_ids: list[int] = [] + parent_id = int(padded_model.body_parentid[root_scene_bid]) + while True: + ancestor_ids.append(parent_id) + if parent_id == 0: + break + parent_id = int(padded_model.body_parentid[parent_id]) + base_root_subtreemass = float(padded_model.body_subtreemass[root_scene_bid]) + + # Compile each variant's source spec in isolation and scatter values + # into the per-world arrays for the worlds assigned to that variant. + for v_idx in range(len(metadata.variant_names)): + worlds = np.where(w2v == v_idx)[0] + if worlds.size == 0: + continue + + source_spec = metadata.variant_source_specs[v_idx].copy() + v_model = source_spec.compile() + + source_root = list(source_spec.worldbody.bodies)[0] + source_body_id_by_path: dict[str, int] = {} + for body_path, body in _iter_body_paths(source_root): + source_body_id_by_path[body_path] = body.id + + # Scatter per-body fields for every body in the variant entity's + # subtree. Bodies without explicit inertials inherit the + # mesh-derived inertia from the variant's mesh assignment in the + # source compile, which is exactly what we want. + for body_path, scene_bid in scene_body_id_by_path.items(): + source_bid = source_body_id_by_path[body_path] + body_mass[worlds, scene_bid] = v_model.body_mass[source_bid] + body_subtreemass[worlds, scene_bid] = v_model.body_subtreemass[source_bid] + body_inertia[worlds, scene_bid] = v_model.body_inertia[source_bid] + body_invweight0[worlds, scene_bid] = v_model.body_invweight0[source_bid] + body_ipos[worlds, scene_bid] = v_model.body_ipos[source_bid] + body_iquat[worlds, scene_bid] = v_model.body_iquat[source_bid] + + # Scatter per-geom fields for each slot this variant fills. Map + # each (body_path, role, ordinal) slot to the corresponding geom + # in the source compile by re-walking the source body tree (same + # ordering rules as ``_compute_slot_metadata``). + for body_path, body in _iter_body_paths(source_root): + role_ordinals: dict[GeomRole, int] = {"visual": 0, "collision": 0} + for g in body.geoms: + if g.type != mesh_type: + continue + role = _classify_geom_role(g) + ordinal = role_ordinals[role] + role_ordinals[role] += 1 + slot_key = SlotKey(body_path=body_path, role=role, ordinal=ordinal) + scene_gid = scene_geom_id_by_slot.get(slot_key) + if scene_gid is None: + continue + source_gid = g.id + geom_size[worlds, scene_gid] = v_model.geom_size[source_gid] + geom_rbound[worlds, scene_gid] = v_model.geom_rbound[source_gid] + geom_aabb[worlds, scene_gid] = v_model.geom_aabb[source_gid].reshape(2, 3) + geom_pos[worlds, scene_gid] = v_model.geom_pos[source_gid] + geom_quat[worlds, scene_gid] = v_model.geom_quat[source_gid] + + # Propagate the variant root's subtreemass delta up the ancestor + # chain. Base ancestor subtreemass already includes the placeholder + # contribution from the merged scene compile; adding the delta + # swaps that placeholder contribution for the variant's actual + # contribution. + delta = ( + float(v_model.body_subtreemass[source_body_id_by_path[root_body_path]]) + - base_root_subtreemass + ) + for anc_bid in ancestor_ids: + body_subtreemass[worlds, anc_bid] += delta + + m.geom_size = wp.array(geom_size, dtype=wp.vec3) + m.geom_rbound = wp.array(geom_rbound, dtype=float) + m.geom_aabb = wp.array(geom_aabb, dtype=wp.vec3) + m.geom_pos = wp.array(geom_pos, dtype=wp.vec3) + m.geom_quat = wp.array(geom_quat, dtype=wp.quat) + m.body_mass = wp.array(body_mass, dtype=float) + m.body_subtreemass = wp.array(body_subtreemass, dtype=float) + m.body_inertia = wp.array(body_inertia, dtype=wp.vec3) + m.body_invweight0 = wp.array(body_invweight0, dtype=wp.vec2) + m.body_ipos = wp.array(body_ipos, dtype=wp.vec3) + m.body_iquat = wp.array(body_iquat, dtype=wp.quat) + + +def allocate_worlds( + weights: tuple[float, ...], + nworld: int, +) -> list[int]: + """Assign worlds proportionally by weight (largest-remainder method). + + Returns a list of length *nworld* containing variant indices. Weights + must be non-negative with at least one positive entry. + """ + if any(w < 0 for w in weights): + raise ValueError(f"weights must be non-negative, got {weights}.") + total = sum(weights) + if total <= 0: + raise ValueError(f"weights must have a positive sum, got {weights}.") + quotas = [(w / total) * nworld for w in weights] + floors = [int(q) for q in quotas] + remainders = sorted( + ((quotas[i] - floors[i], i) for i in range(len(weights))), + key=lambda x: -x[0], + ) + allocated = sum(floors) + for j in range(nworld - allocated): + floors[remainders[j][1]] += 1 + assignment: list[int] = [] + for idx, count in enumerate(floors): + assignment.extend([idx] * count) + return assignment + + +def build_variant_model( + spec: mujoco.MjSpec, + nworld: int, + variant_info: list[tuple[str, VariantMetadata]], + configure_model: Callable[[mujoco.MjModel], None] | None = None, +) -> MeshVariantResult: + """Build a Warp Model with per-world mesh assignments. + + Args: + spec: Scene spec (already merged with padded variant geoms). + nworld: Number of simulation worlds. + variant_info: List of ``(entity_prefix, metadata)`` pairs for + entities that have mesh variants. + configure_model: Optional callback to configure the compiled + MjModel before ``put_model`` (e.g., setting solver options). + + Returns: + A :class:`MeshVariantResult` containing the warp model, host + model, and per-entity world-to-variant mappings. + """ + spec = spec.copy() + model = spec.compile() + if configure_model is not None: + configure_model(model) + + # Start from base dataid tiled for all worlds. + base_dataid = model.geom_dataid.copy() + dataid_table = np.tile(base_dataid, (nworld, 1)) + + # Same treatment for matid so each variant can use its own material. + base_matid = model.geom_matid.copy() + matid_table = np.tile(base_matid, (nworld, 1)) + + world_to_variant: dict[str, np.ndarray] = {} + + for entity_prefix, metadata in variant_info: + slots = metadata.slots + nslots = len(slots) + nvariants = len(metadata.variant_names) + + # Resolve world-to-variant assignment. The metadata's ``assignment`` + # is either a tuple of weights (from None or dict on the cfg) or a + # callable. Use ``callable()`` for the dispatch since tuples are + # not callable. + assignment_spec = metadata.assignment + if callable(assignment_spec): + assignment = list(assignment_spec(nworld)) + if len(assignment) != nworld: + raise ValueError( + f"VariantEntityCfg.assignment (entity '{entity_prefix}') " + f"returned {len(assignment)} indices but nworld={nworld}." + ) + for w, v in enumerate(assignment): + if not (0 <= int(v) < nvariants): + raise ValueError( + f"VariantEntityCfg.assignment (entity '{entity_prefix}') " + f"returned variant index {v} at world {w}, but only " + f"{nvariants} variants are declared." + ) + else: + assignment = allocate_worlds(assignment_spec, nworld) + w2v = np.array(assignment, dtype=np.int32) + world_to_variant[entity_prefix] = w2v + + # Resolve slot geom IDs by template name (deterministic, no + # positional or unnamed-padding heuristics). + slot_geom_ids = np.zeros(nslots, dtype=np.int64) + for s_idx, slot in enumerate(slots): + full_geom_name = _qualified_slot_geom_name(entity_prefix, slot.template_geom_name) + gid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, full_geom_name) + if gid < 0: + raise ValueError( + f"Slot geom '{full_geom_name}' (entity '{entity_prefix}', " + f"slot {slot.key}) not found in compiled model." + ) + slot_geom_ids[s_idx] = gid + + # Resolve every (variant, slot) -> (mesh_id, mat_id). ``-1`` denotes + # an unfilled slot (mesh) or a slot rendered without a material + # (matid). + variant_slot_mesh_ids = np.full((nvariants, nslots), -1, dtype=np.int64) + variant_slot_matids = np.full((nvariants, nslots), -1, dtype=np.int64) + for v_idx, slot_specs in enumerate(metadata.variant_slot_specs): + variant_name = metadata.variant_names[v_idx] + for s_idx, gspec in enumerate(slot_specs): + if gspec is None: + continue + full_mesh_name = _qualified_mesh_name( + entity_prefix, variant_name, gspec.mesh_name + ) + mid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_MESH, full_mesh_name) + if mid < 0: + raise ValueError( + f"Mesh '{full_mesh_name}' (variant '{variant_name}', " + f"slot {slots[s_idx].key}) not found in compiled model." + ) + variant_slot_mesh_ids[v_idx, s_idx] = mid + if gspec.material: + full_mat_name = _qualified_material_name( + entity_prefix, variant_name, gspec.material + ) + matid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_MATERIAL, full_mat_name) + if matid < 0: + raise ValueError( + f"Material '{full_mat_name}' (variant '{variant_name}', " + f"slot {slots[s_idx].key}) not found in compiled model." + ) + variant_slot_matids[v_idx, s_idx] = matid + + # Vectorized scatter: per-world row from variant assignment. + dataid_table[:, slot_geom_ids] = variant_slot_mesh_ids[w2v] + matid_table[:, slot_geom_ids] = variant_slot_matids[w2v] + + # Build warp model. + m = mjwarp.put_model(model) + m.geom_dataid = wp.array(dataid_table, dtype=int) + m.geom_matid = wp.array(matid_table, dtype=int) + + # Populate dependent per-world fields. + _populate_dependent_fields(m, model, nworld, variant_info, world_to_variant) + + return MeshVariantResult( + wp_model=m, + mj_model=model, + world_to_variant=world_to_variant, + ) diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/manager_based_rl_env.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/manager_based_rl_env.py index eee8c32..b123ef8 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/manager_based_rl_env.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/manager_based_rl_env.py @@ -184,7 +184,7 @@ class ManagerBasedRlEnv: # Initialize base environment state. self.cfg = cfg if self.cfg.seed is not None: - self.cfg.seed = self.seed(self.cfg.seed, device=device) + self.cfg.seed = self.seed(self.cfg.seed) self._sim_step_counter = 0 self.extras = {} self.obs_buf = {} @@ -194,21 +194,13 @@ class ManagerBasedRlEnv: # Initialize scene and simulation. self.scene = Scene(self.cfg.scene, device=device) - if self.scene.has_mesh_variants: - self.sim = Simulation( - num_envs=self.scene.num_envs, - cfg=self.cfg.sim, - spec=self.scene.spec, - variant_info=self.scene.collect_variant_info(), - device=device, - ) - else: - self.sim = Simulation( - num_envs=self.scene.num_envs, - cfg=self.cfg.sim, - model=self.scene.compile(), - device=device, - ) + self.sim = Simulation( + num_envs=self.scene.num_envs, + cfg=self.cfg.sim, + spec=self.scene.spec, + variant_info=self.scene.collect_variant_info(), + device=device, + ) self.scene.initialize( mj_model=self.sim.mj_model, @@ -373,6 +365,7 @@ class ManagerBasedRlEnv: env_ids = torch.arange(self.num_envs, dtype=torch.int64, device=self.device) if seed is not None: self.seed(seed) + self.extras["log"] = dict() self._reset_idx(env_ids) self.scene.write_data_to_sim() self.sim.forward() @@ -422,6 +415,7 @@ class ManagerBasedRlEnv: "reset(env_ids=...) before calling step() again when auto_reset=False." ) + self.extras["log"] = dict() self.action_manager.process_action(action.to(self.device)) for _ in range(self.cfg.decimation): @@ -484,6 +478,9 @@ class ManagerBasedRlEnv: self.extras, ) + def get_observations(self) -> dict: + return self.observation_manager.compute() + def render(self) -> np.ndarray | None: if self.render_mode == "human" or self.render_mode is None: return None @@ -506,11 +503,12 @@ class ManagerBasedRlEnv: self._offline_renderer.close() self.recorder_manager.close() - def seed(self, seed: int = -1, device: str | torch.device | None = None) -> int: + @staticmethod + def seed(seed: int = -1) -> int: if seed == -1: seed = np.random.randint(0, 10_000) print_info(f"Setting seed: {seed}") - random_utils.seed_rng(seed, device=device if device is not None else self.device) + random_utils.seed_rng(seed) return seed def update_visualizers(self, visualizer: DebugVisualizer) -> None: @@ -564,7 +562,6 @@ class ManagerBasedRlEnv: ) # NOTE: This is order sensitive. - self.extras["log"] = dict() # observation manager. info = self.observation_manager.reset(env_ids) self.extras["log"].update(info) diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/mdp/dr/actuator.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/mdp/dr/actuator.py index 56f1a8c..a727d7e 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/mdp/dr/actuator.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/mdp/dr/actuator.py @@ -7,23 +7,65 @@ from typing import TYPE_CHECKING, Literal import torch from mjlab.actuator import ( - BuiltinPositionActuator, - BuiltinVelocityActuator, - BuiltinMotorActuator, - IdealPdActuator, + BuiltinDcMotorActuator, + BuiltinPdActuator, + BuiltinPositionActuator, + IdealPdActuator, ) +from mjlab.actuator.actuator import TransmissionType +from mjlab.actuator.builtin_actuator import DcMotorInputMode from mjlab.actuator.xml_actuator import XmlActuator from mjlab.entity import Entity from mjlab.managers.event_manager import requires_model_fields from mjlab.managers.scene_entity_config import SceneEntityCfg from ._core import _DEFAULT_ASSET_CFG -from ._types import resolve_distribution +from ._types import Operation, resolve_distribution, resolve_operation if TYPE_CHECKING: from mjlab.envs import ManagerBasedRlEnv +def _resolve_actuators(asset: Entity, asset_cfg: SceneEntityCfg) -> list: + """Resolve actuator objects from SceneEntityCfg. + + SceneEntityCfg actuator_ids/name resolution is based on spec actuators, while + runtime ``asset.actuators`` may contain grouped actuator objects. For grouped + actuators, map matched actuator target names back to the owning actuator object. + """ + if asset_cfg.actuator_names is not None: + matched_names = asset_cfg.actuator_names + if isinstance(matched_names, str): + matched_names = [matched_names] + resolved = [] + for actuator in asset.actuators: + if any(name in actuator.target_names for name in matched_names): + resolved.append(actuator) + return resolved + + if isinstance(asset_cfg.actuator_ids, list): + if all(0 <= i < len(asset.actuators) for i in asset_cfg.actuator_ids): + return [asset.actuators[i] for i in asset_cfg.actuator_ids] + + resolved = [] + seen = set() + actuator_names = asset.actuator_names + for i in asset_cfg.actuator_ids: + if not (0 <= i < len(actuator_names)): + continue + target_name = actuator_names[i] + for actuator_idx, actuator in enumerate(asset.actuators): + if target_name in actuator.target_names and actuator_idx not in seen: + resolved.append(actuator) + seen.add(actuator_idx) + break + return resolved + elif isinstance(asset_cfg.actuator_ids, slice): + return asset.actuators[asset_cfg.actuator_ids] + else: + return [asset.actuators[asset_cfg.actuator_ids]] + + @requires_model_fields("actuator_gainprm", "actuator_biasprm") def pd_gains( env: ManagerBasedRlEnv, @@ -32,7 +74,7 @@ def pd_gains( kd_range: tuple[float, float], asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, distribution: Literal["uniform", "log_uniform"] = "uniform", - operation: Literal["scale", "abs"] = "scale", + operation: Operation | str = "scale", ) -> None: """Randomize PD stiffness and damping gains. @@ -46,6 +88,11 @@ def pd_gains( operation: "scale" multiplies default gains by sampled values, "abs" sets absolute values. """ + op = resolve_operation(operation) + if op.name not in ("scale", "abs"): + raise ValueError( + f"pd_gains only supports 'scale' and 'abs' operations, got {op.name!r}" + ) asset: Entity = env.scene[asset_cfg.name] if env_ids is None: @@ -53,34 +100,36 @@ def pd_gains( else: env_ids = env_ids.to(env.device, dtype=torch.int) - if isinstance(asset_cfg.actuator_ids, list): - actuators = [asset.actuators[i] for i in asset_cfg.actuator_ids] - elif isinstance(asset_cfg.actuator_ids, slice): - actuators = asset.actuators[asset_cfg.actuator_ids] - else: - actuators = [asset.actuators[asset_cfg.actuator_ids]] + actuators = _resolve_actuators(asset, asset_cfg) for actuator in actuators: ctrl_ids = actuator.global_ctrl_ids + # Each target needs one kp draw and one kd draw. For single-element + # actuators that's len(ctrl_ids) of each; for BuiltinPd the ctrl tensor + # has 2*N entries but only N independent kp/kd values, so we sample + # num_targets to avoid throwing the other half away. + n_gains = ( + actuator.num_targets if isinstance(actuator, BuiltinPdActuator) else len(ctrl_ids) + ) dist = resolve_distribution(distribution) kp_samples = dist.sample( torch.tensor(kp_range[0], device=env.device), torch.tensor(kp_range[1], device=env.device), - (len(env_ids), len(ctrl_ids)), + (len(env_ids), n_gains), env.device, ) kd_samples = dist.sample( torch.tensor(kd_range[0], device=env.device), torch.tensor(kd_range[1], device=env.device), - (len(env_ids), len(ctrl_ids)), + (len(env_ids), n_gains), env.device, ) if isinstance(actuator, BuiltinPositionActuator) or ( isinstance(actuator, XmlActuator) and actuator.command_field == "position" ): - if operation == "scale": + if op.name == "scale": default_gainprm = env.sim.get_default_field("actuator_gainprm") default_biasprm = env.sim.get_default_field("actuator_biasprm") env.sim.model.actuator_gainprm[env_ids[:, None], ctrl_ids, 0] = ( @@ -92,15 +141,69 @@ def pd_gains( env.sim.model.actuator_biasprm[env_ids[:, None], ctrl_ids, 2] = ( default_biasprm[ctrl_ids, 2] * kd_samples ) - elif operation == "abs": + else: + assert op.name == "abs" env.sim.model.actuator_gainprm[env_ids[:, None], ctrl_ids, 0] = kp_samples env.sim.model.actuator_biasprm[env_ids[:, None], ctrl_ids, 1] = -kp_samples env.sim.model.actuator_biasprm[env_ids[:, None], ctrl_ids, 2] = -kd_samples + elif isinstance(actuator, BuiltinDcMotorActuator): + if actuator.cfg.mode == DcMotorInputMode.VOLTAGE: + raise ValueError( + "dr.pd_gains does not apply to BuiltinDcMotorActuator in VOLTAGE " + "mode (no internal PID gains to scale)." + ) + # DC motor stores kp at gainprm[4] and kd at gainprm[6] (set via + # set_to_dcmotor). The bias slots carry back-EMF / cogging, not the PD, + # so we only touch gainprm. + if op.name == "scale": + default_gainprm = env.sim.get_default_field("actuator_gainprm") + env.sim.model.actuator_gainprm[env_ids[:, None], ctrl_ids, 4] = ( + default_gainprm[ctrl_ids, 4] * kp_samples + ) + env.sim.model.actuator_gainprm[env_ids[:, None], ctrl_ids, 6] = ( + default_gainprm[ctrl_ids, 6] * kd_samples + ) + else: + assert op.name == "abs" + env.sim.model.actuator_gainprm[env_ids[:, None], ctrl_ids, 4] = kp_samples + env.sim.model.actuator_gainprm[env_ids[:, None], ctrl_ids, 6] = kd_samples + + elif isinstance(actuator, BuiltinPdActuator): + # ctrl_ids is laid out as [pos_0..pos_{N-1}, vel_0..vel_{N-1}], so the + # first N rows carry kp and the next N carry kd. + n = actuator.num_targets + pos_ids = ctrl_ids[:n] + vel_ids = ctrl_ids[n:] + if op.name == "scale": + default_gainprm = env.sim.get_default_field("actuator_gainprm") + default_biasprm = env.sim.get_default_field("actuator_biasprm") + env.sim.model.actuator_gainprm[env_ids[:, None], pos_ids, 0] = ( + default_gainprm[pos_ids, 0] * kp_samples + ) + env.sim.model.actuator_biasprm[env_ids[:, None], pos_ids, 1] = ( + default_biasprm[pos_ids, 1] * kp_samples + ) + env.sim.model.actuator_gainprm[env_ids[:, None], vel_ids, 0] = ( + default_gainprm[vel_ids, 0] * kd_samples + ) + env.sim.model.actuator_biasprm[env_ids[:, None], vel_ids, 2] = ( + default_biasprm[vel_ids, 2] * kd_samples + ) + else: + assert op.name == "abs" + env.sim.model.actuator_gainprm[env_ids[:, None], pos_ids, 0] = kp_samples + env.sim.model.actuator_biasprm[env_ids[:, None], pos_ids, 1] = -kp_samples + env.sim.model.actuator_gainprm[env_ids[:, None], vel_ids, 0] = kd_samples + env.sim.model.actuator_biasprm[env_ids[:, None], vel_ids, 2] = -kd_samples + # biasprm[2] on the position half stays zero by construction. Writing + # anything else here would inject damping into the position element on + # top of the velocity element, silently double-counting kd. + elif isinstance(actuator, IdealPdActuator): assert actuator.stiffness is not None assert actuator.damping is not None - if operation == "scale": + if op.name == "scale": assert actuator.default_stiffness is not None assert actuator.default_damping is not None actuator.set_gains( @@ -108,25 +211,26 @@ def pd_gains( kp=actuator.default_stiffness[env_ids] * kp_samples, kd=actuator.default_damping[env_ids] * kd_samples, ) - elif operation == "abs": + else: + assert op.name == "abs" actuator.set_gains(env_ids, kp=kp_samples, kd=kd_samples) else: raise TypeError( - f"pd_gains only supports BuiltinPositionActuator, " - f"XmlActuator (position), and IdealPdActuator, " - f"got {type(actuator).__name__}" + f"pd_gains only supports BuiltinPositionActuator, BuiltinPdActuator, " + f"BuiltinDcMotorActuator (position/velocity mode), XmlActuator (position), " + f"and IdealPdActuator, got {type(actuator).__name__}" ) -@requires_model_fields("actuator_forcerange") +@requires_model_fields("actuator_forcerange", "jnt_actfrcrange", "tendon_actfrcrange") def effort_limits( env: ManagerBasedRlEnv, env_ids: torch.Tensor | None, effort_limit_range: tuple[float, float], asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, distribution: Literal["uniform", "log_uniform"] = "uniform", - operation: Literal["scale", "abs"] = "scale", + operation: Operation | str = "scale", ) -> None: """Randomize actuator effort limits. @@ -138,6 +242,11 @@ def effort_limits( distribution: Distribution type ("uniform" or "log_uniform"). operation: "scale" multiplies default limits, "abs" sets absolute values. """ + op = resolve_operation(operation) + if op.name not in ("scale", "abs"): + raise ValueError( + f"effort_limits only supports 'scale' and 'abs' operations, got {op.name!r}" + ) asset: Entity = env.scene[asset_cfg.name] if env_ids is None: @@ -145,36 +254,32 @@ def effort_limits( else: env_ids = env_ids.to(env.device, dtype=torch.int) - if isinstance(asset_cfg.actuator_ids, list): - actuators = [asset.actuators[i] for i in asset_cfg.actuator_ids] - else: - actuators = asset.actuators[asset_cfg.actuator_ids] + actuators = _resolve_actuators(asset, asset_cfg) if not isinstance(actuators, list): actuators = [actuators] for actuator in actuators: ctrl_ids = actuator.global_ctrl_ids - num_actuators = len(ctrl_ids) + # One effort sample per target. For single-element actuators this matches + # ctrl_ids; for BuiltinPd the limit lives on the joint/tendon, so one + # sample per target is sufficient regardless of the two-element ctrl. + n_samples = ( + actuator.num_targets if isinstance(actuator, BuiltinPdActuator) else len(ctrl_ids) + ) dist = resolve_distribution(distribution) effort_samples = dist.sample( torch.tensor(effort_limit_range[0], device=env.device), torch.tensor(effort_limit_range[1], device=env.device), - (len(env_ids), num_actuators), + (len(env_ids), n_samples), env.device, ) - if isinstance( - actuator, - ( - BuiltinPositionActuator, - BuiltinVelocityActuator, - BuiltinMotorActuator, - XmlActuator, - ), + if isinstance(actuator, (BuiltinPositionActuator, BuiltinDcMotorActuator)) or ( + isinstance(actuator, XmlActuator) and actuator.command_field == "position" ): - if operation == "scale": + if op.name == "scale": default_forcerange = env.sim.get_default_field("actuator_forcerange") env.sim.model.actuator_forcerange[env_ids[:, None], ctrl_ids, 0] = ( default_forcerange[ctrl_ids, 0] * effort_samples @@ -182,7 +287,8 @@ def effort_limits( env.sim.model.actuator_forcerange[env_ids[:, None], ctrl_ids, 1] = ( default_forcerange[ctrl_ids, 1] * effort_samples ) - elif operation == "abs": + else: + assert op.name == "abs" env.sim.model.actuator_forcerange[ env_ids[:, None], ctrl_ids, 0 ] = -effort_samples @@ -192,18 +298,42 @@ def effort_limits( elif isinstance(actuator, IdealPdActuator): assert actuator.force_limit is not None - if operation == "scale": + if op.name == "scale": assert actuator.default_force_limit is not None actuator.set_effort_limit( env_ids, effort_limit=actuator.default_force_limit[env_ids] * effort_samples, ) - elif operation == "abs": + else: + assert op.name == "abs" actuator.set_effort_limit(env_ids, effort_limit=effort_samples) + elif isinstance(actuator, BuiltinPdActuator): + # BuiltinPd's effort_limit lives on the joint/tendon as a sum-clamp + # (jnt_actfrcrange / tendon_actfrcrange), not on per-element forcerange. + if actuator.transmission_type == TransmissionType.JOINT: + field = "jnt_actfrcrange" + target_global_ids = asset.indexing.joint_ids[actuator.target_ids] + else: + field = "tendon_actfrcrange" + target_global_ids = asset.indexing.tendon_ids[actuator.target_ids] + arr = getattr(env.sim.model, field) + if op.name == "scale": + default = env.sim.get_default_field(field) + arr[env_ids[:, None], target_global_ids, 0] = ( + default[target_global_ids, 0] * effort_samples + ) + arr[env_ids[:, None], target_global_ids, 1] = ( + default[target_global_ids, 1] * effort_samples + ) + else: + assert op.name == "abs" + arr[env_ids[:, None], target_global_ids, 0] = -effort_samples + arr[env_ids[:, None], target_global_ids, 1] = effort_samples + else: raise TypeError( - f"effort_limits only supports BuiltinPositionActuator, BuiltinVelocityActuator, " - f"BuiltinMotorActuator, XmlActuator, and IdealPdActuator, " + f"effort_limits only supports BuiltinPositionActuator, BuiltinPdActuator, " + f"BuiltinDcMotorActuator, XmlActuator (position), and IdealPdActuator, " f"got {type(actuator).__name__}" ) diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/mdp/events.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/mdp/events.py index eeed9c8..0872ec7 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/mdp/events.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/mdp/events.py @@ -21,6 +21,38 @@ if TYPE_CHECKING: from mjlab.viewer.debug_visualizer import DebugVisualizer _DEFAULT_ASSET_CFG = SceneEntityCfg("robot") +_SE3_KEYS = ("x", "y", "z", "roll", "pitch", "yaw") + + +def _sample_se3_range( + range_dict: dict[str, tuple[float, float]] | None, + shape: tuple[int, ...], + device: str, +) -> torch.Tensor: + """Sample uniform ``[x, y, z, roll, pitch, yaw]`` offsets. + + ``range_dict`` maps any subset of those keys to ``(min, max)`` ranges; missing + keys default to ``(0.0, 0.0)`` (no offset). ``None`` is treated as empty. The + returned tensor has the requested ``shape`` whose last dimension must be 6. + """ + range_dict = range_dict or {} + range_list = [range_dict.get(key, (0.0, 0.0)) for key in _SE3_KEYS] + ranges = torch.tensor(range_list, device=device) + return sample_uniform(ranges[:, 0], ranges[:, 1], shape, device=device) + + +def resolve_env_ids( + env: ManagerBasedRlEnv, env_ids: torch.Tensor | None +) -> torch.Tensor: + """Return ``env_ids`` unchanged, or all environment indices if ``None``. + + Event functions receive ``env_ids=None`` to mean "all environments" (a full + reset, or a global-time interval term). This normalizes that sentinel to a + concrete index tensor so the function body can assume a real ``torch.Tensor``. + """ + if env_ids is None: + return torch.arange(env.num_envs, device=env.device, dtype=torch.int) + return env_ids def randomize_terrain(env: ManagerBasedRlEnv, env_ids: torch.Tensor | None) -> None: @@ -29,8 +61,7 @@ def randomize_terrain(env: ManagerBasedRlEnv, env_ids: torch.Tensor | None) -> N This picks a random terrain type (column) and difficulty level (row) for each environment. Useful for play/evaluation mode to test on varied terrains. """ - if env_ids is None: - env_ids = torch.arange(env.num_envs, device=env.device, dtype=torch.int) + env_ids = resolve_env_ids(env, env_ids) terrain = env.scene.terrain if terrain is not None: @@ -48,8 +79,7 @@ def reset_scene_to_default( Automatically applies env_origins offset to position all entities correctly. """ - if env_ids is None: - env_ids = torch.arange(env.num_envs, device=env.device, dtype=torch.int) + env_ids = resolve_env_ids(env, env_ids) for entity in env.scene.entities.values(): if not isinstance(entity, Entity): @@ -105,19 +135,12 @@ def reset_root_state_uniform( velocity_range: Velocity range (only used for floating-base entities). asset_cfg: Asset configuration. """ - if env_ids is None: - env_ids = torch.arange(env.num_envs, device=env.device, dtype=torch.int) + env_ids = resolve_env_ids(env, env_ids) asset: Entity = env.scene[asset_cfg.name] # Pose. - range_list = [ - pose_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z", "roll", "pitch", "yaw"] - ] - ranges = torch.tensor(range_list, device=env.device) - pose_samples = sample_uniform( - ranges[:, 0], ranges[:, 1], (len(env_ids), 6), device=env.device - ) + pose_samples = _sample_se3_range(pose_range, (len(env_ids), 6), env.device) # Fixed-based entities with mocap=True. if asset.is_fixed_base: @@ -157,16 +180,7 @@ def reset_root_state_uniform( orientations = quat_mul(root_states[:, 3:7], orientations_delta) # Velocities. - if velocity_range is None: - velocity_range = {} - range_list = [ - velocity_range.get(key, (0.0, 0.0)) - for key in ["x", "y", "z", "roll", "pitch", "yaw"] - ] - ranges = torch.tensor(range_list, device=env.device) - vel_samples = sample_uniform( - ranges[:, 0], ranges[:, 1], (len(env_ids), 6), device=env.device - ) + vel_samples = _sample_se3_range(velocity_range, (len(env_ids), 6), env.device) velocities = root_states[:, 7:13] + vel_samples asset.write_root_link_pose_to_sim( @@ -199,8 +213,7 @@ def reset_root_state_from_flat_patches( velocity_range: Optional velocity range (floating-base only). asset_cfg: Asset configuration. """ - if env_ids is None: - env_ids = torch.arange(env.num_envs, device=env.device, dtype=torch.int) + env_ids = resolve_env_ids(env, env_ids) terrain = env.scene.terrain if terrain is None or patch_name not in terrain.flat_patches: @@ -230,15 +243,7 @@ def reset_root_state_from_flat_patches( root_states = default_root_state[env_ids].clone() # Apply optional pose range offset. - if pose_range is None: - pose_range = {} - range_list = [ - pose_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z", "roll", "pitch", "yaw"] - ] - ranges = torch.tensor(range_list, device=env.device) - pose_samples = sample_uniform( - ranges[:, 0], ranges[:, 1], (len(env_ids), 6), device=env.device - ) + pose_samples = _sample_se3_range(pose_range, (len(env_ids), 6), env.device) # Position: flat patch position + optional offset. Use patch z instead of default. final_positions = positions.clone() @@ -262,16 +267,7 @@ def reset_root_state_from_flat_patches( return # Velocities. - if velocity_range is None: - velocity_range = {} - vel_range_list = [ - velocity_range.get(key, (0.0, 0.0)) - for key in ["x", "y", "z", "roll", "pitch", "yaw"] - ] - vel_ranges = torch.tensor(vel_range_list, device=env.device) - vel_samples = sample_uniform( - vel_ranges[:, 0], vel_ranges[:, 1], (len(env_ids), 6), device=env.device - ) + vel_samples = _sample_se3_range(velocity_range, (len(env_ids), 6), env.device) velocities = root_states[:, 7:13] + vel_samples asset.write_root_link_pose_to_sim( @@ -287,8 +283,7 @@ def reset_joints_by_offset( velocity_range: tuple[float, float], asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, ) -> None: - if env_ids is None: - env_ids = torch.arange(env.num_envs, device=env.device, dtype=torch.int) + env_ids = resolve_env_ids(env, env_ids) asset: Entity = env.scene[asset_cfg.name] default_joint_pos = asset.data.default_joint_pos @@ -320,28 +315,60 @@ def reset_joints_by_offset( def push_by_setting_velocity( env: ManagerBasedRlEnv, - env_ids: torch.Tensor, + env_ids: torch.Tensor | None, velocity_range: dict[str, tuple[float, float]], asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, ) -> None: + """Push an entity by overwriting its root velocity with a sampled offset. + + This is an *instantaneous, mass-independent* kick: it adds a uniformly sampled + delta directly to the root velocity, ignoring inertia and contact dynamics. It + is the cheapest disturbance and the standard locomotion "push the robot" term. + Use with ``mode="interval"``. + + For force-based disturbances that respect the entity's dynamics, see + :func:`apply_external_force_torque` (a constant wrench you manage yourself) or + :class:`apply_body_impulse` (transient, self-managing impulses). + """ + env_ids = resolve_env_ids(env, env_ids) asset: Entity = env.scene[asset_cfg.name] vel_w = asset.data.root_link_vel_w[env_ids] - range_list = [ - velocity_range.get(key, (0.0, 0.0)) - for key in ["x", "y", "z", "roll", "pitch", "yaw"] - ] - ranges = torch.tensor(range_list, device=env.device) - vel_w += sample_uniform(ranges[:, 0], ranges[:, 1], vel_w.shape, device=env.device) + vel_w += _sample_se3_range(velocity_range, vel_w.shape, env.device) asset.write_root_link_velocity_to_sim(vel_w, env_ids=env_ids) def apply_external_force_torque( env: ManagerBasedRlEnv, - env_ids: torch.Tensor, + env_ids: torch.Tensor | None, force_range: tuple[float, float], torque_range: tuple[float, float], asset_cfg: SceneEntityCfg = _DEFAULT_ASSET_CFG, ) -> None: + """Apply a single *constant* external wrench to bodies. + + Samples a force and torque once and writes them to ``xfrc_applied``. The wrench + is **stateless and never expires**: MuJoCo holds it constant on every physics + step until something overwrites or zeroes it. There is no duration, cooldown, + or auto-clear. + + **When to use this vs.** :class:`apply_body_impulse`: + + - Use ``apply_external_force_torque`` for a *steady, episode-long* disturbance + such as a fixed payload, a constant wind, or a sustained load. The intended + pattern is ``mode="reset"``: re-randomize the wrench each episode so it holds + for that episode's duration. Because it never turns itself off, **you are + responsible for clearing or overwriting it** (e.g. via the next reset). It is + *not* suited to transient bumps on its own. + + - Use :class:`apply_body_impulse` for *transient, repeated, randomized* + disturbances during an episode (bumps, gusts, collisions). It runs a full + cooldown -> trigger -> sustain -> expire lifecycle per environment, zeroing + the wrench automatically when each impulse ends, and ticks on ``mode="step"``. + + For an instantaneous, mass-independent kick instead of a force, see + :func:`push_by_setting_velocity`. + """ + env_ids = resolve_env_ids(env, env_ids) asset: Entity = env.scene[asset_cfg.name] num_bodies = ( len(asset_cfg.body_ids) @@ -385,6 +412,10 @@ class apply_body_impulse: applied. Use with ``mode="step"``. + + For a *constant* episode-long wrench instead of transient impulses, see + :func:`apply_external_force_torque`. For an instantaneous, mass-independent + velocity kick, see :func:`push_by_setting_velocity`. """ @dataclass @@ -422,9 +453,16 @@ class apply_body_impulse: else self._asset.num_bodies ) + self._cooldown_s: tuple[float, float] = cfg.params["cooldown_s"] self._time_remaining = torch.zeros(self._num_envs, device=self._device) - self._interval_time_left = torch.zeros(self._num_envs, device=self._device) self._active = torch.zeros(self._num_envs, device=self._device, dtype=torch.bool) + # Pre-sample the initial cooldown so the first impulse is preceded by a cooldown + # rather than firing immediately at t=0. + self._interval_time_left = self._sample_cooldown(self._num_envs) + + def _sample_cooldown(self, n: int) -> torch.Tensor: + low, high = self._cooldown_s + return sample_uniform(low, high, n, self._device) def __call__( self, @@ -446,13 +484,14 @@ class apply_body_impulse: torque_range: ``(min, max)`` uniform range for each torque component (Nm). duration_s: ``(min, max)`` uniform range for impulse duration in seconds. cooldown_s: ``(min, max)`` uniform range for the cooldown between consecutive - impulses in seconds. + impulses in seconds. Captured at init so the first impulse can be + preceded by a sampled cooldown; the kwarg passed here is unused. asset_cfg: Entity and body selection. ``body_ids`` on the config selects which bodies receive forces. body_point_offset: Optional ``(x, y, z)`` offset in the body frame where the force is applied. Generates additional torque via ``cross(offset, force)``. """ - del env, env_ids, asset_cfg # Unused. + del env, env_ids, asset_cfg, cooldown_s # Unused at call time. dt = self._step_dt # Decrement timers for active envs. @@ -468,11 +507,7 @@ class apply_body_impulse: ) self._active[expired_ids] = False self._time_remaining[expired_ids] = 0.0 - int_low, int_high = cooldown_s - self._interval_time_left[expired_ids] = ( - torch.rand(len(expired_ids), device=self._device) * (int_high - int_low) - + int_low - ) + self._interval_time_left[expired_ids] = self._sample_cooldown(len(expired_ids)) # Decrement interval timers. self._interval_time_left -= dt @@ -514,10 +549,7 @@ class apply_body_impulse: self._active[trigger_ids] = True # Resample interval timers. - int_low, int_high = cooldown_s - self._interval_time_left[trigger_ids] = ( - torch.rand(n, device=self._device) * (int_high - int_low) + int_low - ) + self._interval_time_left[trigger_ids] = self._sample_cooldown(n) def debug_vis(self, visualizer: DebugVisualizer) -> None: """Draw arrows for active impulse forces.""" @@ -553,13 +585,7 @@ class apply_body_impulse: if env_ids is None: env_ids = slice(None) - # Clear forces for reset envs. - if isinstance(env_ids, slice): - reset_ids = env_ids - else: - reset_ids = env_ids - - if self._active[reset_ids].any(): + if self._active[env_ids].any(): if isinstance(env_ids, slice): active_ids = self._active.nonzero(as_tuple=False).squeeze(-1) else: @@ -573,6 +599,7 @@ class apply_body_impulse: zeros, zeros, env_ids=active_ids, body_ids=self._body_ids ) - self._time_remaining[reset_ids] = 0.0 - self._interval_time_left[reset_ids] = 0.0 - self._active[reset_ids] = False + n = self._num_envs if isinstance(env_ids, slice) else len(env_ids) + self._time_remaining[env_ids] = 0.0 + self._interval_time_left[env_ids] = self._sample_cooldown(n) + self._active[env_ids] = False diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/mdp/observations.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/mdp/observations.py index 89ad4a4..92cbf40 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/mdp/observations.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/envs/mdp/observations.py @@ -106,6 +106,23 @@ def builtin_sensor(env: ManagerBasedRlEnv, sensor_name: str) -> torch.Tensor: return sensor.data +def projected_gravity_from_sensor( + env: ManagerBasedRlEnv, sensor_name: str +) -> torch.Tensor: + """Projected gravity from a ``framezaxis`` up-vector sensor. + + The sensor is expected to output the world Z-axis expressed in the sensor's frame + (e.g. ``framezaxis`` with ``objtype=body objname=world`` and ``reftype=site``). That + is the body-frame "up" vector, so it is negated to point along gravity. + + Unlike :func:`projected_gravity`, which uses the root body orientation, this reads + the sensor's site frame and therefore reflects IMU site pose randomization. + """ + sensor = env.scene[sensor_name] + assert isinstance(sensor, BuiltinSensor) + return -sensor.data + + def height_scan( env: ManagerBasedRlEnv, sensor_name: str, diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/managers/event_manager.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/managers/event_manager.py index 6dc7dc2..a4c4673 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/managers/event_manager.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/managers/event_manager.py @@ -290,9 +290,12 @@ class EventManager(ManagerBase): fired = True elif mode == "reset": assert global_env_step_count is not None + # Reset events require concrete indices: callers (e.g. ManagerBasedRlEnv) + # resolve None to all environments upstream. Enforce that here so a future + # caller passing None fails loudly instead of leaking a slice into event + # functions, which only understand None or a tensor. + assert env_ids is not None, "reset events require concrete env_ids, got None" min_step_count = term_cfg.min_step_count_between_reset - if env_ids is None: - env_ids = slice(None) if min_step_count == 0: self._reset_term_last_triggered_step_id[index][env_ids] = ( global_env_step_count diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/scene/scene.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/scene/scene.py index 4da2049..68e0643 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/scene/scene.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/scene/scene.py @@ -11,7 +11,7 @@ import numpy as np import torch from mjlab.entity import Entity, EntityCfg -from mjlab.entity.entity import VariantMetadata +from mjlab.entity.variants import VariantMetadata from mjlab.sensor import BuiltinSensor, RayCastSensor, Sensor, SensorCfg from mjlab.sensor.camera_sensor import CameraSensor from mjlab.sensor.sensor_context import SensorContext @@ -59,7 +59,7 @@ class Scene: self._default_env_origins: torch.Tensor | None = None self._sensor_context: SensorContext | None = None - self._spec = mujoco.MjSpec.from_string(_SCENE_XML.read_text()) + self._spec = mujoco.MjSpec.from_file(str(_SCENE_XML)) if self._cfg.extent is not None: self._spec.stat.extent = self._cfg.extent self._add_terrain() @@ -132,11 +132,6 @@ class Scene: def device(self) -> str: return self._device - @property - def has_mesh_variants(self) -> bool: - """True if any entity declares per-world mesh variants.""" - return any(ent.variant_metadata is not None for ent in self._entities.values()) - def collect_variant_info( self, ) -> list[tuple[str, VariantMetadata]]: diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/scripts/train.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/scripts/train.py index dd9cb8a..c27e582 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/scripts/train.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/scripts/train.py @@ -59,7 +59,7 @@ def run_train(task_id: str, cfg: TrainConfig, log_dir: Path) -> None: os.environ["MUJOCO_EGL_DEVICE_ID"] = str(local_rank) device = f"cuda:{local_rank}" # Set seed to have diversity in different processes. - seed = cfg.agent.seed + local_rank + seed = cfg.agent.seed + rank configure_torch_backends() @@ -197,7 +197,6 @@ def launch_training(task_id: str, args: TrainConfig | None = None): os.environ["CUDA_VISIBLE_DEVICES"] = "" else: os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(map(str, selected_gpus)) - os.environ["MUJOCO_GL"] = "egl" if num_gpus <= 1: # CPU or single GPU: run directly without torchrunx. diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/sensor/contact_sensor.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/sensor/contact_sensor.py index 946728b..67840da 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/sensor/contact_sensor.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/sensor/contact_sensor.py @@ -428,7 +428,7 @@ class ContactSensor(Sensor[ContactData]): normal = data.normal tangent = data.tangent tangent2 = torch.cross(normal, tangent, dim=-1) - R = torch.stack([tangent, tangent2, normal], dim=-1) + R = torch.stack([normal, tangent, tangent2], dim=-1) has_contact = torch.norm(normal, dim=-1, keepdim=True) > 1e-8 diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/sensor/raycast_sensor.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/sensor/raycast_sensor.py index 22478de..d04bd0e 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/sensor/raycast_sensor.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/sensor/raycast_sensor.py @@ -435,7 +435,7 @@ class RayCastSensor(Sensor[RayCastData]): self._model: mjwarp.Model | None = None self._mj_model: mujoco.MjModel | None = None self._device: str | None = None - self._wp_device: wp.context.Device | None = None + self._wp_device: wp.Device | None = None # Per-frame info: list of (frame_type, obj_id, body_id). self._frame_infos: list[tuple[Literal["body", "site", "geom"], int, int]] = [] diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/sim/mesh_variants.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/sim/mesh_variants.py deleted file mode 100644 index f6826c1..0000000 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/sim/mesh_variants.py +++ /dev/null @@ -1,335 +0,0 @@ -"""Per-world mesh variant support. - -Sibling of :mod:`mjlab.sim.randomization`: that module expands singleton -model fields into per-world arrays for DR; this one writes per-world -arrays whose rows differ by mesh variant. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Callable - -import mujoco -import mujoco_warp as mjwarp -import numpy as np -import warp as wp - -from mjlab.entity.entity import BodyInertialMetadata, VariantMetadata - -# Fields that depend on mesh geometry and must be compiled per-variant. -VARIANT_DEPENDENT_FIELDS = ( - "geom_size", - "geom_rbound", - "geom_aabb", - "geom_pos", - "geom_quat", - "body_mass", - "body_subtreemass", - "body_inertia", - "body_invweight0", - "body_ipos", - "body_iquat", -) - - -@dataclass -class MeshVariantResult: - """Output of :func:`build_mesh_variant_model`.""" - - wp_model: mjwarp.Model - mj_model: mujoco.MjModel - # Maps entity prefix -> array of variant indices per world. - world_to_variant: dict[str, np.ndarray] - - -def _find_entity_mesh_geom_ids( - model: mujoco.MjModel, - entity_prefix: str, -) -> list[int]: - """Find all mesh geom IDs belonging to an entity, including padding.""" - named_ids: list[int] = [] - for gid in range(model.ngeom): - gname = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, gid) - if ( - gname - and gname.startswith(entity_prefix) - and model.geom_type[gid] == mujoco.mjtGeom.mjGEOM_MESH - ): - named_ids.append(gid) - if not named_ids: - return [] - # Include unnamed padding geoms on the same body. - body_id = model.geom_bodyid[named_ids[0]] - all_ids = set(named_ids) - for gid in range(model.ngeom): - if ( - model.geom_bodyid[gid] == body_id - and model.geom_type[gid] == mujoco.mjtGeom.mjGEOM_MESH - ): - all_ids.add(gid) - return sorted(all_ids) - - -def allocate_worlds( - weights: tuple[float, ...], - nworld: int, -) -> list[int]: - """Assign worlds proportionally by weight (largest-remainder method). - - Returns a list of length *nworld* containing variant indices. Weights - must be non-negative with at least one positive entry. - """ - if any(w < 0 for w in weights): - raise ValueError(f"weights must be non-negative, got {weights}.") - total = sum(weights) - if total <= 0: - raise ValueError(f"weights must have a positive sum, got {weights}.") - quotas = [(w / total) * nworld for w in weights] - floors = [int(q) for q in quotas] - remainders = sorted( - ((quotas[i] - floors[i], i) for i in range(len(weights))), - key=lambda x: -x[0], - ) - allocated = sum(floors) - for j in range(nworld - allocated): - floors[remainders[j][1]] += 1 - assignment: list[int] = [] - for idx, count in enumerate(floors): - assignment.extend([idx] * count) - return assignment - - -def build_mesh_variant_model( - spec: mujoco.MjSpec, - nworld: int, - variant_info: list[tuple[str, VariantMetadata]], - configure_model: Callable[[mujoco.MjModel], None] | None = None, -) -> MeshVariantResult: - """Build a warp Model with per-world mesh assignments. - - Args: - spec: Scene spec (already merged with padded variant geoms). - nworld: Number of simulation worlds. - variant_info: List of ``(entity_prefix, metadata)`` pairs for - entities that have mesh variants. - configure_model: Optional callback to configure the compiled - MjModel before ``put_model`` (e.g., setting solver options). - - Returns: - A :class:`MeshVariantResult` containing the warp model, host - model, and per-entity world-to-variant mappings. - """ - spec = spec.copy() - model = spec.compile() - if configure_model is not None: - configure_model(model) - - # Start from base dataid tiled for all worlds. - base_dataid = model.geom_dataid.copy() - dataid_table = np.tile(base_dataid, (nworld, 1)) - - world_to_variant: dict[str, np.ndarray] = {} - - for entity_prefix, metadata in variant_info: - # Allocate worlds by weight. - assignment = allocate_worlds(metadata.variant_weights, nworld) - w2v = np.array(assignment, dtype=np.int32) - world_to_variant[entity_prefix] = w2v - - mesh_geom_ids = _find_entity_mesh_geom_ids(model, entity_prefix) - nslots = len(mesh_geom_ids) - - # Resolve every (variant, slot) -> mesh_id once. Mesh names in the merged - # spec are variant-prefixed ("mug/visual_mesh"); after attaching to the - # scene they also carry the entity prefix ("object/mug/visual_mesh"). - # Padding slots are -1. - nvariants = len(metadata.variant_mesh_names) - variant_slot_ids = np.full((nvariants, nslots), -1, dtype=np.int64) - for v_idx, mesh_names in enumerate(metadata.variant_mesh_names): - for slot in range(min(nslots, len(mesh_names))): - name = mesh_names[slot] - if name is None: - continue - full = f"{entity_prefix}{name}" - mid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_MESH, full) - if mid < 0: - variant_label = metadata.variant_names[v_idx] - raise ValueError( - f"Mesh '{full}' (variant '{variant_label}', slot {slot}) " - f"not found in compiled model." - ) - variant_slot_ids[v_idx, slot] = mid - - # Vectorized scatter: row-select by variant assignment, write into the - # mesh-geom columns of the per-world dataid table. - dataid_table[:, mesh_geom_ids] = variant_slot_ids[w2v] - - # Build warp model. - m = mjwarp.put_model(model) - m.geom_dataid = wp.array(dataid_table, dtype=int) - - # Populate dependent per-world fields. - _populate_dependent_fields( - m, spec, model, dataid_table, nworld, variant_info, world_to_variant - ) - - return MeshVariantResult( - wp_model=m, - mj_model=model, - world_to_variant=world_to_variant, - ) - - -def _populate_dependent_fields( - m: mjwarp.Model, - spec: mujoco.MjSpec, - padded_model: mujoco.MjModel, - dataid_table: np.ndarray, - nworld: int, - variant_info: list[tuple[str, VariantMetadata]], - world_to_variant: dict[str, np.ndarray], -) -> None: - """Compile each unique variant and write per-world dependent fields. - - Each unique variant is compiled from a fresh ``spec.copy()``; the input - ``spec`` is not mutated. - """ - # Find unique dataid rows. - unique_rows: dict[tuple[int, ...], int] = {} - for w in range(nworld): - key = tuple(dataid_table[w]) - if key not in unique_rows: - unique_rows[key] = w - - # Map padded_model geom IDs to geom names (stable across spec copies). - geom_id_to_name: dict[int, str] = {} - for g in spec.geoms: - if not g.name: - continue - gid = mujoco.mj_name2id(padded_model, mujoco.mjtObj.mjOBJ_GEOM, g.name) - if gid >= 0: - geom_id_to_name[gid] = g.name - - # Collect all variant geom IDs in padded_model. - all_variant_geom_ids: set[int] = set() - for entity_prefix, _ in variant_info: - all_variant_geom_ids.update(_find_entity_mesh_geom_ids(padded_model, entity_prefix)) - - # Bodies any variant marks as explicit-inertial: must be reset on the - # fresh spec copy before applying this variant's inertials. Variants - # without an explicit inertial fall back to MuJoCo's mesh-derived path - # during compile, so we clear the diagonal inertial fields. Do NOT - # assign ``body.fullinertia``: any assignment (even zeros) flags the - # field as user-specified and ``spec.compile()`` then rejects it as - # conflicting with ``body.inertia``. - variant_inertial_body_names: set[str] = set() - for entity_prefix, metadata in variant_info: - for variant_inertials in metadata.variant_body_inertials: - for inertial in variant_inertials: - variant_inertial_body_names.add(f"{entity_prefix}{inertial.body_name}") - - # Compile each unique variant from a fresh spec copy. - compiled_variants: dict[tuple[int, ...], mujoco.MjModel] = {} - for key, first_world in unique_rows.items(): - variant_spec = spec.copy() - geoms_by_name = {g.name: g for g in variant_spec.geoms if g.name} - bodies_by_name = {b.name: b for b in variant_spec.bodies if b.name} - - # Apply this variant's mesh selection per geom slot. - for gid in all_variant_geom_ids: - name = geom_id_to_name.get(gid) - if name is None: - continue - geom = geoms_by_name[name] - mesh_id = int(dataid_table[first_world, gid]) - if mesh_id >= 0: - mesh_name = mujoco.mj_id2name(padded_model, mujoco.mjtObj.mjOBJ_MESH, mesh_id) - geom.meshname = mesh_name - geom.contype = 1 - geom.conaffinity = 1 - else: - geom.contype = 0 - geom.conaffinity = 0 - geom.mass = 0.0 - - for body_name in variant_inertial_body_names: - body = bodies_by_name.get(body_name) - if body is None: - continue - body.explicitinertial = 0 - body.mass = 0.0 - body.inertia = np.zeros(3, dtype=np.float64) - body.ipos = np.zeros(3, dtype=np.float64) - body.iquat = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64) - - for entity_prefix, metadata in variant_info: - variant_idx = int(world_to_variant[entity_prefix][first_world]) - if variant_idx >= len(metadata.variant_body_inertials): - continue - for inertial in metadata.variant_body_inertials[variant_idx]: - _apply_body_inertial( - bodies_by_name, - f"{entity_prefix}{inertial.body_name}", - inertial, - ) - - compiled_variants[key] = variant_spec.compile() - - # Build per-world numpy arrays. - ngeom = padded_model.ngeom - nbody = padded_model.nbody - - geom_size = np.zeros((nworld, ngeom, 3), dtype=np.float32) - geom_rbound = np.zeros((nworld, ngeom), dtype=np.float32) - geom_aabb = np.zeros((nworld, ngeom, 2, 3), dtype=np.float32) - geom_pos = np.zeros((nworld, ngeom, 3), dtype=np.float32) - geom_quat = np.zeros((nworld, ngeom, 4), dtype=np.float32) - body_mass = np.zeros((nworld, nbody), dtype=np.float32) - body_subtreemass = np.zeros((nworld, nbody), dtype=np.float32) - body_inertia = np.zeros((nworld, nbody, 3), dtype=np.float32) - body_invweight0 = np.zeros((nworld, nbody, 2), dtype=np.float32) - body_ipos = np.zeros((nworld, nbody, 3), dtype=np.float32) - body_iquat = np.zeros((nworld, nbody, 4), dtype=np.float32) - - for w in range(nworld): - key = tuple(dataid_table[w]) - ref = compiled_variants[key] - geom_size[w] = ref.geom_size - geom_rbound[w] = ref.geom_rbound - geom_aabb[w] = ref.geom_aabb.reshape(ngeom, 2, 3) - geom_pos[w] = ref.geom_pos - geom_quat[w] = ref.geom_quat - body_mass[w] = ref.body_mass - body_subtreemass[w] = ref.body_subtreemass - body_inertia[w] = ref.body_inertia - body_invweight0[w] = ref.body_invweight0 - body_ipos[w] = ref.body_ipos - body_iquat[w] = ref.body_iquat - - m.geom_size = wp.array(geom_size, dtype=wp.vec3) - m.geom_rbound = wp.array(geom_rbound, dtype=float) - m.geom_aabb = wp.array(geom_aabb, dtype=wp.vec3) - m.geom_pos = wp.array(geom_pos, dtype=wp.vec3) - m.geom_quat = wp.array(geom_quat, dtype=wp.quat) - m.body_mass = wp.array(body_mass, dtype=float) - m.body_subtreemass = wp.array(body_subtreemass, dtype=float) - m.body_inertia = wp.array(body_inertia, dtype=wp.vec3) - m.body_invweight0 = wp.array(body_invweight0, dtype=wp.vec2) - m.body_ipos = wp.array(body_ipos, dtype=wp.vec3) - m.body_iquat = wp.array(body_iquat, dtype=wp.quat) - - -def _apply_body_inertial( - bodies_by_name: dict[str, mujoco.MjsBody], - body_name: str, - inertial: BodyInertialMetadata, -) -> None: - body = bodies_by_name.get(body_name) - if body is None: - raise ValueError(f"Body '{body_name}' not found in compiled variant spec.") - body.explicitinertial = 1 - body.mass = inertial.mass - body.ipos = np.asarray(inertial.ipos, dtype=np.float64) - body.inertia = np.asarray(inertial.inertia, dtype=np.float64) - body.iquat = np.asarray(inertial.iquat, dtype=np.float64) diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/sim/sim.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/sim/sim.py index 3b1e0b5..0ead49c 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/sim/sim.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/sim/sim.py @@ -10,14 +10,14 @@ import mujoco_warp as mjwarp import torch import warp as wp +from mjlab.entity.variants import VARIANT_DEPENDENT_FIELDS, build_variant_model from mjlab.managers.event_manager import RecomputeLevel -from mjlab.sim.mesh_variants import VARIANT_DEPENDENT_FIELDS, build_mesh_variant_model from mjlab.sim.randomization import expand_model_fields from mjlab.sim.sim_data import TorchArray, WarpBridge from mjlab.utils.nan_guard import NanGuard, NanGuardCfg if TYPE_CHECKING: - from mjlab.entity.entity import VariantMetadata + from mjlab.entity.variants import VariantMetadata from mjlab.sensor.sensor_context import SensorContext # Type aliases for better IDE support while maintaining runtime compatibility @@ -246,7 +246,7 @@ class Simulation: they are rendering or inspecting. """ with wp.ScopedDevice(self.wp_device): - result = build_mesh_variant_model( + result = build_variant_model( spec, self.num_envs, variant_info, @@ -275,9 +275,10 @@ class Simulation: # viewer syncs them per-world. self._expanded_fields.update(VARIANT_DEPENDENT_FIELDS) self._expanded_fields.add("geom_dataid") + self._expanded_fields.add("geom_matid") # Stash variant assignments as torch tensors keyed by bare entity name - # (mesh_variants emits "/" prefixes; strip the trailing slash for + # (build_variant_model emits "/" prefixes; strip the trailing slash for # the public API). for prefix, arr in result.world_to_variant.items(): key = prefix.rstrip("/") @@ -525,7 +526,7 @@ class Simulation: if not self.wp_device.is_cuda: return False - driver_ver = wp.context.runtime.driver_version + driver_ver = wp.get_cuda_driver_version() has_mempool = wp.is_mempool_enabled(self.wp_device) if driver_ver is None: diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/tasks/tracking/mdp/metrics.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/tasks/tracking/mdp/metrics.py index 99f6316..eb5fe9f 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/tasks/tracking/mdp/metrics.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/tasks/tracking/mdp/metrics.py @@ -14,9 +14,10 @@ def compute_mpkpe(command: MotionCommand) -> torch.Tensor: """Compute Mean Per-Keybody Position Error (MPKPE). MPKPE measures the average Euclidean distance between the reference and - actual positions of all key bodies in world frame. + actual key body positions in the global world frame. It captures all + tracking error, including global translation and heading drift. """ - pos_error = command.body_pos_relative_w - command.robot_body_pos_w + pos_error = command.body_pos_w - command.robot_body_pos_w per_body_error = torch.norm(pos_error, dim=-1) # (num_envs, num_bodies) return per_body_error.mean(dim=-1) # (num_envs,) @@ -24,29 +25,25 @@ def compute_mpkpe(command: MotionCommand) -> torch.Tensor: def compute_root_relative_mpkpe(command: MotionCommand) -> torch.Tensor: """Compute Root-relative Mean Per-Keybody Position Error (R-MPKPE). - R-MPKPE measures pose error independent of global drift by computing - positions relative to the root/anchor body. + R-MPKPE measures intrinsic pose error independent of global drift. It + uses ``body_pos_relative_w``, the reference re-anchored to the robot's + current root position and heading each step (the same quantity the + tracking reward optimizes), so both global translation and yaw drift are + removed and only the local body pose error remains. """ - # Compute reference positions relative to reference anchor. - ref_anchor_pos = command.anchor_pos_w.unsqueeze(1) # (num_envs, 1, 3) - ref_rel_pos = command.body_pos_w - ref_anchor_pos # (num_envs, num_bodies, 3) - - # Compute robot positions relative to robot anchor. - robot_anchor_pos = command.robot_anchor_pos_w.unsqueeze(1) # (num_envs, 1, 3) - robot_rel_pos = ( - command.robot_body_pos_w - robot_anchor_pos - ) # (num_envs, num_bodies, 3) - - # Compute error between relative positions. - pos_error = ref_rel_pos - robot_rel_pos + pos_error = command.body_pos_relative_w - command.robot_body_pos_w per_body_error = torch.norm(pos_error, dim=-1) # (num_envs, num_bodies) return per_body_error.mean(dim=-1) # (num_envs,) def compute_joint_velocity_error(command: MotionCommand) -> torch.Tensor: - """Compute average joint velocity error.""" + """Compute root-mean-square joint velocity error. + + Uses an RMS over joints (rather than a raw L2 norm) so the value is a + per-joint quantity, comparable across robots with different DOF counts. + """ vel_error = command.joint_vel - command.robot_joint_vel - return torch.norm(vel_error, dim=-1) # (num_envs,) + return torch.sqrt(torch.mean(vel_error**2, dim=-1)) # (num_envs,) def compute_ee_position_error( @@ -93,6 +90,18 @@ def _get_body_indices( body_names: Names of bodies to find. Returns: - List of indices into command.cfg.body_names. + List of indices into command.cfg.body_names, in the order requested. + + Raises: + ValueError: If any requested body name is not tracked by the command. + Silently dropping unknown names would otherwise report a spurious + zero error for misconfigured end-effector lists. """ - return [i for i, name in enumerate(command.cfg.body_names) if name in body_names] + name_to_index = {name: i for i, name in enumerate(command.cfg.body_names)} + missing = [name for name in body_names if name not in name_to_index] + if missing: + raise ValueError( + f"Body names {missing} are not tracked by the command. " + f"Available bodies: {tuple(command.cfg.body_names)}." + ) + return [name_to_index[name] for name in body_names] diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/tasks/tracking/rl/runner.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/tasks/tracking/rl/runner.py index 35fae8e..5698af7 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/tasks/tracking/rl/runner.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/tasks/tracking/rl/runner.py @@ -95,7 +95,9 @@ class MotionTrackingOnPolicyRunner(MjlabOnPolicyRunner): try: self.export_policy_to_onnx(str(policy_dir), filename) run_name: str = ( - wandb.run.name if self.logger.logger_type == "wandb" and wandb.run else "local" + wandb.run.name + if self.logger.logger_type in ("wandb", "WandbLogWriter") and wandb.run + else "local" ) # type: ignore[assignment] metadata = get_base_metadata(self.env.unwrapped, run_name) motion_term = cast( @@ -108,7 +110,10 @@ class MotionTrackingOnPolicyRunner(MjlabOnPolicyRunner): } ) attach_metadata_to_onnx(str(onnx_path), metadata) - if self.logger.logger_type in ["wandb"] and self.cfg["upload_model"]: + if ( + self.logger.logger_type in ("wandb", "WandbLogWriter") + and self.cfg["upload_model"] + ): wandb.save(str(onnx_path), base_path=str(policy_dir)) if self.registry_name is not None: wandb.run.use_artifact(self.registry_name) # type: ignore diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/tasks/tracking/scripts/evaluate.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/tasks/tracking/scripts/evaluate.py index 4b5031e..597d3ab 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/tasks/tracking/scripts/evaluate.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/tasks/tracking/scripts/evaluate.py @@ -6,6 +6,7 @@ import json import sys from dataclasses import asdict, dataclass from pathlib import Path +from types import SimpleNamespace from typing import cast import torch @@ -97,35 +98,61 @@ def run_evaluate(task_id: str, cfg: EvaluateConfig) -> dict[str, float]: all_joint_vel_error: list[torch.Tensor] = [] all_ee_pos_error: list[torch.Tensor] = [] all_ee_ori_error: list[torch.Tensor] = [] + all_active: list[torch.Tensor] = [] done_envs = torch.zeros(cfg.num_envs, dtype=torch.bool, device=device) success = torch.zeros(cfg.num_envs, dtype=torch.bool, device=device) obs = env.get_observations() - env.unwrapped.command_manager.compute(dt=env.unwrapped.step_dt) print(f"[INFO] Running {cfg.num_envs} evaluation episodes...") step = 0 while not done_envs.all(): + # Snapshot the reference frame the upcoming step will be scored against. + # env.step computes the reward (against the current reference) and only + # afterwards advances the command's motion frame, so reading the + # reference after stepping would pair the robot with the *next* frame. + # We snapshot here and pair it with the post-step robot state below, + # matching how the reward is computed. + ref = SimpleNamespace( + num_envs=command.num_envs, + device=command.device, + cfg=command.cfg, + body_pos_w=command.body_pos_w.clone(), + body_pos_relative_w=command.body_pos_relative_w.clone(), + body_quat_relative_w=command.body_quat_relative_w.clone(), + joint_vel=command.joint_vel.clone(), + ) + with torch.no_grad(): actions = policy(obs) obs, _, dones, _ = env.step(actions) - # Compute metrics for active envs. + # Pair the snapshotted reference with the post-step robot state. + ref.robot_body_pos_w = command.robot_body_pos_w + ref.robot_body_quat_w = command.robot_body_quat_w + ref.robot_joint_vel = command.robot_joint_vel + ref_command = cast(MotionCommand, ref) + + # Accumulate metrics for envs still running this step. active.any() is + # always true here: the loop runs only while some env is not done, and + # done_envs is updated below after this point. active = ~done_envs - if active.any(): - all_mpkpe.append(torch.where(active, compute_mpkpe(command), 0.0)) - all_r_mpkpe.append(torch.where(active, compute_root_relative_mpkpe(command), 0.0)) - all_joint_vel_error.append( - torch.where(active, compute_joint_velocity_error(command), 0.0) - ) - all_ee_pos_error.append( - torch.where(active, compute_ee_position_error(command, ee_body_names), 0.0) - ) - all_ee_ori_error.append( - torch.where(active, compute_ee_orientation_error(command, ee_body_names), 0.0) - ) + all_active.append(active.float()) + all_mpkpe.append(torch.where(active, compute_mpkpe(ref_command), 0.0)) + all_r_mpkpe.append( + torch.where(active, compute_root_relative_mpkpe(ref_command), 0.0) + ) + all_joint_vel_error.append( + torch.where(active, compute_joint_velocity_error(ref_command), 0.0) + ) + all_ee_pos_error.append( + torch.where(active, compute_ee_position_error(ref_command, ee_body_names), 0.0) + ) + all_ee_ori_error.append( + torch.where(active, compute_ee_orientation_error(ref_command, ee_body_names), 0.0) + ) # Track completions. terminated = env.unwrapped.termination_manager.terminated @@ -142,7 +169,7 @@ def run_evaluate(task_id: str, cfg: EvaluateConfig) -> dict[str, float]: ) step += 1 - # Compute mean metrics. + # Compute mean metrics over the steps each env was active. stacks = [ all_mpkpe, all_r_mpkpe, @@ -151,7 +178,7 @@ def run_evaluate(task_id: str, cfg: EvaluateConfig) -> dict[str, float]: all_ee_ori_error, ] stacks = [torch.stack(s, dim=0) for s in stacks] - active_steps = (stacks[0] != 0).sum(dim=0).float().clamp(min=1) + active_steps = torch.stack(all_active, dim=0).sum(dim=0).clamp(min=1) means = [s.sum(dim=0) / active_steps for s in stacks] metrics = { diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/terrains/heightfield_terrains.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/terrains/heightfield_terrains.py index 0e4ce8f..4c9b746 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/terrains/heightfield_terrains.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/terrains/heightfield_terrains.py @@ -24,14 +24,32 @@ from mjlab.terrains.terrain_generator import ( ) from mjlab.terrains.utils import find_flat_patches_from_heightfield +# Smallest positive hfield elevation/base size, in meters. MuJoCo rejects +# non-positive hfield sizes, so flat heightfields (difficulty 0) are clamped to +# this instead of zero. +_MIN_HFIELD_HEIGHT = 1e-3 + +# Physical height (meters) that maps to full color saturation. Heights are +# colored on this fixed absolute scale rather than normalized per patch, so a +# given height reads the same color across every terrain and small-amplitude +# terrain stays gently tinted instead of stretching into rainbow noise. +_COLOR_SCALE = 0.75 + def color_by_height( spec: mujoco.MjSpec, noise: np.ndarray, unique_id: str, - normalized_elevation: np.ndarray, + physical_heights: np.ndarray, texture_size: int = 128, ) -> str: + """Build a height-colored texture for a heightfield. + + Diverging colormap anchored at the ground plane (z=0): cool blue below ground, + green at z=0, warm red above. ``physical_heights`` is the surface height of + each cell in meters relative to z=0; it is colored on the fixed ``_COLOR_SCALE`` + so color encodes absolute height consistently across all terrains. + """ texture_name = f"hf_texture_{unique_id}" texture = spec.add_texture( name=texture_name, @@ -40,16 +58,20 @@ def color_by_height( height=texture_size, ) - texture_elevation = ndimage.zoom( - normalized_elevation, + texture_height = ndimage.zoom( + physical_heights, (texture_size / noise.shape[0], texture_size / noise.shape[1]), order=1, ) - texture_elevation = np.asarray(texture_elevation) + texture_height = np.asarray(texture_height) - hue = 0.5 - texture_elevation * 0.45 - saturation = 0.6 - texture_elevation * 0.2 - value = 0.4 + texture_elevation * 0.3 + # Signed deviation from the ground plane in [-1, 1] on a fixed absolute scale. + signed = np.clip(texture_height / _COLOR_SCALE, -1.0, 1.0) + + # signed=+1 -> hue 0.0 (red, high), 0 -> 0.33 (green, ground), -1 -> 0.66 (blue, low). + hue = 0.33 - 0.33 * signed + saturation = 0.45 + 0.25 * np.abs(signed) + value = 0.45 + 0.25 * np.abs(signed) c = value * saturation x = c * (1 - np.abs((hue * 6) % 2 - 1)) @@ -326,7 +348,8 @@ class HfPyramidSlopedTerrainCfg(SubTerrainCfg): else: hfield_z_offset = 0 - material_name = color_by_height(spec, noise, unique_id, normalized_elevation) + physical_heights = hfield_z_offset + normalized_elevation * max_physical_height + material_name = color_by_height(spec, noise, unique_id, physical_heights) hfield_geom = body.add_geom( type=mujoco.mjtGeom.mjGEOM_HFIELD, @@ -378,14 +401,24 @@ class HfRandomUniformTerrainCfg(SubTerrainCfg): border_width: float = 0.0 """Width of the flat border around the terrain edges, in meters. Must be >= horizontal_scale if non-zero.""" + scale_with_difficulty: bool = False + """If False (default), the roughness is fixed and ``difficulty`` is ignored, + matching upstream behavior. If True, the noise amplitude scales linearly with + difficulty (flat at 0, full ``noise_range`` at 1) so the terrain progresses in + a curriculum.""" def function( self, difficulty: float, spec: mujoco.MjSpec, rng: np.random.Generator ) -> TerrainOutput: - del difficulty # Unused. - body = spec.body("terrain") + # When difficulty scaling is enabled, ramp the noise amplitude from flat (0) + # to the full configured range (1). Otherwise use the full range regardless + # of difficulty (difficulty is ignored). + scale = difficulty if self.scale_with_difficulty else 1.0 + noise_lo = self.noise_range[0] * scale + noise_hi = self.noise_range[1] * scale + if self.border_width > 0 and self.border_width < self.horizontal_scale: raise ValueError( f"Border width ({self.border_width}) must be >= horizontal scale " @@ -419,8 +452,8 @@ class HfRandomUniformTerrainCfg(SubTerrainCfg): width_downsampled = int(inner_size[0] / downsampled_scale) length_downsampled = int(inner_size[1] / downsampled_scale) - height_min = int(self.noise_range[0] / self.vertical_scale) - height_max = int(self.noise_range[1] / self.vertical_scale) + height_min = int(noise_lo / self.vertical_scale) + height_max = int(noise_hi / self.vertical_scale) height_step = int(self.noise_step / self.vertical_scale) height_range = np.arange(height_min, height_max + height_step, height_step) @@ -443,8 +476,8 @@ class HfRandomUniformTerrainCfg(SubTerrainCfg): else: width_downsampled = int(self.size[0] / downsampled_scale) length_downsampled = int(self.size[1] / downsampled_scale) - height_min = int(self.noise_range[0] / self.vertical_scale) - height_max = int(self.noise_range[1] / self.vertical_scale) + height_min = int(noise_lo / self.vertical_scale) + height_max = int(noise_hi / self.vertical_scale) height_step = int(self.noise_step / self.vertical_scale) height_range = np.arange(height_min, height_max + height_step, height_step) @@ -489,7 +522,8 @@ class HfRandomUniformTerrainCfg(SubTerrainCfg): userdata=normalized_elevation.flatten().astype(np.float32).tolist(), ) - material_name = color_by_height(spec, noise, unique_id, normalized_elevation) + physical_heights = normalized_elevation * max_physical_height + material_name = color_by_height(spec, noise, unique_id, physical_heights) hfield_geom = body.add_geom( type=mujoco.mjtGeom.mjGEOM_HFIELD, @@ -498,7 +532,7 @@ class HfRandomUniformTerrainCfg(SubTerrainCfg): material=material_name, ) - spawn_height = (self.noise_range[0] + self.noise_range[1]) / 2 + spawn_height = (noise_lo + noise_hi) / 2 origin = np.array([self.size[0] / 2, self.size[1] / 2, spawn_height]) flat_patches = _compute_flat_patches( @@ -616,7 +650,11 @@ class HfWaveTerrainCfg(SubTerrainCfg): userdata=normalized_elevation.flatten().astype(np.float32).tolist(), ) - material_name = color_by_height(spec, noise, unique_id, normalized_elevation) + # The wave oscillates around z=0 (geom is offset down by half the range). + physical_heights = ( + normalized_elevation * max_physical_height - max_physical_height / 2 + ) + material_name = color_by_height(spec, noise, unique_id, physical_heights) hfield_geom = body.add_geom( type=mujoco.mjtGeom.mjGEOM_HFIELD, @@ -783,7 +821,9 @@ class HfDiscreteObstaclesTerrainCfg(SubTerrainCfg): else: hfield_z_offset = 0 - material_name = color_by_height(spec, noise, unique_id, normalized_elevation) + # Physical surface height per cell (pits negative, bumps positive about z=0). + physical_heights = hfield_z_offset + normalized_elevation * max_physical_height + material_name = color_by_height(spec, noise, unique_id, physical_heights) hfield_geom = body.add_geom( type=mujoco.mjtGeom.mjGEOM_HFIELD, @@ -887,8 +927,13 @@ class HfPerlinNoiseTerrainCfg(SubTerrainCfg): noise_range = noise_max - noise_min if noise_max > noise_min else 1.0 normalized_elevation = ((noise_raw - noise_min) / noise_range).astype(np.float32) - max_physical_height = target_height - base_thickness = max_physical_height * self.base_thickness_ratio + # MuJoCo requires positive hfield elevation and base sizes. At difficulty 0 + # (target_height == 0) the surface is flat; clamp to a small positive height + # so compilation does not fail with "size parameter is not positive". + max_physical_height = max(target_height, _MIN_HFIELD_HEIGHT) + base_thickness = max( + max_physical_height * self.base_thickness_ratio, _MIN_HFIELD_HEIGHT + ) unique_id = uuid.uuid4().hex field = spec.add_hfield( @@ -904,8 +949,9 @@ class HfPerlinNoiseTerrainCfg(SubTerrainCfg): userdata=normalized_elevation.flatten().tolist(), ) + physical_heights = normalized_elevation * max_physical_height material_name = color_by_height( - spec, normalized_elevation, unique_id, normalized_elevation + spec, normalized_elevation, unique_id, physical_heights ) hfield_geom = body.add_geom( diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/terrains/primitive_terrains.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/terrains/primitive_terrains.py index cd0edc2..3a2d08a 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/terrains/primitive_terrains.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/terrains/primitive_terrains.py @@ -11,7 +11,6 @@ References: from __future__ import annotations from dataclasses import dataclass -from typing import Tuple import mujoco import numpy as np @@ -23,30 +22,19 @@ from mjlab.terrains.terrain_generator import ( ) from mjlab.terrains.utils import make_border, make_plane from mjlab.utils.color import ( - HSV, brand_ramp, - clamp, darken_rgba, - hsv_to_rgb, - rgb_to_hsv, ) _MUJOCO_BLUE = (0.20, 0.45, 0.95) _MUJOCO_RED = (0.90, 0.30, 0.30) _MUJOCO_GREEN = (0.25, 0.80, 0.45) - -def _get_platform_color( - base_rgb: Tuple[float, float, float], - desaturation_factor: float = 0.4, - lightening_factor: float = 0.25, -) -> Tuple[float, float, float, float]: - hsv = rgb_to_hsv(base_rgb) - new_s = hsv.s * desaturation_factor - new_v = clamp(hsv.v + lightening_factor) - new_hsv = HSV(hsv.h, new_s, new_v) - r, g, b = hsv_to_rgb(new_hsv) - return (r, g, b, 1.0) +# Minimum vertical extent of a flat border frame, in meters. The border top sits +# flush at z=0 and extends downward, so this depth is not visible from above; it +# only guarantees the frame is solid (never a degenerate zero-height geom) when +# the step height collapses to zero at difficulty 0. +_MIN_BORDER_HEIGHT = 0.05 @dataclass(kw_only=True) @@ -107,13 +95,18 @@ class BoxPyramidStairsTerrainCfg(SubTerrainCfg): border_rgba = darken_rgba(first_step_rgba, 0.85) if self.border_width > 0.0 and not self.holes: - border_center = (0.5 * self.size[0], 0.5 * self.size[1], -step_height / 2) + # Decouple the border's vertical extent from step_height so difficulty 0 + # (step_height == 0) still produces a solid, gap-free frame instead of + # being skipped or generating degenerate zero-height geoms. The top stays + # flush with the ground at z=0. + border_height = max(step_height, _MIN_BORDER_HEIGHT) + border_center = (0.5 * self.size[0], 0.5 * self.size[1], -border_height / 2) border_inner_size = ( self.size[0] - 2 * self.border_width, self.size[1] - 2 * self.border_width, ) border_boxes = make_border( - body, self.size, border_inner_size, step_height, border_center + body, self.size, border_inner_size, border_height, border_center ) boxes.extend(border_boxes) for _ in range(len(border_boxes)): @@ -280,13 +273,16 @@ class BoxInvertedPyramidStairsTerrainCfg(BoxPyramidStairsTerrainCfg): border_rgba = darken_rgba(first_step_rgba, 0.85) if self.border_width > 0.0 and not self.holes: - border_center = (0.5 * self.size[0], 0.5 * self.size[1], -0.5 * step_height) + # See BoxPyramidStairsTerrainCfg: keep the border solid and flush at z=0 + # even when step_height collapses to 0 at difficulty 0. + border_height = max(step_height, _MIN_BORDER_HEIGHT) + border_center = (0.5 * self.size[0], 0.5 * self.size[1], -0.5 * border_height) border_inner_size = ( self.size[0] - 2 * self.border_width, self.size[1] - 2 * self.border_width, ) border_boxes = make_border( - body, self.size, border_inner_size, step_height, border_center + body, self.size, border_inner_size, border_height, border_center ) boxes.extend(border_boxes) for _ in range(len(border_boxes)): @@ -546,8 +542,7 @@ class BoxRandomGridTerrainCfg(SubTerrainCfg): pos=(self.size[0] / 2, self.size[1] / 2, platform_center_z), ) boxes_list.append(box) - platform_rgba = _get_platform_color(_MUJOCO_GREEN) - box_colors.append(platform_rgba) + box_colors.append(brand_ramp(_MUJOCO_GREEN, 0.5)) origin = np.array([self.size[0] / 2, self.size[1] / 2, grid_height]) @@ -575,6 +570,22 @@ class BoxRandomGridTerrainCfg(SubTerrainCfg): half_border_width = border_width / 2 neg_half_terrain = -terrain_height / 2 + # Mark cells under the center platform as visited so they are never emitted + # or merged; the platform box covers that region and would otherwise z-fight + # with the cells beneath it. + platform_half = self.platform_width / 2 + terrain_center = self.size[0] / 2 + platform_min = terrain_center - platform_half + platform_max = terrain_center + platform_half + for i in range(num_boxes_x): + cx = half_border_width + (i + 0.5) * self.grid_width + if not (platform_min <= cx <= platform_max): + continue + for j in range(num_boxes_y): + cy = half_border_width + (j + 0.5) * self.grid_width + if platform_min <= cy <= platform_max: + visited[i, j] = True + # Quantize heights to create more merging opportunities quantized_heights = ( np.round(height_map / self.height_merge_threshold) * self.height_merge_threshold @@ -588,7 +599,12 @@ class BoxRandomGridTerrainCfg(SubTerrainCfg): # Find rectangular region with similar height height = quantized_heights[i, j] - normalized_height = (height + grid_height) / (2 * grid_height) + # grid_height == 0 (difficulty 0) means a flat grid; use the midpoint + # color and avoid dividing by zero. + if grid_height > 0: + normalized_height = (height + grid_height) / (2 * grid_height) + else: + normalized_height = 0.5 t = float(np.clip(normalized_height, 0.0, 1.0)) rgba = brand_ramp(_MUJOCO_GREEN, t) @@ -653,14 +669,10 @@ class BoxRandomGridTerrainCfg(SubTerrainCfg): half_border_width = border_width / 2 neg_half_terrain = -terrain_height / 2 - if self.holes: - platform_half = self.platform_width / 2 - terrain_center = self.size[0] / 2 - platform_min = terrain_center - platform_half - platform_max = terrain_center + platform_half - else: - platform_min = None - platform_max = None + platform_half = self.platform_width / 2 + terrain_center = self.size[0] / 2 + platform_min = terrain_center - platform_half + platform_max = terrain_center + platform_half for i in range(num_boxes_x): box_center_x = half_border_width + (i + 0.5) * self.grid_width @@ -678,11 +690,24 @@ class BoxRandomGridTerrainCfg(SubTerrainCfg): if not (in_x_strip or in_y_strip): continue + # Skip cells under the center platform so the platform is the only + # geometry there. Otherwise the platform box sits on top of these cells + # and the coplanar faces z-fight. + if (platform_min <= box_center_x <= platform_max) and ( + platform_min <= box_center_y <= platform_max + ): + continue + height_noise = height_map[i, j] box_height = terrain_height + height_noise box_center_z = neg_half_terrain + height_noise / 2 - normalized_height = (height_noise + grid_height) / (2 * grid_height) + # grid_height == 0 (difficulty 0) means a flat grid; use the midpoint + # color and avoid dividing by zero. + if grid_height > 0: + normalized_height = (height_noise + grid_height) / (2 * grid_height) + else: + normalized_height = 0.5 t = float(np.clip(normalized_height, 0.0, 1.0)) rgba = brand_ramp(_MUJOCO_GREEN, t) box_colors.append(rgba) @@ -744,13 +769,17 @@ class BoxRandomSpreadTerrainCfg(SubTerrainCfg): ) geometries.append(TerrainGeometry(geom=floor_geom, color=(0.4, 0.4, 0.4, 1.0))) - # Platform - platform_geom = body.add_geom( - type=mujoco.mjtGeom.mjGEOM_BOX, - size=(self.platform_width / 2, self.platform_width / 2, terrain_height / 2), - pos=(self.size[0] / 2, self.size[1] / 2, -terrain_height / 2), - ) - geometries.append(TerrainGeometry(geom=platform_geom, color=(0.4, 0.4, 0.4, 1.0))) + # Center platform. When a floor is present it already provides flat ground at + # z=0 across the (box-free) center, so an extra platform box would only + # duplicate that surface and z-fight with the floor. Add the platform only + # when there is no floor, where it is the sole ground at the spawn point. + if not self.add_floor: + platform_geom = body.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + size=(self.platform_width / 2, self.platform_width / 2, terrain_height / 2), + pos=(self.size[0] / 2, self.size[1] / 2, -terrain_height / 2), + ) + geometries.append(TerrainGeometry(geom=platform_geom, color=(0.4, 0.4, 0.4, 1.0))) platform_half = self.platform_width / 2 terrain_center = self.size[0] / 2 @@ -840,13 +869,15 @@ class BoxOpenStairsTerrainCfg(SubTerrainCfg): border_rgba = darken_rgba(first_step_rgba, 0.85) if self.border_width > 0.0: - border_center = (0.5 * self.size[0], 0.5 * self.size[1], -step_height / 2) + # Keep the border solid and flush at z=0 even if step_height is 0. + border_height = max(step_height, _MIN_BORDER_HEIGHT) + border_center = (0.5 * self.size[0], 0.5 * self.size[1], -border_height / 2) border_inner_size = ( self.size[0] - 2 * self.border_width, self.size[1] - 2 * self.border_width, ) border_boxes = make_border( - body, self.size, border_inner_size, step_height, border_center + body, self.size, border_inner_size, border_height, border_center ) for box in border_boxes: geometries.append(TerrainGeometry(geom=box, color=border_rgba)) @@ -1132,7 +1163,12 @@ class BoxRandomStairsTerrainCfg(SubTerrainCfg): @dataclass(kw_only=True) class BoxSteppingStonesTerrainCfg(SubTerrainCfg): stone_size_range: tuple[float, float] = (0.4, 0.8) + """Max and min stone side length, in meters. Stones shrink from the max toward + the min as difficulty increases, which widens the gaps between them.""" stone_distance_range: tuple[float, float] = (0.2, 0.5) + """Gap between stones, in meters. The lower bound seeds the (fixed) grid + density; the gap itself grows with difficulty as the stones shrink, so the + upper bound is no longer used directly.""" stone_height: float = 0.2 stone_height_variation: float = 0.1 stone_size_variation: float = 0.1 @@ -1152,23 +1188,56 @@ class BoxSteppingStonesTerrainCfg(SubTerrainCfg): displacement_range = self.displacement_range * difficulty stone_height_variation = self.stone_height_variation * difficulty - # Increase distance between stones with difficulty. - d_low, d_high = self.stone_distance_range - avg_distance = d_low + difficulty * (d_high - d_low) - - # Decrease stone size with difficulty (larger stones are easier). + # Decrease stone size with difficulty (larger stones are easier). With the + # grid pitch held fixed (below), shrinking stones means the gaps between them + # grow, which is the actual difficulty curriculum. s_min, s_max = self.stone_size_range avg_stone_size = s_max - difficulty * (s_max - s_min) - spacing = avg_stone_size + avg_distance - # Aggressive grid density to reach borders. + # Difficulty-INDEPENDENT grid. The count and pitch are fixed across difficulty + # so the layout never re-tiles (previously, num = floor(inner / spacing) + 1 + # jumped by one as the difficulty-varying spacing crossed an integer boundary, + # shifting every stone at once). The pitch exactly spans the inner region so + # edge stones always reach the borders. Density is seeded by the tightest + # nominal spacing (largest stones + smallest gap). inner_w = self.size[0] - 2 * self.border_width inner_h = self.size[1] - 2 * self.border_width - num_x = int(np.floor(inner_w / spacing)) + 1 - num_y = int(np.floor(inner_h / spacing)) + 1 + nominal_spacing = s_max + self.stone_distance_range[0] + num_x = max(2, int(np.floor(inner_w / nominal_spacing)) + 1) + num_y = max(2, int(np.floor(inner_h / nominal_spacing)) + 1) + pitch_x = inner_w / (num_x - 1) + pitch_y = inner_h / (num_y - 1) - offset_x = self.border_width + (inner_w - (num_x - 1) * spacing) / 2 - offset_y = self.border_width + (inner_h - (num_y - 1) * spacing) / 2 + # Inter-stone gap (grows with difficulty as stones shrink). + gap_x = max(0.0, pitch_x - avg_stone_size) + gap_y = max(0.0, pitch_y - avg_stone_size) + + # Snap the central platform out to the grid. It is at least the configured + # width and reaches to exactly one gap before the nearest *full* stone, so the + # ring of stones around it are whole (no clipped slivers that pop in and out + # with difficulty) and sit one consistent gap away. The platform simply + # absorbs the stones that would otherwise be partially under it. + center_x, center_y = self.size[0] / 2, self.size[1] / 2 + half_stone = avg_stone_size / 2 + a0 = self.platform_width / 2 + + def _snapped_half(center: float, pitch: float, gap: float, num: int) -> float: + # Nearest grid stone that can stay full while the platform is >= a0 wide. + threshold = center + a0 + half_stone + gap + i_keep = min(num - 1, int(np.ceil((threshold - self.border_width) / pitch))) + c_keep = self.border_width + i_keep * pitch + return max(a0, c_keep - half_stone - gap - center) + + platform_half_x = _snapped_half(center_x, pitch_x, gap_x, num_x) + platform_half_y = _snapped_half(center_y, pitch_y, gap_y, num_y) + platform_min_x, platform_max_x = ( + center_x - platform_half_x, + center_x + platform_half_x, + ) + platform_min_y, platform_max_y = ( + center_y - platform_half_y, + center_y + platform_half_y, + ) border_rgba = darken_rgba(brand_ramp(_MUJOCO_GREEN, 0.0), 0.85) z_center = (self.stone_height - self.floor_depth) / 2 @@ -1195,25 +1264,20 @@ class BoxSteppingStonesTerrainCfg(SubTerrainCfg): ) geometries.append(TerrainGeometry(geom=floor_geom, color=(0.1, 0.1, 0.1, 1.0))) - # Platform Column. + # Platform Column (grid-snapped, see above). platform_geom = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=( - np.maximum(1e-6, self.platform_width / 2), - np.maximum(1e-6, self.platform_width / 2), + np.maximum(1e-6, platform_half_x), + np.maximum(1e-6, platform_half_y), np.maximum(1e-6, half_height), ), - pos=(self.size[0] / 2, self.size[1] / 2, z_center), + pos=(center_x, center_y, z_center), ) geometries.append( TerrainGeometry(geom=platform_geom, color=brand_ramp(_MUJOCO_GREEN, 0.5)) ) - platform_half = self.platform_width / 2 - terrain_center = self.size[0] / 2 - platform_min = terrain_center - platform_half - platform_max = terrain_center + platform_half - inner_min_x, inner_max_x = self.border_width, self.size[0] - self.border_width inner_min_y, inner_max_y = self.border_width, self.size[1] - self.border_width @@ -1221,12 +1285,17 @@ class BoxSteppingStonesTerrainCfg(SubTerrainCfg): for j in range(num_y): base_size = avg_stone_size - # Proposed position with displacement. + # Proposed position on the fixed grid with random displacement. Centers + # span border to (size - border), so edge stones reach the borders. px = ( - offset_x + i * spacing + rng.uniform(-displacement_range, displacement_range) + self.border_width + + i * pitch_x + + rng.uniform(-displacement_range, displacement_range) ) py = ( - offset_y + j * spacing + rng.uniform(-displacement_range, displacement_range) + self.border_width + + j * pitch_y + + rng.uniform(-displacement_range, displacement_range) ) # Randomized size. @@ -1237,10 +1306,11 @@ class BoxSteppingStonesTerrainCfg(SubTerrainCfg): x_min, x_max = px - size_x / 2, px + size_x / 2 y_min, y_max = py - size_y / 2, py + size_y / 2 - # Skip stones centered inside the platform. Stones whose edges - # extend under the platform are kept; the platform covers the overlap. - if (platform_min <= px <= platform_max) and ( - platform_min <= py <= platform_max + # Drop stones whose center lies under the (grid-snapped) platform; the + # platform absorbs them. Every remaining stone stays full size and sits + # one gap from the platform, so there are no clipped slivers. + if (platform_min_x <= px <= platform_max_x) and ( + platform_min_y <= py <= platform_max_y ): continue @@ -1296,6 +1366,7 @@ class BoxNarrowBeamsTerrainCfg(SubTerrainCfg): def function( self, difficulty: float, spec: mujoco.MjSpec, rng: np.random.Generator ) -> TerrainOutput: + del rng # Beam layout is deterministic. body = spec.body("terrain") geometries = [] @@ -1306,6 +1377,19 @@ class BoxNarrowBeamsTerrainCfg(SubTerrainCfg): w_min, w_max = self.beam_width_range beam_width = w_max - difficulty * (w_max - w_min) + # Shrink the square platform so its corners stay within the beams' angular + # coverage rather than protruding into the pit between beams. A corner sits at + # radius r*sqrt(2) and, in the worst case, pi/num_beams away from the nearest + # beam, so it is covered while r*sqrt(2)*sin(pi/num_beams) <= beam_width/2. + # Beams thin with difficulty, so the safe radius shrinks with it. The beams + # attach at this same radius (below), so shrinking never opens a fall gap. + spacing_sin = float(np.sin(np.pi / num_beams)) if num_beams > 1 else 0.0 + if spacing_sin > 1e-9: + max_no_protrude = beam_width / (2.0 * np.sqrt(2.0) * spacing_sin) + platform_radius = float(min(self.platform_width / 2.0, max_no_protrude)) + else: + platform_radius = self.platform_width / 2.0 + border_rgba = darken_rgba(brand_ramp(_MUJOCO_BLUE, 0.0), 0.85) z_center = (self.beam_height - self.floor_depth) / 2 half_height = (self.beam_height + self.floor_depth) / 2 @@ -1335,8 +1419,8 @@ class BoxNarrowBeamsTerrainCfg(SubTerrainCfg): platform_geom = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=( - np.maximum(1e-6, self.platform_width / 2), - np.maximum(1e-6, self.platform_width / 2), + np.maximum(1e-6, platform_radius), + np.maximum(1e-6, platform_radius), np.maximum(1e-6, half_height), ), pos=(self.size[0] / 2, self.size[1] / 2, z_center), @@ -1347,7 +1431,6 @@ class BoxNarrowBeamsTerrainCfg(SubTerrainCfg): inner_size = self.size[0] - 2 * self.border_width center_x, center_y = self.size[0] / 2, self.size[1] / 2 - platform_radius = self.platform_width / 2 # Radial beams as columns. angles = np.linspace(0, 2 * np.pi, num_beams, endpoint=False) @@ -1526,6 +1609,8 @@ class BoxNestedRingsTerrainCfg(SubTerrainCfg): ring_width_range: tuple[float, float] = (0.3, 0.6) gap_range: tuple[float, float] = (0.0, 0.2) height_range: tuple[float, float] = (0.1, 0.4) + """Min and max ring height, in meters. All rings share a single fixed height + taken as the midpoint of this range; difficulty does not scale it.""" platform_width: float = 1.0 border_width: float = 0.25 floor_depth: float = 2.0 @@ -1533,20 +1618,25 @@ class BoxNestedRingsTerrainCfg(SubTerrainCfg): def function( self, difficulty: float, spec: mujoco.MjSpec, rng: np.random.Generator ) -> TerrainOutput: + del rng # Ring layout is deterministic. body = spec.body("terrain") geometries = [] - # Difficulty scaling: wider width range and higher average height. - h_scale = 1.0 + difficulty * 0.5 + # Concentric ridges of a single fixed height. Difficulty controls + # gap-crossing only: gaps widen and rings narrow, so the terrain reads + # consistently across difficulty instead of weakly scaling height. w_min, w_max = self.ring_width_range ring_width = w_max - difficulty * (w_max - w_min) + ring_height = 0.5 * (self.height_range[0] + self.height_range[1]) + ring_rgba = brand_ramp(_MUJOCO_BLUE, 0.6) + border_rgba = darken_rgba(brand_ramp(_MUJOCO_BLUE, 0.0), 0.85) - # Use ground level z=0 as top of border/beams for consistency with NarrowBeams. - # In beam terrain, border top was at beam_height. if self.border_width > 0.0: - border_h = 0.5 + # Outer border wall matches the ring height so there is no arbitrary + # crossover between the two as difficulty changes. + border_h = ring_height border_center = ( 0.5 * self.size[0], 0.5 * self.size[1], @@ -1582,12 +1672,9 @@ class BoxNestedRingsTerrainCfg(SubTerrainCfg): gap_min, gap_max = self.gap_range gap = gap_min + difficulty * (gap_max - gap_min) - for k in range(self.num_rings): - # Ring k: randomized height. - h = rng.uniform(self.height_range[0], self.height_range[1]) * h_scale - - t = k / max(self.num_rings - 1, 1) - rgba = brand_ramp(_MUJOCO_BLUE, t) + for _ in range(self.num_rings): + h = ring_height + rgba = ring_rgba # Outer dimensions of this ring. ring_outer_size = ( @@ -1655,7 +1742,8 @@ class BoxNestedRingsTerrainCfg(SubTerrainCfg): ), # Fill the ring hole + gap area. np.maximum(1e-2, current_outer_size[1] + 2 * gap), ) - platform_h = 0.2 + # Center pad sits flush with the ring height. + platform_h = ring_height platform_half_h = (platform_h + self.floor_depth) / 2 platform_z = (platform_h - self.floor_depth) / 2 diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/terrains/terrain_generator.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/terrains/terrain_generator.py index f9f6511..6b726d4 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/terrains/terrain_generator.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/terrains/terrain_generator.py @@ -1,7 +1,6 @@ from __future__ import annotations import abc -import time from dataclasses import dataclass, field from typing import Literal @@ -194,16 +193,9 @@ class TerrainGenerator: body = spec.worldbody.add_body(name="terrain") if self.cfg.curriculum: - tic = time.perf_counter() self._generate_curriculum_terrains(spec) - toc = time.perf_counter() - print(f"Curriculum terrain generation took {toc - tic:.4f} seconds.") - else: - tic = time.perf_counter() self._generate_random_terrains(spec) - toc = time.perf_counter() - print(f"Terrain generation took {toc - tic:.4f} seconds.") self._add_terrain_border(spec) self._add_grid_lights(spec) @@ -257,11 +249,11 @@ class TerrainGenerator: # One column per terrain type — proportion is only for spawning. sub_terrains_cfgs = list(self.cfg.sub_terrains.values()) + lower, upper = self.cfg.difficulty_range for sub_col in range(self._num_cols): for sub_row in range(self.cfg.num_rows): - lower, upper = self.cfg.difficulty_range - difficulty = (sub_row + self.np_rng.uniform()) / self.cfg.num_rows - difficulty = lower + (upper - lower) * difficulty + t = sub_row / max(self.cfg.num_rows - 1, 1) + difficulty = lower + (upper - lower) * t world_position = self._get_sub_terrain_position(sub_row, sub_col) spawn_origin = self._create_terrain_geom( spec, diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/buffers/circular_buffer.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/buffers/circular_buffer.py index 1031944..f5daa7d 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/buffers/circular_buffer.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/buffers/circular_buffer.py @@ -209,8 +209,9 @@ class CircularBuffer: # Backfill entire history with first frame for newly initialized batches. is_first_push = self._num_pushes == 0 - if torch.any(is_first_push): - self._buffer[:, is_first_push] = data[is_first_push] + torch.where( + is_first_push[None, :, None], data[None, :, :], self._buffer, out=self._buffer + ) self._num_pushes += 1 @@ -236,8 +237,5 @@ class CircularBuffer: pushes = self._num_pushes.clamp_min(1) valid = torch.minimum(key, pushes - 1).clamp_min(0) - if torch.all(valid == 0): - return self._buffer[self._pointer] - idx = torch.remainder(self._pointer - valid, self._max_len) return self._buffer[idx, self._all_indices] diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/gpu.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/gpu.py index 244e603..4f12cd3 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/gpu.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/gpu.py @@ -3,10 +3,12 @@ import os from typing import Literal +GpuId = int | str + def select_gpus( gpu_ids: list[int] | Literal["all"] | None, -) -> tuple[list[int] | None, int]: +) -> tuple[list[GpuId] | None, int]: """Select GPUs based on CUDA_VISIBLE_DEVICES and user specification. This function treats the `gpu_ids` parameter as indices into the existing @@ -19,7 +21,8 @@ def select_gpus( Returns: A tuple of (selected_gpu_ids, num_gpus) where: - - selected_gpu_ids: List of physical GPU IDs to use, or None for CPU mode + - selected_gpu_ids: List of physical GPU IDs (int for numeric, str for MIG + UUIDs), or None for CPU mode - num_gpus: Number of GPUs selected (0 for CPU mode) Examples: @@ -50,8 +53,11 @@ def select_gpus( if existing_visible_devices is not None: # Parse existing CUDA_VISIBLE_DEVICES. - available_gpus = [ - int(x.strip()) for x in existing_visible_devices.split(",") if x.strip() + # Use int for numeric IDs, keep as string for MIG UUIDs. + available_gpus: list[GpuId] = [ + int(x.strip()) if x.strip().isdigit() else x.strip() + for x in existing_visible_devices.split(",") + if x.strip() ] # Empty CUDA_VISIBLE_DEVICES means CPU mode. if not available_gpus: @@ -60,15 +66,16 @@ def select_gpus( # If not set, default to all available GPUs. import torch.cuda - available_gpus = list(range(torch.cuda.device_count())) + available_gpus: list[GpuId] = list(range(torch.cuda.device_count())) # Map gpu_ids indices to actual GPU IDs. + selected: list[GpuId] if gpu_ids == "all": - selected_gpus = available_gpus + selected = available_gpus else: # gpu_ids are indices into available_gpus. - selected_gpus = [available_gpus[i] for i in gpu_ids] + selected = [available_gpus[i] for i in gpu_ids] - num_gpus = len(selected_gpus) + num_gpus = len(selected) - return selected_gpus, num_gpus + return selected, num_gpus diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/random.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/random.py index be0c601..682a3a0 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/random.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/random.py @@ -6,18 +6,9 @@ import torch import warp as wp -def seed_rng( - seed: int, - torch_deterministic: bool = False, - device: str | torch.device | None = None, -) -> None: +def seed_rng(seed: int, torch_deterministic: bool = False) -> None: """Seed all random number generators for reproducibility. - When ``device`` is a CPU device, ``wp.rand_init`` is skipped so that Warp's - CUDA runtime is not initialized on machines where a GPU is visible but the - caller has explicitly opted into CPU-only execution. When ``device`` is - ``None``, behavior is unchanged (Warp is seeded). - Note: MuJoCo Warp is not fully deterministic yet. See: https://github.com/google-deepmind/mujoco_warp/issues/562 """ @@ -26,8 +17,7 @@ def seed_rng( random.seed(seed) np.random.seed(seed) - if device is None or torch.device(device).type != "cpu": - wp.rand_init(wp.int32(seed)) + wp.rand_init(wp.int32(seed)) # Ref: https://docs.pytorch.org/docs/stable/notes/randomness.html torch.manual_seed(seed) # Seed RNG for all devices. diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/spec.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/spec.py index b12ed71..033e362 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/spec.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/utils/spec.py @@ -120,6 +120,34 @@ _TRANSMISSION_TYPE_MAP = { } +def apply_target_overrides( + spec: mujoco.MjSpec, + target_name: str, + transmission_type: TransmissionType, + *, + armature: float | None, + frictionloss: float | None, + viscous_damping: float | None, +) -> None: + """Apply joint- or tendon-level overrides. ``None`` preserves the XML value. + + SITE transmission is a no-op (sites have no armature / frictionloss / damping); + callers using SITE should not pass non-None overrides. + """ + if transmission_type == TransmissionType.JOINT: + target = spec.joint(target_name) + elif transmission_type == TransmissionType.TENDON: + target = spec.tendon(target_name) + else: + return + if armature is not None: + target.armature = armature + if frictionloss is not None: + target.frictionloss = frictionloss + if viscous_damping is not None: + target.damping[0] = viscous_damping + + def auto_wrap_fixed_base_mocap( spec_fn: Callable[[], mujoco.MjSpec], ) -> Callable[[], mujoco.MjSpec]: @@ -235,21 +263,14 @@ def create_motor_actuator( actuator.ctrllimited = True actuator.ctrlrange[:] = np.array([-effort_limit, effort_limit]) - # Set armature, frictionloss, and viscous_damping (None = preserve XML value). - if transmission_type == TransmissionType.JOINT: - if armature is not None: - spec.joint(joint_name).armature = armature - if frictionloss is not None: - spec.joint(joint_name).frictionloss = frictionloss - if viscous_damping is not None: - spec.joint(joint_name).damping[0] = viscous_damping - elif transmission_type == TransmissionType.TENDON: - if armature is not None: - spec.tendon(joint_name).armature = armature - if frictionloss is not None: - spec.tendon(joint_name).frictionloss = frictionloss - if viscous_damping is not None: - spec.tendon(joint_name).damping[0] = viscous_damping + apply_target_overrides( + spec, + joint_name, + transmission_type, + armature=armature, + frictionloss=frictionloss, + viscous_damping=viscous_damping, + ) return actuator @@ -265,14 +286,21 @@ def create_position_actuator( frictionloss: float | None = None, viscous_damping: float | None = None, transmission_type: TransmissionType = TransmissionType.JOINT, + actuator_name: str | None = None, ) -> mujoco.MjsActuator: """Creates a actuator. An important note about this actuator is that we set `ctrllimited` to False. This is because we want to allow the policy to output setpoints that are outside the kinematic limits of the joint. + + ``actuator_name`` defaults to ``joint_name``; pass a distinct value when multiple + actuators target the same joint (e.g. paired position+velocity elements). """ - actuator = spec.add_actuator(name=joint_name, target=joint_name) + actuator = spec.add_actuator( + name=actuator_name if actuator_name is not None else joint_name, + target=joint_name, + ) actuator.trntype = _TRANSMISSION_TYPE_MAP[transmission_type] actuator.dyntype = mujoco.mjtDyn.mjDYN_NONE @@ -314,21 +342,14 @@ def create_position_actuator( actuator.forcelimited = False # No forcerange needed. - # Set armature, frictionloss, and viscous_damping (None = preserve XML value). - if transmission_type == TransmissionType.JOINT: - if armature is not None: - spec.joint(joint_name).armature = armature - if frictionloss is not None: - spec.joint(joint_name).frictionloss = frictionloss - if viscous_damping is not None: - spec.joint(joint_name).damping[0] = viscous_damping - elif transmission_type == TransmissionType.TENDON: - if armature is not None: - spec.tendon(joint_name).armature = armature - if frictionloss is not None: - spec.tendon(joint_name).frictionloss = frictionloss - if viscous_damping is not None: - spec.tendon(joint_name).damping[0] = viscous_damping + apply_target_overrides( + spec, + joint_name, + transmission_type, + armature=armature, + frictionloss=frictionloss, + viscous_damping=viscous_damping, + ) return actuator @@ -343,14 +364,21 @@ def create_velocity_actuator( frictionloss: float | None = None, viscous_damping: float | None = None, transmission_type: TransmissionType = TransmissionType.JOINT, + actuator_name: str | None = None, ) -> mujoco.MjsActuator: """Creates a actuator. Control inputs are not clamped so that velocity commands work for any joint, including continuous joints that have no range defined. Force output is still bounded when effort_limit is set. + + ``actuator_name`` defaults to ``joint_name``; pass a distinct value when multiple + actuators target the same joint (e.g. paired position+velocity elements). """ - actuator = spec.add_actuator(name=joint_name, target=joint_name) + actuator = spec.add_actuator( + name=actuator_name if actuator_name is not None else joint_name, + target=joint_name, + ) actuator.trntype = _TRANSMISSION_TYPE_MAP[transmission_type] actuator.dyntype = mujoco.mjtDyn.mjDYN_NONE @@ -369,21 +397,14 @@ def create_velocity_actuator( else: actuator.forcelimited = False - # Set armature, frictionloss, and viscous_damping (None = preserve XML value). - if transmission_type == TransmissionType.JOINT: - if armature is not None: - spec.joint(joint_name).armature = armature - if frictionloss is not None: - spec.joint(joint_name).frictionloss = frictionloss - if viscous_damping is not None: - spec.joint(joint_name).damping[0] = viscous_damping - elif transmission_type == TransmissionType.TENDON: - if armature is not None: - spec.tendon(joint_name).armature = armature - if frictionloss is not None: - spec.tendon(joint_name).frictionloss = frictionloss - if viscous_damping is not None: - spec.tendon(joint_name).damping[0] = viscous_damping + apply_target_overrides( + spec, + joint_name, + transmission_type, + armature=armature, + frictionloss=frictionloss, + viscous_damping=viscous_damping, + ) return actuator @@ -467,54 +488,52 @@ def copy_mesh_data(src: mujoco.MjsMesh, dst: mujoco.MjsMesh) -> None: dst.smoothnormal = src.smoothnormal -def validate_variant_structure( - names: list[str], - bodies: list[mujoco.MjsBody], -) -> None: - """Validate that variant specs share the same kinematic structure. +def copy_texture_data(src: mujoco.MjsTexture, dst: mujoco.MjsTexture) -> None: + """Copy texture data from *src* to *dst*. - Checks that all variants have the same number of child bodies, the same number of - joints, the same joint types, and the same joint names. Raises ``ValueError`` with a - descriptive message if any differ. + Copies the file path or builtin/data fields, format, dimensions, and color + settings. The ``name`` field is NOT copied; set it on *dst* before calling. """ - ref_name = names[0] - ref_body = bodies[0] - ref_joints = list(ref_body.joints) - ref_joint_types = [j.type for j in ref_joints] - ref_joint_names = [j.name for j in ref_joints] - ref_sub_bodies = list(ref_body.bodies) + assert dst.name, "dst.name must be set before copy_texture_data." + dst.type = src.type + dst.colorspace = src.colorspace + dst.builtin = src.builtin + dst.mark = src.mark + dst.rgb1[:] = src.rgb1 + dst.rgb2[:] = src.rgb2 + dst.markrgb[:] = src.markrgb + dst.random = src.random + dst.gridsize[:] = src.gridsize + dst.gridlayout = src.gridlayout + dst.width = src.width + dst.height = src.height + dst.nchannel = src.nchannel + dst.hflip = src.hflip + dst.vflip = src.vflip + if src.file: + dst.file = src.file + if len(src.cubefiles) > 0: + dst.cubefiles = src.cubefiles + if len(src.data) > 0: + dst.data = src.data + if src.content_type: + dst.content_type = src.content_type - for i in range(1, len(names)): - other_name = names[i] - other_body = bodies[i] - other_sub_bodies = list(other_body.bodies) - if len(other_sub_bodies) != len(ref_sub_bodies): - raise ValueError( - f"Variant '{other_name}' has {len(other_sub_bodies)} " - f"child bodies, but '{ref_name}' has " - f"{len(ref_sub_bodies)}." - ) +def copy_material_data(src: mujoco.MjsMaterial, dst: mujoco.MjsMaterial) -> None: + """Copy material data from *src* to *dst*. - other_joints = list(other_body.joints) - if len(other_joints) != len(ref_joints): - raise ValueError( - f"Variant '{other_name}' has {len(other_joints)} " - f"joints, but '{ref_name}' has {len(ref_joints)}." - ) - - other_joint_types = [j.type for j in other_joints] - if other_joint_types != ref_joint_types: - raise ValueError( - f"Variant '{other_name}' has joint types " - f"{other_joint_types}, but '{ref_name}' has " - f"{ref_joint_types}." - ) - - other_joint_names = [j.name for j in other_joints] - if other_joint_names != ref_joint_names: - raise ValueError( - f"Variant '{other_name}' has joint names " - f"{other_joint_names}, but '{ref_name}' has " - f"{ref_joint_names}." - ) + Copies appearance settings (rgba, specular, shininess, ...) and texture + bindings. The ``name`` field is NOT copied; set it on *dst* before calling. + """ + assert dst.name, "dst.name must be set before copy_material_data." + dst.rgba[:] = src.rgba + dst.emission = src.emission + dst.specular = src.specular + dst.shininess = src.shininess + dst.reflectance = src.reflectance + dst.roughness = src.roughness + dst.metallic = src.metallic + dst.texuniform = src.texuniform + dst.texrepeat[:] = src.texrepeat + dst.textures = list(src.textures) diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/debug_visualizer.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/debug_visualizer.py index 7b7bcc1..32050c8 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/debug_visualizer.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/debug_visualizer.py @@ -179,6 +179,26 @@ class DebugVisualizer(ABC): """ ... + @abstractmethod + def add_box( + self, + center: np.ndarray, + size: np.ndarray, + mat: np.ndarray, + color: tuple[float, float, float, float], + label: str | None = None, + ) -> None: + """Add an axis-oriented box visualization. + + Args: + center: Center position (3D vector). + size: Half-extents along each local axis (3D vector: a, b, c). + mat: 3x3 rotation matrix (or flattened 9-element array). + color: RGBA color (values 0-1). + label: Optional label for this box. + """ + ... + @abstractmethod def clear(self) -> None: """Clear all debug visualizations.""" @@ -242,5 +262,8 @@ class NullDebugVisualizer: def add_ellipsoid(self, center, size, mat, color, label=None) -> None: pass + def add_box(self, center, size, mat, color, label=None) -> None: + pass + def clear(self) -> None: pass diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/model_sync.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/model_sync.py index aa81fee..16a6611 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/model_sync.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/model_sync.py @@ -25,6 +25,7 @@ VIEWER_MODEL_FIELDS = frozenset( { "qpos0", # Needed for correct mj_forward kinematics (qpos - qpos0). "geom_dataid", # Per-world mesh variants. + "geom_matid", # Per-world material variants. "geom_rgba", "geom_size", "geom_pos", diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/native/visualizer.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/native/visualizer.py index 51cc9d4..22049ed 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/native/visualizer.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/native/visualizer.py @@ -238,6 +238,31 @@ class MujocoNativeDebugVisualizer(DebugVisualizer): rgba=np.asarray(color, dtype=np.float32), ) + @override + def add_box( + self, + center: np.ndarray, + size: np.ndarray, + mat: np.ndarray, + color: tuple[float, float, float, float], + label: str | None = None, + ) -> None: + """Add a box visualization using MuJoCo's box geometry.""" + del label # Unused. + + self.scn.ngeom += 1 + geom = self.scn.geoms[self.scn.ngeom - 1] + geom.category = mujoco.mjtCatBit.mjCAT_DECOR + + mujoco.mjv_initGeom( + geom=geom, + type=mujoco.mjtGeom.mjGEOM_BOX.value, + size=np.asarray(size, dtype=np.float64), + pos=np.asarray(center, dtype=np.float64), + mat=np.asarray(mat, dtype=np.float64).flatten(), + rgba=np.asarray(color, dtype=np.float32), + ) + @override def clear(self) -> None: """Clear debug visualizations by resetting geom count.""" diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/offscreen_renderer.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/offscreen_renderer.py index 69c4586..5f6879c 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/offscreen_renderer.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/offscreen_renderer.py @@ -57,6 +57,7 @@ class OffscreenRenderer: self._opt = mujoco.MjvOption() self._pert = mujoco.MjvPerturb() self._catmask = mujoco.mjtCatBit.mjCAT_DYNAMIC + self._extra_env_ids: list[int] | None = None @property def renderer(self) -> mujoco.Renderer: @@ -134,9 +135,17 @@ class OffscreenRenderer: We render a small local neighborhood around ``env_idx`` instead of the first N environments, so videos stay focused on the tracked robot and nearby peers. + + The neighbor set is computed once and cached. ``env_origins`` can mutate during + training (e.g. the terrain curriculum reassigns origins on reset), so recomputing + every frame would make the context robots pop in and out, causing video flicker. """ + if self._extra_env_ids is not None: + return self._extra_env_ids + if self._cfg.max_extra_envs <= 0 or nworld <= 1: - return [] + self._extra_env_ids = [] + return self._extra_env_ids k = min(self._cfg.max_extra_envs, nworld - 1) origins = self._scene.env_origins[:nworld].cpu().numpy() @@ -146,7 +155,8 @@ class OffscreenRenderer: nearest = np.argpartition(dist2, kth=k - 1)[:k] nearest = nearest[np.argsort(dist2[nearest])] - return [int(i) for i in nearest] + self._extra_env_ids = [int(i) for i in nearest] + return self._extra_env_ids def _sync_model_fields(self, env_idx: int) -> None: """Sync visually relevant per-world model fields into the host MjModel.""" diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/viser/scene.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/viser/scene.py index b2c97ea..2af116f 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/viser/scene.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/viewer/viser/scene.py @@ -263,6 +263,7 @@ class MjlabViserScene(ViserMujocoScene, DebugVisualizer): self._queued_spheres: list = [] self._queued_cylinders: list = [] self._queued_ellipsoids: list = [] + self._queued_boxes: list = [] # Batched mesh handles for simple primitives. def _shaft_mesh() -> trimesh.Trimesh: @@ -287,12 +288,19 @@ class MjlabViserScene(ViserMujocoScene, DebugVisualizer): "ellipsoids", lambda: trimesh.creation.icosphere(subdivisions=2, radius=1.0), ) + # Unit half-extents so that scaling by the box size yields the requested + # half-extents (extents=2 spans -1 to 1 along each axis). + self._boxes = _BatchedPrimitive( + "boxes", + lambda: trimesh.creation.box(extents=(2.0, 2.0, 2.0)), + ) self._all_primitives = [ self._arrow_shafts, self._arrow_heads, self._spheres, self._cylinders, self._ellipsoids, + self._boxes, ] # Ghost mesh state. @@ -955,6 +963,27 @@ class MjlabViserScene(ViserMujocoScene, DebugVisualizer): ) ) + @override + def add_box( + self, + center: np.ndarray | torch.Tensor, + size: np.ndarray | torch.Tensor, + mat: np.ndarray | torch.Tensor, + color: tuple[float, float, float, float], + label: str | None = None, + ) -> None: + if not self.debug_visualization_enabled: + return + del label + self._queued_boxes.append( + ( + np.asarray(_to_numpy(center), dtype=np.float32).copy(), + np.asarray(_to_numpy(size), dtype=np.float32).copy(), + np.asarray(_to_numpy(mat), dtype=np.float32).reshape(3, 3).copy(), + color, + ) + ) + @override def clear(self) -> None: """Clear all debug visualization queues.""" @@ -962,6 +991,7 @@ class MjlabViserScene(ViserMujocoScene, DebugVisualizer): self._queued_spheres.clear() self._queued_cylinders.clear() self._queued_ellipsoids.clear() + self._queued_boxes.clear() self._queued_ghosts.clear() def clear_debug_all(self) -> None: @@ -1039,6 +1069,7 @@ class MjlabViserScene(ViserMujocoScene, DebugVisualizer): self._sync_spheres() self._sync_cylinders() self._sync_ellipsoids() + self._sync_boxes() def _sync_spheres(self) -> None: if not self._queued_spheres: @@ -1125,6 +1156,32 @@ class MjlabViserScene(ViserMujocoScene, DebugVisualizer): opacity, ) + def _sync_boxes(self) -> None: + if not self._queued_boxes: + self._boxes.remove() + return + n = len(self._queued_boxes) + positions = np.zeros((n, 3), dtype=np.float32) + wxyzs = np.zeros((n, 4), dtype=np.float32) + scales = np.zeros((n, 3), dtype=np.float32) + colors = np.zeros((n, 3), dtype=np.uint8) + opacity = 1.0 + for i, (center, size, mat, color) in enumerate(self._queued_boxes): + positions[i] = center + self._scene_offset + wxyzs[i] = vtf.SO3.from_matrix(mat).wxyz + scales[i] = size + colors[i] = _color_uint8(color) + opacity = color[3] + self._boxes.sync( + self.server, + self.env_idx, + positions, + wxyzs, + scales, + colors, + opacity, + ) + def _sync_ghosts(self) -> None: """Render queued ghosts as one batched handle per (model, body).""" if not self._queued_ghosts: diff --git a/05_software/train/rc_mjlab/mjlab/tests/conftest.py b/05_software/train/rc_mjlab/mjlab/tests/conftest.py index 0103551..08dab29 100644 --- a/05_software/train/rc_mjlab/mjlab/tests/conftest.py +++ b/05_software/train/rc_mjlab/mjlab/tests/conftest.py @@ -106,17 +106,25 @@ def initialize_entity(entity: Entity, device: str, num_envs: int = 1): def make_scene_and_sim( device: str, - xml: str, + xml: str | dict[str, str], sensors: tuple, num_envs: int = 1, sim_cfg: SimulationCfg | None = None, ) -> tuple[Scene, Simulation]: - """Create a scene and simulation from inline XML with sensors wired up.""" - entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(xml)) + """Create a scene and simulation from inline XML with sensors wired up. + + ``xml`` may be a single XML string (registered as the ``robot`` entity) or a + mapping of entity name to XML string for multi-entity scenes. + """ + xml_by_entity = {"robot": xml} if isinstance(xml, str) else xml + entities = { + name: EntityCfg(spec_fn=lambda s=s: mujoco.MjSpec.from_string(s)) + for name, s in xml_by_entity.items() + } scene_cfg = SceneCfg( num_envs=num_envs, env_spacing=5.0, - entities={"robot": entity_cfg}, + entities=entities, sensors=sensors, ) scene = Scene(scene_cfg, device) diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_builtin_dcmotor_actuator.py b/05_software/train/rc_mjlab/mjlab/tests/test_builtin_dcmotor_actuator.py new file mode 100644 index 0000000..f5cff2a --- /dev/null +++ b/05_software/train/rc_mjlab/mjlab/tests/test_builtin_dcmotor_actuator.py @@ -0,0 +1,653 @@ +"""Tests for BuiltinDcMotorActuator. + +Covers wiring of MuJoCo's native ```` element through mjlab: the +three input modes (voltage / position / velocity), torque saturation, +config validation, and DR integration. +""" + +import math +from unittest.mock import Mock + +import mujoco +import pytest +import torch +from conftest import ( + create_entity_with_actuator, + get_test_device, + initialize_entity, + load_fixture_xml, +) + +from mjlab.actuator import ( + BuiltinDcMotorActuator, + BuiltinDcMotorActuatorCfg, + DcMotorDatasheetParams, + DcMotorInputMode, + DcMotorPhysicalParams, +) +from mjlab.actuator.actuator import TransmissionType +from mjlab.entity import Entity, EntityArticulationInfoCfg, EntityCfg +from mjlab.envs.mdp import dr +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.scene import Scene, SceneCfg +from mjlab.sim.sim import Simulation, SimulationCfg + +ROBOT_XML = load_fixture_xml("floating_base_articulated") + +# Motor characterization used throughout (resolves to K=0.24, R=2.88). +V_NOM, TAU_STALL, OMEGA_NL = 24.0, 2.0, 100.0 +K = V_NOM / OMEGA_NL +R = K * V_NOM / TAU_STALL + + +@pytest.fixture(scope="module") +def device(): + return get_test_device() + + +DATASHEET = DcMotorDatasheetParams( + nominal_voltage=V_NOM, stall_torque=TAU_STALL, no_load_speed=OMEGA_NL +) + + +def _make_cfg( + *, + mode: DcMotorInputMode = DcMotorInputMode.POSITION, + stiffness=5.0, + damping=0.5, + voltage_limit=24.0, + **extra, +) -> BuiltinDcMotorActuatorCfg: + """Build a cfg with sensible PID defaults. ``extra`` forwards any other + BuiltinDcMotorActuatorCfg kwarg (effort_limit, integral_gain, thermal, + delay_*, etc.).""" + return BuiltinDcMotorActuatorCfg( + target_names_expr=("joint.*",), + mode=mode, + motor_params=DATASHEET, + stiffness=stiffness, + damping=damping, + voltage_limit=voltage_limit, + **extra, + ) + + +def _make_entity(**kwargs) -> Entity: + return create_entity_with_actuator(ROBOT_XML, _make_cfg(**kwargs)) + + +def _make_initialized(device, **kwargs): + """Build entity from cfg kwargs and initialize it through the sim.""" + return initialize_entity(_make_entity(**kwargs), device) + + +def _drive( + entity: Entity, + sim, + device: str, + *, + pos_target=None, + vel_target=None, + effort_target=None, + q0=None, + qd0=None, +) -> None: + zero = torch.zeros(1, 2, device=device) + entity.write_joint_state_to_sim( + position=q0 if q0 is not None else zero, + velocity=qd0 if qd0 is not None else zero, + ) + entity.set_joint_position_target(pos_target if pos_target is not None else zero) + entity.set_joint_velocity_target(vel_target if vel_target is not None else zero) + entity.set_joint_effort_target(effort_target if effort_target is not None else zero) + entity.write_data_to_sim() + sim.forward() + + +# Wiring sanity. + + +def test_kr_packed_into_gainprm(device): + """The XML compiler derives K and R from the nominal triplet.""" + _, sim = initialize_entity(_make_entity(effort_limit=1.5), device) + m = sim.mj_model + for i in range(2): + assert m.actuator_gainprm[i, 0] == pytest.approx(R, abs=1e-6) + assert m.actuator_gainprm[i, 1] == pytest.approx(K, abs=1e-6) + assert m.actuator_gainprm[i, 4] == pytest.approx(5.0) # kp + assert m.actuator_gainprm[i, 6] == pytest.approx(0.5) # kd + assert m.actuator_gainprm[i, 7] == pytest.approx(24.0) # Vmax + assert m.actuator_gainprm[i, 8] == pytest.approx(1.0) # input_mode=position + assert m.actuator_gaintype[i] == mujoco.mjtGain.mjGAIN_DCMOTOR + assert m.actuator_biastype[i] == mujoco.mjtBias.mjBIAS_DCMOTOR + # No activation state: ki=0, no inductance, no thermal/lugre/slew. + assert m.actuator_actnum[i] == 0 + + +def test_motor_const_path(device): + """Physical params pack K = sqrt(Kt*Ke) and R verbatim.""" + cfg = BuiltinDcMotorActuatorCfg( + target_names_expr=("joint.*",), + mode=DcMotorInputMode.VOLTAGE, + motor_params=DcMotorPhysicalParams(kt=0.1, ke=0.05, resistance=2.0), + ) + _, sim = initialize_entity(create_entity_with_actuator(ROBOT_XML, cfg), device) + m = sim.mj_model + for i in range(2): + assert m.actuator_gainprm[i, 0] == pytest.approx(2.0, abs=1e-6) + assert m.actuator_gainprm[i, 1] == pytest.approx((0.1 * 0.05) ** 0.5, abs=1e-6) + + +# Stateless motor physics. + + +def test_voltage_mode_steady_state(device): + """At rest, ctrl = V -> tau = K * V / R.""" + cfg = BuiltinDcMotorActuatorCfg( + target_names_expr=("joint.*",), + mode=DcMotorInputMode.VOLTAGE, + motor_params=DATASHEET, + ) + entity, sim = initialize_entity(create_entity_with_actuator(ROBOT_XML, cfg), device) + V = torch.tensor([[10.0, -5.0]], device=device) + _drive(entity, sim, device, effort_target=V) + v_adr = entity.indexing.joint_v_adr + expected = K * V[0] / R + assert torch.allclose(sim.data.qfrc_actuator[0, v_adr], expected, atol=1e-4) + + +def test_voltage_mode_voltage_limit_zero_is_noop(device): + """Docstring promises ``voltage_limit=0`` disables clamping. Verify against + MuJoCo's ``dcmotor_voltage`` (which only clamps when ``Vmax > 0``).""" + cfg = BuiltinDcMotorActuatorCfg( + target_names_expr=("joint.*",), + mode=DcMotorInputMode.VOLTAGE, + motor_params=DATASHEET, + voltage_limit=0.0, + ) + entity, sim = initialize_entity(create_entity_with_actuator(ROBOT_XML, cfg), device) + V = torch.tensor([[1000.0, 0.0]], device=device) # absurdly high voltage. + _drive(entity, sim, device, effort_target=V) + v_adr = entity.indexing.joint_v_adr + expected = K * V[0] / R + assert torch.allclose(sim.data.qfrc_actuator[0, v_adr], expected, atol=1e-2) + + +def test_back_emf_reduces_torque_at_velocity(device): + """Same V, joint moving at omega: tau = K * (V - K * omega) / R.""" + cfg = BuiltinDcMotorActuatorCfg( + target_names_expr=("joint.*",), + mode=DcMotorInputMode.VOLTAGE, + motor_params=DATASHEET, + ) + entity, sim = initialize_entity(create_entity_with_actuator(ROBOT_XML, cfg), device) + V = torch.tensor([[10.0, 0.0]], device=device) + omega0 = torch.tensor([[2.0, 0.0]], device=device) + _drive(entity, sim, device, effort_target=V, qd0=omega0) + v_adr = entity.indexing.joint_v_adr + expected = K * (V[0] - K * omega0[0]) / R + assert torch.allclose(sim.data.qfrc_actuator[0, v_adr], expected, atol=1e-4) + + +def test_position_mode_pid_at_rest(device): + """kd=0, no Vmax clamp: tau = K * kp * (target - q) / R.""" + # voltage_limit must be >0 (cfg invariant), pick it big enough not to clamp. + entity, sim = initialize_entity( + _make_entity(damping=0.0, voltage_limit=1000.0), device + ) + pos = torch.tensor([[0.1, -0.05]], device=device) + _drive(entity, sim, device, pos_target=pos) + v_adr = entity.indexing.joint_v_adr + expected = K * 5.0 * pos[0] / R + assert torch.allclose(sim.data.qfrc_actuator[0, v_adr], expected, atol=1e-4) + + +def test_position_mode_voltage_clamp(device): + """Huge position error -> PID voltage saturates at Vmax.""" + entity, sim = initialize_entity( + _make_entity(stiffness=100.0, damping=0.0, voltage_limit=2.0), + device, + ) + # kp * err = 100 * 0.5 = 50 V, well above Vmax=2. + pos = torch.tensor([[0.5, 0.0]], device=device) + _drive(entity, sim, device, pos_target=pos) + v_adr = entity.indexing.joint_v_adr + qfrc = sim.data.qfrc_actuator[0, v_adr] + expected_first = K * 2.0 / R # tau at clamped V. + assert qfrc[0].item() == pytest.approx(expected_first, abs=1e-4) + assert qfrc[1].item() == pytest.approx(0.0, abs=1e-4) + + +def test_velocity_mode_pid(device): + """P-only velocity tracking: tau = K * kp * (target - qdot) / R.""" + entity, sim = initialize_entity( + _make_entity(mode=DcMotorInputMode.VELOCITY, damping=0.0, voltage_limit=1000.0), + device, + ) + qd0 = torch.tensor([[1.0, 0.0]], device=device) + vel_target = torch.tensor([[3.0, 0.0]], device=device) + _drive(entity, sim, device, vel_target=vel_target, qd0=qd0) + v_adr = entity.indexing.joint_v_adr + # back-EMF subtracts K*omega; this is folded into the dcmotor bias. + # voltage = kp*(target - qdot); tau = K*(voltage - K*omega)/R. + voltage = 5.0 * (vel_target[0] - qd0[0]) + expected = K * (voltage - K * qd0[0]) / R + assert torch.allclose(sim.data.qfrc_actuator[0, v_adr], expected, atol=1e-4) + + +def test_effort_limit_clamps_torque(device): + """forcerange clamps the algebraic torque output.""" + entity, sim = initialize_entity( + _make_entity(stiffness=100.0, damping=0.0, voltage_limit=1000.0, effort_limit=0.1), + device, + ) + m = sim.mj_model + for i in range(2): + assert m.actuator_forcelimited[i] == 1 + assert m.actuator_forcerange[i, 0] == pytest.approx(-0.1) + assert m.actuator_forcerange[i, 1] == pytest.approx(0.1) + + # Unclamped tau would be K * 100 * 0.5 / R ~= K*50/R, well above 0.1. + pos = torch.tensor([[0.5, 0.0]], device=device) + _drive(entity, sim, device, pos_target=pos) + v_adr = entity.indexing.joint_v_adr + qfrc = sim.data.qfrc_actuator[0, v_adr] + assert qfrc[0].item() == pytest.approx(0.1, abs=1e-4) + assert qfrc[1].item() == pytest.approx(0.0, abs=1e-4) + + +# Cogging. + + +def test_cogging_packed_into_biasprm(device): + """``cogging=(A, Np, phi)`` packs into ``biasprm[0:3]``.""" + cfg = BuiltinDcMotorActuatorCfg( + target_names_expr=("joint.*",), + mode=DcMotorInputMode.VOLTAGE, + motor_params=DATASHEET, + cogging=(0.5, 4.0, 0.1), + ) + _, sim = initialize_entity(create_entity_with_actuator(ROBOT_XML, cfg), device) + m = sim.mj_model + for i in range(2): + assert m.actuator_biasprm[i, 0] == pytest.approx(0.5) + assert m.actuator_biasprm[i, 1] == pytest.approx(4.0) + assert m.actuator_biasprm[i, 2] == pytest.approx(0.1) + + +def test_cogging_contributes_torque(device): + """At ctrl=0 (no electromagnetic torque), qfrc_actuator equals the cogging + term ``A * sin(Np * q + phi)`` evaluated at the joint angle.""" + A, Np, phi = 0.5, 4.0, 0.1 + cfg = BuiltinDcMotorActuatorCfg( + target_names_expr=("joint.*",), + mode=DcMotorInputMode.VOLTAGE, + motor_params=DATASHEET, + cogging=(A, Np, phi), + ) + entity, sim = initialize_entity(create_entity_with_actuator(ROBOT_XML, cfg), device) + q0, q1 = 0.3, -0.2 + _drive( + entity, + sim, + device, + q0=torch.tensor([[q0, q1]], device=device), + effort_target=torch.zeros(1, 2, device=device), + ) + v_adr = entity.indexing.joint_v_adr + qfrc = sim.data.qfrc_actuator[0, v_adr] + assert qfrc[0].item() == pytest.approx(A * math.sin(Np * q0 + phi), abs=1e-5) + assert qfrc[1].item() == pytest.approx(A * math.sin(Np * q1 + phi), abs=1e-5) + + +def test_cogging_bypasses_effort_limit(device): + """Cogging is added *after* the forcerange clamp (MuJoCo's intentional + model: ``effort_limit`` bounds electromagnetic torque, cogging is + mechanical). Total torque can exceed ``effort_limit`` by up to the + cogging amplitude.""" + A, Np, phi = 0.5, 0.0, math.pi / 2 # sin(pi/2)=1, so cogging = A at any q. + cfg = BuiltinDcMotorActuatorCfg( + target_names_expr=("joint.*",), + mode=DcMotorInputMode.VOLTAGE, + motor_params=DATASHEET, + cogging=(A, Np, phi), + effort_limit=0.05, # An order of magnitude below A. + ) + entity, sim = initialize_entity(create_entity_with_actuator(ROBOT_XML, cfg), device) + # Pick a voltage large enough that the electromagnetic torque alone + # would saturate forcerange at +/- 0.05. + V = torch.tensor([[100.0, 0.0]], device=device) + _drive(entity, sim, device, effort_target=V) + v_adr = entity.indexing.joint_v_adr + qfrc = sim.data.qfrc_actuator[0, v_adr] + # joint1: electromagnetic clamped to +0.05, plus cogging A=0.5. + assert qfrc[0].item() == pytest.approx(0.05 + A, abs=1e-5) + # joint2: zero voltage, electromagnetic=0, only cogging. + assert qfrc[1].item() == pytest.approx(A, abs=1e-5) + + +# Optional stateful extensions (integral, slew, inductance, thermal, LuGre). +# Each behavior check compares against a baseline with the feature disabled +# so that removing the wiring in edit_spec causes the comparison to fail. + + +def _step_n(entity, sim, device, n: int, *, pos_target=None, eff_target=None): + zero = torch.zeros(1, 2, device=device) + entity.write_joint_state_to_sim(position=zero, velocity=zero) + for _ in range(n): + entity.set_joint_position_target(pos_target if pos_target is not None else zero) + entity.set_joint_velocity_target(zero) + entity.set_joint_effort_target(eff_target if eff_target is not None else zero) + entity.write_data_to_sim() + sim.step() + + +def _qfrc(entity, sim) -> torch.Tensor: + return sim.data.qfrc_actuator[0, entity.indexing.joint_v_adr].clone() + + +def test_integral_gain_ramps_torque(device): + """Integrator in position mode ramps torque over time even with ``kp`` + and ``kd`` near zero.""" + # stiffness must be > 0 (validation); choose tiny so ki dominates. + base = dict( + mode=DcMotorInputMode.POSITION, stiffness=1e-4, damping=0.0, voltage_limit=24.0 + ) + ent_off, sim_off = _make_initialized(device, **base, integral_gain=0.0) + ent_on, sim_on = _make_initialized(device, **base, integral_gain=10.0) + + target = torch.tensor([[0.5, 0.0]], device=device) + for sim, ent in ((sim_off, ent_off), (sim_on, ent_on)): + _step_n(ent, sim, device, n=20, pos_target=target) + assert _qfrc(ent_on, sim_on)[0].abs() > 100.0 * _qfrc(ent_off, sim_off)[0].abs() + + +def test_slew_rate_limits_voltage(device): + """``slew_rate`` rate-limits ``ctrl``: after one step, effective voltage + is far below the requested input.""" + base = dict( + mode=DcMotorInputMode.VOLTAGE, stiffness=0.0, damping=0.0, voltage_limit=0.0 + ) + ent_off, sim_off = _make_initialized(device, **base, slew_rate=0.0) + ent_on, sim_on = _make_initialized(device, **base, slew_rate=10.0) + + V = torch.tensor([[100.0, 0.0]], device=device) + for sim, ent in ((sim_off, ent_off), (sim_on, ent_on)): + _step_n(ent, sim, device, n=1, eff_target=V) + assert _qfrc(ent_off, sim_off)[0] > 100.0 * _qfrc(ent_on, sim_on)[0] + + +def test_inductance_lags_current(device): + """Large ``inductance`` (te >> dt) suppresses early-step torque.""" + base = dict( + mode=DcMotorInputMode.VOLTAGE, stiffness=0.0, damping=0.0, voltage_limit=0.0 + ) + ent_off, sim_off = _make_initialized(device, **base, inductance=0.0) + ent_on, sim_on = _make_initialized(device, **base, inductance=1.0) + + V = torch.tensor([[10.0, 0.0]], device=device) + for sim, ent in ((sim_off, ent_off), (sim_on, ent_on)): + _step_n(ent, sim, device, n=2, eff_target=V) + assert _qfrc(ent_off, sim_off)[0].abs() > 10.0 * _qfrc(ent_on, sim_on)[0].abs() + + +def test_thermal_decays_torque(device): + """I^2R heating raises T, which raises effective resistance and decays + torque over time.""" + # Params chosen for visible effect in a handful of steps without going + # numerically unstable: small C (fast heating) and modest alpha. + base = dict( + mode=DcMotorInputMode.VOLTAGE, stiffness=0.0, damping=0.0, voltage_limit=0.0 + ) + ent_off, sim_off = _make_initialized(device, **base) + ent_on, sim_on = _make_initialized( + device, **base, thermal=(1.0, 0.1, 0.0, 0.01, 0.0, 0.0) + ) + + V = torch.tensor([[100.0, 0.0]], device=device) + for sim, ent in ((sim_off, ent_off), (sim_on, ent_on)): + _step_n(ent, sim, device, n=5, eff_target=V) + assert _qfrc(ent_on, sim_on)[0].abs() < 0.5 * _qfrc(ent_off, sim_off)[0].abs() + + +def test_lugre_subtracts_friction(device): + """LuGre friction subtracts a velocity-dependent force after the + ``effort_limit`` clamp (mechanical, like cogging).""" + # Static comparison at v>0, ctrl=0; avoids feedback between LuGre slowing + # the joint and back-EMF easing off under sim.step(). + # no LuGre: qfrc = -K^2 * v / R (back-EMF only) + # w/ LuGre: qfrc = -K^2 * v / R - sigma1*v - ... + base = dict( + mode=DcMotorInputMode.VOLTAGE, stiffness=0.0, damping=0.0, voltage_limit=0.0 + ) + ent_off, sim_off = _make_initialized(device, **base) + ent_on, sim_on = _make_initialized( + device, **base, lugre=(1e4, 100.0, 0.1, 0.15, 0.01) + ) + + zero = torch.zeros(1, 2, device=device) + v0 = torch.tensor([[1.0, 0.0]], device=device) + for sim, ent in ((sim_off, ent_off), (sim_on, ent_on)): + ent.write_joint_state_to_sim(position=zero, velocity=v0) + ent.set_joint_position_target(zero) + ent.set_joint_velocity_target(zero) + ent.set_joint_effort_target(zero) + ent.write_data_to_sim() + sim.forward() + assert abs(_qfrc(ent_on, sim_on)[0]) > 100.0 * abs(_qfrc(ent_off, sim_off)[0]) + + +# Config validation. + + +def test_pid_mode_requires_gains(): + with pytest.raises(ValueError, match="stiffness"): + BuiltinDcMotorActuatorCfg( + target_names_expr=("j",), + mode=DcMotorInputMode.POSITION, + motor_params=DATASHEET, + voltage_limit=1.0, + ) + with pytest.raises(ValueError, match="voltage_limit"): + BuiltinDcMotorActuatorCfg( + target_names_expr=("j",), + mode=DcMotorInputMode.POSITION, + motor_params=DATASHEET, + stiffness=1.0, + ) + + +def test_voltage_mode_rejects_pid_gains(): + with pytest.raises(ValueError, match="VOLTAGE"): + BuiltinDcMotorActuatorCfg( + target_names_expr=("j",), + mode=DcMotorInputMode.VOLTAGE, + motor_params=DATASHEET, + stiffness=1.0, + ) + + +def test_site_rejected(): + with pytest.raises(ValueError, match="SITE"): + BuiltinDcMotorActuatorCfg( + target_names_expr=("j",), + motor_params=DATASHEET, + stiffness=1.0, + voltage_limit=1.0, + transmission_type=TransmissionType.SITE, + ) + + +# Joint-level passthrough. + + +def test_armature_applied(device): + _, sim = initialize_entity(_make_entity(armature=0.7), device) + m = sim.mj_model + for jname in ("joint1", "joint2"): + dof_id = m.jnt_dofadr[m.joint(jname).id] + assert m.dof_armature[dof_id] == pytest.approx(0.7) + + +# Domain randomization. + + +def _scene_env( + device, + num_envs=2, + mode: DcMotorInputMode = DcMotorInputMode.POSITION, +): + def spec_fn(): + spec = mujoco.MjSpec.from_string(ROBOT_XML) + for a in list(spec.actuators): + spec.delete(a) + return spec + + entity_cfg = EntityCfg( + spec_fn=spec_fn, + articulation=EntityArticulationInfoCfg( + actuators=( + BuiltinDcMotorActuatorCfg( + target_names_expr=("joint.*",), + mode=mode, + motor_params=DATASHEET, + stiffness=5.0 if mode != DcMotorInputMode.VOLTAGE else 0.0, + damping=0.5 if mode != DcMotorInputMode.VOLTAGE else 0.0, + voltage_limit=24.0 if mode != DcMotorInputMode.VOLTAGE else 0.0, + effort_limit=50.0, + ), + ) + ), + ) + scene_cfg = SceneCfg(num_envs=num_envs, entities={"robot": entity_cfg}) + scene = Scene(scene_cfg, device) + model = scene.compile() + sim = Simulation(num_envs=num_envs, cfg=SimulationCfg(), model=model, device=device) + scene.initialize(model, sim.model, sim.data) + + env = Mock() + env.num_envs = num_envs + env.device = device + env.scene = {"robot": scene["robot"]} + env.sim = sim + return env + + +@pytest.mark.parametrize( + "operation, kp_in, kd_in, kp_expected, kd_expected", + [ + # scale: multiplies the configured defaults (kp=5.0, kd=0.5). + ("scale", 2.0, 3.0, 2.0 * 5.0, 3.0 * 0.5), + # abs: writes the value directly. + ("abs", 10.0, 2.0, 10.0, 2.0), + ], +) +def test_dr_pd_gains_position_mode( + device, operation, kp_in, kd_in, kp_expected, kd_expected +): + env = _scene_env(device) + robot = env.scene["robot"] + act = robot.actuators[0] + assert isinstance(act, BuiltinDcMotorActuator) + ctrl_ids = act.global_ctrl_ids + env.sim.expand_model_fields(("actuator_gainprm", "actuator_biasprm")) + + dr.pd_gains( + env, + env_ids=torch.tensor([0], device=device), + kp_range=(kp_in, kp_in), + kd_range=(kd_in, kd_in), + asset_cfg=SceneEntityCfg("robot"), + operation=operation, + ) + + m = env.sim.model + n = len(ctrl_ids) + assert torch.allclose( + m.actuator_gainprm[0, ctrl_ids, 4], torch.full((n,), kp_expected, device=device) + ) + assert torch.allclose( + m.actuator_gainprm[0, ctrl_ids, 6], torch.full((n,), kd_expected, device=device) + ) + # Other env untouched (cfg defaults). + assert torch.allclose(m.actuator_gainprm[1, ctrl_ids, 4], torch.tensor(5.0)) + assert torch.allclose(m.actuator_gainprm[1, ctrl_ids, 6], torch.tensor(0.5)) + + +def test_dr_pd_gains_voltage_mode_rejected(device): + env = _scene_env(device, mode=DcMotorInputMode.VOLTAGE) + env.sim.expand_model_fields(("actuator_gainprm", "actuator_biasprm")) + with pytest.raises(ValueError, match="VOLTAGE"): + dr.pd_gains( + env, + env_ids=torch.tensor([0], device=device), + kp_range=(1.0, 1.0), + kd_range=(1.0, 1.0), + asset_cfg=SceneEntityCfg("robot"), + ) + + +def test_dr_effort_limits_writes_forcerange(device): + env = _scene_env(device) + robot = env.scene["robot"] + act = robot.actuators[0] + ctrl_ids = act.global_ctrl_ids + env.sim.expand_model_fields( + ("actuator_forcerange", "jnt_actfrcrange", "tendon_actfrcrange") + ) + + dr.effort_limits( + env, + env_ids=torch.tensor([0], device=device), + effort_limit_range=(123.0, 123.0), + asset_cfg=SceneEntityCfg("robot"), + operation="abs", + ) + + m = env.sim.model + n = len(ctrl_ids) + assert torch.allclose( + m.actuator_forcerange[0, ctrl_ids, 0], + torch.full((n,), -123.0, device=device), + ) + assert torch.allclose( + m.actuator_forcerange[0, ctrl_ids, 1], + torch.full((n,), 123.0, device=device), + ) + # Env 1 keeps the configured default of 50. + assert torch.allclose(m.actuator_forcerange[1, ctrl_ids, 1], torch.tensor(50.0)) + + +# Delay. + + +def test_delay_position_mode(device): + """A 2-step lag should make position-mode torque reference step-0 target.""" + entity, sim = initialize_entity( + _make_entity( + stiffness=10.0, + damping=0.0, + voltage_limit=1000.0, + delay_min_lag=2, + delay_max_lag=2, + ), + device, + ) + zero = torch.zeros(1, 2, device=device) + entity.write_joint_state_to_sim(position=zero, velocity=zero) + targets = [ + torch.tensor([[0.1, 0.0]], device=device), + torch.tensor([[0.3, 0.0]], device=device), + torch.tensor([[0.5, 0.0]], device=device), + ] + for p in targets: + entity.set_joint_position_target(p) + entity.set_joint_velocity_target(zero) + entity.set_joint_effort_target(zero) + entity.write_data_to_sim() + sim.forward() + + v_adr = entity.indexing.joint_v_adr + # With lag=2 and three writes, the effective target is targets[0]. + expected = K * 10.0 * targets[0][0] / R + assert torch.allclose(sim.data.qfrc_actuator[0, v_adr], expected, atol=1e-4) diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_builtin_pd_actuator.py b/05_software/train/rc_mjlab/mjlab/tests/test_builtin_pd_actuator.py new file mode 100644 index 0000000..e474e7a --- /dev/null +++ b/05_software/train/rc_mjlab/mjlab/tests/test_builtin_pd_actuator.py @@ -0,0 +1,468 @@ +"""Tests for BuiltinPdActuator. + +Covers the unique surface of the actuator: paired / +elements per target, joint/tendon-level actfrcrange sum-clamp, DR for both +gains and effort limits, delay synchronization, and the ordering invariant +that DR depends on. +""" + +from unittest.mock import Mock + +import mujoco +import pytest +import torch +from conftest import ( + create_entity_with_actuator, + get_test_device, + initialize_entity, + load_fixture_xml, +) + +from mjlab.actuator import BuiltinPdActuator, BuiltinPdActuatorCfg +from mjlab.actuator.actuator import TransmissionType +from mjlab.entity import Entity, EntityArticulationInfoCfg, EntityCfg +from mjlab.envs.mdp import dr +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.scene import Scene, SceneCfg +from mjlab.sim.sim import Simulation, SimulationCfg + +ROBOT_XML = load_fixture_xml("floating_base_articulated") +KP = 100.0 +KD = 10.0 + + +@pytest.fixture(scope="module") +def device(): + return get_test_device() + + +def _make_entity( + *, + effort_limit: float | None = 50.0, + armature: float | None = None, + delay_max_lag: int = 0, + delay_min_lag: int = 0, + delay_hold_prob: float = 0.0, +) -> Entity: + cfg = BuiltinPdActuatorCfg( + target_names_expr=("joint.*",), + stiffness=KP, + damping=KD, + effort_limit=effort_limit, + armature=armature, + delay_min_lag=delay_min_lag, + delay_max_lag=delay_max_lag, + delay_hold_prob=delay_hold_prob, + ) + return create_entity_with_actuator(ROBOT_XML, cfg) + + +def _at_rest_with_targets( + entity: Entity, + sim, + device: str, + pos_target: torch.Tensor, + vel_target: torch.Tensor, +) -> None: + entity.write_joint_state_to_sim( + position=torch.zeros(1, 2, device=device), + velocity=torch.zeros(1, 2, device=device), + ) + entity.set_joint_position_target(pos_target) + entity.set_joint_velocity_target(vel_target) + entity.set_joint_effort_target(torch.zeros(1, 2, device=device)) + entity.write_data_to_sim() + sim.forward() + + +# --------------------------------------------------------------------------- +# Structural invariants +# --------------------------------------------------------------------------- + + +def test_two_ctrls_per_target_with_pos_then_vel_layout(device): + """Each target gets one + one , in halves.""" + entity, sim = initialize_entity(_make_entity(), device) + act = entity.actuators[0] + assert isinstance(act, BuiltinPdActuator) + + n = act.num_targets + assert n == len(act.target_names) == 2 + assert len(act.ctrl_ids) == 2 * n + assert len(act.global_ctrl_ids) == 2 * n + + names = [sim.mj_model.actuator(i).name for i in act.global_ctrl_ids.tolist()] + assert names[:n] == [f"{name}_pd_pos" for name in act.target_names] + assert names[n:] == [f"{name}_pd_vel" for name in act.target_names] + + +def test_site_transmission_rejected(): + with pytest.raises(ValueError, match="SITE"): + BuiltinPdActuatorCfg( + target_names_expr=("x",), + stiffness=1.0, + damping=1.0, + transmission_type=TransmissionType.SITE, + ) + + +def test_armature_applied_once(device): + """Joint armature must come from the position element only; double-applying + would silently double dof_armature.""" + _, sim = initialize_entity(_make_entity(armature=0.7), device) + m = sim.mj_model + for jname in ("joint1", "joint2"): + dof_id = m.jnt_dofadr[m.joint(jname).id] + assert m.dof_armature[dof_id] == pytest.approx(0.7) + + +# --------------------------------------------------------------------------- +# Force computation +# --------------------------------------------------------------------------- + + +def test_position_only(device): + """Zero vel target: qfrc = kp * pos_target.""" + entity, sim = initialize_entity(_make_entity(effort_limit=None), device) + pos = torch.tensor([[0.1, -0.05]], device=device) + _at_rest_with_targets(entity, sim, device, pos, torch.zeros(1, 2, device=device)) + v_adr = entity.indexing.joint_v_adr + assert torch.allclose(sim.data.qfrc_actuator[0, v_adr], KP * pos[0], atol=1e-4) + + +def test_velocity_only(device): + """Zero pos target, joint at rest: qfrc = kd * vel_target.""" + entity, sim = initialize_entity(_make_entity(effort_limit=None), device) + vel = torch.tensor([[0.3, -0.2]], device=device) + _at_rest_with_targets(entity, sim, device, torch.zeros(1, 2, device=device), vel) + v_adr = entity.indexing.joint_v_adr + assert torch.allclose(sim.data.qfrc_actuator[0, v_adr], KD * vel[0], atol=1e-4) + + +def test_pd_superposition(device): + """Both targets nonzero: qfrc = kp * pos_target + kd * vel_target.""" + entity, sim = initialize_entity(_make_entity(effort_limit=None), device) + pos = torch.tensor([[0.1, -0.05]], device=device) + vel = torch.tensor([[0.2, -0.1]], device=device) + _at_rest_with_targets(entity, sim, device, pos, vel) + v_adr = entity.indexing.joint_v_adr + expected = KP * pos[0] + KD * vel[0] + assert torch.allclose(sim.data.qfrc_actuator[0, v_adr], expected, atol=1e-4) + + +def test_actfrcrange_sum_clamp(device): + """A pos error big enough to make kp*err exceed effort_limit must be + clamped at the joint, not allowed to ride through the unbounded element.""" + entity, sim = initialize_entity(_make_entity(effort_limit=5.0), device) + # kp * 10.0 = 1000, well over the 5.0 clamp. + pos = torch.tensor([[10.0, 0.0]], device=device) + _at_rest_with_targets(entity, sim, device, pos, torch.zeros(1, 2, device=device)) + v_adr = entity.indexing.joint_v_adr + qfrc = sim.data.qfrc_actuator[0, v_adr] + assert qfrc[0].item() == pytest.approx(5.0, abs=1e-4) + assert qfrc[1].item() == pytest.approx(0.0, abs=1e-4) + + +def test_effort_limit_none_leaves_joint_unlimited(device): + """effort_limit=None: jnt_actfrclimited stays 0 on the targeted joints.""" + _, sim = initialize_entity(_make_entity(effort_limit=None), device) + m = sim.mj_model + for jname in ("joint1", "joint2"): + jid = m.joint(jname).id + assert m.jnt_actfrclimited[jid] == 0 + + +def test_actuator_forcerange_not_set(device): + """We deliberately leave per-element forcerange unset; the limit lives on + the joint. Inspection of actuator_force[i] thus shows the unclamped value.""" + entity, sim = initialize_entity(_make_entity(effort_limit=5.0), device) + m = sim.mj_model + for ctrl_id in entity.actuators[0].global_ctrl_ids.tolist(): + assert m.actuator_forcelimited[ctrl_id] == 0 + + +# --------------------------------------------------------------------------- +# Delay synchronization +# --------------------------------------------------------------------------- + + +def test_delay_syncs_pos_and_vel(device): + """The shared delay buffer must lag pos and vel together.""" + entity, sim = initialize_entity( + _make_entity(effort_limit=None, delay_min_lag=2, delay_max_lag=2), + device, + ) + pos_targets = [ + torch.tensor([[0.1, 0.0]], device=device), + torch.tensor([[0.3, 0.0]], device=device), + torch.tensor([[0.5, 0.0]], device=device), + ] + vel_targets = [ + torch.tensor([[1.0, 0.0]], device=device), + torch.tensor([[2.0, 0.0]], device=device), + torch.tensor([[3.0, 0.0]], device=device), + ] + entity.write_joint_state_to_sim( + position=torch.zeros(1, 2, device=device), + velocity=torch.zeros(1, 2, device=device), + ) + for p, v in zip(pos_targets, vel_targets, strict=True): + entity.set_joint_position_target(p) + entity.set_joint_velocity_target(v) + entity.set_joint_effort_target(torch.zeros(1, 2, device=device)) + entity.write_data_to_sim() + sim.forward() + + v_adr = entity.indexing.joint_v_adr + # With lag=2, both halves should reference step-0 values. + expected = KP * pos_targets[0][0] + KD * vel_targets[0][0] + assert torch.allclose(sim.data.qfrc_actuator[0, v_adr], expected, atol=1e-4) + + +def test_reset_clears_delay_buffer(device): + entity, _ = initialize_entity(_make_entity(delay_min_lag=1, delay_max_lag=3), device) + act = entity.actuators[0] + assert act._delay_buffer is not None + entity.set_joint_position_target(torch.full((1, 2), 0.5, device=device)) + entity.set_joint_velocity_target(torch.zeros(1, 2, device=device)) + entity.write_data_to_sim() + + entity.reset(torch.tensor([0], device=device)) + assert act._delay_buffer.current_lags[0] == 0 + + +# --------------------------------------------------------------------------- +# Domain randomization +# --------------------------------------------------------------------------- + + +def _scene_env(device, transmission=TransmissionType.JOINT, num_envs=2): + """Build a real scene/sim with one BuiltinPd-driven entity for DR tests.""" + if transmission == TransmissionType.JOINT: + xml = ROBOT_XML + targets = ("joint.*",) + else: + xml = load_fixture_xml("tendon_finger") + # tendon_finger ships with motor/position/velocity actuators; we need a + # bare spec so BuiltinPd can attach to the tendon without name clashes. + targets = ("finger_tendon",) + + def spec_fn(): + spec = mujoco.MjSpec.from_string(xml) + # Strip any pre-existing actuators so BuiltinPd's added elements own ctrl. + for a in list(spec.actuators): + spec.delete(a) + return spec + + entity_cfg = EntityCfg( + spec_fn=spec_fn, + articulation=EntityArticulationInfoCfg( + actuators=( + BuiltinPdActuatorCfg( + target_names_expr=targets, + stiffness=KP, + damping=KD, + effort_limit=50.0, + transmission_type=transmission, + ), + ) + ), + ) + scene_cfg = SceneCfg(num_envs=num_envs, entities={"robot": entity_cfg}) + scene = Scene(scene_cfg, device) + model = scene.compile() + sim = Simulation(num_envs=num_envs, cfg=SimulationCfg(), model=model, device=device) + scene.initialize(model, sim.model, sim.data) + + env = Mock() + env.num_envs = num_envs + env.device = device + env.scene = {"robot": scene["robot"]} + env.sim = sim + return env + + +def test_dr_pd_gains_scales_halves_independently(device): + env = _scene_env(device) + robot = env.scene["robot"] + act = robot.actuators[0] + assert isinstance(act, BuiltinPdActuator) + n = act.num_targets + pos_ids = act.global_ctrl_ids[:n] + vel_ids = act.global_ctrl_ids[n:] + + # Expand fields so DR can write per-env. + env.sim.expand_model_fields(("actuator_gainprm", "actuator_biasprm")) + + dr.pd_gains( + env, + env_ids=torch.tensor([0], device=device), + kp_range=(2.0, 2.0), + kd_range=(3.0, 3.0), + asset_cfg=SceneEntityCfg("robot"), + operation="scale", + ) + + m = env.sim.model + # Position half: gainprm[0] and biasprm[1] both scaled by kp=2, biasprm[2] + # must stay zero (no kd injection). + assert torch.allclose( + m.actuator_gainprm[0, pos_ids, 0], + torch.full((n,), 2.0 * KP, device=device), + ) + assert torch.allclose( + m.actuator_biasprm[0, pos_ids, 1], + torch.full((n,), -2.0 * KP, device=device), + ) + assert torch.allclose( + m.actuator_biasprm[0, pos_ids, 2], torch.zeros(n, device=device) + ) + # Velocity half: gainprm[0] and biasprm[2] both scaled by kd=3, biasprm[1] + # stays zero (no kp injection). + assert torch.allclose( + m.actuator_gainprm[0, vel_ids, 0], + torch.full((n,), 3.0 * KD, device=device), + ) + assert torch.allclose( + m.actuator_biasprm[0, vel_ids, 2], + torch.full((n,), -3.0 * KD, device=device), + ) + assert torch.allclose( + m.actuator_biasprm[0, vel_ids, 1], torch.zeros(n, device=device) + ) + # The other env must be untouched. + assert torch.allclose(m.actuator_gainprm[1, pos_ids, 0], torch.tensor(KP)) + assert torch.allclose(m.actuator_gainprm[1, vel_ids, 0], torch.tensor(KD)) + + +def test_dr_pd_gains_abs_writes_correct_columns(device): + env = _scene_env(device) + robot = env.scene["robot"] + act = robot.actuators[0] + assert isinstance(act, BuiltinPdActuator) + n = act.num_targets + pos_ids = act.global_ctrl_ids[:n] + vel_ids = act.global_ctrl_ids[n:] + env.sim.expand_model_fields(("actuator_gainprm", "actuator_biasprm")) + + dr.pd_gains( + env, + env_ids=torch.tensor([0, 1], device=device), + kp_range=(200.0, 200.0), + kd_range=(25.0, 25.0), + asset_cfg=SceneEntityCfg("robot"), + operation="abs", + ) + + m = env.sim.model + assert torch.allclose( + m.actuator_gainprm[:, pos_ids, 0], + torch.full((env.num_envs, n), 200.0, device=device), + ) + assert torch.allclose( + m.actuator_biasprm[:, pos_ids, 1], + torch.full((env.num_envs, n), -200.0, device=device), + ) + assert torch.allclose( + m.actuator_biasprm[:, pos_ids, 2], + torch.zeros(env.num_envs, n, device=device), + ) + assert torch.allclose( + m.actuator_gainprm[:, vel_ids, 0], + torch.full((env.num_envs, n), 25.0, device=device), + ) + assert torch.allclose( + m.actuator_biasprm[:, vel_ids, 2], + torch.full((env.num_envs, n), -25.0, device=device), + ) + + +def test_dr_effort_limits_writes_jnt_actfrcrange(device): + env = _scene_env(device, transmission=TransmissionType.JOINT) + robot = env.scene["robot"] + act = robot.actuators[0] + assert isinstance(act, BuiltinPdActuator) + env.sim.expand_model_fields( + ("actuator_forcerange", "jnt_actfrcrange", "tendon_actfrcrange") + ) + + joint_ids = robot.indexing.joint_ids[act.target_ids] + pre_forcerange = env.sim.model.actuator_forcerange.clone() + + dr.effort_limits( + env, + env_ids=torch.tensor([0], device=device), + effort_limit_range=(123.0, 123.0), + asset_cfg=SceneEntityCfg("robot"), + operation="abs", + ) + + m = env.sim.model + # The joint sum-clamp was rewritten on env 0 only. + assert torch.allclose( + m.jnt_actfrcrange[0, joint_ids], + torch.tensor([[-123.0, 123.0]] * len(joint_ids), device=device), + ) + assert torch.allclose( + m.jnt_actfrcrange[1, joint_ids], + torch.tensor([[-50.0, 50.0]] * len(joint_ids), device=device), + ) + # Per-element actuator_forcerange must be untouched for BuiltinPd: that + # field belongs to the existing single-element actuator semantic. + assert torch.allclose(m.actuator_forcerange, pre_forcerange) + + +def test_dr_effort_limits_scale_multiplies_default(device): + """``scale`` multiplies the configured ``effort_limit`` (50.0) by the sample.""" + env = _scene_env(device, transmission=TransmissionType.JOINT) + robot = env.scene["robot"] + act = robot.actuators[0] + assert isinstance(act, BuiltinPdActuator) + env.sim.expand_model_fields( + ("actuator_forcerange", "jnt_actfrcrange", "tendon_actfrcrange") + ) + joint_ids = robot.indexing.joint_ids[act.target_ids] + + dr.effort_limits( + env, + env_ids=torch.tensor([0], device=device), + effort_limit_range=(2.0, 2.0), + asset_cfg=SceneEntityCfg("robot"), + operation="scale", + ) + + m = env.sim.model + # Default is [-50, 50], scaled by 2 -> [-100, 100]. + assert torch.allclose( + m.jnt_actfrcrange[0, joint_ids], + torch.tensor([[-100.0, 100.0]] * len(joint_ids), device=device), + ) + + +def test_dr_effort_limits_writes_tendon_actfrcrange(device): + env = _scene_env(device, transmission=TransmissionType.TENDON) + robot = env.scene["robot"] + act = robot.actuators[0] + assert isinstance(act, BuiltinPdActuator) + env.sim.expand_model_fields( + ("actuator_forcerange", "jnt_actfrcrange", "tendon_actfrcrange") + ) + tendon_ids = robot.indexing.tendon_ids[act.target_ids] + + dr.effort_limits( + env, + env_ids=torch.tensor([0], device=device), + effort_limit_range=(77.0, 77.0), + asset_cfg=SceneEntityCfg("robot"), + operation="abs", + ) + + m = env.sim.model + assert torch.allclose( + m.tendon_actfrcrange[0, tendon_ids], + torch.tensor([[-77.0, 77.0]] * len(tendon_ids), device=device), + ) + assert torch.allclose( + m.tendon_actfrcrange[1, tendon_ids], + torch.tensor([[-50.0, 50.0]] * len(tendon_ids), device=device), + ) diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_contact_sensor.py b/05_software/train/rc_mjlab/mjlab/tests/test_contact_sensor.py index 8e341ca..9bb3c8e 100644 --- a/05_software/train/rc_mjlab/mjlab/tests/test_contact_sensor.py +++ b/05_software/train/rc_mjlab/mjlab/tests/test_contact_sensor.py @@ -1072,3 +1072,37 @@ def test_history_captures_impact_forces(device): assert torch.all(max_force_seen > steady_state_force * 1.5), ( f"Peak force {max_force_seen} should be significantly above mg={steady_state_force}" ) + + +def test_global_frame_maxforce_rotation(device): + """A box at rest on a plane has its contact normals all vertical.""" + cfg = ContactSensorCfg( + name="box_contact", + primary=ContactMatch(mode="geom", pattern="box_geom", entity="box"), + fields=("found", "force", "normal", "tangent"), + reduce="maxforce", + global_frame=True, + ) + scene, sim = create_scene_with_sensor(FALLING_BOX_XML, "box", cfg, device) + + root_state = torch.zeros((2, 13), device=sim.device) + root_state[:, 2] = 0.11 + root_state[:, 3] = 1.0 + scene["box"].write_root_state_to_sim(root_state) + for _ in range(150): + sim.step() + scene.update(dt=sim.cfg.mujoco.timestep) + + sensor_force = scene["box_contact"].data.force[:, 0, :] + + # On a flat plane the contact normal is vertical, so a correctly rotated + # global-frame force should have its magnitude entirely on the z axis. + assert torch.all(sensor_force[:, 0].abs() < 0.05), ( + f"sensor_force x-component should be ~0, got {sensor_force[:, 0].tolist()}" + ) + assert torch.all(sensor_force[:, 1].abs() < 0.05), ( + f"sensor_force y-component should be ~0, got {sensor_force[:, 1].tolist()}" + ) + assert torch.all(sensor_force[:, 2].abs() > 1.0), ( + f"sensor_force z-component should be non-trivial, got {sensor_force[:, 2].tolist()}" + ) diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_delayed_actuator.py b/05_software/train/rc_mjlab/mjlab/tests/test_delayed_actuator.py index 206ba3d..6d90bf2 100644 --- a/05_software/train/rc_mjlab/mjlab/tests/test_delayed_actuator.py +++ b/05_software/train/rc_mjlab/mjlab/tests/test_delayed_actuator.py @@ -125,6 +125,72 @@ def test_delayed_ideal_applies_delay(device): assert torch.allclose(qfrc, expected_torque, atol=1e-4) +def test_delayed_ideal_delays_velocity(device): + """Velocity targets share the same delay as position targets. + + Regression test: the velocity reference used to bypass the delay buffer, so + the damping term consumed the latest target instead of the delayed one. + """ + entity = create_entity_with_delayed_ideal(delay_min_lag=2, delay_max_lag=2) + entity, sim = initialize_entity(entity, device) + + joint_pos = torch.zeros(1, 2, device=device) + joint_vel = torch.zeros(1, 2, device=device) + entity.write_joint_state_to_sim(joint_pos, joint_vel) + + # Only the velocity target varies; position and effort stay zero. + vel_targets = [ + torch.tensor([[0.1, 0.2]], device=device), + torch.tensor([[0.3, 0.4]], device=device), + torch.tensor([[0.5, 0.6]], device=device), + ] + + for vel_target in vel_targets: + entity.set_joint_position_target(joint_pos) + entity.set_joint_velocity_target(vel_target) + entity.set_joint_effort_target(torch.zeros(1, 2, device=device)) + entity.write_data_to_sim() + sim.forward() + + joint_v_adr = entity.indexing.joint_v_adr + qfrc = sim.data.qfrc_actuator[0, joint_v_adr] + + # With lag=2, the damping term uses the velocity target from step 0: + # kd * (delayed_vel_target - 0) = 10.0 * [0.1, 0.2]. + expected_torque = 10.0 * vel_targets[0][0] + assert torch.allclose(qfrc, expected_torque, atol=1e-4) + + +def test_delayed_ideal_delays_effort(device): + """Feedforward effort targets share the same delay as position targets.""" + entity = create_entity_with_delayed_ideal(delay_min_lag=2, delay_max_lag=2) + entity, sim = initialize_entity(entity, device) + + joint_pos = torch.zeros(1, 2, device=device) + joint_vel = torch.zeros(1, 2, device=device) + entity.write_joint_state_to_sim(joint_pos, joint_vel) + + effort_targets = [ + torch.tensor([[1.0, 2.0]], device=device), + torch.tensor([[3.0, 4.0]], device=device), + torch.tensor([[5.0, 6.0]], device=device), + ] + + for effort_target in effort_targets: + entity.set_joint_position_target(joint_pos) + entity.set_joint_velocity_target(joint_vel) + entity.set_joint_effort_target(effort_target) + entity.write_data_to_sim() + sim.forward() + + joint_v_adr = entity.indexing.joint_v_adr + qfrc = sim.data.qfrc_actuator[0, joint_v_adr] + + # With lag=2, the feedforward term uses the effort target from step 0. + expected_torque = effort_targets[0][0] + assert torch.allclose(qfrc, expected_torque, atol=1e-4) + + def test_delayed_actuator_reset(device): """Test that reset clears the delay buffer.""" entity = create_entity_with_delayed_builtin(delay_min_lag=1, delay_max_lag=3) diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_entity.py b/05_software/train/rc_mjlab/mjlab/tests/test_entity.py index f6d3eed..62adbba 100644 --- a/05_software/train/rc_mjlab/mjlab/tests/test_entity.py +++ b/05_software/train/rc_mjlab/mjlab/tests/test_entity.py @@ -243,6 +243,27 @@ def test_unnamed_freejoint_gets_default_name(): assert "floating_base_joint" in entity.all_joint_names +def test_multiple_freejoints_raises(): + """An entity with more than one freejoint is rejected at construction.""" + xml = """ + + + + + + + + + + + + + """ + cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(xml)) + with pytest.raises(ValueError, match="2 freejoints"): + Entity(cfg) + + def test_find_methods(): """Test find methods with exact and regex matches.""" entity = create_floating_articulated_entity() diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_events.py b/05_software/train/rc_mjlab/mjlab/tests/test_events.py index 18de45f..92b61bc 100644 --- a/05_software/train/rc_mjlab/mjlab/tests/test_events.py +++ b/05_software/train/rc_mjlab/mjlab/tests/test_events.py @@ -125,7 +125,9 @@ def test_dr_fields_registered_in_event_manager(device): assert "actuator_gainprm" in manager.domain_randomization_fields assert "actuator_biasprm" in manager.domain_randomization_fields assert "actuator_forcerange" in manager.domain_randomization_fields - assert len(manager.domain_randomization_fields) == 5 + assert "jnt_actfrcrange" in manager.domain_randomization_fields + assert "tendon_actfrcrange" in manager.domain_randomization_fields + assert len(manager.domain_randomization_fields) == 7 def test_recompute_level_ordering(): @@ -418,6 +420,62 @@ def test_effort_limits_scale_no_accumulation(device): assert abs(actual_upper - 200.0) < 1e-5 +def test_pd_gains_accepts_operation_object(device): + """dr.scale / dr.abs Operation objects produce the same result as strings.""" + env_str, ideal_str = _make_pd_env(device) + env_obj, ideal_obj = _make_pd_env(device) + + ids = torch.tensor([0], device=device) + kwargs = dict( + kp_range=(1.5, 1.5), kd_range=(2.0, 2.0), asset_cfg=SceneEntityCfg("robot") + ) + + torch.manual_seed(0) + dr.pd_gains(env_str, ids, operation="scale", **kwargs) + torch.manual_seed(0) + dr.pd_gains(env_obj, ids, operation=dr.scale, **kwargs) + + assert torch.allclose( + env_str.sim.model.actuator_gainprm[0], env_obj.sim.model.actuator_gainprm[0] + ) + assert torch.allclose(ideal_str.stiffness, ideal_obj.stiffness) + + +def test_effort_limits_accepts_operation_object(device): + """dr.abs Operation object produces the same result as the string.""" + env_str, ideal_str = _make_effort_env(device) + env_obj, ideal_obj = _make_effort_env(device) + + ids = torch.tensor([0], device=device) + kwargs = dict(effort_limit_range=(150.0, 150.0), asset_cfg=SceneEntityCfg("robot")) + + dr.effort_limits(env_str, ids, operation="abs", **kwargs) + dr.effort_limits(env_obj, ids, operation=dr.abs, **kwargs) + + assert torch.allclose( + env_str.sim.model.actuator_forcerange[0], env_obj.sim.model.actuator_forcerange[0] + ) + assert torch.allclose(ideal_str.force_limit, ideal_obj.force_limit) + + +def test_pd_gains_rejects_unsupported_operation(device): + """Operations other than scale/abs raise ValueError.""" + env, _ = _make_pd_env(device) + ids = torch.tensor([0], device=device) + + with pytest.raises(ValueError, match="only supports 'scale' and 'abs'"): + dr.pd_gains(env, ids, kp_range=(1.0, 1.0), kd_range=(1.0, 1.0), operation=dr.add) + + +def test_effort_limits_rejects_unsupported_operation(device): + """Operations other than scale/abs raise ValueError.""" + env, _ = _make_effort_env(device) + ids = torch.tensor([0], device=device) + + with pytest.raises(ValueError, match="only supports 'scale' and 'abs'"): + dr.effort_limits(env, ids, effort_limit_range=(1.0, 1.0), operation=dr.add) + + # =========================================================================== # Section 3: Other events # =========================================================================== @@ -503,7 +561,9 @@ def test_step_mode_fires_every_call(device): assert call_count[0] == 5 -def _make_impulse_env(device, num_envs=2, num_bodies=1, body_ids=None): +def _make_impulse_env( + device, num_envs=2, num_bodies=1, body_ids=None, cooldown_s=(0.0, 0.0) +): """Create a mock env for apply_body_impulse tests.""" if body_ids is None: body_ids = [0] @@ -523,7 +583,7 @@ def _make_impulse_env(device, num_envs=2, num_bodies=1, body_ids=None): asset_cfg = SceneEntityCfg("robot", body_ids=body_ids) term_cfg = Mock() - term_cfg.params = {"asset_cfg": asset_cfg} + term_cfg.params = {"asset_cfg": asset_cfg, "cooldown_s": cooldown_s} impulse = events.apply_body_impulse(cfg=term_cfg, env=env) return env, mock_entity, asset_cfg, impulse @@ -531,11 +591,13 @@ def _make_impulse_env(device, num_envs=2, num_bodies=1, body_ids=None): def test_apply_body_impulse_basic(device): """Impulse is applied and cleared after duration expires.""" env, mock_entity, asset_cfg, impulse = _make_impulse_env( - device, num_envs=2, num_bodies=3, body_ids=[1] + device, num_envs=2, num_bodies=3, body_ids=[1], cooldown_s=(10.0, 10.0) ) - # First call: cooldown_s starts at 0 and gets decremented by dt, - # so it becomes <= 0 and triggers. + # Skip the initial cooldown so the first call triggers immediately; + # the trigger/sustain/expire cycle is what's under test here. + impulse._interval_time_left[:] = 0.0 + impulse( env, None, @@ -643,6 +705,43 @@ def test_apply_body_impulse_reset_clears(device): assert env_ids_arg[0].item() == 0 +def test_apply_body_impulse_initial_cooldown(device): + """The first call after init/reset enters cooldown, not an immediate impulse. + + Regression test for #973. + """ + env, mock_entity, asset_cfg, impulse = _make_impulse_env( + device, num_envs=1, num_bodies=1, body_ids=[0], cooldown_s=(0.05, 0.05) + ) + + def step(): + impulse( + env, + None, + force_range=(10.0, 10.0), + torque_range=(0.0, 0.0), + duration_s=(1.0, 1.0), + cooldown_s=(0.05, 0.05), # ~2.5 steps at dt=0.02 + asset_cfg=asset_cfg, + ) + + # First two steps consume the sampled cooldown; impulse must not fire yet. + step() + assert not impulse._active.any() + step() + assert not impulse._active.any() + + # Third step crosses the cooldown boundary and triggers. + step() + assert impulse._active.all() + + # Reset re-enters cooldown: next step should not immediately re-trigger. + impulse.reset(env_ids=torch.tensor([0], device=device)) + assert not impulse._active.any() + step() + assert not impulse._active.any() + + # =========================================================================== # Section 5: Recomputation integration # =========================================================================== diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_gpu_selection.py b/05_software/train/rc_mjlab/mjlab/tests/test_gpu_selection.py index 6cee098..f02cadb 100644 --- a/05_software/train/rc_mjlab/mjlab/tests/test_gpu_selection.py +++ b/05_software/train/rc_mjlab/mjlab/tests/test_gpu_selection.py @@ -113,3 +113,16 @@ def test_select_gpus_cpu_mode_empty_cuda_visible_devices(): selected, num = select_gpus([0]) assert selected is None assert num == 0 + + +def test_select_gpus_mig_uuids(): + """Handles MIG GPU UUIDs in CUDA_VISIBLE_DEVICES.""" + os.environ["CUDA_VISIBLE_DEVICES"] = "MIG-GPU-abc-123,MIG-GPU-def-456" + + selected, num = select_gpus("all") + assert selected == ["MIG-GPU-abc-123", "MIG-GPU-def-456"] + assert num == 2 + + selected, num = select_gpus([0]) + assert selected == ["MIG-GPU-abc-123"] + assert num == 1 diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_mesh_variants.py b/05_software/train/rc_mjlab/mjlab/tests/test_mesh_variants.py deleted file mode 100644 index f799845..0000000 --- a/05_software/train/rc_mjlab/mjlab/tests/test_mesh_variants.py +++ /dev/null @@ -1,961 +0,0 @@ -"""Tests for per-world mesh variant support.""" - -from __future__ import annotations - -from typing import Any, cast - -import mujoco -import numpy as np -import pytest -import torch - -from mjlab.entity import EntityCfg, VariantCfg, VariantEntityCfg -from mjlab.sim.mesh_variants import allocate_worlds, build_mesh_variant_model -from mjlab.viewer.model_sync import ( - disable_model_sameframe_shortcuts, - sync_model_fields, -) - -# Helpers: variant specs with visual + collision mesh geoms. - - -def _sphere_2col_spec() -> mujoco.MjSpec: - """Sphere: 1 visual + 2 collision geoms.""" - spec = mujoco.MjSpec() - mv = spec.add_mesh() - mv.name = "visual" - mv.make_sphere(subdivision=3) - for i in range(2): - mc = spec.add_mesh() - mc.name = f"col_{i}" - mc.make_sphere(subdivision=1) - body = spec.worldbody.add_body() - body.name = "prop" - body.add_freejoint() - gv = body.add_geom() - gv.name = "visual" - gv.type = mujoco.mjtGeom.mjGEOM_MESH - gv.meshname = "visual" - gv.contype = 0 - gv.conaffinity = 0 - for i in range(2): - gc = body.add_geom() - gc.name = f"col_{i}" - gc.type = mujoco.mjtGeom.mjGEOM_MESH - gc.meshname = f"col_{i}" - return spec - - -def _cone_4col_spec() -> mujoco.MjSpec: - """Cone: 1 visual + 4 collision geoms (more than sphere).""" - spec = mujoco.MjSpec() - mv = spec.add_mesh() - mv.name = "visual" - mv.make_cone(nedge=8, radius=0.05) - for i in range(4): - mc = spec.add_mesh() - mc.name = f"col_{i}" - mc.make_sphere(subdivision=1) - body = spec.worldbody.add_body() - body.name = "prop" - body.add_freejoint() - gv = body.add_geom() - gv.name = "visual" - gv.type = mujoco.mjtGeom.mjGEOM_MESH - gv.meshname = "visual" - gv.contype = 0 - gv.conaffinity = 0 - for i in range(4): - gc = body.add_geom() - gc.name = f"col_{i}" - gc.type = mujoco.mjtGeom.mjGEOM_MESH - gc.meshname = f"col_{i}" - return spec - - -def _simple_sphere_spec() -> mujoco.MjSpec: - """Single-geom sphere for simple tests.""" - spec = mujoco.MjSpec() - m = spec.add_mesh() - m.name = "sphere" - m.make_sphere(subdivision=2) - body = spec.worldbody.add_body() - body.name = "prop" - body.add_freejoint() - g = body.add_geom() - g.name = "visual" - g.type = mujoco.mjtGeom.mjGEOM_MESH - g.meshname = "sphere" - return spec - - -def _simple_cone_spec() -> mujoco.MjSpec: - """Single-geom cone for simple tests.""" - spec = mujoco.MjSpec() - m = spec.add_mesh() - m.name = "cone" - m.make_cone(nedge=8, radius=0.05) - body = spec.worldbody.add_body() - body.name = "prop" - body.add_freejoint() - g = body.add_geom() - g.name = "visual" - g.type = mujoco.mjtGeom.mjGEOM_MESH - g.meshname = "cone" - return spec - - -def _hinge_spec() -> mujoco.MjSpec: - """Object with a hinge joint (incompatible with freejoint variants).""" - spec = mujoco.MjSpec() - m = spec.add_mesh() - m.name = "box" - m.make_sphere(subdivision=1) - body = spec.worldbody.add_body() - body.name = "prop" - j = body.add_joint() - j.name = "hinge" - j.type = mujoco.mjtJoint.mjJNT_HINGE - g = body.add_geom() - g.name = "visual" - g.type = mujoco.mjtGeom.mjGEOM_MESH - g.meshname = "box" - return spec - - -def _build_scene_with_variants( - variant_a_fn, variant_b_fn, *, weight_a=0.5, weight_b=0.5 -): - """Build a scene spec + variant_info from two variant spec_fns.""" - cfg = VariantEntityCfg( - variants={ - "a": VariantCfg(spec_fn=variant_a_fn, weight=weight_a), - "b": VariantCfg(spec_fn=variant_b_fn, weight=weight_b), - }, - ) - entity = cfg.build() - assert entity.variant_metadata is not None - scene_spec = mujoco.MjSpec() - frame = scene_spec.worldbody.add_frame() - scene_spec.attach(entity.spec, prefix="object/", frame=frame) - return scene_spec, [("object/", entity.variant_metadata)] - - -# allocate_worlds. - - -def test_allocate_worlds_proportional(): - result = allocate_worlds((0.6, 0.4), 10) - assert len(result) == 10 - assert result.count(0) == 6 - assert result.count(1) == 4 - - -def test_allocate_worlds_uniform(): - result = allocate_worlds((1.0, 1.0), 8) - assert result.count(0) == 4 - assert result.count(1) == 4 - - -def test_allocate_worlds_single_variant(): - result = allocate_worlds((1.0,), 5) - assert result == [0, 0, 0, 0, 0] - - -def test_allocate_worlds_zero_weight_skips_variant(): - """A zero-weight variant gets zero worlds; the rest split nworld.""" - result = allocate_worlds((1.0, 0.0, 1.0), 10) - assert len(result) == 10 - assert result.count(1) == 0 - assert result.count(0) == 5 - assert result.count(2) == 5 - - -def test_allocate_worlds_rejects_negative_weight(): - with pytest.raises(ValueError, match="non-negative"): - allocate_worlds((1.0, -0.1), 10) - - -def test_allocate_worlds_rejects_all_zero(): - with pytest.raises(ValueError, match="positive sum"): - allocate_worlds((0.0, 0.0), 10) - - -def test_allocate_worlds_largest_remainder_sums_to_nworld(): - """Largest-remainder rounding must always allocate exactly nworld worlds.""" - for nworld in (3, 7, 100, 1000): - result = allocate_worlds((1.0, 1.0, 1.0), nworld) - assert len(result) == nworld - # Difference between any two variant counts is at most 1 (uniform). - counts = [result.count(i) for i in range(3)] - assert max(counts) - min(counts) <= 1 - - -# Entity merging. - - -def test_entity_builds_with_variants(): - cfg = VariantEntityCfg( - variants={ - "sphere": VariantCfg(spec_fn=_simple_sphere_spec, weight=0.5), - "cone": VariantCfg(spec_fn=_simple_cone_spec, weight=0.5), - }, - ) - entity = cfg.build() - meta = entity.variant_metadata - assert meta is not None - assert meta.variant_names == ("sphere", "cone") - assert meta.num_mesh_geoms == 1 - mesh_names = [m.name for m in entity.spec.meshes] - assert any("sphere" in n for n in mesh_names) - assert any("cone" in n for n in mesh_names) - - -def test_multi_geom_body_padding(): - """Sphere (3 geoms) + cone (5 geoms) -> body padded to 5 mesh geoms.""" - cfg = VariantEntityCfg( - variants={ - "sphere": VariantCfg(spec_fn=_sphere_2col_spec, weight=0.5), - "cone": VariantCfg(spec_fn=_cone_4col_spec, weight=0.5), - }, - ) - entity = cfg.build() - meta = entity.variant_metadata - assert meta is not None - assert meta.num_mesh_geoms == 5 # max(3, 5) - # Sphere: 3 real + 2 padding (None). - assert sum(1 for n in meta.variant_mesh_names[0] if n is None) == 2 - # Cone: 5 real, no padding. - assert all(n is not None for n in meta.variant_mesh_names[1]) - - -# Validation. - - -def test_mismatched_joint_structure_raises(): - cfg = VariantEntityCfg( - variants={ - "sphere": VariantCfg(spec_fn=_simple_sphere_spec, weight=0.5), - "hinge": VariantCfg(spec_fn=_hinge_spec, weight=0.5), - }, - ) - with pytest.raises(ValueError, match="joint"): - cfg.build() - - -def test_single_variant_builds(): - """A single variant degenerates cleanly; useful for templated variant sets.""" - cfg = VariantEntityCfg( - variants={"only": VariantCfg(spec_fn=_simple_sphere_spec)}, - ) - entity = cfg.build() - assert entity.variant_metadata is not None - assert entity.variant_metadata.variant_names == ("only",) - - -def test_empty_variants_raises(): - cfg = VariantEntityCfg(variants={}) - with pytest.raises(ValueError, match="at least one"): - cfg.build() - - -def _fixed_base_sphere_spec() -> mujoco.MjSpec: - """Fixed-base sphere variant (no free joint): currently unsupported.""" - spec = mujoco.MjSpec() - m = spec.add_mesh(name="sphere") - m.make_sphere(subdivision=2) - body = spec.worldbody.add_body(name="prop") - body.add_geom(type=mujoco.mjtGeom.mjGEOM_MESH, meshname="sphere") - return spec - - -def test_fixed_base_variants_rejected(): - """Variants must be floating-base; fixed-base raises with a clear message.""" - cfg = VariantEntityCfg( - variants={ - "a": VariantCfg(spec_fn=_fixed_base_sphere_spec, weight=0.5), - "b": VariantCfg(spec_fn=_fixed_base_sphere_spec, weight=0.5), - }, - ) - with pytest.raises(ValueError, match="floating-base"): - cfg.build() - - -def test_setting_spec_fn_on_variant_cfg_raises(): - """VariantEntityCfg.spec_fn is unused; setting it should fail loudly.""" - with pytest.raises(ValueError, match="spec_fn cannot be set"): - VariantEntityCfg( - variants={"only": VariantCfg(spec_fn=_simple_sphere_spec)}, - spec_fn=_simple_sphere_spec, - ) - - -def test_no_variants_unchanged(): - cfg = EntityCfg(spec_fn=_simple_sphere_spec) - entity = cfg.build() - assert entity.variant_metadata is None - - -# build_mesh_variant_model: dataid and dependent fields. - - -def test_dataid_assigned_per_world(): - """Each world's geom_dataid points to its variant's meshes.""" - scene_spec, vi = _build_scene_with_variants(_simple_sphere_spec, _simple_cone_spec) - result = build_mesh_variant_model(scene_spec, 4, vi) - - dataid = result.wp_model.geom_dataid.numpy() - assert dataid.shape == (4, result.mj_model.ngeom) - assert dataid.ndim == 2 - - w2v = result.world_to_variant["object/"] - assert w2v[0] == 0 # variant a (sphere) - assert w2v[2] == 1 # variant b (cone) - - # Sphere and cone worlds must have different dataid values. - assert not np.array_equal(dataid[0], dataid[2]) - - -def test_padding_slots_get_disabled(): - """Shorter variant's padding geom slots have dataid == -1.""" - scene_spec, vi = _build_scene_with_variants(_sphere_2col_spec, _cone_4col_spec) - result = build_mesh_variant_model(scene_spec, 4, vi) - - dataid = result.wp_model.geom_dataid.numpy() - w2v = result.world_to_variant["object/"] - - # Find a sphere world (variant 0, 3 mesh geoms -> 2 padding slots). - sphere_world = int(np.where(w2v == 0)[0][0]) - # Find mesh geom columns (skip non-mesh geoms like worldbody). - mesh_geom_ids = [ - gid - for gid in range(result.mj_model.ngeom) - if result.mj_model.geom_type[gid] == mujoco.mjtGeom.mjGEOM_MESH - ] - sphere_dataid = dataid[sphere_world, mesh_geom_ids] - # Last 2 mesh geom slots should be -1 (disabled padding). - assert sphere_dataid[-1] == -1 - assert sphere_dataid[-2] == -1 - # Padding slots must still be collision-enabled in the template/warp model. - # Short variants are disabled by per-world dataid=-1; long variants need the - # same slots enabled so their extra hulls can collide. - assert np.all(result.mj_model.geom_contype[mesh_geom_ids[-2:]] == 1) - assert np.all(result.mj_model.geom_conaffinity[mesh_geom_ids[-2:]] == 1) - assert np.all(result.wp_model.geom_contype.numpy()[mesh_geom_ids[-2:]] == 1) - assert np.all(result.wp_model.geom_conaffinity.numpy()[mesh_geom_ids[-2:]] == 1) - # First 3 should be valid (>= 0). - assert all(d >= 0 for d in sphere_dataid[:3]) - - -def test_dependent_fields_match_individual_compilation(): - """Per-world body_mass matches independently compiled variant models.""" - scene_spec, vi = _build_scene_with_variants(_simple_sphere_spec, _simple_cone_spec) - result = build_mesh_variant_model(scene_spec, 4, vi) - - # Compile each variant independently for reference values. - sphere_model = _simple_sphere_spec().compile() - cone_model = _simple_cone_spec().compile() - - body_mass = result.wp_model.body_mass.numpy() - w2v = result.world_to_variant["object/"] - - sphere_w = int(np.where(w2v == 0)[0][0]) - cone_w = int(np.where(w2v == 1)[0][0]) - - # The object body is the last body in the scene. - obj_body = result.mj_model.nbody - 1 - - # Mass should match individually compiled models. - np.testing.assert_allclose( - body_mass[sphere_w, obj_body], - sphere_model.body_mass[-1], - atol=1e-4, - ) - np.testing.assert_allclose( - body_mass[cone_w, obj_body], - cone_model.body_mass[-1], - atol=1e-4, - ) - - # Sphere and cone should have different masses. - assert not np.isclose(body_mass[sphere_w, obj_body], body_mass[cone_w, obj_body]) - - -def test_select_default_values_uses_per_world_variant_defaults(): - """Per-world defaults are indexed by env first, then by entity.""" - from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg - from mjlab.envs.mdp.dr._core import _select_default_values - from mjlab.scene import SceneCfg - from mjlab.terrains import TerrainEntityCfg - - def _explicit_variant( - mesh_name: str, - mass: float, - inertia: tuple[float, float, float], - *, - cone: bool = False, - ) -> mujoco.MjSpec: - spec = mujoco.MjSpec() - mesh = spec.add_mesh() - mesh.name = mesh_name - if cone: - mesh.make_cone(nedge=8, radius=0.05) - else: - mesh.make_sphere(subdivision=1) - body = spec.worldbody.add_body(name="prop") - body.add_freejoint() - body.explicitinertial = 1 - body.mass = mass - body.ipos[:] = (0.0, 0.0, 0.0) - body.inertia[:] = inertia - body.iquat[:] = (1.0, 0.0, 0.0, 0.0) - body.add_geom( - name="visual", - type=mujoco.mjtGeom.mjGEOM_MESH, - meshname=mesh_name, - contype=0, - conaffinity=0, - mass=0.0, - ) - return spec - - object_cfg = VariantEntityCfg( - variants={ - "sphere": VariantCfg( - lambda: _explicit_variant("sphere", 0.2, (1e-4, 2e-4, 3e-4)), - weight=0.5, - ), - "cone": VariantCfg( - lambda: _explicit_variant("cone", 0.7, (4e-4, 5e-4, 6e-4), cone=True), - weight=0.5, - ), - }, - init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)), - ) - env_cfg = ManagerBasedRlEnvCfg( - decimation=1, - scene=SceneCfg( - terrain=TerrainEntityCfg(terrain_type="plane"), - num_envs=4, - env_spacing=1.0, - entities={"object": object_cfg}, - ), - ) - - env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu") - try: - obj_body = int(env.scene["object"].indexing.root_body_id) - env_ids = torch.arange(env.num_envs, device=env.device) - body_ids = torch.tensor([obj_body], device=env.device) - - for field in ("body_mass", "body_ipos", "body_inertia", "body_iquat"): - selected = _select_default_values(env, field, env_ids, body_ids) - torch.testing.assert_close( - selected[:, 0], - getattr(env.sim.model, field)[:, obj_body], - ) - finally: - env.close() - - -def test_viser_builds_per_world_mesh_handles_for_variants(): - """Viser dynamic meshes must not collapse all worlds onto env0's mesh.""" - from contextlib import nullcontext - - from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg - from mjlab.scene import SceneCfg - from mjlab.terrains import TerrainEntityCfg - from mjlab.viewer.viser.scene import MjlabViserScene, _PerWorldMeshGroup - - class _Handle: - def __init__(self, **kwargs): - self.visible = kwargs.get("visible", True) - self.batched_positions = kwargs.get("batched_positions", np.zeros((0, 3))) - self.batched_wxyzs = kwargs.get("batched_wxyzs", np.zeros((0, 4))) - self.batched_scales = kwargs.get("batched_scales") - self.batched_colors = kwargs.get("batched_colors") - self.batched_opacities = kwargs.get("batched_opacities") - self.position = kwargs.get("position", np.zeros(3)) - self.wxyz = kwargs.get("wxyz", np.array([1.0, 0.0, 0.0, 0.0])) - - def remove(self) -> None: - pass - - class _Scene: - def __init__(self): - self.batched: list[tuple[tuple, dict, _Handle]] = [] - - def configure_environment_map(self, **_kwargs) -> None: - pass - - def add_frame(self, *_args, **kwargs) -> _Handle: - return _Handle(**kwargs) - - def add_grid(self, *_args, **kwargs) -> _Handle: - return _Handle(**kwargs) - - def add_mesh_trimesh(self, *_args, **kwargs) -> _Handle: - return _Handle(**kwargs) - - def add_batched_meshes_trimesh(self, *args, **kwargs) -> _Handle: - handle = _Handle(**kwargs) - self.batched.append((args, kwargs, handle)) - return handle - - def add_batched_meshes_simple(self, *args, **kwargs) -> _Handle: - handle = _Handle(**kwargs) - self.batched.append((args, kwargs, handle)) - return handle - - class _Server: - def __init__(self): - self.scene = _Scene() - - def atomic(self): - return nullcontext() - - def flush(self) -> None: - pass - - env_cfg = ManagerBasedRlEnvCfg( - decimation=1, - scene=SceneCfg( - terrain=TerrainEntityCfg(terrain_type="plane"), - num_envs=4, - env_spacing=1.0, - entities={ - "object": VariantEntityCfg( - variants={ - "sphere": VariantCfg(_simple_sphere_spec, weight=0.5), - "cone": VariantCfg(_simple_cone_spec, weight=0.5), - }, - init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)), - ) - }, - ), - ) - - env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu") - try: - env.sim.expand_model_fields(("geom_rgba",)) - env.sim.model.geom_rgba[:, :, :3] = torch.linspace( - 0.2, - 0.9, - env.num_envs, - device=env.device, - )[:, None, None] - server = _Server() - scene = MjlabViserScene( - cast(Any, server), - env.sim.mj_model, - env.num_envs, - sim_model=env.sim.model, - expanded_fields=env.sim.expanded_fields, - ) - groups = [mg for mg in scene._mesh_groups if isinstance(mg, _PerWorldMeshGroup)] - - assert groups - assert sum(len(mg.env_ids) for mg in groups) >= env.num_envs - - body_xpos = env.sim.data.xpos.cpu().numpy() - body_xmat = env.sim.data.xmat.cpu().numpy() - mocap_pos = ( - env.sim.data.mocap_pos.cpu().numpy() if env.sim.mj_model.nmocap > 0 else None - ) - mocap_quat = ( - env.sim.data.mocap_quat.cpu().numpy() if env.sim.mj_model.nmocap > 0 else None - ) - scene.show_only_selected = True - scene.update_from_arrays(body_xpos, body_xmat, mocap_pos, mocap_quat, env_idx=0) - scene.update_from_arrays(body_xpos, body_xmat, mocap_pos, mocap_quat, env_idx=1) - - assert any(mg.handle.visible for mg in groups) - - handle_count = len(server.scene.batched) - env.sim.model.geom_rgba[:, :, :3] = torch.linspace( - 0.9, - 0.2, - env.num_envs, - device=env.device, - )[:, None, None] - scene.update_from_arrays(body_xpos, body_xmat, mocap_pos, mocap_quat, env_idx=0) - assert len(server.scene.batched) > handle_count - finally: - env.close() - - -def test_viser_convex_hulls_are_per_variant(): - """Convex-hull handles must differ across variants, not all show env0's hull.""" - from contextlib import nullcontext - - from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg - from mjlab.scene import SceneCfg - from mjlab.terrains import TerrainEntityCfg - from mjlab.viewer.viser.scene import MjlabViserScene, _PerWorldHullGroup - - class _Handle: - def __init__(self, **kwargs): - self.visible = kwargs.get("visible", True) - self.batched_positions = kwargs.get("batched_positions", np.zeros((0, 3))) - self.batched_wxyzs = kwargs.get("batched_wxyzs", np.zeros((0, 4))) - self.batched_scales = kwargs.get("batched_scales") - self.batched_colors = kwargs.get("batched_colors") - self.batched_opacities = kwargs.get("batched_opacities") - self.position = kwargs.get("position", np.zeros(3)) - self.wxyz = kwargs.get("wxyz", np.array([1.0, 0.0, 0.0, 0.0])) - self.vertices = kwargs.get("vertices") - self.faces = kwargs.get("faces") - - def remove(self) -> None: - pass - - class _Scene: - def __init__(self): - self.batched: list[tuple[tuple, dict, _Handle]] = [] - - def configure_environment_map(self, **_kwargs) -> None: - pass - - def add_frame(self, *_args, **kwargs) -> _Handle: - return _Handle(**kwargs) - - def add_grid(self, *_args, **kwargs) -> _Handle: - return _Handle(**kwargs) - - def add_mesh_trimesh(self, *_args, **kwargs) -> _Handle: - return _Handle(**kwargs) - - def add_batched_meshes_trimesh(self, *args, **kwargs) -> _Handle: - handle = _Handle(**kwargs) - self.batched.append((args, kwargs, handle)) - return handle - - def add_batched_meshes_simple(self, path, vertices, faces, **kwargs) -> _Handle: - # Capture the mesh identity so the test can compare hull shapes. - kwargs = dict(kwargs) - kwargs["vertices"] = np.asarray(vertices) - kwargs["faces"] = np.asarray(faces) - handle = _Handle(**kwargs) - self.batched.append(((path,), kwargs, handle)) - return handle - - class _Server: - def __init__(self): - self.scene = _Scene() - - def atomic(self): - return nullcontext() - - def flush(self) -> None: - pass - - # Sphere and cone produce visibly different convex hulls. - env_cfg = ManagerBasedRlEnvCfg( - decimation=1, - scene=SceneCfg( - terrain=TerrainEntityCfg(terrain_type="plane"), - num_envs=4, - env_spacing=1.0, - entities={ - "object": VariantEntityCfg( - variants={ - "sphere": VariantCfg(_simple_sphere_spec, weight=0.5), - "cone": VariantCfg(_simple_cone_spec, weight=0.5), - }, - init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)), - ) - }, - ), - ) - - env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu") - try: - server = _Server() - scene = MjlabViserScene( - cast(Any, server), - env.sim.mj_model, - env.num_envs, - sim_model=env.sim.model, - expanded_fields=env.sim.expanded_fields, - ) - groups: list[_PerWorldHullGroup] = list(scene._hull_per_world_groups) - # Two distinct variants -> at least two hull handles on the same body. - assert len(groups) >= 2, f"expected >=2 hull variants, got {len(groups)}" - all_envs = np.concatenate([g.env_ids for g in groups]) - assert sorted(all_envs.tolist()) == list(range(env.num_envs)) - # Hulls must be shape-distinct, not all copies of env0's hull. - shapes = {(g.handle.vertices.shape, g.handle.faces.shape) for g in groups} - assert len(shapes) >= 2, ( - f"hull variants collapsed to one shape: {shapes} " - "(all envs would share env0's hull)" - ) - - body_xpos = env.sim.data.xpos.cpu().numpy() - body_xmat = env.sim.data.xmat.cpu().numpy() - scene.show_convex_hull = True - scene.show_only_selected = True - for target_env in range(env.num_envs): - scene.update_from_arrays(body_xpos, body_xmat, env_idx=target_env) - visible_groups = [g for g in groups if g.handle.visible] - assert len(visible_groups) == 1 - assert target_env in visible_groups[0].env_ids - assert visible_groups[0].handle.batched_positions.shape[0] == 1 - - scene.show_only_selected = False - scene.update_from_arrays(body_xpos, body_xmat, env_idx=0) - assert all(g.handle.visible for g in groups) - finally: - env.close() - - -# DR consistency on variant scenes. - - -def _explicit_mass_variant( - mesh_name: str, - mass: float, - *, - cone: bool = False, -) -> mujoco.MjSpec: - """Build a single-geom freejoint variant with an explicit body mass.""" - spec = mujoco.MjSpec() - mesh = spec.add_mesh() - mesh.name = mesh_name - if cone: - mesh.make_cone(nedge=8, radius=0.05) - else: - mesh.make_sphere(subdivision=1) - body = spec.worldbody.add_body(name="prop") - body.add_freejoint() - body.explicitinertial = 1 - body.mass = mass - body.ipos[:] = (0.0, 0.0, 0.0) - body.inertia[:] = (1e-4, 1e-4, 1e-4) - body.iquat[:] = (1.0, 0.0, 0.0, 0.0) - body.add_geom( - name="visual", - type=mujoco.mjtGeom.mjGEOM_MESH, - meshname=mesh_name, - contype=0, - conaffinity=0, - mass=0.0, - ) - return spec - - -def test_dr_body_mass_scale_preserves_variant_baseline(): - """``dr.body_mass`` scale must use each variant's own baseline. - - This is the load-bearing claim of ``_per_world_default_fields``: scaling - body_mass on a variant scene by a per-env factor must produce - ``variant_default[env] * scale[env]``, not ``template_default * scale[env]``. - """ - from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg - from mjlab.envs.mdp import dr - from mjlab.managers.event_manager import EventTermCfg - from mjlab.managers.scene_entity_config import SceneEntityCfg - from mjlab.scene import SceneCfg - from mjlab.terrains import TerrainEntityCfg - - light_mass = 0.1 - heavy_mass = 1.0 - scale = 2.0 - - object_cfg = VariantEntityCfg( - variants={ - "light": VariantCfg( - lambda: _explicit_mass_variant("light", light_mass), weight=0.5 - ), - "heavy": VariantCfg( - lambda: _explicit_mass_variant("heavy", heavy_mass, cone=True), weight=0.5 - ), - }, - init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)), - ) - env_cfg = ManagerBasedRlEnvCfg( - decimation=1, - scene=SceneCfg( - terrain=TerrainEntityCfg(terrain_type="plane"), - num_envs=4, - env_spacing=1.0, - entities={"object": object_cfg}, - ), - events={ - "scale_mass": EventTermCfg( - func=dr.body_mass, - mode="startup", - params={ - "asset_cfg": SceneEntityCfg("object", body_names=("prop",)), - "operation": "scale", - "ranges": (scale, scale), # deterministic factor - }, - ), - }, - ) - - with pytest.warns(UserWarning, match="dr.body_mass only randomizes mass"): - env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu") - try: - obj_body = int(env.scene["object"].indexing.root_body_id) - w2v = env.sim.world_to_variant["object"] - actual = env.sim.model.body_mass[:, obj_body].cpu() - - variant_baseline = torch.tensor([light_mass, heavy_mass], dtype=actual.dtype) - expected = variant_baseline[w2v.cpu()] * scale - torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5) - - # Sanity: at least one env per variant, otherwise the test is vacuous. - assert (w2v == 0).any() and (w2v == 1).any() - finally: - env.close() - - -# Full env lifecycle. - - -def test_env_step_with_variants(): - """Build a full ManagerBasedRlEnv with variants; step without crashing.""" - from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg - from mjlab.envs.mdp.events import reset_root_state_uniform - from mjlab.managers.event_manager import EventTermCfg - from mjlab.managers.scene_entity_config import SceneEntityCfg - from mjlab.scene import SceneCfg - from mjlab.terrains import TerrainEntityCfg - - object_cfg = VariantEntityCfg( - variants={ - "sphere": VariantCfg(_simple_sphere_spec, weight=0.5), - "cone": VariantCfg(_simple_cone_spec, weight=0.5), - }, - init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)), - ) - - env_cfg = ManagerBasedRlEnvCfg( - decimation=2, - scene=SceneCfg( - terrain=TerrainEntityCfg(terrain_type="plane"), - num_envs=4, - env_spacing=1.0, - entities={"object": object_cfg}, - ), - events={ - "reset": EventTermCfg( - func=reset_root_state_uniform, - mode="reset", - params={ - "pose_range": {}, - "velocity_range": {}, - "asset_cfg": SceneEntityCfg("object"), - }, - ), - }, - ) - - env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu") - obs, _ = env.reset() - actions = torch.zeros(env.num_envs, 0) - for _ in range(10): - obs, rew, term, trunc, info = env.step(actions) - # No NaN in positions. - qpos = env.sim.data.qpos[:].cpu().numpy() - assert np.all(np.isfinite(qpos)) - env.close() - - -# Viewer: sameframe shortcut fix. - - -def _viewer_regression_sphere_spec() -> mujoco.MjSpec: - spec = mujoco.MjSpec() - m = spec.add_mesh() - m.name = "sphere" - m.make_sphere(subdivision=3) - m.scale[:] = (0.05, 0.05, 0.05) - body = spec.worldbody.add_body() - body.name = "prop" - body.add_freejoint() - g = body.add_geom() - g.name = "visual" - g.type = mujoco.mjtGeom.mjGEOM_MESH - g.meshname = "sphere" - return spec - - -def _viewer_regression_cone_spec() -> mujoco.MjSpec: - spec = mujoco.MjSpec() - m = spec.add_mesh() - m.name = "cone" - m.make_cone(nedge=16, radius=0.04) - m.scale[:] = (0.05, 0.05, 0.05) - body = spec.worldbody.add_body() - body.name = "prop" - body.add_freejoint() - g = body.add_geom() - g.name = "visual" - g.type = mujoco.mjtGeom.mjGEOM_MESH - g.meshname = "cone" - return spec - - -def test_sameframe_fix_makes_host_forward_match_variant(): - """Clearing sameframe shortcuts aligns host mj_forward with variant.""" - base_model = _viewer_regression_sphere_spec().compile() - cone_model = _viewer_regression_cone_spec().compile() - - # Sync cone's kinematic fields onto sphere's model (like viewer does). - for field in ( - "geom_size", - "geom_pos", - "geom_quat", - "body_mass", - "body_inertia", - "body_ipos", - "body_iquat", - ): - getattr(base_model, field)[:] = getattr(cone_model, field) - - base_data = mujoco.MjData(base_model) - base_data.qpos[:] = cone_model.qpos0 - base_data.qpos[2] = 0.05 - mujoco.mj_forward(base_model, base_data) - - cone_data = mujoco.MjData(cone_model) - cone_data.qpos[:] = cone_model.qpos0 - cone_data.qpos[2] = 0.05 - mujoco.mj_forward(cone_model, cone_data) - - # Before fix: positions differ due to stale sameframe flags. - assert not np.allclose(base_data.geom_xpos, cone_data.geom_xpos) - - # After fix: clearing sameframe makes them match. - disable_model_sameframe_shortcuts(base_model) - mujoco.mj_forward(base_model, base_data) - np.testing.assert_allclose(base_data.geom_xpos, cone_data.geom_xpos, atol=1e-6) - - -def test_sync_model_fields_copies_only_requested_env_fields(): - """Viewer model sync copies explicit fields and leaves others unchanged.""" - model = _simple_sphere_spec().compile() - - class _SimModel: - geom_rgba = torch.tensor( - [ - [[0.1, 0.2, 0.3, 0.4]], - [[0.5, 0.6, 0.7, 0.8]], - ], - dtype=torch.float32, - ) - geom_pos = torch.tensor( - [ - [[1.0, 2.0, 3.0]], - [[4.0, 5.0, 6.0]], - ], - dtype=torch.float32, - ) - - original_geom_pos = model.geom_pos.copy() - - sync_model_fields(model, _SimModel(), {"geom_rgba"}, env_idx=1) - - np.testing.assert_allclose(model.geom_rgba, [[0.5, 0.6, 0.7, 0.8]]) - np.testing.assert_allclose(model.geom_pos, original_geom_pos) diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_projected_gravity_sensor.py b/05_software/train/rc_mjlab/mjlab/tests/test_projected_gravity_sensor.py new file mode 100644 index 0000000..738d7e5 --- /dev/null +++ b/05_software/train/rc_mjlab/mjlab/tests/test_projected_gravity_sensor.py @@ -0,0 +1,213 @@ +"""Tests for sensor-based projected gravity (framezaxis up-vector sensor). + +The shipped robots expose a ``framezaxis`` sensor that outputs the world Z-axis in the +IMU site frame; negating it gives projected gravity. These tests check the sensor (and +the ``projected_gravity_from_sensor`` observation that wraps it) against an independent +ground-truth computation, and verify that -- unlike the entity-data +``projected_gravity_b`` -- it tracks the IMU site orientation, which is what makes IMU +mounting domain randomization observable. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, cast + +import mujoco +import pytest +import torch +from conftest import get_test_device + +from mjlab.entity import EntityCfg +from mjlab.envs.mdp import dr +from mjlab.envs.mdp.observations import projected_gravity_from_sensor +from mjlab.managers.scene_entity_config import SceneEntityCfg +from mjlab.scene import Scene, SceneCfg +from mjlab.sim.sim import Simulation, SimulationCfg + +if TYPE_CHECKING: + from mjlab.envs import ManagerBasedRlEnv + +# Gravity points along world -Z; projected gravity is this expressed in a body frame. +_GRAVITY_DIR_W = (0.0, 0.0, -1.0) + + +def _quat_to_mat(q: tuple[float, float, float, float]) -> torch.Tensor: + """Rotation matrix from a (w, x, y, z) quaternion. Independent of MuJoCo/mjlab.""" + w, x, y, z = q + return torch.tensor( + [ + [1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)], + [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)], + [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)], + ], + dtype=torch.float64, + ) + + +def _expected_projected_gravity(q: tuple[float, float, float, float]) -> torch.Tensor: + """Ground-truth projected gravity for a body with world orientation ``q``. + + proj = R(q)^T @ g_world, computed from an explicit rotation matrix so it does not + share a code path with the sensor or with ``projected_gravity_b``. + """ + g_w = torch.tensor(_GRAVITY_DIR_W, dtype=torch.float64) + return _quat_to_mat(q).T @ g_w + + +class Env: + """Minimal env stub for driving observation and dr functions in tests.""" + + def __init__(self, scene, sim, device): + self.scene = scene + self.sim = sim + self.num_envs = scene.num_envs + self.device = device + + +def _make_env(scene, sim, device) -> ManagerBasedRlEnv: + """Build the env stub, typed as the real env for the functions under test.""" + return cast("ManagerBasedRlEnv", Env(scene, sim, device)) + + +@pytest.fixture(scope="module") +def device(): + return get_test_device() + + +def _robot_xml(site_euler: str = "0 0 0") -> str: + """Free-floating box with an IMU site and the framezaxis up-vector sensor.""" + return f""" + + + + + + + + + + + + + """ + + +def _build(xml: str, device: str, num_envs: int = 2): + entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(xml)) + scene = Scene( + SceneCfg(num_envs=num_envs, env_spacing=3.0, entities={"robot": entity_cfg}), + device, + ) + model = scene.compile() + sim = Simulation( + num_envs=num_envs, cfg=SimulationCfg(njmax=20), model=model, device=device + ) + scene.initialize(sim.mj_model, sim.model, sim.data) + return scene, sim + + +def _set_root_quat(robot, q: tuple[float, float, float, float], device: str) -> None: + root_state = robot.data.default_root_state.clone() + root_state[:, 3:7] = torch.tensor(q, device=device, dtype=root_state.dtype) + robot.write_root_state_to_sim(root_state) + + +def test_sensor_matches_ground_truth_when_site_aligned(device): + """Sensor and entity both equal hand-computed projected gravity for a tilted base.""" + scene, sim = _build(_robot_xml(), device) + robot = scene["robot"] + + # Compose a 0.6 rad roll with a 0.3 rad pitch into a single root quaternion. + ax = (math.cos(0.3), math.sin(0.3), 0.0, 0.0) + ay = (math.cos(0.15), 0.0, math.sin(0.15), 0.0) + q = ( + ax[0] * ay[0] - ax[1] * ay[1] - ax[2] * ay[2] - ax[3] * ay[3], + ax[0] * ay[1] + ax[1] * ay[0] + ax[2] * ay[3] - ax[3] * ay[2], + ax[0] * ay[2] - ax[1] * ay[3] + ax[2] * ay[0] + ax[3] * ay[1], + ax[0] * ay[3] + ax[1] * ay[2] - ax[2] * ay[1] + ax[3] * ay[0], + ) + _set_root_quat(robot, q, device) + sim.forward() + + expected = _expected_projected_gravity(q).to(device=device, dtype=torch.float32) + # Guard against a vacuous pass: the tilt must actually move gravity off straight-down. + straight_down = torch.tensor(_GRAVITY_DIR_W, device=device) + assert (expected - straight_down).abs().max() > 0.3 + + sensor_grav = -scene["robot/imu_upvector"].data + entity_grav = robot.data.projected_gravity_b + torch.testing.assert_close(sensor_grav[0], expected, atol=1e-5, rtol=0) + torch.testing.assert_close(entity_grav[0], expected, atol=1e-5, rtol=0) + + +def test_observation_fn_tracks_site_orientation(device): + """The observation fn reflects IMU site tilt; the entity-data version does not. + + With the base upright but the IMU site rolled 30 deg about x, projected gravity in the + site frame is (0, -sin30, -cos30). The entity-data version stays straight-down because + it uses the root body orientation and is blind to the site. + """ + scene_rot, sim_rot = _build(_robot_xml(site_euler="30 0 0"), device) + scene_flat, sim_flat = _build(_robot_xml(site_euler="0 0 0"), device) + sim_rot.forward() + sim_flat.forward() + + # Drive through the actual shipped observation function, not the raw sensor. + env_rot = _make_env(scene_rot, sim_rot, device) + env_flat = _make_env(scene_flat, sim_flat, device) + grav_rot = projected_gravity_from_sensor(env_rot, "robot/imu_upvector") + grav_flat = projected_gravity_from_sensor(env_flat, "robot/imu_upvector") + + expected_rot = torch.tensor( + [0.0, -math.sin(math.radians(30)), -math.cos(math.radians(30))], device=device + ) + straight_down = torch.tensor(_GRAVITY_DIR_W, device=device) + torch.testing.assert_close(grav_rot[0], expected_rot, atol=1e-5, rtol=0) + torch.testing.assert_close(grav_flat[0], straight_down, atol=1e-5, rtol=0) + + # The entity-data version is unchanged by the site rotation (so it cannot be used to + # observe IMU mounting randomization), confirming why the sensor path is needed. + entity_rot = scene_rot["robot"].data.projected_gravity_b + torch.testing.assert_close(entity_rot[0], straight_down, atol=1e-5, rtol=0) + + +@pytest.mark.filterwarnings( + "ignore:Use of index_put_ on expanded tensors is deprecated:UserWarning" +) +def test_site_quat_randomization_changes_sensor(device): + """The full DR path: running ``dr.site_quat`` perturbs the gravity observation. + + This is what the G1 example configs rely on -- randomizing the IMU site orientation + must show up in the sensor-based projected gravity, per-environment. + """ + scene, sim = _build(_robot_xml(), device, num_envs=4) + sim.expand_model_fields(("site_quat",)) + env = _make_env(scene, sim, device) + + sim.forward() + straight_down = torch.tensor(_GRAVITY_DIR_W, device=device) + before = projected_gravity_from_sensor(env, "robot/imu_upvector").clone() + # Upright base + identity site quat => straight-down gravity in every env. + torch.testing.assert_close(before, straight_down.expand_as(before), atol=1e-5, rtol=0) + + torch.manual_seed(0) + dr.site_quat( + env, + env_ids=None, + roll_range=(-0.3, 0.3), + pitch_range=(-0.3, 0.3), + yaw_range=(-0.3, 0.3), + asset_cfg=SceneEntityCfg("robot", site_names=("imu",)), + ) + sim.forward() + after = projected_gravity_from_sensor(env, "robot/imu_upvector") + + # Randomization moved the reading off straight-down and made it env-dependent. + assert (after - straight_down).abs().max() > 0.05 + assert not torch.allclose(after, before, atol=1e-3) + assert torch.unique(after, dim=0).shape[0] >= 2 + # The perturbation is a rotation, so gravity stays a unit vector. + norms = torch.linalg.norm(after, dim=-1) + torch.testing.assert_close(norms, torch.ones_like(norms), atol=1e-5, rtol=0) diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_random.py b/05_software/train/rc_mjlab/mjlab/tests/test_random.py deleted file mode 100644 index 9998cfd..0000000 --- a/05_software/train/rc_mjlab/mjlab/tests/test_random.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Tests for mjlab.utils.random.""" - -import subprocess -import sys -import textwrap - - -def test_seed_rng_cpu_device_does_not_initialize_warp_cuda() -> None: - """seed_rng(device="cpu") must not initialize Warp's CUDA runtime. - - Runs in a subprocess so that Warp is guaranteed uninitialized before the - call. - """ - script = textwrap.dedent(""" - import warp as wp - from mjlab.utils.random import seed_rng - - assert wp._src.context.runtime is None, "Warp must not be initialized yet" - seed_rng(42, device="cpu") - rt = wp._src.context.runtime - if rt is not None: - cuda = [d for d in wp.get_devices() if "cuda" in str(d)] - assert not cuda, f"seed_rng(device='cpu') initialized CUDA devices {cuda}" - """) - result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True - ) - assert result.returncode == 0, ( - f"subprocess failed:\nstdout={result.stdout}\nstderr={result.stderr}" - ) diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_raycast_sensor.py b/05_software/train/rc_mjlab/mjlab/tests/test_raycast_sensor.py index 6491826..a5c69d3 100644 --- a/05_software/train/rc_mjlab/mjlab/tests/test_raycast_sensor.py +++ b/05_software/train/rc_mjlab/mjlab/tests/test_raycast_sensor.py @@ -936,7 +936,7 @@ def test_multi_frame_body_exclusion(device): should skip body_b's own geom but HIT body_a's platform. Frame A's rays should skip body_a and hit the floor. """ - xml = """ + body_a_xml = """ @@ -945,6 +945,12 @@ def test_multi_frame_body_exclusion(device): + + + """ + body_b_xml = """ + + @@ -957,15 +963,17 @@ def test_multi_frame_body_exclusion(device): cfg = RayCastSensorCfg( name="multi", frame=( - ObjRef(type="site", name="site_a", entity="robot"), - ObjRef(type="site", name="site_b", entity="robot"), + ObjRef(type="site", name="site_a", entity="body_a"), + ObjRef(type="site", name="site_b", entity="body_b"), ), pattern=GridPatternCfg(size=(0.0, 0.0), resolution=0.1), max_distance=10.0, exclude_parent_body=True, ) - scene, sim = make_scene_and_sim(device, xml, (cfg,)) + scene, sim = make_scene_and_sim( + device, {"body_a": body_a_xml, "body_b": body_b_xml}, (cfg,) + ) sim.step() sim.sense() diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_runner.py b/05_software/train/rc_mjlab/mjlab/tests/test_runner.py index a64ad17..50ebfbd 100644 --- a/05_software/train/rc_mjlab/mjlab/tests/test_runner.py +++ b/05_software/train/rc_mjlab/mjlab/tests/test_runner.py @@ -4,6 +4,7 @@ import ast import tempfile from dataclasses import asdict from pathlib import Path +from unittest.mock import MagicMock, patch import mujoco import onnx @@ -521,3 +522,85 @@ def test_onnx_motion_model_clamps_out_of_bounds_time_step(): _, joint_pos, *_ = model(x, time_step) torch.testing.assert_close(joint_pos, motion.joint_pos[num_steps - 1 : num_steps]) + + +def _make_tracking_runner_shell(registry_name, logger_type, upload_model=True): + """Build a MotionTrackingOnPolicyRunner with all heavy parts mocked out.""" + from mjlab.tasks.tracking.rl.runner import MotionTrackingOnPolicyRunner + + runner = MotionTrackingOnPolicyRunner.__new__(MotionTrackingOnPolicyRunner) + runner.registry_name = registry_name + runner.cfg = {"upload_model": upload_model} + runner.logger = MagicMock() + runner.logger.logger_type = logger_type + + mock_motion_term = MagicMock() + mock_motion_term.cfg.anchor_body_name = "pelvis" + mock_motion_term.cfg.body_names = ["body1"] + runner.env = MagicMock() + runner.env.unwrapped.command_manager.get_term.return_value = mock_motion_term + return runner + + +@pytest.mark.parametrize("logger_type", ["wandb", "WandbLogWriter"]) +def test_tracking_runner_registers_artifact_for_wandb_logger_types( + logger_type, monkeypatch, tmp_path +): + """use_artifact is called for both legacy 'wandb' and current 'WandbLogWriter' logger types. + + Regression test: rsl-rl-lib 5.4 renamed the WandB logger type from 'wandb' + to 'WandbLogWriter'. If only 'wandb' is checked, use_artifact is silently + skipped and the nightly report fails with 'No motion artifact found in the run.' + """ + from mjlab.rl.runner import MjlabOnPolicyRunner + from mjlab.tasks.tracking.rl import runner as runner_mod + + runner = _make_tracking_runner_shell("org/motions/motion:latest", logger_type) + + monkeypatch.setattr(MjlabOnPolicyRunner, "save", lambda *a, **kw: None) + monkeypatch.setattr(runner_mod, "get_base_metadata", lambda *a: {}) + monkeypatch.setattr(runner_mod, "attach_metadata_to_onnx", lambda *a: None) + monkeypatch.setattr( + runner.env.unwrapped.__class__, + "export_policy_to_onnx", + lambda *a, **kw: None, + raising=False, + ) + + checkpoint = tmp_path / "run-dir" / "model_100.pt" + checkpoint.parent.mkdir() + checkpoint.touch() + + mock_run = MagicMock() + mock_run.name = "test-run" + + with patch.object(runner_mod, "wandb") as mock_wandb: + mock_wandb.run = mock_run + runner.export_policy_to_onnx = MagicMock() + runner.save(str(checkpoint)) + + mock_run.use_artifact.assert_called_once_with("org/motions/motion:latest") + + +def test_tracking_runner_does_not_register_artifact_for_tensorboard( + monkeypatch, tmp_path +): + """use_artifact is NOT called when using the tensorboard logger.""" + from mjlab.rl.runner import MjlabOnPolicyRunner + from mjlab.tasks.tracking.rl import runner as runner_mod + + runner = _make_tracking_runner_shell("org/motions/motion:latest", "tensorboard") + + monkeypatch.setattr(MjlabOnPolicyRunner, "save", lambda *a, **kw: None) + monkeypatch.setattr(runner_mod, "get_base_metadata", lambda *a: {}) + monkeypatch.setattr(runner_mod, "attach_metadata_to_onnx", lambda *a: None) + + checkpoint = tmp_path / "run-dir" / "model_100.pt" + checkpoint.parent.mkdir() + checkpoint.touch() + + with patch.object(runner_mod, "wandb") as mock_wandb: + runner.export_policy_to_onnx = MagicMock() + runner.save(str(checkpoint)) + + mock_wandb.run.use_artifact.assert_not_called() diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_terrains.py b/05_software/train/rc_mjlab/mjlab/tests/test_terrains.py index cf81057..283c9a5 100644 --- a/05_software/train/rc_mjlab/mjlab/tests/test_terrains.py +++ b/05_software/train/rc_mjlab/mjlab/tests/test_terrains.py @@ -2,8 +2,15 @@ import mujoco import numpy as np +import pytest -from mjlab.terrains.primitive_terrains import BoxSteppingStonesTerrainCfg +from mjlab.terrains.config import ALL_TERRAIN_PRESETS +from mjlab.terrains.primitive_terrains import ( + _MIN_BORDER_HEIGHT, + BoxInvertedPyramidStairsTerrainCfg, + BoxPyramidStairsTerrainCfg, + BoxSteppingStonesTerrainCfg, +) _CFG = BoxSteppingStonesTerrainCfg( proportion=1.0, @@ -37,12 +44,10 @@ def _generate_stones( if geom is None: continue pos, size = geom.pos, geom.size - # Skip platform, floor, and border geoms. - is_platform = ( - np.isclose(pos[0], center) - and np.isclose(pos[1], center) - and np.isclose(size[0], cfg.platform_width / 2, atol=1e-4) - ) + # Skip platform, floor, and border geoms. The platform is the geom centered + # exactly at the patch center (its size is grid-snapped, not the configured + # width, so it is identified by position alone). + is_platform = np.isclose(pos[0], center) and np.isclose(pos[1], center) is_full_span = np.isclose(size[0], cfg.size[0] / 2) or np.isclose( size[1], cfg.size[1] / 2 ) @@ -74,3 +79,50 @@ def test_stone_size_decreases_with_difficulty(): sizes[difficulty] = np.mean([hx + hy for _, _, hx, hy in stones]) assert sizes[0.0] > sizes[1.0] + + +@pytest.mark.parametrize( + "cfg_cls", [BoxPyramidStairsTerrainCfg, BoxInvertedPyramidStairsTerrainCfg] +) +def test_pyramid_stairs_border_present_at_zero_difficulty(cfg_cls): + """At difficulty 0 the step height collapses to 0, but the flat border frame + must still be generated as solid, non-degenerate geometry (regression for the + empty-boundary bug, issue #1033).""" + cfg = cfg_cls( + size=(8.0, 8.0), + step_height_range=(0.0, 0.2), + step_width=0.3, + platform_width=3.0, + border_width=1.0, + ) + spec = mujoco.MjSpec() + spec.worldbody.add_body(name="terrain") + output = cfg.function(difficulty=0.0, spec=spec, rng=np.random.default_rng(0)) + + # The border frame sits below z=0 (top flush at ground level); inner step + # boxes are centered at z=0. Identify the frame by its downward offset. + border_geoms = [ + g.geom for g in output.geometries if g.geom is not None and g.geom.pos[2] < -1e-4 + ] + assert len(border_geoms) == 4, "Expected four border frame boxes." + for geom in border_geoms: + # Each frame box must be solid, not a degenerate zero-height geom, and its + # top must be flush with the ground plane at z=0. + assert geom.size[2] >= _MIN_BORDER_HEIGHT / 2 - 1e-9 + assert np.isclose(geom.pos[2] + geom.size[2], 0.0, atol=1e-6) + + +@pytest.mark.parametrize("preset_name", sorted(ALL_TERRAIN_PRESETS)) +@pytest.mark.parametrize("difficulty", [0.0, 1.0]) +def test_preset_compiles_across_difficulty(preset_name, difficulty): + """Every terrain preset must generate compilable MuJoCo geometry across the + full difficulty range. Difficulty 0 is exercised explicitly because curriculum + row 0 lands there deterministically, which previously produced degenerate + geometry (zero-height hfields, NaN colors, missing borders).""" + cfg = ALL_TERRAIN_PRESETS[preset_name](size=(8.0, 8.0)) + spec = mujoco.MjSpec() + spec.worldbody.add_body(name="terrain") + cfg.function(difficulty=difficulty, spec=spec, rng=np.random.default_rng(0)) + # Compiling validates geom/hfield sizes and rgba values (catches NaNs and + # non-positive sizes that MuJoCo rejects). + spec.compile() diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_tracking_metrics.py b/05_software/train/rc_mjlab/mjlab/tests/test_tracking_metrics.py index 15c6540..388f264 100644 --- a/05_software/train/rc_mjlab/mjlab/tests/test_tracking_metrics.py +++ b/05_software/train/rc_mjlab/mjlab/tests/test_tracking_metrics.py @@ -1,5 +1,6 @@ """Tests for motion tracking evaluation metrics.""" +import math from unittest.mock import Mock import pytest @@ -34,11 +35,11 @@ def mock_command(): def test_mpkpe_zero_when_positions_match(mock_command): - """Test MPKPE is zero when positions are identical.""" + """Test MPKPE is zero when global positions are identical.""" num_bodies = len(mock_command.cfg.body_names) positions = torch.rand(mock_command.num_envs, num_bodies, 3) - mock_command.body_pos_relative_w = positions.clone() + mock_command.body_pos_w = positions.clone() mock_command.robot_body_pos_w = positions.clone() mpkpe = compute_mpkpe(mock_command) @@ -48,10 +49,10 @@ def test_mpkpe_zero_when_positions_match(mock_command): def test_mpkpe_correct_error(mock_command): - """Test MPKPE computes correct mean error.""" + """Test MPKPE computes the correct mean global error.""" num_bodies = len(mock_command.cfg.body_names) - mock_command.body_pos_relative_w = torch.zeros(mock_command.num_envs, num_bodies, 3) + mock_command.body_pos_w = torch.zeros(mock_command.num_envs, num_bodies, 3) mock_command.robot_body_pos_w = torch.zeros(mock_command.num_envs, num_bodies, 3) mock_command.robot_body_pos_w[:, :, 0] = 1.0 # 1 unit offset in x @@ -60,58 +61,71 @@ def test_mpkpe_correct_error(mock_command): assert torch.allclose(mpkpe, torch.ones(mock_command.num_envs), atol=1e-6) -def test_r_mpkpe_invariant_to_global_translation(mock_command): - """Test R-MPKPE is invariant to global translation.""" +def test_mpkpe_uses_global_reference(mock_command): + """MPKPE must read the global reference, not the drift-cancelled one. + + Pins issue #1006: setting body_pos_relative_w to match the robot exactly + would yield zero error if it were (incorrectly) used; the metric must + instead follow body_pos_w. + """ num_bodies = len(mock_command.cfg.body_names) + robot_pos = torch.rand(mock_command.num_envs, num_bodies, 3) + mock_command.robot_body_pos_w = robot_pos.clone() + mock_command.body_pos_relative_w = robot_pos.clone() # zero error if misused + mock_command.body_pos_w = robot_pos.clone() + mock_command.body_pos_w[:, :, 0] += 1.0 # 1 unit of global drift - mock_command.anchor_pos_w = torch.zeros(mock_command.num_envs, 3) - mock_command.body_pos_w = torch.rand(mock_command.num_envs, num_bodies, 3) - mock_command.robot_anchor_pos_w = torch.zeros(mock_command.num_envs, 3) - mock_command.robot_body_pos_w = mock_command.body_pos_w.clone() + mpkpe = compute_mpkpe(mock_command) - r_mpkpe_1 = compute_root_relative_mpkpe(mock_command) - - # Translate everything by large offset. - offset = torch.tensor([100.0, 200.0, 300.0]) - mock_command.anchor_pos_w = offset.expand(mock_command.num_envs, 3).clone() - mock_command.body_pos_w = mock_command.body_pos_w + offset - mock_command.robot_anchor_pos_w = offset.expand(mock_command.num_envs, 3).clone() - mock_command.robot_body_pos_w = mock_command.robot_body_pos_w + offset - - r_mpkpe_2 = compute_root_relative_mpkpe(mock_command) - - assert torch.allclose(r_mpkpe_1, r_mpkpe_2, atol=1e-5) + assert torch.allclose(mpkpe, torch.ones(mock_command.num_envs), atol=1e-6) -def test_r_mpkpe_detects_relative_error(mock_command): - """Test R-MPKPE detects errors in relative positions.""" +def test_r_mpkpe_zero_when_relative_positions_match(mock_command): + """R-MPKPE is zero when re-anchored positions are identical.""" num_bodies = len(mock_command.cfg.body_names) + positions = torch.rand(mock_command.num_envs, num_bodies, 3) - mock_command.anchor_pos_w = torch.zeros(mock_command.num_envs, 3) - mock_command.body_pos_w = torch.zeros(mock_command.num_envs, num_bodies, 3) - mock_command.body_pos_w[:, :, 0] = 1.0 # Bodies 1 unit from anchor + mock_command.body_pos_relative_w = positions.clone() + mock_command.robot_body_pos_w = positions.clone() - mock_command.robot_anchor_pos_w = torch.zeros(mock_command.num_envs, 3) - mock_command.robot_body_pos_w = torch.zeros(mock_command.num_envs, num_bodies, 3) - mock_command.robot_body_pos_w[:, :, 0] = 2.0 # Bodies 2 units from anchor + r_mpkpe = compute_root_relative_mpkpe(mock_command) + + assert r_mpkpe.shape == (mock_command.num_envs,) + assert torch.allclose(r_mpkpe, torch.zeros(mock_command.num_envs), atol=1e-6) + + +def test_r_mpkpe_uses_relative_reference(mock_command): + """R-MPKPE reads the re-anchored reference, not the global one. + + Setting body_pos_w to match the robot exactly would yield zero error if + it were (incorrectly) used; the metric must instead follow + body_pos_relative_w. + """ + num_bodies = len(mock_command.cfg.body_names) + robot_pos = torch.rand(mock_command.num_envs, num_bodies, 3) + mock_command.robot_body_pos_w = robot_pos.clone() + mock_command.body_pos_w = robot_pos.clone() # zero error if misused + mock_command.body_pos_relative_w = robot_pos.clone() + mock_command.body_pos_relative_w[:, :, 0] += 1.0 # 1 unit of local pose error r_mpkpe = compute_root_relative_mpkpe(mock_command) assert torch.allclose(r_mpkpe, torch.ones(mock_command.num_envs), atol=1e-6) -def test_joint_velocity_error(mock_command): - """Test joint velocity error computes correct L2 norm.""" +def test_joint_velocity_error_rms(mock_command): + """Joint velocity error is the per-joint RMS of the velocity error.""" num_joints = 3 mock_command.joint_vel = torch.zeros(mock_command.num_envs, num_joints) mock_command.robot_joint_vel = torch.zeros(mock_command.num_envs, num_joints) mock_command.robot_joint_vel[:, 0] = 3.0 - mock_command.robot_joint_vel[:, 1] = 4.0 # Error [3, 4, 0] has norm 5 + mock_command.robot_joint_vel[:, 1] = 4.0 # Error [3, 4, 0] error = compute_joint_velocity_error(mock_command) - assert torch.allclose(error, torch.ones(mock_command.num_envs) * 5.0, atol=1e-6) + expected = math.sqrt((3.0**2 + 4.0**2 + 0.0**2) / num_joints) + assert torch.allclose(error, torch.ones(mock_command.num_envs) * expected, atol=1e-6) def test_ee_position_error_only_uses_specified_bodies(mock_command): @@ -153,3 +167,13 @@ def test_ee_orientation_error_detects_rotation(mock_command): # Error should be approximately pi/2 radians. expected = torch.ones(mock_command.num_envs) * (3.14159 / 2) assert torch.allclose(error, expected, atol=0.01) + + +def test_ee_metrics_raise_on_unknown_body(mock_command): + """Unknown end-effector names raise instead of silently scoring zero.""" + num_bodies = len(mock_command.cfg.body_names) + mock_command.body_pos_relative_w = torch.zeros(mock_command.num_envs, num_bodies, 3) + mock_command.robot_body_pos_w = torch.zeros(mock_command.num_envs, num_bodies, 3) + + with pytest.raises(ValueError, match="not tracked"): + compute_ee_position_error(mock_command, ("nonexistent_body",)) diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_variants.py b/05_software/train/rc_mjlab/mjlab/tests/test_variants.py new file mode 100644 index 0000000..5acca0b --- /dev/null +++ b/05_software/train/rc_mjlab/mjlab/tests/test_variants.py @@ -0,0 +1,1772 @@ +"""Tests for per-world mesh variant support.""" + +from __future__ import annotations + +from typing import Any, cast + +import mujoco +import numpy as np +import pytest +import torch + +from mjlab.entity import ( + EntityCfg, + VariantEntityCfg, +) +from mjlab.entity.variants import ( + SlotKey, + VariantGeomSpec, + VariantSlot, + allocate_worlds, + build_variant_model, +) +from mjlab.viewer.model_sync import ( + disable_model_sameframe_shortcuts, + sync_model_fields, +) + +# Helpers: variant specs with visual + collision mesh geoms. + + +def _sphere_2col_spec() -> mujoco.MjSpec: + """Sphere: 1 visual + 2 collision geoms.""" + spec = mujoco.MjSpec() + mv = spec.add_mesh() + mv.name = "visual" + mv.make_sphere(subdivision=3) + for i in range(2): + mc = spec.add_mesh() + mc.name = f"col_{i}" + mc.make_sphere(subdivision=1) + body = spec.worldbody.add_body() + body.name = "prop" + body.add_freejoint() + gv = body.add_geom() + gv.name = "visual" + gv.type = mujoco.mjtGeom.mjGEOM_MESH + gv.meshname = "visual" + gv.contype = 0 + gv.conaffinity = 0 + for i in range(2): + gc = body.add_geom() + gc.name = f"col_{i}" + gc.type = mujoco.mjtGeom.mjGEOM_MESH + gc.meshname = f"col_{i}" + return spec + + +def _cone_4col_spec() -> mujoco.MjSpec: + """Cone: 1 visual + 4 collision geoms (more than sphere).""" + spec = mujoco.MjSpec() + mv = spec.add_mesh() + mv.name = "visual" + mv.make_cone(nedge=8, radius=0.05) + for i in range(4): + mc = spec.add_mesh() + mc.name = f"col_{i}" + mc.make_sphere(subdivision=1) + body = spec.worldbody.add_body() + body.name = "prop" + body.add_freejoint() + gv = body.add_geom() + gv.name = "visual" + gv.type = mujoco.mjtGeom.mjGEOM_MESH + gv.meshname = "visual" + gv.contype = 0 + gv.conaffinity = 0 + for i in range(4): + gc = body.add_geom() + gc.name = f"col_{i}" + gc.type = mujoco.mjtGeom.mjGEOM_MESH + gc.meshname = f"col_{i}" + return spec + + +def _simple_sphere_spec() -> mujoco.MjSpec: + """Single-geom sphere for simple tests.""" + spec = mujoco.MjSpec() + m = spec.add_mesh() + m.name = "sphere" + m.make_sphere(subdivision=2) + body = spec.worldbody.add_body() + body.name = "prop" + body.add_freejoint() + g = body.add_geom() + g.name = "visual" + g.type = mujoco.mjtGeom.mjGEOM_MESH + g.meshname = "sphere" + return spec + + +def _simple_cone_spec() -> mujoco.MjSpec: + """Single-geom cone for simple tests.""" + spec = mujoco.MjSpec() + m = spec.add_mesh() + m.name = "cone" + m.make_cone(nedge=8, radius=0.05) + body = spec.worldbody.add_body() + body.name = "prop" + body.add_freejoint() + g = body.add_geom() + g.name = "visual" + g.type = mujoco.mjtGeom.mjGEOM_MESH + g.meshname = "cone" + return spec + + +def _hinge_spec() -> mujoco.MjSpec: + """Object with a hinge joint (incompatible with freejoint variants).""" + spec = mujoco.MjSpec() + m = spec.add_mesh() + m.name = "box" + m.make_sphere(subdivision=1) + body = spec.worldbody.add_body() + body.name = "prop" + j = body.add_joint() + j.name = "hinge" + j.type = mujoco.mjtJoint.mjJNT_HINGE + g = body.add_geom() + g.name = "visual" + g.type = mujoco.mjtGeom.mjGEOM_MESH + g.meshname = "box" + return spec + + +def _build_scene_with_variants( + variant_a_fn, variant_b_fn, *, weight_a=0.5, weight_b=0.5 +): + """Build a scene spec + variant_info from two variant spec_fns.""" + cfg = VariantEntityCfg( + variants={"a": variant_a_fn, "b": variant_b_fn}, + assignment={"a": weight_a, "b": weight_b}, + ) + entity = cfg.build() + assert entity.variant_metadata is not None + scene_spec = mujoco.MjSpec() + frame = scene_spec.worldbody.add_frame() + scene_spec.attach(entity.spec, prefix="object/", frame=frame) + return scene_spec, [("object/", entity.variant_metadata)] + + +# allocate_worlds. + + +def test_allocate_worlds_proportional(): + result = allocate_worlds((0.6, 0.4), 10) + assert len(result) == 10 + assert result.count(0) == 6 + assert result.count(1) == 4 + + +def test_allocate_worlds_uniform(): + result = allocate_worlds((1.0, 1.0), 8) + assert result.count(0) == 4 + assert result.count(1) == 4 + + +def test_allocate_worlds_single_variant(): + result = allocate_worlds((1.0,), 5) + assert result == [0, 0, 0, 0, 0] + + +def test_allocate_worlds_zero_weight_skips_variant(): + """A zero-weight variant gets zero worlds; the rest split nworld.""" + result = allocate_worlds((1.0, 0.0, 1.0), 10) + assert len(result) == 10 + assert result.count(1) == 0 + assert result.count(0) == 5 + assert result.count(2) == 5 + + +def test_allocate_worlds_rejects_negative_weight(): + with pytest.raises(ValueError, match="non-negative"): + allocate_worlds((1.0, -0.1), 10) + + +def test_allocate_worlds_rejects_all_zero(): + with pytest.raises(ValueError, match="positive sum"): + allocate_worlds((0.0, 0.0), 10) + + +def test_allocate_worlds_largest_remainder_sums_to_nworld(): + """Largest-remainder rounding must always allocate exactly nworld worlds.""" + for nworld in (3, 7, 100, 1000): + result = allocate_worlds((1.0, 1.0, 1.0), nworld) + assert len(result) == nworld + # Difference between any two variant counts is at most 1 (uniform). + counts = [result.count(i) for i in range(3)] + assert max(counts) - min(counts) <= 1 + + +# assignment_fn override. + + +def test_assignment_fn_overrides_weights(): + """When assignment_fn is set, it dictates the per-world variant indices.""" + cfg = VariantEntityCfg( + variants={ + "sphere": _simple_sphere_spec, + "cone": _simple_cone_spec, # ignored + }, + assignment=lambda nworld: [0, 1, 0, 1] * (nworld // 4), + ) + entity = cfg.build() + assert entity.variant_metadata is not None + scene_spec = mujoco.MjSpec() + frame = scene_spec.worldbody.add_frame() + scene_spec.attach(entity.spec, prefix="object/", frame=frame) + result = build_variant_model(scene_spec, 8, [("object/", entity.variant_metadata)]) + w2v = result.world_to_variant["object/"] + assert list(w2v) == [0, 1, 0, 1, 0, 1, 0, 1] + + +def test_assignment_fn_seeded_is_nworld_invariant(): + """Per-world independent RNG draws make world W's variant a function of W + alone, independent of nworld.""" + weights = (1.0, 2.0, 1.0) + cum = np.cumsum(np.asarray(weights) / sum(weights)) + + def seeded_assignment(seed: int): + def fn(nworld: int) -> list[int]: + return [ + int(np.searchsorted(cum, np.random.default_rng((seed, w)).random())) + for w in range(nworld) + ] + + return fn + + fn_64 = seeded_assignment(seed=42)(64) + fn_256 = seeded_assignment(seed=42)(256) + # World 0..63 must agree across both batch sizes. + assert fn_64 == fn_256[:64] + + +def test_assignment_fn_rejects_wrong_length(): + cfg = VariantEntityCfg( + variants={ + "sphere": _simple_sphere_spec, + "cone": _simple_cone_spec, + }, + assignment=lambda nworld: [0] * (nworld - 1), # one short + ) + entity = cfg.build() + assert entity.variant_metadata is not None + scene_spec = mujoco.MjSpec() + frame = scene_spec.worldbody.add_frame() + scene_spec.attach(entity.spec, prefix="object/", frame=frame) + with pytest.raises(ValueError, match="returned .* indices but nworld="): + build_variant_model(scene_spec, 4, [("object/", entity.variant_metadata)]) + + +def test_assignment_fn_rejects_out_of_range_index(): + cfg = VariantEntityCfg( + variants={ + "sphere": _simple_sphere_spec, + "cone": _simple_cone_spec, + }, + assignment=lambda nworld: [0, 1, 0, 99], # 99 is out of range + ) + entity = cfg.build() + assert entity.variant_metadata is not None + scene_spec = mujoco.MjSpec() + frame = scene_spec.worldbody.add_frame() + scene_spec.attach(entity.spec, prefix="object/", frame=frame) + with pytest.raises(ValueError, match="returned variant index 99"): + build_variant_model(scene_spec, 4, [("object/", entity.variant_metadata)]) + + +# Entity merging. + + +def test_entity_builds_with_variants(): + cfg = VariantEntityCfg( + variants={ + "sphere": _simple_sphere_spec, + "cone": _simple_cone_spec, + }, + ) + entity = cfg.build() + meta = entity.variant_metadata + assert meta is not None + assert meta.variant_names == ("sphere", "cone") + assert meta.num_mesh_geoms == 1 + mesh_names = [m.name for m in entity.spec.meshes] + assert any("sphere" in n for n in mesh_names) + assert any("cone" in n for n in mesh_names) + + +def test_multi_geom_body_padding(): + """Sphere (3 geoms) + cone (5 geoms) -> body padded to 5 mesh geoms.""" + cfg = VariantEntityCfg( + variants={ + "sphere": _sphere_2col_spec, + "cone": _cone_4col_spec, + }, + ) + entity = cfg.build() + meta = entity.variant_metadata + assert meta is not None + assert meta.num_mesh_geoms == 5 # max(3, 5) + # Sphere: 3 real + 2 padding (None). + assert sum(1 for n in meta.variant_mesh_names[0] if n is None) == 2 + # Cone: 5 real, no padding. + assert all(n is not None for n in meta.variant_mesh_names[1]) + + +# Validation. + + +def test_mismatched_joint_structure_raises(): + cfg = VariantEntityCfg( + variants={ + "sphere": _simple_sphere_spec, + "hinge": _hinge_spec, + }, + ) + with pytest.raises(ValueError, match="joint"): + cfg.build() + + +def test_single_variant_builds(): + """A single variant degenerates cleanly; useful for templated variant sets.""" + cfg = VariantEntityCfg( + variants={"only": _simple_sphere_spec}, + ) + entity = cfg.build() + assert entity.variant_metadata is not None + assert entity.variant_metadata.variant_names == ("only",) + + +def test_empty_variants_raises(): + cfg = VariantEntityCfg(variants={}) + with pytest.raises(ValueError, match="at least one"): + cfg.build() + + +def _fixed_base_sphere_spec() -> mujoco.MjSpec: + """Fixed-base sphere variant (no free joint): currently unsupported.""" + spec = mujoco.MjSpec() + m = spec.add_mesh(name="sphere") + m.make_sphere(subdivision=2) + body = spec.worldbody.add_body(name="prop") + body.add_geom(type=mujoco.mjtGeom.mjGEOM_MESH, meshname="sphere") + return spec + + +def test_fixed_base_variants_rejected(): + """Variants must be floating-base; fixed-base raises with a clear message.""" + cfg = VariantEntityCfg( + variants={ + "a": _fixed_base_sphere_spec, + "b": _fixed_base_sphere_spec, + }, + ) + with pytest.raises(ValueError, match="floating-base"): + cfg.build() + + +def test_setting_spec_fn_on_variant_cfg_raises(): + """VariantEntityCfg.spec_fn is unused; setting it should fail loudly.""" + with pytest.raises(ValueError, match="spec_fn cannot be set"): + VariantEntityCfg( + variants={"only": _simple_sphere_spec}, + spec_fn=_simple_sphere_spec, + ) + + +# Recursive validation: helpers and tests. + + +def _articulated_spec( + *, + root_mesh: str = "root_mesh", + child_mesh: str = "child_mesh", + with_grandchild: bool = False, +) -> mujoco.MjSpec: + """Root + child body (hinge joint). Optional grandchild for arity tests.""" + spec = mujoco.MjSpec() + rm = spec.add_mesh(name=root_mesh) + rm.make_sphere(subdivision=2) + cm = spec.add_mesh(name=child_mesh) + cm.make_sphere(subdivision=2) + root = spec.worldbody.add_body(name="prop") + root.add_freejoint() + rg = root.add_geom() + rg.name = "root_geom" + rg.type = mujoco.mjtGeom.mjGEOM_MESH + rg.meshname = root_mesh + child = root.add_body(name="lid") + cj = child.add_joint() + cj.name = "hinge" + cj.type = mujoco.mjtJoint.mjJNT_HINGE + cg = child.add_geom() + cg.name = "child_geom" + cg.type = mujoco.mjtGeom.mjGEOM_MESH + cg.meshname = child_mesh + if with_grandchild: + gm = spec.add_mesh(name="grand_mesh") + gm.make_sphere(subdivision=2) + grand = child.add_body(name="grand") + grand.add_geom( + name="grand_geom", type=mujoco.mjtGeom.mjGEOM_MESH, meshname="grand_mesh" + ) + return spec + + +def _spec_with_actuator(actuator_name: str = "act") -> mujoco.MjSpec: + """Single-body sphere with a hinge child + a position actuator.""" + spec = mujoco.MjSpec() + m = spec.add_mesh(name="sphere") + m.make_sphere(subdivision=2) + root = spec.worldbody.add_body(name="prop") + root.add_freejoint() + root.add_geom(name="visual", type=mujoco.mjtGeom.mjGEOM_MESH, meshname="sphere") + child = root.add_body(name="lid") + cj = child.add_joint() + cj.name = "hinge" + cj.type = mujoco.mjtJoint.mjJNT_HINGE + child.add_geom(name="lid_geom", type=mujoco.mjtGeom.mjGEOM_MESH, meshname="sphere") + act = spec.add_actuator() + act.name = actuator_name + act.set_to_motor() + act.target = "hinge" + return spec + + +def _spec_with_primitive(primitive_role: str = "collision") -> mujoco.MjSpec: + """Sphere variant with an additional primitive box (visual or collision).""" + spec = mujoco.MjSpec() + m = spec.add_mesh(name="sphere") + m.make_sphere(subdivision=2) + body = spec.worldbody.add_body(name="prop") + body.add_freejoint() + box = body.add_geom() + box.name = "primitive" + box.type = mujoco.mjtGeom.mjGEOM_BOX + box.size = np.array([0.05, 0.05, 0.05]) + if primitive_role == "visual": + box.contype = 0 + box.conaffinity = 0 + body.add_geom(name="mesh_geom", type=mujoco.mjtGeom.mjGEOM_MESH, meshname="sphere") + return spec + + +def _spec_with_diagonal_inertia() -> mujoco.MjSpec: + spec = _simple_sphere_spec() + body = list(spec.worldbody.bodies)[0] + body.explicitinertial = 1 + body.mass = 1.0 + body.ipos = np.array([0.0, 0.0, 0.0]) + body.inertia = np.array([0.001, 0.001, 0.001]) + body.iquat = np.array([1.0, 0.0, 0.0, 0.0]) + return spec + + +def _spec_with_fullinertia() -> mujoco.MjSpec: + spec = _simple_sphere_spec() + body = list(spec.worldbody.bodies)[0] + body.explicitinertial = 1 + body.mass = 1.0 + body.ipos = np.array([0.0, 0.0, 0.0]) + body.fullinertia = np.array([0.001, 0.001, 0.001, 0.0, 0.0, 0.0]) + return spec + + +def _spec_with_reserved_mesh_name() -> mujoco.MjSpec: + spec = mujoco.MjSpec() + m = spec.add_mesh(name="mjlab/pad/sneaky") + m.make_sphere(subdivision=2) + body = spec.worldbody.add_body(name="prop") + body.add_freejoint() + body.add_geom( + name="visual", type=mujoco.mjtGeom.mjGEOM_MESH, meshname="mjlab/pad/sneaky" + ) + return spec + + +def test_articulated_same_topology_validates(): + """Articulated variants with matching topology pass validation + (build still rejects via floating-base check; this verifies validation + itself does not complain).""" + cfg = VariantEntityCfg( + variants={ + "a": lambda: _articulated_spec(root_mesh="r_a", child_mesh="c_a"), + "b": lambda: _articulated_spec(root_mesh="r_b", child_mesh="c_b"), + }, + ) + entity = cfg.build() + assert entity.variant_metadata is not None + + +def test_recursive_child_body_count_mismatch_rejected(): + """Variants with different grandchild counts fail recursive validation.""" + cfg = VariantEntityCfg( + variants={ + "shallow": lambda: _articulated_spec(with_grandchild=False), + "deep": lambda: _articulated_spec(with_grandchild=True), + }, + ) + with pytest.raises(ValueError, match="child bodies"): + cfg.build() + + +def test_recursive_child_body_name_mismatch_rejected(): + def variant_lid(): + return _articulated_spec() + + def variant_renamed_child(): + spec = _articulated_spec() + list(spec.worldbody.bodies)[0].bodies[0].name = "drawer_top" + return spec + + cfg = VariantEntityCfg( + variants={ + "lid": variant_lid, + "drawer": variant_renamed_child, + }, + ) + with pytest.raises(ValueError, match="body path"): + cfg.build() + + +def test_recursive_joint_mismatch_in_child_body_rejected(): + def variant_a(): + return _articulated_spec() + + def variant_b_slide(): + spec = _articulated_spec() + child = list(spec.worldbody.bodies)[0].bodies[0] + list(child.joints)[0].type = mujoco.mjtJoint.mjJNT_SLIDE + return spec + + cfg = VariantEntityCfg( + variants={ + "hinge": variant_a, + "slide": variant_b_slide, + }, + ) + with pytest.raises(ValueError, match="joint"): + cfg.build() + + +def test_primitive_geom_count_mismatch_rejected(): + cfg = VariantEntityCfg( + variants={ + "with_box": _spec_with_primitive, + "without_box": _simple_sphere_spec, + }, + ) + with pytest.raises(ValueError, match="non-mesh geoms"): + cfg.build() + + +def test_primitive_geom_role_mismatch_rejected(): + cfg = VariantEntityCfg( + variants={ + "col": lambda: _spec_with_primitive("collision"), + "vis": lambda: _spec_with_primitive("visual"), + }, + ) + with pytest.raises(ValueError, match="primitive geom"): + cfg.build() + + +def test_actuator_count_mismatch_rejected(): + cfg = VariantEntityCfg( + variants={ + "no_act": _articulated_spec, + "with_act": _spec_with_actuator, + }, + ) + with pytest.raises(ValueError, match="actuator count"): + cfg.build() + + +def test_actuator_name_mismatch_rejected(): + cfg = VariantEntityCfg( + variants={ + "act_a": lambda: _spec_with_actuator("motor_a"), + "act_b": lambda: _spec_with_actuator("motor_b"), + }, + ) + with pytest.raises(ValueError, match="actuator #0"): + cfg.build() + + +def test_fullinertia_diagonal_mixing_rejected(): + cfg = VariantEntityCfg( + variants={ + "diag": _spec_with_diagonal_inertia, + "full": _spec_with_fullinertia, + }, + ) + with pytest.raises(ValueError, match="inertial representation"): + cfg.build() + + +def test_diagonal_inertia_consistent_accepted(): + cfg = VariantEntityCfg( + variants={ + "a": _spec_with_diagonal_inertia, + "b": _spec_with_diagonal_inertia, + }, + ) + entity = cfg.build() + assert entity.variant_metadata is not None + + +def test_reserved_prefix_in_mesh_rejected(): + cfg = VariantEntityCfg( + variants={ + "good": _simple_sphere_spec, + "bad": _spec_with_reserved_mesh_name, + }, + ) + with pytest.raises(ValueError, match="reserved name prefix"): + cfg.build() + + +def test_validation_error_format(): + """Error messages use the standardized mjlab.entity prefix and Hint suffix.""" + cfg = VariantEntityCfg( + variants={ + "ok": _simple_sphere_spec, + "bad": _hinge_spec, + }, + ) + with pytest.raises(ValueError) as excinfo: + cfg.build() + msg = str(excinfo.value) + assert msg.startswith("mjlab.entity: VariantEntityCfg 'bad': ") + assert "Hint:" in msg + + +def _spec_with_sensor() -> mujoco.MjSpec: + """Sphere with a free joint and a velocity sensor on it.""" + spec = _simple_sphere_spec() + s = spec.add_sensor() + s.name = "vel" + s.type = mujoco.mjtSensor.mjSENS_VELOCIMETER + s.objtype = mujoco.mjtObj.mjOBJ_SITE + s.objname = "site_a" + # Sensor needs a site target; add one. + body = list(spec.worldbody.bodies)[0] + site = body.add_site() + site.name = "site_a" + return spec + + +def test_sensor_count_mismatch_rejected(): + cfg = VariantEntityCfg( + variants={ + "no_sens": _simple_sphere_spec, + "with_sens": _spec_with_sensor, + }, + ) + with pytest.raises(ValueError, match="sensor count"): + cfg.build() + + +def test_validate_specs_directly_rejects_zero_root_bodies(): + """Empty worldbody fails with a clear root-body message.""" + from mjlab.entity.variants import validate_variant_specs + + empty = mujoco.MjSpec() + ok = _simple_sphere_spec() + with pytest.raises(ValueError, match="exactly one root body"): + validate_variant_specs(["empty", "ok"], [empty, ok]) + + +# Slot metadata (Workstream 3). + + +def _slot_is_padding(meta, slot_index: int) -> bool: + """True if any variant leaves the slot at ``slot_index`` unfilled.""" + return any(specs[slot_index] is None for specs in meta.variant_slot_specs) + + +def test_slot_metadata_single_variant_single_geom(): + cfg = VariantEntityCfg(variants={"only": _simple_sphere_spec}) + meta = cfg.build().variant_metadata + assert meta is not None + assert len(meta.slots) == 1 + slot = meta.slots[0] + assert isinstance(slot, VariantSlot) + assert slot.key == SlotKey(body_path="/prop", role="collision", ordinal=0) + assert _slot_is_padding(meta, 0) is False + assert slot.template_geom_name == "mjlab/pad/prop/collision/0" + # source_geom_names has one entry per variant. + assert slot.source_geom_names == ("visual",) + # variant_slot_specs aligns with slots. + assert len(meta.variant_slot_specs) == 1 + assert len(meta.variant_slot_specs[0]) == 1 + vgs = meta.variant_slot_specs[0][0] + assert isinstance(vgs, VariantGeomSpec) + assert vgs.geom_name == "visual" + assert vgs.mesh_name == "sphere" + + +def test_slot_metadata_visual_collision_split(): + """Visual and collision geoms on the same body get distinct slots.""" + cfg = VariantEntityCfg( + variants={ + "sphere": _sphere_2col_spec, + "cone": _cone_4col_spec, + } + ) + meta = cfg.build().variant_metadata + assert meta is not None + # Body /prop has 1 visual + max(2, 4) = 4 collision slots. + visual_slots = [s for s in meta.slots if s.key.role == "visual"] + collision_slots = [s for s in meta.slots if s.key.role == "collision"] + assert len(visual_slots) == 1 + assert len(collision_slots) == 4 + assert all(s.key.body_path == "/prop" for s in meta.slots) + + +def test_slot_metadata_visual_before_collision(): + """Slot ordering: visual slots come before collision slots per body.""" + cfg = VariantEntityCfg( + variants={ + "sphere": _sphere_2col_spec, + "cone": _cone_4col_spec, + } + ) + meta = cfg.build().variant_metadata + assert meta is not None + roles = [s.key.role for s in meta.slots] + # Find first collision; all visual should come before it. + first_col = roles.index("collision") + assert all(r == "visual" for r in roles[:first_col]) + assert all(r == "collision" for r in roles[first_col:]) + + +def test_slot_metadata_padding_derivable_from_specs(): + """A slot held by some variants but not others is unfilled (None) for those.""" + cfg = VariantEntityCfg( + variants={ + "sphere": _sphere_2col_spec, + "cone": _cone_4col_spec, + } + ) + meta = cfg.build().variant_metadata + assert meta is not None + col_positions = [i for i, s in enumerate(meta.slots) if s.key.role == "collision"] + # Sphere has 2 collision; cone has 4. Slots 0, 1 fully populated; 2, 3 are padding. + assert _slot_is_padding(meta, col_positions[0]) is False + assert _slot_is_padding(meta, col_positions[1]) is False + assert _slot_is_padding(meta, col_positions[2]) is True + assert _slot_is_padding(meta, col_positions[3]) is True + # Sphere has None at the padding slots. + variant_idx_sphere = meta.variant_names.index("sphere") + specs_sphere = meta.variant_slot_specs[variant_idx_sphere] + assert specs_sphere[col_positions[2]] is None + assert specs_sphere[col_positions[3]] is None + # Cone has VariantGeomSpec for all collision slots. + variant_idx_cone = meta.variant_names.index("cone") + specs_cone = meta.variant_slot_specs[variant_idx_cone] + for cp in col_positions: + assert specs_cone[cp] is not None + + +def test_slot_metadata_articulated_per_body_slots(): + """Articulated variants produce slots per (body, role).""" + cfg = VariantEntityCfg( + variants={ + "a": lambda: _articulated_spec(root_mesh="ar", child_mesh="ac"), + "b": lambda: _articulated_spec(root_mesh="br", child_mesh="bc"), + } + ) + meta = cfg.build().variant_metadata + assert meta is not None + # Two bodies (/prop, /prop/lid), each with 1 collision mesh -> 2 slots. + paths = sorted({s.key.body_path for s in meta.slots}) + assert paths == ["/prop", "/prop/lid"] + # Each body has exactly one collision slot, no visuals. + for path in paths: + body_slots = [s for s in meta.slots if s.key.body_path == path] + assert len(body_slots) == 1 + assert body_slots[0].key.role == "collision" + slot_idx = meta.slots.index(body_slots[0]) + assert _slot_is_padding(meta, slot_idx) is False + # Mesh names captured per variant per slot. + variant_a_specs = meta.variant_slot_specs[meta.variant_names.index("a")] + variant_b_specs = meta.variant_slot_specs[meta.variant_names.index("b")] + a_meshes = sorted(s.mesh_name for s in variant_a_specs if s is not None) + b_meshes = sorted(s.mesh_name for s in variant_b_specs if s is not None) + assert a_meshes == ["ac", "ar"] + assert b_meshes == ["bc", "br"] + + +def test_slot_metadata_template_name_under_reserved_prefix(): + """Every template slot name starts with the reserved mjlab/pad/ prefix.""" + cfg = VariantEntityCfg( + variants={ + "a": _simple_sphere_spec, + "b": _simple_cone_spec, + } + ) + meta = cfg.build().variant_metadata + assert meta is not None + for slot in meta.slots: + assert slot.template_geom_name.startswith("mjlab/pad/") + + +def test_slot_metadata_captures_visual_role_from_zero_contact_bits(): + """A geom with contype=0 and conaffinity=0 is classified as visual.""" + cfg = VariantEntityCfg( + variants={ + "sphere": _sphere_2col_spec, + "cone": _cone_4col_spec, + } + ) + meta = cfg.build().variant_metadata + assert meta is not None + visual_slots = [s for s in meta.slots if s.key.role == "visual"] + assert len(visual_slots) == 1 + visual_slot = visual_slots[0] + for variant_specs in meta.variant_slot_specs: + spec_at_visual = variant_specs[meta.slots.index(visual_slot)] + assert spec_at_visual is not None + assert spec_at_visual.contype == 0 + assert spec_at_visual.conaffinity == 0 + + +def test_slot_metadata_ordinals_are_zero_based_per_body_and_role(): + """Within a (body, role), slot ordinals are 0..max-1.""" + cfg = VariantEntityCfg( + variants={ + "sphere": _sphere_2col_spec, + "cone": _cone_4col_spec, + } + ) + meta = cfg.build().variant_metadata + assert meta is not None + collision_slots = [s for s in meta.slots if s.key.role == "collision"] + assert [s.key.ordinal for s in collision_slots] == [0, 1, 2, 3] + visual_slots = [s for s in meta.slots if s.key.role == "visual"] + assert [s.key.ordinal for s in visual_slots] == [0] + + +def test_slot_metadata_alignment_invariant(): + """variant_slot_specs[v] aligns with slots positionally for every variant.""" + cfg = VariantEntityCfg( + variants={ + "sphere": _sphere_2col_spec, + "cone": _cone_4col_spec, + } + ) + meta = cfg.build().variant_metadata + assert meta is not None + for variant_specs in meta.variant_slot_specs: + assert len(variant_specs) == len(meta.slots) + + +def test_template_geom_contype_matches_slot_role(): + """Template contype/conaffinity are derived from slot role (union of variants).""" + scene_spec, vi = _build_scene_with_variants(_sphere_2col_spec, _cone_4col_spec) + result = build_variant_model(scene_spec, 4, vi) + metadata = vi[0][1] + for slot in metadata.slots: + full_name = f"object/{slot.template_geom_name}" + gid = mujoco.mj_name2id(result.mj_model, mujoco.mjtObj.mjOBJ_GEOM, full_name) + assert gid >= 0, f"slot geom '{full_name}' missing from compiled model" + contype = int(result.mj_model.geom_contype[gid]) + conaffinity = int(result.mj_model.geom_conaffinity[gid]) + if slot.key.role == "visual": + assert contype == 0, f"visual slot {slot.key} has contype={contype}" + assert conaffinity == 0, f"visual slot {slot.key} has conaffinity={conaffinity}" + else: + assert contype == 1, f"collision slot {slot.key} has contype={contype}" + assert conaffinity == 1, ( + f"collision slot {slot.key} has conaffinity={conaffinity}" + ) + + +def test_template_geoms_use_mjlab_pad_prefix(): + """All entity mesh-geom names in the compiled template use mjlab/pad/ prefix.""" + scene_spec, vi = _build_scene_with_variants(_sphere_2col_spec, _cone_4col_spec) + result = build_variant_model(scene_spec, 4, vi) + for gid in range(result.mj_model.ngeom): + if result.mj_model.geom_type[gid] != mujoco.mjtGeom.mjGEOM_MESH: + continue + name = mujoco.mj_id2name(result.mj_model, mujoco.mjtObj.mjOBJ_GEOM, gid) or "" + if not name.startswith("object/"): + continue + suffix = name[len("object/") :] + assert suffix.startswith("mjlab/pad/"), ( + f"entity mesh geom '{name}' does not use mjlab/pad/ prefix" + ) + + +def test_visual_collision_split_inertia_matches_independent_compile(): + """Per-world body_mass matches independent compile when variants have visual+collision split. + + This verifies the slot-driven reference compile preserves the visual + role (contype=0/conaffinity=0) on the visual mesh; if the old + contype=1/conaffinity=1 reset still ran, the visual mesh's + inertia-inference behavior would not change for default groups, but + this exercise pins the contract end-to-end. + """ + scene_spec, vi = _build_scene_with_variants(_sphere_2col_spec, _cone_4col_spec) + result = build_variant_model(scene_spec, 4, vi) + + sphere_model = _sphere_2col_spec().compile() + cone_model = _cone_4col_spec().compile() + + body_mass = result.wp_model.body_mass.numpy() + w2v = result.world_to_variant["object/"] + obj_body = result.mj_model.nbody - 1 + + sphere_w = int(np.where(w2v == 0)[0][0]) + cone_w = int(np.where(w2v == 1)[0][0]) + + np.testing.assert_allclose( + body_mass[sphere_w, obj_body], + sphere_model.body_mass[-1], + atol=1e-4, + ) + np.testing.assert_allclose( + body_mass[cone_w, obj_body], + cone_model.body_mass[-1], + atol=1e-4, + ) + + +def test_variant_order_irrelevant_per_variant_compile(): + """Reordering the variant dict does not change per-variant per-world fields.""" + cfg_ab = VariantEntityCfg( + variants={ + "a": _simple_sphere_spec, + "b": _simple_cone_spec, + } + ) + cfg_ba = VariantEntityCfg( + variants={ + "b": _simple_cone_spec, + "a": _simple_sphere_spec, + } + ) + + def _build_scene(cfg: VariantEntityCfg): + entity = cfg.build() + assert entity.variant_metadata is not None + scene_spec = mujoco.MjSpec() + frame = scene_spec.worldbody.add_frame() + scene_spec.attach(entity.spec, prefix="object/", frame=frame) + return scene_spec, [("object/", entity.variant_metadata)] + + scene_ab, vi_ab = _build_scene(cfg_ab) + scene_ba, vi_ba = _build_scene(cfg_ba) + res_ab = build_variant_model(scene_ab, 4, vi_ab) + res_ba = build_variant_model(scene_ba, 4, vi_ba) + + obj_body_ab = res_ab.mj_model.nbody - 1 + obj_body_ba = res_ba.mj_model.nbody - 1 + + body_mass_ab = res_ab.wp_model.body_mass.numpy() + body_mass_ba = res_ba.wp_model.body_mass.numpy() + + # In cfg_ab, "a" is variant index 0; in cfg_ba, "a" is variant index 1. + w2v_ab = res_ab.world_to_variant["object/"] + w2v_ba = res_ba.world_to_variant["object/"] + a_world_ab = int(np.where(w2v_ab == 0)[0][0]) + a_world_ba = int(np.where(w2v_ba == 1)[0][0]) + b_world_ab = int(np.where(w2v_ab == 1)[0][0]) + b_world_ba = int(np.where(w2v_ba == 0)[0][0]) + + np.testing.assert_allclose( + body_mass_ab[a_world_ab, obj_body_ab], + body_mass_ba[a_world_ba, obj_body_ba], + atol=1e-5, + err_msg="variant 'a' body_mass differs across orderings", + ) + np.testing.assert_allclose( + body_mass_ab[b_world_ab, obj_body_ab], + body_mass_ba[b_world_ba, obj_body_ba], + atol=1e-5, + err_msg="variant 'b' body_mass differs across orderings", + ) + + +def test_slot_metadata_source_geom_names_record_padding_as_none(): + """source_geom_names has None where a variant doesn't fill a slot.""" + cfg = VariantEntityCfg( + variants={ + "sphere": _sphere_2col_spec, + "cone": _cone_4col_spec, + } + ) + meta = cfg.build().variant_metadata + assert meta is not None + sphere_idx = meta.variant_names.index("sphere") + collision_slots = [s for s in meta.slots if s.key.role == "collision"] + # Sphere has 2 collision -> ordinals 2, 3 are None for sphere. + assert collision_slots[2].source_geom_names[sphere_idx] is None + assert collision_slots[3].source_geom_names[sphere_idx] is None + # Cone fills all 4. + cone_idx = meta.variant_names.index("cone") + for cs in collision_slots: + assert cs.source_geom_names[cone_idx] is not None + + +def test_no_variants_unchanged(): + cfg = EntityCfg(spec_fn=_simple_sphere_spec) + entity = cfg.build() + assert entity.variant_metadata is None + + +# build_variant_model: dataid and dependent fields. + + +def test_dataid_assigned_per_world(): + """Each world's geom_dataid points to its variant's meshes.""" + scene_spec, vi = _build_scene_with_variants(_simple_sphere_spec, _simple_cone_spec) + result = build_variant_model(scene_spec, 4, vi) + + dataid = result.wp_model.geom_dataid.numpy() + assert dataid.shape == (4, result.mj_model.ngeom) + assert dataid.ndim == 2 + + w2v = result.world_to_variant["object/"] + assert w2v[0] == 0 # variant a (sphere) + assert w2v[2] == 1 # variant b (cone) + + # Sphere and cone worlds must have different dataid values. + assert not np.array_equal(dataid[0], dataid[2]) + + +def _sphere_with_material_spec() -> mujoco.MjSpec: + """Single-geom sphere whose visual references a named material.""" + spec = mujoco.MjSpec() + m = spec.add_mesh() + m.name = "sphere" + m.make_sphere(subdivision=2) + mat = spec.add_material() + mat.name = "red_mat" + mat.rgba[:] = (1.0, 0.0, 0.0, 1.0) + body = spec.worldbody.add_body() + body.name = "prop" + body.add_freejoint() + g = body.add_geom() + g.name = "visual" + g.type = mujoco.mjtGeom.mjGEOM_MESH + g.meshname = "sphere" + g.material = "red_mat" + return spec + + +def _cone_with_material_spec() -> mujoco.MjSpec: + """Single-geom cone whose visual references a different named material.""" + spec = mujoco.MjSpec() + m = spec.add_mesh() + m.name = "cone" + m.make_cone(nedge=8, radius=0.05) + mat = spec.add_material() + mat.name = "blue_mat" + mat.rgba[:] = (0.0, 0.0, 1.0, 1.0) + body = spec.worldbody.add_body() + body.name = "prop" + body.add_freejoint() + g = body.add_geom() + g.name = "visual" + g.type = mujoco.mjtGeom.mjGEOM_MESH + g.meshname = "cone" + g.material = "blue_mat" + return spec + + +def test_materials_merged_under_variant_prefix(): + """Both variants' materials end up in the merged spec, name-prefixed.""" + scene_spec, vi = _build_scene_with_variants( + _sphere_with_material_spec, _cone_with_material_spec + ) + model = scene_spec.compile() + mat_names = {model.material(i).name for i in range(model.nmat)} + assert "object/a/red_mat" in mat_names + assert "object/b/blue_mat" in mat_names + + +def test_matid_assigned_per_world(): + """Each world's geom_matid points to its variant's material.""" + scene_spec, vi = _build_scene_with_variants( + _sphere_with_material_spec, _cone_with_material_spec + ) + result = build_variant_model(scene_spec, 4, vi) + + matid = result.wp_model.geom_matid.numpy() + assert matid.shape == (4, result.mj_model.ngeom) + + w2v = result.world_to_variant["object/"] + red_id = mujoco.mj_name2id( + result.mj_model, mujoco.mjtObj.mjOBJ_MATERIAL, "object/a/red_mat" + ) + blue_id = mujoco.mj_name2id( + result.mj_model, mujoco.mjtObj.mjOBJ_MATERIAL, "object/b/blue_mat" + ) + assert red_id >= 0 and blue_id >= 0 and red_id != blue_id + + # Slot geom is the last mesh geom (single-geom variants -> ordinal 0). + slot_gid = next( + gid + for gid in range(result.mj_model.ngeom - 1, -1, -1) + if result.mj_model.geom_type[gid] == mujoco.mjtGeom.mjGEOM_MESH + ) + + for w in range(4): + expected = red_id if w2v[w] == 0 else blue_id + assert int(matid[w, slot_gid]) == expected + + +def test_matid_minus_one_when_variant_has_no_material(): + """A variant slot without a material yields geom_matid == -1 in its worlds.""" + scene_spec, vi = _build_scene_with_variants( + _sphere_with_material_spec, + _simple_cone_spec, # cone has no material + ) + result = build_variant_model(scene_spec, 4, vi) + + matid = result.wp_model.geom_matid.numpy() + w2v = result.world_to_variant["object/"] + slot_gid = next( + gid + for gid in range(result.mj_model.ngeom - 1, -1, -1) + if result.mj_model.geom_type[gid] == mujoco.mjtGeom.mjGEOM_MESH + ) + cone_world = int(np.where(w2v == 1)[0][0]) + assert int(matid[cone_world, slot_gid]) == -1 + + +def test_padding_slots_get_disabled(): + """Shorter variant's padding geom slots have dataid == -1.""" + scene_spec, vi = _build_scene_with_variants(_sphere_2col_spec, _cone_4col_spec) + result = build_variant_model(scene_spec, 4, vi) + + dataid = result.wp_model.geom_dataid.numpy() + w2v = result.world_to_variant["object/"] + + # Find a sphere world (variant 0, 3 mesh geoms -> 2 padding slots). + sphere_world = int(np.where(w2v == 0)[0][0]) + # Find mesh geom columns (skip non-mesh geoms like worldbody). + mesh_geom_ids = [ + gid + for gid in range(result.mj_model.ngeom) + if result.mj_model.geom_type[gid] == mujoco.mjtGeom.mjGEOM_MESH + ] + sphere_dataid = dataid[sphere_world, mesh_geom_ids] + # Last 2 mesh geom slots should be -1 (disabled padding). + assert sphere_dataid[-1] == -1 + assert sphere_dataid[-2] == -1 + # Padding slots must still be collision-enabled in the template/warp model. + # Short variants are disabled by per-world dataid=-1; long variants need the + # same slots enabled so their extra hulls can collide. + assert np.all(result.mj_model.geom_contype[mesh_geom_ids[-2:]] == 1) + assert np.all(result.mj_model.geom_conaffinity[mesh_geom_ids[-2:]] == 1) + assert np.all(result.wp_model.geom_contype.numpy()[mesh_geom_ids[-2:]] == 1) + assert np.all(result.wp_model.geom_conaffinity.numpy()[mesh_geom_ids[-2:]] == 1) + # First 3 should be valid (>= 0). + assert all(d >= 0 for d in sphere_dataid[:3]) + + +def test_dependent_fields_match_individual_compilation(): + """Per-world body_mass matches independently compiled variant models.""" + scene_spec, vi = _build_scene_with_variants(_simple_sphere_spec, _simple_cone_spec) + result = build_variant_model(scene_spec, 4, vi) + + # Compile each variant independently for reference values. + sphere_model = _simple_sphere_spec().compile() + cone_model = _simple_cone_spec().compile() + + body_mass = result.wp_model.body_mass.numpy() + w2v = result.world_to_variant["object/"] + + sphere_w = int(np.where(w2v == 0)[0][0]) + cone_w = int(np.where(w2v == 1)[0][0]) + + # The object body is the last body in the scene. + obj_body = result.mj_model.nbody - 1 + + # Mass should match individually compiled models. + np.testing.assert_allclose( + body_mass[sphere_w, obj_body], + sphere_model.body_mass[-1], + atol=1e-4, + ) + np.testing.assert_allclose( + body_mass[cone_w, obj_body], + cone_model.body_mass[-1], + atol=1e-4, + ) + + # Sphere and cone should have different masses. + assert not np.isclose(body_mass[sphere_w, obj_body], body_mass[cone_w, obj_body]) + + +def test_select_default_values_uses_per_world_variant_defaults(): + """Per-world defaults are indexed by env first, then by entity.""" + from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg + from mjlab.envs.mdp.dr._core import _select_default_values + from mjlab.scene import SceneCfg + from mjlab.terrains import TerrainEntityCfg + + def _explicit_variant( + mesh_name: str, + mass: float, + inertia: tuple[float, float, float], + *, + cone: bool = False, + ) -> mujoco.MjSpec: + spec = mujoco.MjSpec() + mesh = spec.add_mesh() + mesh.name = mesh_name + if cone: + mesh.make_cone(nedge=8, radius=0.05) + else: + mesh.make_sphere(subdivision=1) + body = spec.worldbody.add_body(name="prop") + body.add_freejoint() + body.explicitinertial = 1 + body.mass = mass + body.ipos[:] = (0.0, 0.0, 0.0) + body.inertia[:] = inertia + body.iquat[:] = (1.0, 0.0, 0.0, 0.0) + body.add_geom( + name="visual", + type=mujoco.mjtGeom.mjGEOM_MESH, + meshname=mesh_name, + contype=0, + conaffinity=0, + mass=0.0, + ) + return spec + + object_cfg = VariantEntityCfg( + variants={ + "sphere": lambda: _explicit_variant("sphere", 0.2, (1e-4, 2e-4, 3e-4)), + "cone": lambda: _explicit_variant("cone", 0.7, (4e-4, 5e-4, 6e-4), cone=True), + }, + init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)), + ) + env_cfg = ManagerBasedRlEnvCfg( + decimation=1, + scene=SceneCfg( + terrain=TerrainEntityCfg(terrain_type="plane"), + num_envs=4, + env_spacing=1.0, + entities={"object": object_cfg}, + ), + ) + + env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu") + try: + obj_body = int(env.scene["object"].indexing.root_body_id) + env_ids = torch.arange(env.num_envs, device=env.device) + body_ids = torch.tensor([obj_body], device=env.device) + + for field in ("body_mass", "body_ipos", "body_inertia", "body_iquat"): + selected = _select_default_values(env, field, env_ids, body_ids) + torch.testing.assert_close( + selected[:, 0], + getattr(env.sim.model, field)[:, obj_body], + ) + finally: + env.close() + + +def test_viser_builds_per_world_mesh_handles_for_variants(): + """Viser dynamic meshes must not collapse all worlds onto env0's mesh.""" + from contextlib import nullcontext + + from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg + from mjlab.scene import SceneCfg + from mjlab.terrains import TerrainEntityCfg + from mjlab.viewer.viser.scene import MjlabViserScene, _PerWorldMeshGroup + + class _Handle: + def __init__(self, **kwargs): + self.visible = kwargs.get("visible", True) + self.batched_positions = kwargs.get("batched_positions", np.zeros((0, 3))) + self.batched_wxyzs = kwargs.get("batched_wxyzs", np.zeros((0, 4))) + self.batched_scales = kwargs.get("batched_scales") + self.batched_colors = kwargs.get("batched_colors") + self.batched_opacities = kwargs.get("batched_opacities") + self.position = kwargs.get("position", np.zeros(3)) + self.wxyz = kwargs.get("wxyz", np.array([1.0, 0.0, 0.0, 0.0])) + + def remove(self) -> None: + pass + + class _Scene: + def __init__(self): + self.batched: list[tuple[tuple, dict, _Handle]] = [] + + def configure_environment_map(self, **_kwargs) -> None: + pass + + def add_frame(self, *_args, **kwargs) -> _Handle: + return _Handle(**kwargs) + + def add_grid(self, *_args, **kwargs) -> _Handle: + return _Handle(**kwargs) + + def add_mesh_trimesh(self, *_args, **kwargs) -> _Handle: + return _Handle(**kwargs) + + def add_batched_meshes_trimesh(self, *args, **kwargs) -> _Handle: + handle = _Handle(**kwargs) + self.batched.append((args, kwargs, handle)) + return handle + + def add_batched_meshes_simple(self, *args, **kwargs) -> _Handle: + handle = _Handle(**kwargs) + self.batched.append((args, kwargs, handle)) + return handle + + class _Server: + def __init__(self): + self.scene = _Scene() + + def atomic(self): + return nullcontext() + + def flush(self) -> None: + pass + + env_cfg = ManagerBasedRlEnvCfg( + decimation=1, + scene=SceneCfg( + terrain=TerrainEntityCfg(terrain_type="plane"), + num_envs=4, + env_spacing=1.0, + entities={ + "object": VariantEntityCfg( + variants={ + "sphere": _simple_sphere_spec, + "cone": _simple_cone_spec, + }, + init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)), + ) + }, + ), + ) + + env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu") + try: + env.sim.expand_model_fields(("geom_rgba",)) + env.sim.model.geom_rgba[:, :, :3] = torch.linspace( + 0.2, + 0.9, + env.num_envs, + device=env.device, + )[:, None, None] + server = _Server() + scene = MjlabViserScene( + cast(Any, server), + env.sim.mj_model, + env.num_envs, + sim_model=env.sim.model, + expanded_fields=env.sim.expanded_fields, + ) + groups = [mg for mg in scene._mesh_groups if isinstance(mg, _PerWorldMeshGroup)] + + assert groups + assert sum(len(mg.env_ids) for mg in groups) >= env.num_envs + + body_xpos = env.sim.data.xpos.cpu().numpy() + body_xmat = env.sim.data.xmat.cpu().numpy() + mocap_pos = ( + env.sim.data.mocap_pos.cpu().numpy() if env.sim.mj_model.nmocap > 0 else None + ) + mocap_quat = ( + env.sim.data.mocap_quat.cpu().numpy() if env.sim.mj_model.nmocap > 0 else None + ) + scene.show_only_selected = True + scene.update_from_arrays(body_xpos, body_xmat, mocap_pos, mocap_quat, env_idx=0) + scene.update_from_arrays(body_xpos, body_xmat, mocap_pos, mocap_quat, env_idx=1) + + assert any(mg.handle.visible for mg in groups) + + handle_count = len(server.scene.batched) + env.sim.model.geom_rgba[:, :, :3] = torch.linspace( + 0.9, + 0.2, + env.num_envs, + device=env.device, + )[:, None, None] + scene.update_from_arrays(body_xpos, body_xmat, mocap_pos, mocap_quat, env_idx=0) + assert len(server.scene.batched) > handle_count + finally: + env.close() + + +def test_viser_convex_hulls_are_per_variant(): + """Convex-hull handles must differ across variants, not all show env0's hull.""" + from contextlib import nullcontext + + from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg + from mjlab.scene import SceneCfg + from mjlab.terrains import TerrainEntityCfg + from mjlab.viewer.viser.scene import MjlabViserScene, _PerWorldHullGroup + + class _Handle: + def __init__(self, **kwargs): + self.visible = kwargs.get("visible", True) + self.batched_positions = kwargs.get("batched_positions", np.zeros((0, 3))) + self.batched_wxyzs = kwargs.get("batched_wxyzs", np.zeros((0, 4))) + self.batched_scales = kwargs.get("batched_scales") + self.batched_colors = kwargs.get("batched_colors") + self.batched_opacities = kwargs.get("batched_opacities") + self.position = kwargs.get("position", np.zeros(3)) + self.wxyz = kwargs.get("wxyz", np.array([1.0, 0.0, 0.0, 0.0])) + self.vertices = kwargs.get("vertices") + self.faces = kwargs.get("faces") + + def remove(self) -> None: + pass + + class _Scene: + def __init__(self): + self.batched: list[tuple[tuple, dict, _Handle]] = [] + + def configure_environment_map(self, **_kwargs) -> None: + pass + + def add_frame(self, *_args, **kwargs) -> _Handle: + return _Handle(**kwargs) + + def add_grid(self, *_args, **kwargs) -> _Handle: + return _Handle(**kwargs) + + def add_mesh_trimesh(self, *_args, **kwargs) -> _Handle: + return _Handle(**kwargs) + + def add_batched_meshes_trimesh(self, *args, **kwargs) -> _Handle: + handle = _Handle(**kwargs) + self.batched.append((args, kwargs, handle)) + return handle + + def add_batched_meshes_simple(self, path, vertices, faces, **kwargs) -> _Handle: + # Capture the mesh identity so the test can compare hull shapes. + kwargs = dict(kwargs) + kwargs["vertices"] = np.asarray(vertices) + kwargs["faces"] = np.asarray(faces) + handle = _Handle(**kwargs) + self.batched.append(((path,), kwargs, handle)) + return handle + + class _Server: + def __init__(self): + self.scene = _Scene() + + def atomic(self): + return nullcontext() + + def flush(self) -> None: + pass + + # Sphere and cone produce visibly different convex hulls. + env_cfg = ManagerBasedRlEnvCfg( + decimation=1, + scene=SceneCfg( + terrain=TerrainEntityCfg(terrain_type="plane"), + num_envs=4, + env_spacing=1.0, + entities={ + "object": VariantEntityCfg( + variants={ + "sphere": _simple_sphere_spec, + "cone": _simple_cone_spec, + }, + init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)), + ) + }, + ), + ) + + env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu") + try: + server = _Server() + scene = MjlabViserScene( + cast(Any, server), + env.sim.mj_model, + env.num_envs, + sim_model=env.sim.model, + expanded_fields=env.sim.expanded_fields, + ) + groups: list[_PerWorldHullGroup] = list(scene._hull_per_world_groups) + # Two distinct variants -> at least two hull handles on the same body. + assert len(groups) >= 2, f"expected >=2 hull variants, got {len(groups)}" + all_envs = np.concatenate([g.env_ids for g in groups]) + assert sorted(all_envs.tolist()) == list(range(env.num_envs)) + # Hulls must be shape-distinct, not all copies of env0's hull. + shapes = {(g.handle.vertices.shape, g.handle.faces.shape) for g in groups} + assert len(shapes) >= 2, ( + f"hull variants collapsed to one shape: {shapes} " + "(all envs would share env0's hull)" + ) + + body_xpos = env.sim.data.xpos.cpu().numpy() + body_xmat = env.sim.data.xmat.cpu().numpy() + scene.show_convex_hull = True + scene.show_only_selected = True + for target_env in range(env.num_envs): + scene.update_from_arrays(body_xpos, body_xmat, env_idx=target_env) + visible_groups = [g for g in groups if g.handle.visible] + assert len(visible_groups) == 1 + assert target_env in visible_groups[0].env_ids + assert visible_groups[0].handle.batched_positions.shape[0] == 1 + + scene.show_only_selected = False + scene.update_from_arrays(body_xpos, body_xmat, env_idx=0) + assert all(g.handle.visible for g in groups) + finally: + env.close() + + +# DR consistency on variant scenes. + + +def _explicit_mass_variant( + mesh_name: str, + mass: float, + *, + cone: bool = False, +) -> mujoco.MjSpec: + """Build a single-geom freejoint variant with an explicit body mass.""" + spec = mujoco.MjSpec() + mesh = spec.add_mesh() + mesh.name = mesh_name + if cone: + mesh.make_cone(nedge=8, radius=0.05) + else: + mesh.make_sphere(subdivision=1) + body = spec.worldbody.add_body(name="prop") + body.add_freejoint() + body.explicitinertial = 1 + body.mass = mass + body.ipos[:] = (0.0, 0.0, 0.0) + body.inertia[:] = (1e-4, 1e-4, 1e-4) + body.iquat[:] = (1.0, 0.0, 0.0, 0.0) + body.add_geom( + name="visual", + type=mujoco.mjtGeom.mjGEOM_MESH, + meshname=mesh_name, + contype=0, + conaffinity=0, + mass=0.0, + ) + return spec + + +def test_dr_body_mass_scale_preserves_variant_baseline(): + """``dr.body_mass`` scale must use each variant's own baseline. + + This is the load-bearing claim of ``_per_world_default_fields``: scaling + body_mass on a variant scene by a per-env factor must produce + ``variant_default[env] * scale[env]``, not ``template_default * scale[env]``. + """ + from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg + from mjlab.envs.mdp import dr + from mjlab.managers.event_manager import EventTermCfg + from mjlab.managers.scene_entity_config import SceneEntityCfg + from mjlab.scene import SceneCfg + from mjlab.terrains import TerrainEntityCfg + + light_mass = 0.1 + heavy_mass = 1.0 + scale = 2.0 + + object_cfg = VariantEntityCfg( + variants={ + "light": lambda: _explicit_mass_variant("light", light_mass), + "heavy": lambda: _explicit_mass_variant("heavy", heavy_mass, cone=True), + }, + init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)), + ) + env_cfg = ManagerBasedRlEnvCfg( + decimation=1, + scene=SceneCfg( + terrain=TerrainEntityCfg(terrain_type="plane"), + num_envs=4, + env_spacing=1.0, + entities={"object": object_cfg}, + ), + events={ + "scale_mass": EventTermCfg( + func=dr.body_mass, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("object", body_names=("prop",)), + "operation": "scale", + "ranges": (scale, scale), # deterministic factor + }, + ), + }, + ) + + with pytest.warns(UserWarning, match="dr.body_mass only randomizes mass"): + env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu") + try: + obj_body = int(env.scene["object"].indexing.root_body_id) + w2v = env.sim.world_to_variant["object"] + actual = env.sim.model.body_mass[:, obj_body].cpu() + + variant_baseline = torch.tensor([light_mass, heavy_mass], dtype=actual.dtype) + expected = variant_baseline[w2v.cpu()] * scale + torch.testing.assert_close(actual, expected, atol=1e-5, rtol=1e-5) + + # Sanity: at least one env per variant, otherwise the test is vacuous. + assert (w2v == 0).any() and (w2v == 1).any() + finally: + env.close() + + +# Full env lifecycle. + + +def test_env_step_with_variants(): + """Build a full ManagerBasedRlEnv with variants; step without crashing.""" + from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg + from mjlab.envs.mdp.events import reset_root_state_uniform + from mjlab.managers.event_manager import EventTermCfg + from mjlab.managers.scene_entity_config import SceneEntityCfg + from mjlab.scene import SceneCfg + from mjlab.terrains import TerrainEntityCfg + + object_cfg = VariantEntityCfg( + variants={ + "sphere": _simple_sphere_spec, + "cone": _simple_cone_spec, + }, + init_state=EntityCfg.InitialStateCfg(pos=(0.0, 0.0, 0.2)), + ) + + env_cfg = ManagerBasedRlEnvCfg( + decimation=2, + scene=SceneCfg( + terrain=TerrainEntityCfg(terrain_type="plane"), + num_envs=4, + env_spacing=1.0, + entities={"object": object_cfg}, + ), + events={ + "reset": EventTermCfg( + func=reset_root_state_uniform, + mode="reset", + params={ + "pose_range": {}, + "velocity_range": {}, + "asset_cfg": SceneEntityCfg("object"), + }, + ), + }, + ) + + env = ManagerBasedRlEnv(cfg=env_cfg, device="cpu") + obs, _ = env.reset() + actions = torch.zeros(env.num_envs, 0) + for _ in range(10): + obs, rew, term, trunc, info = env.step(actions) + # No NaN in positions. + qpos = env.sim.data.qpos[:].cpu().numpy() + assert np.all(np.isfinite(qpos)) + env.close() + + +# Viewer: sameframe shortcut fix. + + +def _viewer_regression_sphere_spec() -> mujoco.MjSpec: + spec = mujoco.MjSpec() + m = spec.add_mesh() + m.name = "sphere" + m.make_sphere(subdivision=3) + m.scale[:] = (0.05, 0.05, 0.05) + body = spec.worldbody.add_body() + body.name = "prop" + body.add_freejoint() + g = body.add_geom() + g.name = "visual" + g.type = mujoco.mjtGeom.mjGEOM_MESH + g.meshname = "sphere" + return spec + + +def _viewer_regression_cone_spec() -> mujoco.MjSpec: + spec = mujoco.MjSpec() + m = spec.add_mesh() + m.name = "cone" + m.make_cone(nedge=16, radius=0.04) + m.scale[:] = (0.05, 0.05, 0.05) + body = spec.worldbody.add_body() + body.name = "prop" + body.add_freejoint() + g = body.add_geom() + g.name = "visual" + g.type = mujoco.mjtGeom.mjGEOM_MESH + g.meshname = "cone" + return spec + + +def test_sameframe_fix_makes_host_forward_match_variant(): + """Clearing sameframe shortcuts aligns host mj_forward with variant.""" + base_model = _viewer_regression_sphere_spec().compile() + cone_model = _viewer_regression_cone_spec().compile() + + # Sync cone's kinematic fields onto sphere's model (like viewer does). + for field in ( + "geom_size", + "geom_pos", + "geom_quat", + "body_mass", + "body_inertia", + "body_ipos", + "body_iquat", + ): + getattr(base_model, field)[:] = getattr(cone_model, field) + + base_data = mujoco.MjData(base_model) + base_data.qpos[:] = cone_model.qpos0 + base_data.qpos[2] = 0.05 + mujoco.mj_forward(base_model, base_data) + + cone_data = mujoco.MjData(cone_model) + cone_data.qpos[:] = cone_model.qpos0 + cone_data.qpos[2] = 0.05 + mujoco.mj_forward(cone_model, cone_data) + + # Before fix: positions differ due to stale sameframe flags. + assert not np.allclose(base_data.geom_xpos, cone_data.geom_xpos) + + # After fix: clearing sameframe makes them match. + disable_model_sameframe_shortcuts(base_model) + mujoco.mj_forward(base_model, base_data) + np.testing.assert_allclose(base_data.geom_xpos, cone_data.geom_xpos, atol=1e-6) + + +def test_sync_model_fields_copies_only_requested_env_fields(): + """Viewer model sync copies explicit fields and leaves others unchanged.""" + model = _simple_sphere_spec().compile() + + class _SimModel: + geom_rgba = torch.tensor( + [ + [[0.1, 0.2, 0.3, 0.4]], + [[0.5, 0.6, 0.7, 0.8]], + ], + dtype=torch.float32, + ) + geom_pos = torch.tensor( + [ + [[1.0, 2.0, 3.0]], + [[4.0, 5.0, 6.0]], + ], + dtype=torch.float32, + ) + + original_geom_pos = model.geom_pos.copy() + + sync_model_fields(model, _SimModel(), {"geom_rgba"}, env_idx=1) + + np.testing.assert_allclose(model.geom_rgba, [[0.5, 0.6, 0.7, 0.8]]) + np.testing.assert_allclose(model.geom_pos, original_geom_pos) diff --git a/05_software/train/rc_mjlab/mjlab/tests/test_xml_actuator.py b/05_software/train/rc_mjlab/mjlab/tests/test_xml_actuator.py index 15255a2..02cf9ac 100644 --- a/05_software/train/rc_mjlab/mjlab/tests/test_xml_actuator.py +++ b/05_software/train/rc_mjlab/mjlab/tests/test_xml_actuator.py @@ -4,7 +4,7 @@ import mujoco import pytest from conftest import get_test_device -from mjlab.actuator import XmlActuatorCfg +from mjlab.actuator import XmlActuator, XmlActuatorCfg from mjlab.entity import Entity, EntityArticulationInfoCfg, EntityCfg from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg, mdp from mjlab.managers.observation_manager import ObservationGroupCfg, ObservationTermCfg @@ -160,6 +160,7 @@ def test_xml_actuator_explicit_command_field_bypasses_detection(): entity.compile() actuator = entity._actuators[0] + assert isinstance(actuator, XmlActuator) assert actuator.command_field == "effort" assert actuator._target_names == ["joint1"] diff --git a/05_software/train/rc_mjlab/mjlab/uv.lock b/05_software/train/rc_mjlab/mjlab/uv.lock index 4308c7f..37fd5fb 100644 --- a/05_software/train/rc_mjlab/mjlab/uv.lock +++ b/05_software/train/rc_mjlab/mjlab/uv.lock @@ -42,7 +42,7 @@ conflicts = [[ [manifest] constraints = [ - { name = "gitpython", specifier = ">=3.1.47" }, + { name = "gitpython", specifier = ">=3.1.49" }, { name = "lxml", specifier = ">=6.1.0" }, ] overrides = [{ name = "mujoco", specifier = ">=3.8.0.dev0", index = "https://py.mujoco.org/" }] @@ -823,14 +823,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.47" +version = "3.1.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/bd/50db468e9b1310529a19fce651b3b0e753b5c07954d486cba31bbee9a5d5/gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd", size = 216978, upload-time = "2026-04-22T02:44:44.059Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/c5/a1bc0996af85757903cf2bf444a7824e68e0035ce63fb41d6f76f9def68b/gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905", size = 209547, upload-time = "2026-04-22T02:44:41.271Z" }, + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] [[package]] @@ -1647,7 +1647,7 @@ wheels = [ [[package]] name = "mjlab" -version = "1.3.0" +version = "1.4.0" source = { editable = "." } dependencies = [ { name = "imageio-ffmpeg" }, @@ -1658,6 +1658,8 @@ dependencies = [ { name = "onnxscript" }, { name = "prettytable" }, { name = "rsl-rl-lib" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-5-mjlab-cpu' and extra == 'extra-5-mjlab-cu128')" }, + { name = "scipy", version = "1.16.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-5-mjlab-cpu' and extra == 'extra-5-mjlab-cu128')" }, { name = "tensorboard" }, { name = "tensordict" }, { name = "torch", version = "2.9.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin' or (extra == 'extra-5-mjlab-cpu' and extra == 'extra-5-mjlab-cu128') or (extra != 'extra-5-mjlab-cpu' and extra != 'extra-5-mjlab-cu128')" }, @@ -1715,12 +1717,13 @@ docs = [ requires-dist = [ { name = "imageio-ffmpeg" }, { name = "mediapy", specifier = ">=1.2.6" }, - { name = "mjviser", git = "https://github.com/mujocolab/mjviser?rev=1bdfd6fe79066b847a5f430000fcfbb53ec31a6f" }, - { name = "mujoco", specifier = ">=3.8.0", index = "https://py.mujoco.org/" }, - { name = "mujoco-warp", git = "https://github.com/google-deepmind/mujoco_warp?rev=6f235d4" }, + { name = "mjviser", specifier = ">=0.0.14" }, + { name = "mujoco", specifier = "~=3.8.0", index = "https://py.mujoco.org/" }, + { name = "mujoco-warp", git = "https://github.com/google-deepmind/mujoco_warp?rev=88b55fc2696960b927bc12584994bb8412b36558" }, { name = "onnxscript", specifier = ">=0.5.4" }, { name = "prettytable" }, - { name = "rsl-rl-lib", specifier = "==5.2.0" }, + { name = "rsl-rl-lib", specifier = "==5.4.0" }, + { name = "scipy", specifier = ">=1.15" }, { name = "tensorboard", specifier = ">=2.20.0" }, { name = "tensordict" }, { name = "torch", specifier = ">=2.7.0" }, @@ -1732,7 +1735,7 @@ requires-dist = [ { name = "tqdm" }, { name = "trimesh", specifier = ">=4.8.3" }, { name = "tyro", specifier = ">=1.0.1" }, - { name = "viser", specifier = ">=1.0.26" }, + { name = "viser", specifier = ">=1.0.27" }, { name = "wandb", specifier = ">=0.22.3" }, { name = "warp-lang", marker = "sys_platform != 'darwin'", specifier = ">=1.12.0", index = "https://pypi.nvidia.com/" }, { name = "warp-lang", marker = "sys_platform == 'darwin'", specifier = ">=1.12.0" }, @@ -1767,8 +1770,8 @@ docs = [ [[package]] name = "mjviser" -version = "0.0.13" -source = { git = "https://github.com/mujocolab/mjviser?rev=1bdfd6fe79066b847a5f430000fcfbb53ec31a6f#1bdfd6fe79066b847a5f430000fcfbb53ec31a6f" } +version = "0.0.14" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mujoco" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-5-mjlab-cpu' and extra == 'extra-5-mjlab-cu128')" }, @@ -1777,6 +1780,10 @@ dependencies = [ { name = "trimesh" }, { name = "viser" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/1d/e4/eef89b279fb1811b5f120a99ac9284c32ff2ca4fad5e6f5c93035f72ba9a/mjviser-0.0.14.tar.gz", hash = "sha256:ebde2203dab89959a13ae549b4d3e5e5cf9eb69de11a1a2fd759cbe8f8c641f3", size = 29576, upload-time = "2026-05-07T03:35:13.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/c2/4534d678ad1b3f7dee6fd83110112800287be21ba007d988abb9ddc8e0ac/mjviser-0.0.14-py3-none-any.whl", hash = "sha256:4b09f8e90506fc4a71d76fc628872147157947e9292428213ab78cfe137c9a26", size = 32296, upload-time = "2026-05-07T03:35:14.252Z" }, +] [[package]] name = "ml-dtypes" @@ -1958,8 +1965,8 @@ wheels = [ [[package]] name = "mujoco-warp" -version = "3.8.0" -source = { git = "https://github.com/google-deepmind/mujoco_warp?rev=6f235d4#6f235d46cb2ecf8f37c8f967f8dd9c87d0ca5807" } +version = "3.8.0.2" +source = { git = "https://github.com/google-deepmind/mujoco_warp?rev=88b55fc2696960b927bc12584994bb8412b36558#88b55fc2696960b927bc12584994bb8412b36558" } dependencies = [ { name = "absl-py" }, { name = "etils", extra = ["epath"] }, @@ -2542,7 +2549,7 @@ wheels = [ [[package]] name = "paramiko" -version = "4.0.0" +version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bcrypt" }, @@ -2550,9 +2557,9 @@ dependencies = [ { name = "invoke" }, { name = "pynacl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, + { url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" }, ] [[package]] @@ -3253,7 +3260,7 @@ wheels = [ [[package]] name = "rsl-rl-lib" -version = "5.2.0" +version = "5.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitpython" }, @@ -3261,6 +3268,7 @@ dependencies = [ { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-5-mjlab-cpu' and extra == 'extra-5-mjlab-cu128')" }, { name = "onnx" }, { name = "onnxscript" }, + { name = "tensorboard" }, { name = "tensordict" }, { name = "torch", version = "2.9.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin' or (extra == 'extra-5-mjlab-cpu' and extra == 'extra-5-mjlab-cu128') or (extra != 'extra-5-mjlab-cpu' and extra != 'extra-5-mjlab-cu128')" }, { name = "torch", version = "2.9.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "(sys_platform != 'darwin' and extra == 'extra-5-mjlab-cu128') or (extra == 'extra-5-mjlab-cpu' and extra == 'extra-5-mjlab-cu128')" }, @@ -3268,9 +3276,9 @@ dependencies = [ { name = "torchvision", version = "0.24.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin' or extra != 'extra-5-mjlab-cpu' or (extra == 'extra-5-mjlab-cpu' and extra == 'extra-5-mjlab-cu128')" }, { name = "torchvision", version = "0.25.0", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform != 'darwin' and extra == 'extra-5-mjlab-cpu') or (extra == 'extra-5-mjlab-cpu' and extra == 'extra-5-mjlab-cu128')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/a8/fb9aae0573a83dd510e228085f5e6ff9076a91f2bff6699270f07d31854c/rsl_rl_lib-5.2.0.tar.gz", hash = "sha256:cbb4eee96af9574495208381115d45d68ad1c0403710a2bc6512a2ee5bf57124", size = 60558, upload-time = "2026-04-23T12:40:54.259Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/51/2d4c95b3642c0f659fbcddcd17fa0401903733250fa382f51f9b913bb85f/rsl_rl_lib-5.4.0.tar.gz", hash = "sha256:e1aa5cd5771f2d9a9e7a7ba5456b942ab588410cfd397b3c63e32d00a0744f0d", size = 65902, upload-time = "2026-05-27T10:42:39.656Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/3a/3e8f39049cc5a8994bfdb2dd09d727f90a2781b9755adf59e49fa509500e/rsl_rl_lib-5.2.0-py3-none-any.whl", hash = "sha256:fc767059f329a184527dd10766c3382354f6deca4b049f23febc8140c3dc6029", size = 86451, upload-time = "2026-04-23T12:40:52.762Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dd/6797be77ce0cc12881f29cd9363b791f15276fde6a136a73443b6b6c2ce3/rsl_rl_lib-5.4.0-py3-none-any.whl", hash = "sha256:b30a0e59dac0ef7236f8f793c9bedfa8f2b8f8d29c2965140feeb1439153ebab", size = 92871, upload-time = "2026-05-27T10:42:38.415Z" }, ] [[package]] @@ -4593,11 +4601,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -4672,7 +4680,7 @@ wheels = [ [[package]] name = "viser" -version = "1.0.26" +version = "1.0.27" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "imageio" }, @@ -4688,9 +4696,9 @@ dependencies = [ { name = "yourdfpy" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/ce/82a0e50fae21f5e02fcc5d9aff2ab59dccb9c319b6c4cf528f2228049b05/viser-1.0.26.tar.gz", hash = "sha256:dc08c6f505e70324b0603bdddf9714c00ac828c259ee49abd8ad094bfc90c91c", size = 4828261, upload-time = "2026-03-30T11:43:19.513Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/f5/48adb4e5e4234f48e96a1e7fc50cca6731280df0c279833e333963f9ea5c/viser-1.0.27.tar.gz", hash = "sha256:87e3239d6c1c2c003db93ac4072430ec790e336ffe7214781f035e54faebc0af", size = 4897986, upload-time = "2026-05-06T10:30:47.556Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/f7/762a2d5fab509d0c632b271e21e634462397cc02cca649771c3e9d2e0bcc/viser-1.0.26-py3-none-any.whl", hash = "sha256:03b177b4ef584f58f7b74fdf44cccb165b8a220ffd90728ef5c1e3d1b1fcf258", size = 4922888, upload-time = "2026-03-30T11:43:21.355Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/8ae712579e294b4395fb39f7d65524b51fc7b731eacce26af096b7e59b61/viser-1.0.27-py3-none-any.whl", hash = "sha256:8da5b7934416e6e2d3a7ebcf39fc840f21030b51eb63231e8cfef457bfb49031", size = 4998748, upload-time = "2026-05-06T10:30:49.965Z" }, ] [[package]] diff --git a/05_software/train/rc_mjlab/model_crawl.pt b/05_software/train/rc_mjlab/model_crawl.pt deleted file mode 100644 index cbf15ef..0000000 Binary files a/05_software/train/rc_mjlab/model_crawl.pt and /dev/null differ diff --git a/05_software/train/rc_mjlab/model_rough.pt b/05_software/train/rc_mjlab/model_rough.pt index 0c99ff1..e1b2eee 100644 Binary files a/05_software/train/rc_mjlab/model_rough.pt and b/05_software/train/rc_mjlab/model_rough.pt differ diff --git a/05_software/train/rc_mjlab/mujoco_sim/config.py b/05_software/train/rc_mjlab/mujoco_sim/config.py deleted file mode 100644 index 0e28d37..0000000 --- a/05_software/train/rc_mjlab/mujoco_sim/config.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Robot constants and control parameters.""" - -import numpy as np -from pathlib import Path - -# Paths -REPO_ROOT = Path(__file__).resolve().parents[1] -SCENE_XML = REPO_ROOT / "mjcf" / "scene.xml" -MJCF_PATH = REPO_ROOT / "mjcf" / "wheelleg.xml" - -# Robot geometry -WHEEL_RADIUS = 0.10 # m -WHEEL_TRACK = 0.32 # m (left-right distance) -ROBOT_MASS = 12.3 # kg -MAX_TORQUE = 17.0 # Nm per joint -MAX_JOINT_VEL = 13.0 # rad/s - -# Leg link lengths (from MJCF) -L_THIGH = 0.25 # m -L_CALF = 0.20 # m (to wheel center) - -# Leg names and joint ordering -LEG_NAMES = ("fl", "fr", "rl", "rr") -LEG_JOINTS = ("hip_abduction_joint", "hip_pitch_joint", "knee_joint") -WHEEL_JOINT = "wheel_joint" - -# Default standing pose (from go2w_sim2sim: [0, 0.8, -1.5]) -DEFAULT_JOINT_ANGLES = { - "hip_abduction": 0.0, - "hip_pitch": 0.93, - "knee": -1.65, -} - -# Actuator modes (MJCF native): -# Leg joints: position PD (kp=120, kd=8), ctrl = target angle -# Wheel joints: velocity (gain=0.5), ctrl = target velocity (rad/s) - -# Control rates -SIM_DT = 0.002 # 500 Hz (from scene.xml) -CTRL_DT = 0.004 # 250 Hz control loop -CTRL_DECIMATION = int(CTRL_DT / SIM_DT) - -# Wheel drive -WHEEL_VEL_MAX = 10.0 # rad/s max wheel command - -# Body pose control gains (for height/roll/pitch compensation) -KP_HEIGHT = 3.0 # rad/m error → joint angle correction -KP_ROLL = 0.5 # compensation gain -KP_PITCH = 0.5 # compensation gain - -# Gait parameters -GAIT_FREQ = 2.5 # Hz -GAIT_DUTY = 0.6 # stance fraction -SWING_HEIGHT = 0.06 # m - -# Trot phase offsets: FL/RR in phase, FR/RL in phase -PHASE_OFFSETS = {"fl": 0.0, "fr": 0.5, "rl": 0.5, "rr": 0.0} diff --git a/05_software/train/rc_mjlab/mujoco_sim/controller.py b/05_software/train/rc_mjlab/mujoco_sim/controller.py deleted file mode 100644 index 9611e8c..0000000 --- a/05_software/train/rc_mjlab/mujoco_sim/controller.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Main controller: wheel mode + trot mode for wheeled-legged robot. - -Wheel mode: differential drive + leg posture hold (height/roll/pitch compensation) -Trot mode: quadruped gait with wheel-assisted propulsion - -Actuator interface: - - Leg joints: ctrl = target angle (PD: kp=60, kd=3) - - Wheel joints: ctrl = target velocity in rad/s (gain=2.0) -""" - -import numpy as np -from robot import Robot, RobotState -from dynamics import Dynamics -from mpc_controller import MPCController -from config import ( - LEG_NAMES, DEFAULT_JOINT_ANGLES, WHEEL_RADIUS, WHEEL_TRACK, - WHEEL_VEL_MAX, KP_ROLL, KP_PITCH, - GAIT_FREQ, GAIT_DUTY, SWING_HEIGHT, PHASE_OFFSETS, -) - - -class Controller: - """Wheeled-legged robot controller.""" - - def __init__(self, robot: Robot): - self.robot = robot - self.dynamics = Dynamics() - - # User commands - self.vel_x = 0.0 # m/s forward - self.vel_y = 0.0 # m/s lateral - self.yaw_rate = 0.0 # rad/s - self.height = 0.33 # m desired body height - - # Mode: "wheel", "trot", or "mpc" - self.mode = "wheel" - - # Prone (lie down) state - self.prone = False - - # MPC controller - self._mpc_ctrl = MPCController(robot) - self._mpc_active = False # track torque mode state - - # Gait state - self._gait_phase = 0.0 - - # Smoothed commands for trot mode (avoid sudden jumps) - self._smooth_vx = 0.0 - self._smooth_vy = 0.0 - self._smooth_yaw = 0.0 - - # Default leg angles - self._default_q = np.array([ - DEFAULT_JOINT_ANGLES["hip_abduction"], - DEFAULT_JOINT_ANGLES["hip_pitch"], - DEFAULT_JOINT_ANGLES["knee"], - ]) - - # Swing leg memory - self._swing_start_foot = {leg: np.zeros(3) for leg in LEG_NAMES} - self._last_contact = {leg: True for leg in LEG_NAMES} - - def compute(self, state: RobotState, dt: float) -> tuple[np.ndarray, np.ndarray]: - # Smooth all velocity commands (both modes) - alpha = min(dt * 3.0, 1.0) # ~0.33s time constant - self._smooth_vx += alpha * (self.vel_x - self._smooth_vx) - self._smooth_vy += alpha * (self.vel_y - self._smooth_vy) - self._smooth_yaw += alpha * (self.yaw_rate - self._smooth_yaw) - - if self.prone: - self._ensure_position_mode() - return self._prone_mode() - if self.mode == "mpc": - return self._mpc_mode(state, dt) - if self.mode == "wheel": - self._ensure_position_mode() - return self._wheel_mode(state, dt) - else: - self._ensure_position_mode() - return self._trot_mode(state, dt) - - def _mpc_mode(self, state: RobotState, dt: float): - """MPC locomotion: MIT motor protocol (PD + MPC feedforward torque).""" - # Switch to torque mode if not already - if not self._mpc_active: - self.robot.enable_torque_mode() - self._mpc_active = True - - # Sync commands to MPC controller - self._mpc_ctrl.vel_x = self.vel_x - self._mpc_ctrl.vel_y = self.vel_y - self._mpc_ctrl.yaw_rate = self.yaw_rate - self._mpc_ctrl.height = self.height - - # Compute and apply (sets ctrl directly via set_ctrl_mit) - self._mpc_ctrl.compute(state, dt) - # Return dummy - ctrl already set - return np.zeros(12), np.zeros(4) - - def _ensure_position_mode(self): - """Switch back to position PD mode if coming from MPC.""" - if self._mpc_active: - self.robot.enable_position_mode() - self._mpc_active = False - - def _prone_mode(self): - """Lie down: actual prone pose from real robot.""" - leg_targets = np.zeros(12) - for i, leg in enumerate(LEG_NAMES): - side = 1.0 if leg[1] == "l" else -1.0 - leg_targets[i*3] = side * 0.3 # fl/rl: +0.3, fr/rr: -0.3 - leg_targets[i*3+1] = 1.5 # hip pitch - leg_targets[i*3+2] = -2.65 # knee fully folded - return leg_targets, np.zeros(4) - - # ───────────────────────────────────────────────────────────────────── - # WHEEL MODE - # ───────────────────────────────────────────────────────────────────── - - def _wheel_mode(self, state: RobotState, dt: float): - """Wheel drive + leg posture hold. - - vel_y: limited effect in wheel mode (differential drive cannot produce - pure lateral motion). Uses hip_abduction lean for small lateral force. - For significant lateral motion, use trot mode. - """ - wheel_targets = self._differential_drive(self._smooth_vx, self._smooth_yaw) - leg_targets = self._posture_control(state) - return leg_targets, wheel_targets - - def _posture_control(self, state: RobotState) -> np.ndarray: - """Leg joint targets: table-interpolated height control.""" - leg_targets = np.zeros(12) - - # Calibrated height→angle lookup (measured from simulation) - _H = [0.157, 0.248, 0.311, 0.366, 0.411, 0.448] - _HIP = [1.5, 1.2, 1.0, 0.8, 0.6, 0.4] - _KNEE = [-2.5, -2.1, -1.8, -1.5, -1.2, -0.9] - - h_clamp = np.clip(self.height, _H[0], _H[-1]) - q_hip_base = float(np.interp(h_clamp, _H, _HIP)) - q_knee_base = float(np.interp(h_clamp, _H, _KNEE)) - - roll_corr = -KP_ROLL * state.rpy[0] - pitch_corr = -KP_PITCH * state.rpy[1] - lateral_lean = 0.3 * self.vel_y - - for i, leg in enumerate(LEG_NAMES): - side = 1.0 if leg[1] == "l" else -1.0 - leg_targets[i*3] = np.clip(side * roll_corr + lateral_lean, -0.5, 0.5) - leg_targets[i*3+1] = np.clip(q_hip_base + pitch_corr, -1.0, 2.5) - leg_targets[i*3+2] = np.clip(q_knee_base, -2.6, -0.3) - - return leg_targets - - # ───────────────────────────────────────────────────────────────────── - # TROT MODE - # ───────────────────────────────────────────────────────────────────── - - def _trot_mode(self, state: RobotState, dt: float): - """Trot gait with wheel assist.""" - # Advance gait phase - self._gait_phase = (self._gait_phase + dt * GAIT_FREQ) % 1.0 - - # Contact state - contacts = {} - for leg in LEG_NAMES: - phase = (self._gait_phase + PHASE_OFFSETS[leg]) % 1.0 - contacts[leg] = phase < GAIT_DUTY - - # Pinocchio update - q_pin, dq_pin = self.robot.get_qpos_qvel_for_pinocchio() - self.dynamics.update(q_pin, dq_pin) - - leg_targets = np.zeros(12) - wheel_targets = np.zeros(4) - - for i, leg in enumerate(LEG_NAMES): - if contacts[leg]: - # Stance: posture hold - leg_targets[i*3:(i+1)*3] = self._stance_leg_target(state, leg) - self._swing_start_foot[leg] = self.dynamics.get_foot_pos(leg) - self._last_contact[leg] = True - # Wheel: drive with smoothed velocity - wheel_targets[i] = self._differential_drive_single( - self._smooth_vx, self._smooth_yaw, leg) - else: - # Swing: IK trajectory - swing_phase = self._get_swing_phase(leg) - target_foot = self._compute_swing_target(leg, state, swing_phase) - q_ik = self.dynamics.inverse_kinematics(leg, target_foot, q_pin) - leg_targets[i*3:(i+1)*3] = q_ik - self._last_contact[leg] = False - # Wheel: zero (free during swing) - wheel_targets[i] = 0.0 - - return leg_targets, wheel_targets - - def _stance_leg_target(self, state: RobotState, leg: str) -> np.ndarray: - """Stance leg: table-interpolated height + attitude compensation.""" - _H = [0.157, 0.248, 0.311, 0.366, 0.411, 0.448] - _HIP = [1.5, 1.2, 1.0, 0.8, 0.6, 0.4] - _KNEE = [-2.5, -2.1, -1.8, -1.5, -1.2, -0.9] - - h_clamp = np.clip(self.height, _H[0], _H[-1]) - q_hip = float(np.interp(h_clamp, _H, _HIP)) - q_knee = float(np.interp(h_clamp, _H, _KNEE)) - - roll_corr = -KP_ROLL * state.rpy[0] - pitch_corr = -KP_PITCH * state.rpy[1] - side = 1.0 if leg[1] == "l" else -1.0 - lateral_lean = 0.3 * self.vel_y - - return np.array([ - np.clip(side * roll_corr + lateral_lean, -0.5, 0.5), - np.clip(q_hip + pitch_corr, -1.0, 2.5), - np.clip(q_knee, -2.6, -0.3), - ]) - - # ───────────────────────────────────────────────────────────────────── - # DIFFERENTIAL DRIVE - # ───────────────────────────────────────────────────────────────────── - - def _differential_drive(self, vel_x: float, yaw_rate: float) -> np.ndarray: - """4 wheel velocities from body commands.""" - vel_left = (vel_x - 0.5 * WHEEL_TRACK * yaw_rate) / WHEEL_RADIUS - vel_right = (vel_x + 0.5 * WHEEL_TRACK * yaw_rate) / WHEEL_RADIUS - targets = np.zeros(4) - for i, leg in enumerate(LEG_NAMES): - targets[i] = vel_left if leg[1] == "l" else vel_right - return np.clip(targets, -WHEEL_VEL_MAX, WHEEL_VEL_MAX) - - def _differential_drive_single(self, vel_x: float, yaw_rate: float, leg: str) -> float: - if leg[1] == "l": - v = (vel_x - 0.5 * WHEEL_TRACK * yaw_rate) / WHEEL_RADIUS - else: - v = (vel_x + 0.5 * WHEEL_TRACK * yaw_rate) / WHEEL_RADIUS - return np.clip(v, -WHEEL_VEL_MAX, WHEEL_VEL_MAX) - - # ───────────────────────────────────────────────────────────────────── - # SWING TRAJECTORY - # ───────────────────────────────────────────────────────────────────── - - def _get_swing_phase(self, leg: str) -> float: - phase = (self._gait_phase + PHASE_OFFSETS[leg]) % 1.0 - if phase < GAIT_DUTY: - return 0.0 - return (phase - GAIT_DUTY) / (1.0 - GAIT_DUTY) - - def _compute_swing_target(self, leg: str, state: RobotState, - swing_phase: float) -> np.ndarray: - """Swing foot target with Raibert heuristic using COMMANDED velocity.""" - p_start = self._swing_start_foot[leg] - p_end = self._compute_touchdown(leg, state) - - s = swing_phase - s_mj = 10*s**3 - 15*s**4 + 6*s**5 - - pos = p_start + (p_end - p_start) * s_mj - - # Z lift - z_lift = 64.0 * s**3 * (1.0 - s)**3 - pos[2] = p_start[2] + SWING_HEIGHT * z_lift - - return pos - - def _compute_touchdown(self, leg: str, state: RobotState) -> np.ndarray: - """Raibert heuristic using COMMANDED velocity. - - When commands are zero, foot lands at its takeoff position (no net motion). - When commands are nonzero, foot placement is offset by commanded velocity. - """ - # Base: land where the foot took off (zero net displacement) - td = self._swing_start_foot[leg].copy() - - # Add commanded velocity offset (Raibert-style) - t_stance = (1.0 / GAIT_FREQ) * GAIT_DUTY - yaw = state.rpy[2] - c, s = np.cos(yaw), np.sin(yaw) - R_z = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]]) - cmd_vel_world = R_z @ np.array([self._smooth_vx, self._smooth_vy, 0.0]) - - td[0] += cmd_vel_world[0] * t_stance * 0.5 - td[1] += cmd_vel_world[1] * t_stance * 0.5 - td[2] = WHEEL_RADIUS # ground level - return td diff --git a/05_software/train/rc_mjlab/mujoco_sim/dynamics.py b/05_software/train/rc_mjlab/mujoco_sim/dynamics.py deleted file mode 100644 index d4f1e22..0000000 --- a/05_software/train/rc_mjlab/mujoco_sim/dynamics.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Pinocchio dynamics: FK, Jacobian, IK for the wheeled-legged robot.""" - -import numpy as np -import pinocchio as pin -from config import MJCF_PATH, LEG_NAMES - -# Foot frame names in Pinocchio model (wheel link centers) -FOOT_FRAMES = {leg: f"{leg}_wheel_Link" for leg in LEG_NAMES} - -# Leg joint names for each leg -_LEG_JOINT_NAMES = { - leg: [f"{leg}_{jt}" for jt in ("hip_abduction_joint", "hip_pitch_joint", "knee_joint")] - for leg in LEG_NAMES -} - - -class Dynamics: - """Pinocchio-based kinematics/dynamics. Deployable on real hardware.""" - - def __init__(self): - self.model = pin.buildModelFromMJCF(str(MJCF_PATH)) - self.data = self.model.createData() - - # Cache frame IDs - self._foot_fids = {} - for leg, fname in FOOT_FRAMES.items(): - self._foot_fids[leg] = self.model.getFrameId(fname) - - # Cache joint velocity indices for each leg (3 joints) - self._leg_v_indices = {} - for leg, jnames in _LEG_JOINT_NAMES.items(): - indices = [] - for jn in jnames: - jid = self.model.getJointId(jn) - indices.append(self.model.joints[jid].idx_v) - self._leg_v_indices[leg] = indices - - # Cache joint config indices for each leg - self._leg_q_indices = {} - for leg, jnames in _LEG_JOINT_NAMES.items(): - indices = [] - for jn in jnames: - jid = self.model.getJointId(jn) - indices.append(self.model.joints[jid].idx_q) - self._leg_q_indices[leg] = indices - - def update(self, q: np.ndarray, dq: np.ndarray): - """Forward kinematics + Jacobians. - - Args: - q: Pinocchio config (nq=23: pos3, quat_xyzw4, joints16) - dq: Pinocchio velocity (nv=22: v_body3, w_body3, joints16) - """ - pin.forwardKinematics(self.model, self.data, q, dq) - pin.updateFramePlacements(self.model, self.data) - pin.computeJointJacobians(self.model, self.data, q) - - def get_foot_pos(self, leg: str) -> np.ndarray: - """Foot (wheel center) position in world frame (3,).""" - return self.data.oMf[self._foot_fids[leg]].translation.copy() - - def get_foot_jacobian_leg(self, leg: str) -> np.ndarray: - """3x3 linear Jacobian of foot w.r.t. 3 leg joints (world frame).""" - fid = self._foot_fids[leg] - J_full = pin.getFrameJacobian( - self.model, self.data, fid, pin.LOCAL_WORLD_ALIGNED)[:3, :] - cols = self._leg_v_indices[leg] - return J_full[:, cols] - - def inverse_kinematics(self, leg: str, target_pos: np.ndarray, - q_current: np.ndarray, max_iter=30, eps=1e-4) -> np.ndarray: - """Numerical IK for one leg. Returns (3,) joint angles. - - Args: - leg: Leg name - target_pos: Desired foot position in world frame (3,) - q_current: Current full Pinocchio config (nq=23) - """ - q = q_current.copy() - fid = self._foot_fids[leg] - q_indices = self._leg_q_indices[leg] - - for _ in range(max_iter): - pin.forwardKinematics(self.model, self.data, q) - pin.updateFramePlacements(self.model, self.data) - err = target_pos - self.data.oMf[fid].translation - if np.linalg.norm(err) < eps: - break - pin.computeJointJacobians(self.model, self.data, q) - J = pin.getFrameJacobian( - self.model, self.data, fid, pin.LOCAL_WORLD_ALIGNED)[:3, :] - J_leg = J[:, self._leg_v_indices[leg]] - dq = np.linalg.solve(J_leg.T @ J_leg + 1e-6 * np.eye(3), J_leg.T @ err) - for i, idx in enumerate(q_indices): - q[idx] += dq[i] - - return np.array([q[idx] for idx in q_indices]) diff --git a/05_software/train/rc_mjlab/mujoco_sim/gui.py b/05_software/train/rc_mjlab/mujoco_sim/gui.py deleted file mode 100644 index bbdd130..0000000 --- a/05_software/train/rc_mjlab/mujoco_sim/gui.py +++ /dev/null @@ -1,127 +0,0 @@ -"""GUI control panel for the wheeled-legged robot.""" - -import tkinter as tk -from tkinter import ttk - - -class GUI: - """Tkinter control panel: sliders + gait buttons + status display.""" - - def __init__(self, controller): - self.ctrl = controller - self.root = tk.Tk() - self.root.title("WheelLeg Control") - self.root.geometry("400x500") - self.root.protocol("WM_DELETE_WINDOW", self._on_close) - self._closed = False - - self._build() - - def _build(self): - # Mode buttons - mf = ttk.LabelFrame(self.root, text="Mode") - mf.pack(fill="x", padx=8, pady=4) - for mode in ("wheel", "trot", "mpc"): - ttk.Button(mf, text=mode.upper(), - command=lambda m=mode: self._set_mode(m) - ).pack(side="left", padx=4, expand=True) - ttk.Button(mf, text="PRONE/STAND", - command=self._toggle_prone).pack(side="left", padx=4, expand=True) - - # Command sliders - cf = ttk.LabelFrame(self.root, text="Commands") - cf.pack(fill="x", padx=8, pady=4) - - self.vel_x_var = tk.DoubleVar(value=0.0) - self.vel_y_var = tk.DoubleVar(value=0.0) - self.yaw_var = tk.DoubleVar(value=0.0) - self.height_var = tk.DoubleVar(value=self.ctrl.height) - - self._slider(cf, "Vel X", self.vel_x_var, -1.5, 1.5) - self._slider(cf, "Vel Y*", self.vel_y_var, -0.5, 0.5) - self._slider(cf, "Yaw", self.yaw_var, -2.0, 2.0) - self._slider(cf, "Height", self.height_var, 0.16, 0.45) - - ttk.Label(cf, text="* Vel Y: trot mode only (diff-drive can't sidestep)", - font=("", 8)).pack(anchor="w", padx=8) - - ttk.Button(cf, text="Reset", command=self._reset).pack(pady=4) - - # Status display - sf = ttk.LabelFrame(self.root, text="Status") - sf.pack(fill="both", expand=True, padx=8, pady=4) - self.status_text = tk.Text(sf, height=12, width=45, font=("Consolas", 9)) - self.status_text.pack(fill="both", expand=True, padx=4, pady=4) - - def _slider(self, parent, label, var, lo, hi): - f = ttk.Frame(parent) - f.pack(fill="x", padx=4, pady=2) - ttk.Label(f, text=label, width=7).pack(side="left") - ttk.Scale(f, from_=lo, to=hi, variable=var, - command=lambda *_: self._sync()).pack(side="left", fill="x", expand=True) - lbl = ttk.Label(f, text="0.00", width=6) - lbl.pack(side="left") - var.trace_add("write", lambda *_, v=var, l=lbl: l.config(text=f"{v.get():.2f}")) - - def _set_mode(self, mode): - self.ctrl.mode = mode - self.ctrl.prone = False - - def _toggle_prone(self): - self.ctrl.prone = not self.ctrl.prone - - def _sync(self): - self.ctrl.vel_x = self.vel_x_var.get() - self.ctrl.vel_y = self.vel_y_var.get() - self.ctrl.yaw_rate = self.yaw_var.get() - self.ctrl.height = self.height_var.get() - - def _reset(self): - self.vel_x_var.set(0.0) - self.vel_y_var.set(0.0) - self.yaw_var.set(0.0) - self._sync() - - def _on_close(self): - self._closed = True - self.root.destroy() - - @property - def closed(self): - return self._closed - - def update_status(self, state, step): - """Update status text with current robot state.""" - txt = ( - f"Mode: {self.ctrl.mode} Step: {step}\n" - f"Pos: x={state.pos[0]:.3f} y={state.pos[1]:.3f} z={state.pos[2]:.3f}\n" - f"RPY: r={np.degrees(state.rpy[0]):.1f}° p={np.degrees(state.rpy[1]):.1f}° " - f"y={np.degrees(state.rpy[2]):.1f}°\n" - f"Vel: vx={state.lin_vel[0]:.3f} vy={state.lin_vel[1]:.3f} vz={state.lin_vel[2]:.3f}\n" - f"Cmd: vx={self.ctrl.vel_x:.2f} yaw={self.ctrl.yaw_rate:.2f} h={self.ctrl.height:.3f}\n" - f"─────────────────────────────────\n" - ) - # Joint angles (compact) - for i, leg in enumerate(("FL", "FR", "RL", "RR")): - q = state.joint_pos[i*4:i*4+3] - w = state.joint_vel[i*4+3] - txt += f"{leg}: [{q[0]:+.2f} {q[1]:+.2f} {q[2]:+.2f}] w={w:+.1f}\n" - - self.status_text.delete("1.0", tk.END) - self.status_text.insert(tk.END, txt) - - def tick(self): - """Process GUI events. Returns False if window closed.""" - if self._closed: - return False - try: - self.root.update_idletasks() - self.root.update() - return True - except tk.TclError: - self._closed = True - return False - - -# Need numpy for degrees conversion in update_status -import numpy as np diff --git a/05_software/train/rc_mjlab/mujoco_sim/mpc.py b/05_software/train/rc_mjlab/mujoco_sim/mpc.py deleted file mode 100644 index 3491651..0000000 --- a/05_software/train/rc_mjlab/mujoco_sim/mpc.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Convex MPC solver for wheeled-legged robot. - -Centroidal dynamics: single rigid body model with 4 contact forces. -State: x = [pos(3), rpy(3), vel(3), omega(3)] = 12 -Input: u = [f1(3), f2(3), f3(3), f4(3)] = 12 -Friction pyramid constraints on each foot. - -Reference: MIT Cheetah 3 Convex MPC (Di Carlo et al.) -""" - -import numpy as np -from scipy import sparse -from scipy.linalg import block_diag -import osqp - -from config import ROBOT_MASS, LEG_NAMES - -# MPC parameters -MPC_HORIZON = 10 # prediction steps -MPC_DT = 0.02 # 50 Hz MPC update -MU = 0.6 # friction coefficient -FZ_MAX = 200.0 # max vertical force per leg -FZ_MIN = 10.0 # min vertical force (stance) -NX = 12 # state dim -NU = 12 # input dim (4 legs × 3D force) - -# Cost weights: [pos_x, pos_y, pos_z, roll, pitch, yaw, vx, vy, vz, wx, wy, wz] -Q_WEIGHTS = np.array([2.0, 2.0, 50.0, 50.0, 50.0, 10.0, 2.0, 2.0, 1.0, 1.0, 1.0, 1.0]) -R_WEIGHTS = np.array([1e-6] * 12) - - -def _skew(v): - return np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]]) - - -class ConvexMPC: - """Convex MPC: solves QP for optimal ground reaction forces.""" - - def __init__(self, mass=ROBOT_MASS, inertia=None): - self.mass = mass - # Approximate body inertia (diagonal, world-aligned) - if inertia is None: - self.I_body = np.diag([0.07, 0.26, 0.24]) - else: - self.I_body = np.array(inertia).reshape(3, 3) - - self.N = MPC_HORIZON - self.dt = MPC_DT - self.Q = np.diag(Q_WEIGHTS) - self.R = np.diag(R_WEIGHTS) - self.gravity = np.array([0, 0, -9.81]) - - self._last_forces = np.zeros(NU) - - def solve(self, x0, x_ref, foot_positions, contact_schedule): - """Solve MPC QP. - - Args: - x0: (12,) current state [pos, rpy, vel, omega] - x_ref: (12, N) reference trajectory over horizon - foot_positions: (4, 3) foot positions in world frame (relative to CoM) - contact_schedule: (4, N) binary contact table (1=stance) - - Returns: - forces: (12,) optimal forces for current timestep [f1x,f1y,f1z,...,f4x,f4y,f4z] - """ - N = self.N - - # Build dynamics matrices - Ad, Bd_list, gd = self._discretize_dynamics(x0, foot_positions) - - # Build QP: min 0.5 z'Hz + f'z s.t. lb <= Az <= ub, lbx <= z <= ubx - # Decision variables: z = [x1,...,xN, u0,...,uN-1] - nvars = N * NX + N * NU - - # --- Hessian --- - H_diag = np.concatenate([np.tile(2 * Q_WEIGHTS, N), np.tile(2 * R_WEIGHTS, N)]) - H = sparse.diags(H_diag, format='csc') - - # --- Gradient --- - g = np.zeros(nvars) - for k in range(N): - g[k*NX:(k+1)*NX] = -2 * self.Q @ x_ref[:, k] - - # --- Dynamics equality constraints --- - # x_{k+1} = Ad @ x_k + Bd_k @ u_k + gd - # Rewrite: x_{k+1} - Ad @ x_k - Bd_k @ u_k = gd (for k>0) - # x_1 - Bd_0 @ u_0 = Ad @ x0 + gd (for k=0) - n_eq = N * NX - A_eq = np.zeros((n_eq, nvars)) - b_eq = np.zeros(n_eq) - - # k=0: x_1 = Ad @ x0 + Bd_0 @ u_0 + gd - A_eq[0:NX, 0:NX] = np.eye(NX) # x_1 - A_eq[0:NX, N*NX:N*NX+NU] = -Bd_list[0] # -Bd_0 @ u_0 - b_eq[0:NX] = Ad @ x0 + gd - - for k in range(1, N): - row = k * NX - # x_{k+1} - A_eq[row:row+NX, k*NX:(k+1)*NX] = np.eye(NX) - # -Ad @ x_k - A_eq[row:row+NX, (k-1)*NX:k*NX] = -Ad - # -Bd_k @ u_k - A_eq[row:row+NX, N*NX+k*NU:N*NX+(k+1)*NU] = -Bd_list[k] - b_eq[row:row+NX] = gd - - # --- Friction pyramid inequality constraints --- - # For each stance leg at each timestep: 4 faces - # fx - mu*fz <= 0, -fx - mu*fz <= 0, fy - mu*fz <= 0, -fy - mu*fz <= 0 - n_ineq_max = 4 * 4 * N - A_ineq = np.zeros((n_ineq_max, nvars)) - u_ineq = np.zeros(n_ineq_max) - - row = 0 - for k in range(N): - u_base = N * NX + k * NU - for leg in range(4): - if contact_schedule[leg, k] == 1: - fx_idx = u_base + leg * 3 - fy_idx = u_base + leg * 3 + 1 - fz_idx = u_base + leg * 3 + 2 - - # Friction pyramid: stance leg - A_ineq[row, fx_idx] = 1.0 - A_ineq[row, fz_idx] = -MU - row += 1 - A_ineq[row, fx_idx] = -1.0 - A_ineq[row, fz_idx] = -MU - row += 1 - A_ineq[row, fy_idx] = 1.0 - A_ineq[row, fz_idx] = -MU - row += 1 - A_ineq[row, fy_idx] = -1.0 - A_ineq[row, fz_idx] = -MU - row += 1 - - A_ineq = A_ineq[:row] - u_ineq = u_ineq[:row] - - # Stack constraints - A_full = np.vstack([A_eq, A_ineq]) - l_full = np.concatenate([b_eq, -np.inf * np.ones(row)]) - u_full = np.concatenate([b_eq, u_ineq]) - - # --- Box constraints on forces (as identity rows in A) --- - A_box = np.zeros((N * NU, nvars)) - l_box = -np.inf * np.ones(N * NU) - u_box = np.inf * np.ones(N * NU) - - for k in range(N): - u_base = N * NX + k * NU - for leg in range(4): - idx = u_base + leg * 3 - box_row = k * NU + leg * 3 - # Identity rows for fx, fy, fz - for j in range(3): - A_box[box_row + j, idx + j] = 1.0 - - if contact_schedule[leg, k] == 1: - # Stance: fz bounded - l_box[box_row + 2] = FZ_MIN - u_box[box_row + 2] = FZ_MAX - else: - # Swing: all forces = 0 - l_box[box_row:box_row+3] = 0.0 - u_box[box_row:box_row+3] = 0.0 - - # Final constraint matrix - A_full = np.vstack([A_full, A_box]) - l_full = np.concatenate([l_full, l_box]) - u_full = np.concatenate([u_full, u_box]) - - # --- Solve with OSQP --- - A_sparse = sparse.csc_matrix(A_full) - H_sparse = sparse.triu(H, format='csc') - - solver = osqp.OSQP() - solver.setup(H_sparse, g, A_sparse, l_full, u_full, - eps_abs=1e-4, eps_rel=1e-4, - max_iter=500, polish=True, verbose=False, - warm_start=True) - - # Warm start with previous solution - if self._last_forces is not None: - x_warm = np.zeros(nvars) - x_warm[N*NX:N*NX+NU] = self._last_forces - solver.warm_start(x=x_warm) - - result = solver.solve() - - if result.info.status == 'solved' or result.info.status == 'solved_inaccurate': - # Extract first timestep forces - forces = result.x[N*NX:N*NX+NU] - self._last_forces = forces.copy() - else: - forces = self._last_forces - - return forces - - def _discretize_dynamics(self, x0, foot_positions): - """Build discrete-time centroidal dynamics. - - State: [pos, rpy, vel, omega] (12) - Continuous: dx/dt = Ac @ x + Bc @ u + gc - Discrete: x_{k+1} = Ad @ x + Bd @ u + gd - """ - m = self.mass - I_inv = np.linalg.inv(self.I_body) - dt = self.dt - yaw = x0[5] - cy, sy = np.cos(yaw), np.sin(yaw) - - # Rotation for rpy rate ≈ R_z^T @ omega - R_zT = np.array([[cy, sy, 0], [-sy, cy, 0], [0, 0, 1]]) - - # Ac (12×12) - Ac = np.zeros((NX, NX)) - Ac[0:3, 6:9] = np.eye(3) # pos_dot = vel - Ac[3:6, 9:12] = R_zT # rpy_dot ≈ R_z^T @ omega - - # Ad = I + Ac*dt (first-order) - Ad = np.eye(NX) + Ac * dt - - # Bc varies per timestep (foot positions change contact point) - Bd_list = [] - for k in range(self.N): - Bc = np.zeros((NX, NU)) - for leg in range(4): - r = foot_positions[leg] - # vel_dot += f/m - Bc[6:9, leg*3:(leg+1)*3] = np.eye(3) / m - # omega_dot += I^{-1} @ (r × f) - Bc[9:12, leg*3:(leg+1)*3] = I_inv @ _skew(r) - Bd = Bc * dt - Bd_list.append(Bd) - - # Gravity contribution - gd = np.zeros(NX) - gd[6:9] = self.gravity * dt # vel += g*dt - - return Ad, Bd_list, gd diff --git a/05_software/train/rc_mjlab/mujoco_sim/mpc_controller.py b/05_software/train/rc_mjlab/mujoco_sim/mpc_controller.py deleted file mode 100644 index 91f373c..0000000 --- a/05_software/train/rc_mjlab/mujoco_sim/mpc_controller.py +++ /dev/null @@ -1,261 +0,0 @@ -"""MPC controller integration for wheeled-legged robot. - -Integrates: gait scheduler + reference trajectory + ConvexMPC solver + - swing leg control + stance force mapping + wheel drive. - -Architecture (following go2-convex-mpc): - - MPC runs at ~50 Hz (every MPC_DECIMATION control steps) - - Swing/stance leg controller runs at control rate (250 Hz) - - Wheel drive: stance legs use differential drive, swing legs coast -""" - -import numpy as np -from robot import Robot, RobotState -from dynamics import Dynamics -from mpc import ConvexMPC, MPC_DT -from config import ( - LEG_NAMES, WHEEL_RADIUS, WHEEL_TRACK, WHEEL_VEL_MAX, - CTRL_DT, GAIT_FREQ, GAIT_DUTY, SWING_HEIGHT, PHASE_OFFSETS, - DEFAULT_JOINT_ANGLES, ROBOT_MASS, -) - -# MPC update decimation (relative to control loop) -MPC_DECIMATION = max(1, int(MPC_DT / CTRL_DT)) # ~5 steps at 250Hz - - -class MPCController: - """Convex MPC locomotion controller for wheeled-legged robot.""" - - def __init__(self, robot: Robot): - self.robot = robot - self.dynamics = Dynamics() - self.mpc = ConvexMPC(mass=ROBOT_MASS) - - # User commands - self.vel_x = 0.0 - self.vel_y = 0.0 - self.yaw_rate = 0.0 - self.height = 0.35 # actual standing height with default joint angles - - # Gait state - start at phase 0 with all legs in stance (duty=0.6) - self._gait_phase = 0.0 - self._step_count = 0 - self._initialized = False - - # MPC solution cache - initialize with gravity compensation - self._mpc_forces = np.zeros(12) - self._init_gravity_comp() - - # Swing trajectory state - self._swing_start_foot = {leg: np.zeros(3) for leg in LEG_NAMES} - self._swing_start_time = {leg: 0.0 for leg in LEG_NAMES} - self._last_contact = {leg: True for leg in LEG_NAMES} - - # Smoothed commands - self._smooth_vx = 0.0 - self._smooth_vy = 0.0 - self._smooth_yaw = 0.0 - - def _init_gravity_comp(self): - """Pre-fill MPC forces with static gravity compensation.""" - fz_per_leg = ROBOT_MASS * 9.81 / 4.0 - for i in range(4): - self._mpc_forces[i*3 + 2] = fz_per_leg - - def compute(self, state: RobotState, dt: float): - """Main MPC control loop. - - Uses MIT motor protocol: tau = kp*(q_des-q) + kd*(dq_des-dq) + tau_ff - where tau_ff comes from MPC force mapping via Jacobian transpose. - - Returns: - tau_legs: (12,) feedforward torques for MIT mode - wheel_targets: (4,) wheel velocity targets - """ - # Smooth commands - alpha = min(dt * 3.0, 1.0) - self._smooth_vx += alpha * (self.vel_x - self._smooth_vx) - self._smooth_vy += alpha * (self.vel_y - self._smooth_vy) - self._smooth_yaw += alpha * (self.yaw_rate - self._smooth_yaw) - - # Update Pinocchio - q_pin, dq_pin = self.robot.get_qpos_qvel_for_pinocchio() - self.dynamics.update(q_pin, dq_pin) - - # Initialize foot positions on first call - if not self._initialized: - for leg in LEG_NAMES: - self._swing_start_foot[leg] = self.dynamics.get_foot_pos(leg) - self._initialized = True - - # Decide if we should trot or just stand - moving = (abs(self._smooth_vx) > 0.02 or - abs(self._smooth_vy) > 0.02 or - abs(self._smooth_yaw) > 0.05) - - if moving: - self._gait_phase = (self._gait_phase + dt * GAIT_FREQ) % 1.0 - else: - self._gait_phase = 0.0 # all legs in stance - - # Contact schedule - contacts = {} - for leg in LEG_NAMES: - phase = (self._gait_phase + PHASE_OFFSETS[leg]) % 1.0 - contacts[leg] = phase < GAIT_DUTY - - # Get foot positions relative to CoM - foot_positions = np.zeros((4, 3)) - for i, leg in enumerate(LEG_NAMES): - foot_positions[i] = self.dynamics.get_foot_pos(leg) - state.pos - - # --- Run MPC at lower rate --- - if self._step_count % MPC_DECIMATION == 0: - x0 = self._build_state_vector(state) - x_ref = self._build_reference(state) - contact_table = self._build_contact_table() - self._mpc_forces = self.mpc.solve(x0, x_ref, foot_positions, contact_table) - - self._step_count += 1 - - # --- Compute feedforward torques and desired joint positions --- - tau_ff = np.zeros(12) - q_des = np.zeros(12) - dq_des = np.zeros(12) - kp = np.zeros(12) - kd = np.zeros(12) - wheel_targets = np.zeros(4) - - for i, leg in enumerate(LEG_NAMES): - if contacts[leg]: - # Stance: MPC force → feedforward torque, PD holds posture - f_leg = self._mpc_forces[i*3:(i+1)*3] - J = self.dynamics.get_foot_jacobian_leg(leg) - tau_ff[i*3:(i+1)*3] = J.T @ (-f_leg) - - # PD target: default standing angles (posture hold) - q_des[i*3] = DEFAULT_JOINT_ANGLES["hip_abduction"] - q_des[i*3+1] = DEFAULT_JOINT_ANGLES["hip_pitch"] - q_des[i*3+2] = DEFAULT_JOINT_ANGLES["knee"] - kp[i*3:(i+1)*3] = [40.0, 40.0, 40.0] - kd[i*3:(i+1)*3] = [3.0, 3.0, 3.0] - - # Record foot position - self._swing_start_foot[leg] = self.dynamics.get_foot_pos(leg) - self._last_contact[leg] = True - - # Wheel drive - wheel_targets[i] = self._wheel_cmd(leg) - else: - # Swing: IK target position, strong PD, no feedforward - if self._last_contact[leg]: - self._swing_start_foot[leg] = self.dynamics.get_foot_pos(leg) - self._swing_start_time[leg] = state.time - self._last_contact[leg] = False - - q_ik = self._swing_leg_ik(leg, state, q_pin) - q_des[i*3:(i+1)*3] = q_ik - kp[i*3:(i+1)*3] = [60.0, 60.0, 60.0] # strong PD for swing - kd[i*3:(i+1)*3] = [3.0, 3.0, 3.0] - # tau_ff stays 0 for swing - - wheel_targets[i] = 0.0 - - # Use MIT protocol via robot interface - self.robot.set_ctrl_mit(q_des, dq_des, kp, kd, tau_ff, wheel_targets) - # Return dummy (actual ctrl is set directly above) - return None, None - - def _build_state_vector(self, state: RobotState): - """Build MPC state: [pos, rpy, vel, omega].""" - return np.concatenate([state.pos, state.rpy, state.lin_vel, state.ang_vel]) - - def _build_reference(self, state: RobotState): - """Build reference trajectory over MPC horizon.""" - N = self.mpc.N - x_ref = np.zeros((12, N)) - - yaw = state.rpy[2] - cy, sy = np.cos(yaw), np.sin(yaw) - R_z = np.array([[cy, -sy, 0], [sy, cy, 0], [0, 0, 1]]) - vel_world = R_z @ np.array([self._smooth_vx, self._smooth_vy, 0.0]) - - for k in range(N): - t = (k + 1) * self.mpc.dt - # Position: integrate from current - x_ref[0, k] = state.pos[0] + vel_world[0] * t - x_ref[1, k] = state.pos[1] + vel_world[1] * t - x_ref[2, k] = self.height - # RPY: keep roll/pitch zero, integrate yaw - x_ref[3, k] = 0.0 - x_ref[4, k] = 0.0 - x_ref[5, k] = yaw + self._smooth_yaw * t - # Velocity - x_ref[6, k] = vel_world[0] - x_ref[7, k] = vel_world[1] - x_ref[8, k] = 0.0 - # Angular velocity - x_ref[9, k] = 0.0 - x_ref[10, k] = 0.0 - x_ref[11, k] = self._smooth_yaw - - return x_ref - - def _build_contact_table(self): - """Build contact schedule over MPC horizon.""" - N = self.mpc.N - table = np.zeros((4, N), dtype=int) - for k in range(N): - future_phase = (self._gait_phase + (k + 1) * self.mpc.dt * GAIT_FREQ) % 1.0 - for i, leg in enumerate(LEG_NAMES): - leg_phase = (future_phase + PHASE_OFFSETS[leg]) % 1.0 - table[i, k] = 1 if leg_phase < GAIT_DUTY else 0 - return table - - def _swing_leg_ik(self, leg: str, state: RobotState, q_pin: np.ndarray): - """Swing leg: compute IK target joint angles for trajectory.""" - swing_phase = self._get_swing_phase(leg) - - p_start = self._swing_start_foot[leg] - p_end = self._compute_touchdown(leg, state) - - s = swing_phase - s_mj = 10*s**3 - 15*s**4 + 6*s**5 - - pos_des = p_start + (p_end - p_start) * s_mj - # Z lift - z_lift = 64.0 * s**3 * (1.0 - s)**3 - pos_des[2] = p_start[2] + SWING_HEIGHT * z_lift - - # IK to get joint angles - q_ik = self.dynamics.inverse_kinematics(leg, pos_des, q_pin) - return q_ik - - def _get_swing_phase(self, leg: str) -> float: - phase = (self._gait_phase + PHASE_OFFSETS[leg]) % 1.0 - if phase < GAIT_DUTY: - return 0.0 - return (phase - GAIT_DUTY) / (1.0 - GAIT_DUTY) - - def _compute_touchdown(self, leg: str, state: RobotState) -> np.ndarray: - """Raibert heuristic for touchdown position.""" - td = self._swing_start_foot[leg].copy() - t_stance = GAIT_DUTY / GAIT_FREQ - - yaw = state.rpy[2] - cy, sy = np.cos(yaw), np.sin(yaw) - R_z = np.array([[cy, -sy, 0], [sy, cy, 0], [0, 0, 1]]) - cmd_vel_world = R_z @ np.array([self._smooth_vx, self._smooth_vy, 0.0]) - - td[0] += cmd_vel_world[0] * t_stance * 0.5 - td[1] += cmd_vel_world[1] * t_stance * 0.5 - td[2] = WHEEL_RADIUS - return td - - def _wheel_cmd(self, leg: str) -> float: - """Differential drive for a single wheel.""" - if leg[1] == "l": - v = (self._smooth_vx - 0.5 * WHEEL_TRACK * self._smooth_yaw) / WHEEL_RADIUS - else: - v = (self._smooth_vx + 0.5 * WHEEL_TRACK * self._smooth_yaw) / WHEEL_RADIUS - return np.clip(v, -WHEEL_VEL_MAX, WHEEL_VEL_MAX) diff --git a/05_software/train/rc_mjlab/mujoco_sim/robot.py b/05_software/train/rc_mjlab/mujoco_sim/robot.py deleted file mode 100644 index d002f02..0000000 --- a/05_software/train/rc_mjlab/mujoco_sim/robot.py +++ /dev/null @@ -1,229 +0,0 @@ -"""MuJoCo interface for the wheeled-legged robot. - -Configures actuators as proper PD controllers at runtime: - - Leg joints: force = kp*(ctrl - qpos) - kd*qvel, ctrl = target angle - - Wheel joints: force = gain*(ctrl - qvel), ctrl = target velocity (rad/s) -""" - -import numpy as np -import mujoco -from dataclasses import dataclass -from config import (SCENE_XML, LEG_NAMES, LEG_JOINTS, WHEEL_JOINT, - DEFAULT_JOINT_ANGLES, WHEEL_RADIUS, WHEEL_TRACK) - - -@dataclass -class RobotState: - """Robot state from MuJoCo.""" - pos: np.ndarray # (3,) world position - quat: np.ndarray # (4,) quaternion (w,x,y,z) MuJoCo convention - rot: np.ndarray # (3,3) body→world rotation - rpy: np.ndarray # (3,) roll, pitch, yaw - lin_vel: np.ndarray # (3,) world frame linear velocity - ang_vel: np.ndarray # (3,) body frame angular velocity - joint_pos: np.ndarray # (16,) all joint positions [fl3+wheel, fr3+wheel, rl3+wheel, rr3+wheel] - joint_vel: np.ndarray # (16,) all joint velocities - time: float - - -class Robot: - """MuJoCo simulation interface with proper PD actuator configuration.""" - - # Leg PD gains (tuned for 12.3kg robot) - LEG_KP = 60.0 - LEG_KD = 3.0 - # Wheel velocity gain - WHEEL_KP = 2.0 - - def __init__(self, xml_path=None): - self.model = mujoco.MjModel.from_xml_path(str(xml_path or SCENE_XML)) - self.data = mujoco.MjData(self.model) - - # Cache IDs - self._base_bid = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "base_link") - self._actuator_ids = {} # name → actuator index - self._joint_qpos_adr = {} # name → qpos address - self._joint_qvel_adr = {} # name → qvel address - - # Build joint/actuator maps - self._ctrl_order = [] - for leg in LEG_NAMES: - for jt in (*LEG_JOINTS, WHEEL_JOINT): - name = f"{leg}_{jt}" - aid = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_ACTUATOR, name) - jid = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, name) - self._actuator_ids[name] = aid - self._joint_qpos_adr[name] = self.model.jnt_qposadr[jid] - self._joint_qvel_adr[name] = self.model.jnt_dofadr[jid] - self._ctrl_order.append(name) - - # Configure actuators as proper PD controllers - self._configure_actuators() - - def _configure_actuators(self): - """Set actuators to proper PD mode. - - Leg joints: force = kp*(ctrl - qpos) - kd*qvel - Wheels: force = gain*(ctrl - qvel) (velocity tracking) - """ - for i in range(self.model.nu): - name = mujoco.mj_id2name(self.model, mujoco.mjtObj.mjOBJ_ACTUATOR, i) - self.model.actuator_biastype[i] = 1 # affine bias - self.model.actuator_gaintype[i] = 0 # fixed gain - self.model.actuator_forcelimited[i] = 0 # no force clamp (17Nm is in actuatorfrcrange) - - if 'wheel' not in name: - self.model.actuator_gainprm[i, 0] = self.LEG_KP - self.model.actuator_biasprm[i, 0] = 0.0 - self.model.actuator_biasprm[i, 1] = -self.LEG_KP - self.model.actuator_biasprm[i, 2] = -self.LEG_KD - self.model.actuator_ctrlrange[i] = [-3.14, 3.14] - else: - self.model.actuator_gainprm[i, 0] = self.WHEEL_KP - self.model.actuator_biasprm[i, 0] = 0.0 - self.model.actuator_biasprm[i, 1] = 0.0 - self.model.actuator_biasprm[i, 2] = -self.WHEEL_KP - self.model.actuator_ctrlrange[i] = [-20.0, 20.0] - - @property - def dt(self): - return self.model.opt.timestep - - def reset(self): - """Reset to standing pose at correct height for default joint angles.""" - mujoco.mj_resetData(self.model, self.data) - - # Set default leg angles - for leg in LEG_NAMES: - for jt, key in zip(LEG_JOINTS, ("hip_abduction", "hip_pitch", "knee")): - name = f"{leg}_{jt}" - adr = self._joint_qpos_adr[name] - self.data.qpos[adr] = DEFAULT_JOINT_ANGLES[key] - - # Compute correct base height from default angles using 2R FK - # leg_length = sqrt(L1^2 + L2^2 - 2*L1*L2*cos(pi + knee)) - import math - L1, L2 = 0.25, 0.20 - knee = DEFAULT_JOINT_ANGLES["knee"] - leg_length = math.sqrt(L1**2 + L2**2 - 2*L1*L2*math.cos(math.pi + knee)) - # base_z = wheel_radius + leg_length - hip_z_offset - base_z = 0.10 + leg_length - 0.054 - self.data.qpos[2] = base_z - self.data.qpos[3] = 1.0 # quat w - - mujoco.mj_forward(self.model, self.data) - - # Set ctrl to match initial pose (so PD doesn't jerk) - for leg in LEG_NAMES: - for jt, key in zip(LEG_JOINTS, ("hip_abduction", "hip_pitch", "knee")): - name = f"{leg}_{jt}" - self.data.ctrl[self._actuator_ids[name]] = DEFAULT_JOINT_ANGLES[key] - # Wheels: zero velocity - self.data.ctrl[self._actuator_ids[f"{leg}_{WHEEL_JOINT}"]] = 0.0 - - def get_state(self) -> RobotState: - """Extract robot state.""" - pos = self.data.xpos[self._base_bid].copy() - quat = self.data.xquat[self._base_bid].copy() # (w,x,y,z) - rot = self.data.xmat[self._base_bid].reshape(3, 3).copy() - - rpy = np.array([ - np.arctan2(rot[2, 1], rot[2, 2]), - np.arctan2(-rot[2, 0], np.sqrt(rot[2, 1]**2 + rot[2, 2]**2)), - np.arctan2(rot[1, 0], rot[0, 0]), - ]) - - # Base velocity (world frame) - lin_vel = self.data.qvel[0:3].copy() - ang_vel = self.data.qvel[3:6].copy() - - # Joint states (16 joints: 4 legs × 4 joints each) - joint_pos = np.zeros(16) - joint_vel = np.zeros(16) - for i, name in enumerate(self._ctrl_order): - joint_pos[i] = self.data.qpos[self._joint_qpos_adr[name]] - joint_vel[i] = self.data.qvel[self._joint_qvel_adr[name]] - - return RobotState( - pos=pos, quat=quat, rot=rot, rpy=rpy, - lin_vel=lin_vel, ang_vel=ang_vel, - joint_pos=joint_pos, joint_vel=joint_vel, - time=self.data.time, - ) - - def set_ctrl(self, leg_targets: np.ndarray, wheel_targets: np.ndarray): - """Set actuator commands (position PD mode). - - Args: - leg_targets: (12,) target joint angles for legs [fl3, fr3, rl3, rr3] - wheel_targets: (4,) target wheel velocities [fl, fr, rl, rr] in rad/s - """ - for i, leg in enumerate(LEG_NAMES): - for j, jt in enumerate(LEG_JOINTS): - name = f"{leg}_{jt}" - self.data.ctrl[self._actuator_ids[name]] = leg_targets[i * 3 + j] - name = f"{leg}_{WHEEL_JOINT}" - self.data.ctrl[self._actuator_ids[name]] = wheel_targets[i] - - def set_ctrl_mit(self, q_des: np.ndarray, dq_des: np.ndarray, - kp: np.ndarray, kd: np.ndarray, tau_ff: np.ndarray, - wheel_targets: np.ndarray): - """MIT motor protocol: tau = kp*(q_des-q) + kd*(dq_des-dq) + tau_ff. - - Computes torque in software, sends to actuators in torque mode. - Call enable_torque_mode() first. - - Args: - q_des: (12,) desired joint angles - dq_des: (12,) desired joint velocities - kp: (12,) position gains (0 for pure torque) - kd: (12,) velocity gains - tau_ff: (12,) feedforward torques - wheel_targets: (4,) wheel velocity targets - """ - for i, leg in enumerate(LEG_NAMES): - for j, jt in enumerate(LEG_JOINTS): - name = f"{leg}_{jt}" - aid = self._actuator_ids[name] - idx = i * 3 + j - q = self.data.qpos[self._joint_qpos_adr[name]] - dq = self.data.qvel[self._joint_qvel_adr[name]] - tau = (kp[idx] * (q_des[idx] - q) - + kd[idx] * (dq_des[idx] - dq) - + tau_ff[idx]) - self.data.ctrl[aid] = np.clip(tau, -17.0, 17.0) - name = f"{leg}_{WHEEL_JOINT}" - self.data.ctrl[self._actuator_ids[name]] = wheel_targets[i] - - def enable_torque_mode(self): - """Switch leg actuators to direct torque mode (for MPC/MIT).""" - for i in range(self.model.nu): - name = mujoco.mj_id2name(self.model, mujoco.mjtObj.mjOBJ_ACTUATOR, i) - if 'wheel' not in name: - self.model.actuator_gainprm[i, 0] = 1.0 - self.model.actuator_biasprm[i, :3] = [0, 0, 0] - self.model.actuator_biastype[i] = 0 - self.model.actuator_ctrlrange[i] = [-17.0, 17.0] - - def enable_position_mode(self): - """Switch leg actuators back to position PD mode.""" - self._configure_actuators() - - def step(self): - """Advance one simulation timestep.""" - mujoco.mj_step(self.model, self.data) - - def get_qpos_qvel_for_pinocchio(self): - """Get full qpos/qvel for Pinocchio (reorder quaternion).""" - qpos = self.data.qpos.copy() - qvel = self.data.qvel.copy() - # MuJoCo quat: (w,x,y,z) → Pinocchio: (x,y,z,w) - w, x, y, z = qpos[3], qpos[4], qpos[5], qpos[6] - q_pin = np.concatenate([qpos[0:3], [x, y, z, w], qpos[7:]]) - # MuJoCo vel is already [lin_world(3), ang_body(3), joints(16)] - # Pinocchio wants [lin_body(3), ang_body(3), joints(16)] - from scipy.spatial.transform import Rotation - R = Rotation.from_quat([x, y, z, w]).as_matrix() - v_body = R.T @ qvel[0:3] - dq_pin = np.concatenate([v_body, qvel[3:]]) - return q_pin, dq_pin diff --git a/05_software/train/rc_mjlab/mujoco_sim/run.py b/05_software/train/rc_mjlab/mujoco_sim/run.py deleted file mode 100644 index fc782d0..0000000 --- a/05_software/train/rc_mjlab/mujoco_sim/run.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Main entry point: wheeled-legged robot simulation. - -Controls: - Mode: wheel (default) - differential drive + posture hold - trot - quadruped gait with wheel assist - mpc - convex MPC locomotion (torque control) - - Keyboard (in MuJoCo viewer): - W/S: vel_x ±0.1 - A/D: yaw_rate ±0.2 - Q/E: height ±0.02 - 1: wheel mode - 2: trot mode - 3: MPC mode - 4: prone toggle - Z: reset commands -""" - -import time -import numpy as np -import mujoco.viewer as mjv - -from robot import Robot -from controller import Controller -from gui import GUI -from config import CTRL_DECIMATION - - -def main(): - robot = Robot() - robot.reset() - ctrl = Controller(robot) - gui = GUI(ctrl) - - step = 0 - sim_steps_per_ctrl = CTRL_DECIMATION - - def key_callback(keycode): - """Called from MuJoCo render thread - only modify ctrl directly, not tkinter.""" - try: - c = chr(keycode).lower() - except (ValueError, OverflowError): - return - if c == 'w': - ctrl.vel_x = min(ctrl.vel_x + 0.1, 1.5) - elif c == 's': - ctrl.vel_x = max(ctrl.vel_x - 0.1, -1.5) - elif c == 'a': - ctrl.yaw_rate = min(ctrl.yaw_rate + 0.2, 2.0) - elif c == 'd': - ctrl.yaw_rate = max(ctrl.yaw_rate - 0.2, -2.0) - elif c == 'q': - ctrl.height = min(ctrl.height + 0.02, 0.45) - elif c == 'e': - ctrl.height = max(ctrl.height - 0.02, 0.15) - elif c == '1': - ctrl.mode = "wheel"; ctrl.prone = False - elif c == '2': - ctrl.mode = "trot"; ctrl.prone = False - elif c == '3': - ctrl.mode = "mpc"; ctrl.prone = False - elif c == '4': - ctrl.prone = not ctrl.prone - elif c == 'z': - ctrl.vel_x = 0.0; ctrl.vel_y = 0.0; ctrl.yaw_rate = 0.0 - - with mjv.launch_passive(robot.model, robot.data, key_callback=key_callback) as viewer: - viewer.cam.distance = 2.5 - viewer.cam.elevation = -20 - viewer.cam.azimuth = 135 - - last_time = robot.data.time - - while viewer.is_running() and not gui.closed: - t_start = time.perf_counter() - - # Detect viewer reset (Backspace) - time jumps back to 0 - if robot.data.time < last_time: - robot.reset() - last_time = robot.data.time - - # Get state and compute control - state = robot.get_state() - leg_targets, wheel_targets = ctrl.compute(state, robot.dt * sim_steps_per_ctrl) - - # Apply control and step simulation - # MPC mode sets ctrl directly via set_ctrl_mit, skip set_ctrl - if ctrl.mode != "mpc": - robot.set_ctrl(leg_targets, wheel_targets) - for _ in range(sim_steps_per_ctrl): - robot.step() - - viewer.sync() - step += 1 - - # Update GUI every 25 steps (~10 Hz) - if step % 25 == 0: - state = robot.get_state() - gui.update_status(state, step) - if not gui.tick(): - break - - # Real-time sync - elapsed = time.perf_counter() - t_start - target_dt = robot.dt * sim_steps_per_ctrl - if elapsed < target_dt: - time.sleep(target_dt - elapsed) - - -if __name__ == "__main__": - main() diff --git a/05_software/train/rc_mjlab/sim2sim/interface/mujoco_io.py b/05_software/train/rc_mjlab/sim2sim/interface/mujoco_io.py index 222c0c1..276aa64 100644 --- a/05_software/train/rc_mjlab/sim2sim/interface/mujoco_io.py +++ b/05_software/train/rc_mjlab/sim2sim/interface/mujoco_io.py @@ -25,7 +25,7 @@ class MuJoCoIO: print("[MuJoCoIO] Loading MuJoCo model...") spec = mujoco.MjSpec.from_file(str(temp_xml)) - # Override actuators to match mjlab exactly + # Override actuators to match training/runtime actuator semantics. self._rebuild_actuators(spec) self.m = spec.compile() @@ -91,8 +91,10 @@ class MuJoCoIO: for act in actuators_to_delete: spec.delete(act) - KP_LEG, KD_LEG = 40.0, 1.0 - KD_WHEEL = 0.5 + # Keep sim2sim aligned with the training robot config and sim2real runtime: + # leg position PD = (50.0, 1.5), wheel velocity damping = 1.0. + KP_LEG, KD_LEG = 50.0, 1.5 + KD_WHEEL = 1.0 EFFORT_LIMIT = 17.0 leg_jnames = [ diff --git a/05_software/train/rc_mjlab/sim2sim/policy/policy_runner.py b/05_software/train/rc_mjlab/sim2sim/policy/policy_runner.py index f909094..3b15db4 100644 --- a/05_software/train/rc_mjlab/sim2sim/policy/policy_runner.py +++ b/05_software/train/rc_mjlab/sim2sim/policy/policy_runner.py @@ -77,6 +77,7 @@ class PolicyRunner: # Load both policy networks self.policies = {} + self.policy_obs_dims = {} for name, path in self.policy_paths.items(): print(f"[PolicyRunner] Loading {name} policy from: {path}") if Path(path).exists(): @@ -84,6 +85,7 @@ class PolicyRunner: else: print(f"[PolicyRunner] WARNING: {name} policy file not found! Falling back to rough.") self.policies[name] = load_policy(self.policy_paths["rough"], device) + self.policy_obs_dims[name] = int(self.policies[name].obs_mean.numel()) # Default DOF positions for each policy self.default_dof_poses = { @@ -226,11 +228,23 @@ class PolicyRunner: raw_actions_out = {} for name in active_policies: - # Flatten observation history - obs_history_array = np.array(self.obs_histories[name]) - term_dims = [3, 3, 3, 12, 12, 4, 16] - term_histories = np.split(obs_history_array, np.cumsum(term_dims)[:-1], axis=1) - flat_obs = np.concatenate([h.flatten() for h in term_histories]) + expected_obs_dim = self.policy_obs_dims[name] + + if expected_obs_dim == current_obs_53d.shape[0]: + # Newer policies consume the current 53D observation directly. + flat_obs = self.obs_histories[name][-1] + elif expected_obs_dim == current_obs_53d.shape[0] * self.history_length: + # Legacy policies expect 6-step history stacking grouped by term. + obs_history_array = np.array(self.obs_histories[name]) + term_dims = [3, 3, 3, 12, 12, 4, 16] + term_histories = np.split(obs_history_array, np.cumsum(term_dims)[:-1], axis=1) + flat_obs = np.concatenate([h.flatten() for h in term_histories]) + else: + raise RuntimeError( + f"Policy '{name}' expects obs dim {expected_obs_dim}, " + f"but sim2sim can only provide {current_obs_53d.shape[0]} or " + f"{current_obs_53d.shape[0] * self.history_length}." + ) obs_tensor = torch.tensor(flat_obs, device=self.device, dtype=torch.float32).unsqueeze(0) diff --git a/05_software/train/rc_mjlab/src/robot/config/env_cfgs.py b/05_software/train/rc_mjlab/src/robot/config/env_cfgs.py index fd676b4..7b720de 100644 --- a/05_software/train/rc_mjlab/src/robot/config/env_cfgs.py +++ b/05_software/train/rc_mjlab/src/robot/config/env_cfgs.py @@ -74,7 +74,11 @@ from ..mdp.rewards import ( joint_mirror, feet_contact_without_cmd, upright_roll_only, - pitch_control_penalty, + upward, + joint_power, + ang_vel_xy_l2, + undesired_contacts, + contact_forces, ) from ..mdp.curriculums import terrain_levels_vel_strict from ..mdp.commands import UniformThresholdVelocityCommandCfg @@ -174,7 +178,7 @@ def _make_base_env_cfg() -> ManagerBasedRlEnvCfg: params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*_wheel_joint",))}, scale=0.05, noise=Unoise(n_min=-1.0, n_max=1.0), ), - "actions": ObservationTermCfg(func=velocity_mdp.last_action, history_length=1), + "actions": ObservationTermCfg(func=velocity_mdp.last_action), } critic_terms = { @@ -192,7 +196,7 @@ def _make_base_env_cfg() -> ManagerBasedRlEnvCfg: observations = { "actor": ObservationGroupCfg( terms=actor_terms, concatenate_terms=True, - enable_corruption=True, history_length=6, + enable_corruption=True, ), "critic": ObservationGroupCfg( terms=critic_terms, concatenate_terms=True, enable_corruption=False, @@ -239,8 +243,15 @@ def _make_base_env_cfg() -> ManagerBasedRlEnvCfg: "reset_base": EventTermCfg( func=envs_mdp.reset_root_state_uniform, mode="reset", params={ - "pose_range": {"z": (0.30, 0.50), "yaw": (-math.pi, math.pi)}, - "velocity_range": {"x": (-0.5, 0.5), "y": (-0.15, 0.15), "yaw": (-0.35, 0.35)}, + "pose_range": {"x": (-0.5, 0.5), "y": (-0.5, 0.5), "yaw": (-math.pi, math.pi)}, + "velocity_range": { + "x": (-0.5, 0.5), + "y": (-0.5, 0.5), + "z": (-0.5, 0.5), + "roll": (-0.5, 0.5), + "pitch": (-0.5, 0.5), + "yaw": (-0.5, 0.5), + }, "asset_cfg": SceneEntityCfg("robot"), }, ), @@ -254,13 +265,9 @@ def _make_base_env_cfg() -> ManagerBasedRlEnvCfg: params={"asset_cfg": SceneEntityCfg("robot", body_names=("base_link",)), "operation": "add", "ranges": {0: (-0.05, 0.05), 1: (-0.05, 0.05), 2: (-0.05, 0.05)}}, ), - "encoder_bias": EventTermCfg( - func=envs_dr.encoder_bias, mode="startup", - params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*",)), "bias_range": (-0.015, 0.015)}, - ), "body_friction": EventTermCfg( func=envs_dr.geom_friction, mode="startup", - params={"asset_cfg": SceneEntityCfg("robot", geom_names=(".*",)), "operation": "abs", "ranges": (0.3, 1.2)}, + params={"asset_cfg": SceneEntityCfg("robot", geom_names=(".*",)), "operation": "abs", "ranges": (0.3, 1.0)}, ), "actuator_stiffness": EventTermCfg( func=envs_dr.joint_stiffness, mode="startup", @@ -270,16 +277,7 @@ def _make_base_env_cfg() -> ManagerBasedRlEnvCfg: func=envs_dr.joint_damping, mode="startup", params={"asset_cfg": SceneEntityCfg("robot"), "ranges": (0.9, 1.1), "operation": "scale", "distribution": "log_uniform"}, ), - "actuator_effort_limit": EventTermCfg( - func=envs_dr.effort_limits, mode="startup", - params={ - "asset_cfg": SceneEntityCfg("robot"), - "effort_limit_range": (0.8, 1.0), - "operation": "scale", - "distribution": "uniform", - }, - ), - "payload_mass": EventTermCfg( + "body_mass_base": EventTermCfg( func=envs_dr.body_mass, mode="startup", params={ "asset_cfg": SceneEntityCfg("robot", body_names=("base_link",)), @@ -287,16 +285,6 @@ def _make_base_env_cfg() -> ManagerBasedRlEnvCfg: "ranges": (-1.0, 3.0), }, ), - "continuous_disturbance": EventTermCfg( - func=apply_continuous_disturbance, mode="step", - params={ - "asset_cfg": SceneEntityCfg("robot", body_names=("base_link",)), - "force_range": (-15.0, 15.0), - "torque_range": (-10.0, 10.0), - "resample_time_range": (0.5, 2.0), - "time_constant": 0.5, - }, - ), } # ------------------ @@ -346,8 +334,8 @@ def _make_base_env_cfg() -> ManagerBasedRlEnvCfg: ), commands=commands, actions=actions, observations=observations, rewards=rewards, terminations=terminations, events=events, - metrics=metrics, curriculum=curriculum, decimation=10, episode_length_s=20.0, - sim=SimulationCfg(mujoco=MujocoCfg(impratio=100, cone="elliptic")), + metrics=metrics, curriculum=curriculum, decimation=4, episode_length_s=20.0, + sim=SimulationCfg(mujoco=MujocoCfg(timestep=0.005, impratio=100, cone="elliptic")), viewer=ViewerConfig(body_name="base_link", distance=3.0, elevation=-20.0, azimuth=45.0), ) @@ -380,33 +368,36 @@ def rough_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: size=(8.0, 8.0), border_width=20.0, num_rows=10, num_cols=20, curriculum=True, sub_terrains={ "flat": BoxFlatTerrainCfg(proportion=0.05, size=(8.0, 8.0)), - "pyramid_stairs": BoxPyramidStairsTerrainCfg(proportion=0.25, step_height_range=(0.0, 0.3), step_width=0.30, size=(8.0, 8.0)), - "pyramid_stairs_inv": BoxInvertedPyramidStairsTerrainCfg(proportion=0.10, step_height_range=(0.0, 0.3), step_width=0.30, size=(8.0, 8.0)), - "random_grid": BoxRandomGridTerrainCfg(proportion=0.1, grid_width=0.45, grid_height_range=(0.0, 0.3), size=(8.0, 8.0)), - "random_rough": HfRandomUniformTerrainCfg(proportion=0.05, noise_range=(0.0, 0.06), noise_step=0.01, horizontal_scale=0.20, downsampled_scale=0.20, border_width=0.25, base_thickness_ratio=100.0, size=(8.0, 8.0)), - "perlin_noise": HfPerlinNoiseTerrainCfg(proportion=0.05, height_range=(0.0, 0.06), octaves=2, persistence=0.4, lacunarity=2.0, horizontal_scale=0.20, resolution=0.20, border_width=0.50, base_thickness_ratio=100.0, size=(8.0, 8.0)), - "rc_wall": RCWallTerrainCfg(proportion=0.25, wall_height_range=(0.0, 0.45), size=(8.0, 8.0)), - "sloped_terrain": HfPyramidSlopedTerrainCfg(proportion=0.15, slope_range=(0.052, 0.325), platform_width=2.0, border_width=0.25, base_thickness_ratio=100.0, horizontal_scale=0.20, size=(8.0, 8.0)), + "pyramid_stairs": BoxPyramidStairsTerrainCfg(proportion=0.05, step_height_range=(0.0, 0.3), step_width=0.30, size=(8.0, 8.0)), + "pyramid_stairs_inv": BoxInvertedPyramidStairsTerrainCfg(proportion=0.45, step_height_range=(0.0, 0.3), step_width=0.30, size=(8.0, 8.0)), + "random_grid": BoxRandomGridTerrainCfg(proportion=0.27, grid_width=0.45, grid_height_range=(0.0, 0.3), size=(8.0, 8.0)), + "random_rough": HfRandomUniformTerrainCfg(proportion=0.01, noise_range=(0.0, 0.06), noise_step=0.01, horizontal_scale=0.20, downsampled_scale=0.20, border_width=0.25, base_thickness_ratio=100.0, size=(8.0, 8.0)), + "perlin_noise": HfPerlinNoiseTerrainCfg(proportion=0.01, height_range=(0.0, 0.06), octaves=2, persistence=0.4, lacunarity=2.0, horizontal_scale=0.20, resolution=0.20, border_width=0.50, base_thickness_ratio=100.0, size=(8.0, 8.0)), + "rc_wall": RCWallTerrainCfg(proportion=0.15, wall_height_range=(0.0, 0.45), size=(8.0, 8.0)), + "sloped_terrain": HfPyramidSlopedTerrainCfg(proportion=0.01, slope_range=(0.052, 0.325), platform_width=2.0, border_width=0.25, base_thickness_ratio=100.0, horizontal_scale=0.20, size=(8.0, 8.0)), }, ), - max_init_terrain_level=0, + max_init_terrain_level=5, ) - # Disable default velocity stages command and bind strict velocity terrain curriculum + # Keep the custom terrain set, but align command/curriculum behavior with go2w rough. cfg.curriculum.pop("command_vel", None) - cfg.curriculum["terrain_levels"] = CurriculumTermCfg(func=terrain_levels_vel_strict, params={"command_name": "twist"}) + cfg.curriculum["terrain_levels"] = CurriculumTermCfg(func=velocity_mdp.terrain_levels_vel, params={"command_name": "twist"}) cfg.commands["twist"].heading_command = True - cfg.commands["twist"].rel_heading_envs = 0.5 - cfg.commands["twist"].heading_control_stiffness = 0.6 + cfg.commands["twist"].rel_heading_envs = 1.0 + cfg.commands["twist"].heading_control_stiffness = 0.5 cfg.commands["twist"].ranges.heading = (-math.pi, math.pi) - cfg.commands["twist"].rel_standing_envs = 0.2 + cfg.commands["twist"].rel_standing_envs = 0.02 + cfg.commands["twist"].ranges.lin_vel_x = (-1.0, 1.0) + cfg.commands["twist"].ranges.lin_vel_y = (-0.6, 0.6) + cfg.commands["twist"].ranges.ang_vel_z = (-1.0, 1.0) # ------------------ # Startup & Reset Randomizations # ------------------ - cfg.events["joint_friction"] = EventTermCfg(func=envs_dr.joint_friction, mode="startup", params={"asset_cfg": SceneEntityCfg("robot"), "ranges": (0.7, 1.3), "operation": "scale"}) - cfg.events["reset_joints"] = EventTermCfg(func=envs_mdp.reset_joints_by_offset, mode="reset", params={"position_range": (0.0, 0.1), "velocity_range": (0.0, 0.0), "asset_cfg": SceneEntityCfg("robot", joint_names=(".*",))}) + cfg.events.pop("joint_friction", None) + cfg.events["reset_joints"] = EventTermCfg(func=envs_mdp.reset_joints_by_offset, mode="reset", params={"position_range": (0.0, 0.0), "velocity_range": (0.0, 0.0), "asset_cfg": SceneEntityCfg("robot", joint_names=(".*",))}) cfg.events["reset_base"] = EventTermCfg( func=envs_mdp.reset_root_state_uniform, mode="reset", @@ -426,42 +417,36 @@ def rough_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: # Rewards Integration # ------------------ cfg.rewards["track_lin_vel"] = RewardTermCfg( - func=track_linear_velocity_l1, - weight=4.5, + func=track_linear_velocity, + weight=3.0, + params={"std": 0.5, "command_name": "twist"} + ) + cfg.rewards["track_ang_vel"] = RewardTermCfg( + func=track_angular_velocity, + weight=1.5, params={"std": 0.5, "command_name": "twist"} ) - cfg.rewards["track_ang_vel"].weight = 2.0 - cfg.rewards["track_ang_vel"].params["std"] = 0.5 - cfg.rewards["lin_vel_z"] = RewardTermCfg(func=lin_vel_z_l2, weight=-0.5) # 🌟 增强垂直速度惩罚,抑制越障后的惯性暴冲 - cfg.rewards["ang_vel_xy"] = RewardTermCfg(func=velocity_mdp.body_angular_velocity_penalty, weight=-0.3, params={"asset_cfg": SceneEntityCfg("robot", body_names=("base_link",))}) # 🌟 增强角速度惩罚,防止突发性翻转/后仰 + cfg.rewards["lin_vel_z"] = RewardTermCfg(func=lin_vel_z_l2, weight=-2.0) + cfg.rewards["ang_vel_xy"] = RewardTermCfg(func=ang_vel_xy_l2, weight=-0.05, params={"asset_cfg": SceneEntityCfg("robot")}) cfg.rewards.pop("upright", None) - cfg.rewards["roll_penalty"] = RewardTermCfg( - func=upright_roll_only, - weight=-1.0, - params={"asset_cfg": SceneEntityCfg("robot")} - ) + cfg.rewards.pop("roll_penalty", None) # 🌟 限制俯仰角死区(Pitch Dead-zone):允许正常爬坡时有最大 29 度(0.50 rad)的仰角,但严厉惩罚超过该仰角的“前轮悬空暴冲/后翻” - cfg.rewards["pitch_penalty"] = RewardTermCfg( - func=pitch_control_penalty, - weight=-1.5, - params={"max_pitch_rad": 0.50, "asset_cfg": SceneEntityCfg("robot")} - ) - # 动态课程奖励与动作惩罚衰减 cfg.rewards.pop("terrain_level_bonus", None) - cfg.rewards.pop("action_rate", None) - cfg.rewards["action_rate_curriculum"] = RewardTermCfg(func=action_rate_curriculum_l2, weight=-0.005) + cfg.rewards.pop("action_rate_curriculum", None) + cfg.rewards["action_rate"].weight = -0.01 - cfg.rewards["joint_torques"].weight = -1e-4 + cfg.rewards["joint_torques"].weight = -2.5e-5 + cfg.rewards["joint_power"] = RewardTermCfg(func=joint_power, weight=-2.0e-5) cfg.rewards.pop("joint_acc", None) cfg.rewards["leg_joint_acc_l2"] = RewardTermCfg(func=envs_mdp.joint_acc_l2, weight=-2.5e-7, params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*_hip_abduction_joint", ".*_hip_pitch_joint", ".*_knee_joint"))}) cfg.rewards["wheel_joint_acc_l2"] = RewardTermCfg(func=envs_mdp.joint_acc_l2, weight=-2.5e-9, params={"asset_cfg": SceneEntityCfg("robot", joint_names=(".*_wheel_joint",))}) - cfg.rewards["joint_pos_limits"].weight = -0.2 + cfg.rewards["joint_pos_limits"].weight = -5.0 cfg.rewards.pop("leg_motion_penalty", None) cfg.rewards["is_terminated"].weight = 0.0 cfg.rewards.pop("leg_symmetry", None) @@ -487,7 +472,7 @@ def rough_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: cfg.rewards.pop("joint_deviation_l2", None) cfg.rewards["joint_pos_penalty"] = RewardTermCfg( func=joint_pos_penalty, - weight=-0.8, + weight=-1.0, params={ "stand_still_scale": 5.0, "velocity_threshold": 0.5, @@ -502,37 +487,29 @@ def rough_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: weight=0.1, params={"command_name": "twist", "sensor_name": "feet_ground_contact"} ) + cfg.rewards["feet_air_time"].weight = 0.0 + cfg.rewards["upward"] = RewardTermCfg(func=upward, weight=1.0) - cfg.rewards["base_height_l2"].weight = -0.5 + cfg.rewards["base_height_l2"].weight = 0.0 + cfg.rewards["base_height_l2"].params["target_height"] = 0.40 cfg.rewards["base_height_l2"].params["sensor_cfg"] = SceneEntityCfg("height_scanner") # 恢复机身碰撞惩罚为-1.0,逼迫机器人高抬腿跨越障碍,防止拖地 cfg.rewards.pop("body_collision", None) - cfg.rewards["body_collision"] = RewardTermCfg(func=velocity_mdp.self_collision_cost, weight=-1.0, params={"sensor_name": "body_collision"}) + cfg.rewards["undesired_contacts"] = RewardTermCfg(func=undesired_contacts, weight=-1.0, params={"sensor_name": "body_collision", "threshold": 1.0}) + cfg.rewards["contact_forces"] = RewardTermCfg(func=contact_forces, weight=-1.5e-4, params={"sensor_name": "feet_ground_contact", "threshold": 100.0}) # 🌟 严厉惩罚机身/胸部碰撞(防止硬撞高墙),逼迫机器人学会用前轮触墙并主动抬腿攀爬的“触觉反射” - cfg.rewards["base_collision"] = RewardTermCfg( - func=velocity_mdp.self_collision_cost, - weight=-5.0, - params={"sensor_name": "base_ground_contact"} - ) - # 彻底移除机身俯仰约束,允许机器人抬头爬高? cfg.rewards.pop("flat_orientation", None) - cfg.rewards.pop("feet_air_time", None) - # Remove non-applicable rewards - for key in ("wheel_roll_tracking", "wheel_contact_bonus", "body_ang_vel"): + for key in ("wheel_roll_tracking", "wheel_contact_bonus", "body_ang_vel", "terrain_level_bonus", "action_rate_curriculum"): cfg.rewards.pop(key, None) - cfg.episode_length_s = 30.0 - cfg.sim = SimulationCfg(contact_sensor_maxmatch=128, mujoco=MujocoCfg(impratio=100, cone="elliptic", ccd_iterations=80)) + cfg.episode_length_s = 20.0 + cfg.sim = SimulationCfg(contact_sensor_maxmatch=128, mujoco=MujocoCfg(timestep=0.005, impratio=100, cone="elliptic", ccd_iterations=80)) # 移除 orientation 终止,允许机器人翻倒以学习回复 - cfg.terminations.pop("bad_orientation", None) - # 移除 base_ground_contact 终止,越障时机身会碰到障碍物 - cfg.terminations.pop("base_ground_contact", None) - cfg.seed = 42 if cfg.scene.terrain is not None: cfg.scene.terrain.num_envs = 2048 @@ -663,7 +640,7 @@ def crawl_env_cfg(play: bool = False) -> ManagerBasedRlEnvCfg: cfg.rewards.pop(key, None) cfg.episode_length_s = 30.0 - cfg.sim = SimulationCfg(contact_sensor_maxmatch=128, mujoco=MujocoCfg(impratio=100, cone="elliptic", ccd_iterations=80)) + cfg.sim = SimulationCfg(contact_sensor_maxmatch=128, mujoco=MujocoCfg(timestep=0.005, impratio=100, cone="elliptic", ccd_iterations=80)) # Loosen orientation bad threshold to 80 degrees for steep crawling tilts cfg.terminations["bad_orientation"].params["limit_angle"] = math.radians(80.0) diff --git a/05_software/train/rc_mjlab/src/robot/config/rl_cfg.py b/05_software/train/rc_mjlab/src/robot/config/rl_cfg.py index 94ab331..700ab99 100644 --- a/05_software/train/rc_mjlab/src/robot/config/rl_cfg.py +++ b/05_software/train/rc_mjlab/src/robot/config/rl_cfg.py @@ -33,10 +33,10 @@ def rough_ppo_runner_cfg() -> RslRlOnPolicyRunnerCfg: value_loss_coef=1.0, use_clipped_value_loss=True, clip_param=0.2, - entropy_coef=0.002, + entropy_coef=0.001,#第一轮为0.003 num_learning_epochs=5, num_mini_batches=4, - learning_rate=2.0e-4, + learning_rate=8.0e-4, schedule="adaptive", gamma=0.99, lam=0.95, @@ -44,9 +44,9 @@ def rough_ppo_runner_cfg() -> RslRlOnPolicyRunnerCfg: max_grad_norm=1.0, ), experiment_name="robot_rough", - save_interval=50, + save_interval=100, num_steps_per_env=24, - max_iterations=15_000, + max_iterations=20_000, ) @@ -55,5 +55,3 @@ def crawl_ppo_runner_cfg() -> RslRlOnPolicyRunnerCfg: cfg.experiment_name = "robot_crawl" return cfg - - diff --git a/05_software/train/rc_mjlab/src/robot/mdp/rewards.py b/05_software/train/rc_mjlab/src/robot/mdp/rewards.py index dc0199d..e8dfde6 100644 --- a/05_software/train/rc_mjlab/src/robot/mdp/rewards.py +++ b/05_software/train/rc_mjlab/src/robot/mdp/rewards.py @@ -28,6 +28,7 @@ def track_linear_velocity( actual = asset.data.root_link_lin_vel_b xy_error = torch.sum(torch.square(command[:, :2] - actual[:, :2]), dim=1) reward = torch.exp(-xy_error / std**2) + reward *= torch.clamp(-asset.data.projected_gravity_b[:, 2], 0.0, 0.7) / 0.7 return reward @@ -45,6 +46,7 @@ def track_angular_velocity( actual = asset.data.root_link_ang_vel_b z_error = torch.square(command[:, 2] - actual[:, 2]) reward = torch.exp(-z_error / std**2) + reward *= torch.clamp(-asset.data.projected_gravity_b[:, 2], 0.0, 0.7) / 0.7 return reward @@ -94,6 +96,7 @@ def base_height_l2( error = root_z - target_height reward = torch.square(error) + reward *= torch.clamp(-asset.data.projected_gravity_b[:, 2], 0.0, 0.7) / 0.7 return reward @@ -304,6 +307,7 @@ def stand_still( angular_norm = torch.abs(command[:, 2]) inactive = (linear_norm + angular_norm < command_threshold).float() reward = cost * inactive + reward *= torch.clamp(-asset.data.projected_gravity_b[:, 2], 0.0, 0.7) / 0.7 return reward def hip_deviation( @@ -355,6 +359,20 @@ def lin_vel_z_l2( asset_cfg = SceneEntityCfg("robot") asset: Entity = env.scene[asset_cfg.name] reward = torch.square(asset.data.root_link_lin_vel_b[:, 2]) + reward *= torch.clamp(-asset.data.projected_gravity_b[:, 2], 0.0, 0.7) / 0.7 + return reward + + +def ang_vel_xy_l2( + env: ManagerBasedRlEnv, + asset_cfg: SceneEntityCfg | None = None, +) -> torch.Tensor: + """Penalize xy-axis base angular velocity using the go2w kernel.""" + if asset_cfg is None: + asset_cfg = SceneEntityCfg("robot") + asset: Entity = env.scene[asset_cfg.name] + reward = torch.sum(torch.square(asset.data.root_link_ang_vel_b[:, :2]), dim=1) + reward *= torch.clamp(-asset.data.projected_gravity_b[:, 2], 0.0, 0.7) / 0.7 return reward @@ -650,6 +668,8 @@ def feet_contact_without_cmd(env, command_name: str, sensor_name: str) -> torch. linear_norm = torch.norm(cmd[:, :2], dim=1) angular_norm = torch.abs(cmd[:, 2]) reward *= (linear_norm + angular_norm) < 0.1 + asset = env.scene["robot"] + reward *= torch.clamp(-asset.data.projected_gravity_b[:, 2], 0.0, 0.7) / 0.7 return reward def joint_pos_penalty( @@ -674,6 +694,7 @@ def joint_pos_penalty( running_reward, stand_still_scale * running_reward, ) + reward *= torch.clamp(-asset.data.projected_gravity_b[:, 2], 0.0, 0.7) / 0.7 return reward def joint_mirror(env, mirror_joints: list[list[str]], asset_cfg: SceneEntityCfg | None = None) -> torch.Tensor: @@ -694,6 +715,50 @@ def joint_mirror(env, mirror_joints: list[list[str]], asset_cfg: SceneEntityCfg ) reward += diff reward *= 1 / len(mirror_joints) if len(mirror_joints) > 0 else 0 + reward *= torch.clamp(-asset.data.projected_gravity_b[:, 2], 0.0, 0.7) / 0.7 + return reward + + +def undesired_contacts( + env: ManagerBasedRlEnv, + sensor_name: str, + threshold: float = 1.0, +) -> torch.Tensor: + """Penalize non-wheel contacts above a force threshold.""" + from mjlab.sensor import ContactSensor + + sensor: ContactSensor = env.scene[sensor_name] + data = sensor.data + if data.force_history is not None: + force_mag = torch.norm(data.force_history, dim=-1) + is_contact = torch.max(force_mag, dim=2)[0] > threshold + else: + force_mag = torch.norm(data.force, dim=-1) + is_contact = force_mag > threshold + reward = torch.sum(is_contact, dim=1).float() + asset = env.scene["robot"] + reward *= torch.clamp(-asset.data.projected_gravity_b[:, 2], 0.0, 0.7) / 0.7 + return reward + + +def contact_forces( + env: ManagerBasedRlEnv, + sensor_name: str, + threshold: float = 100.0, +) -> torch.Tensor: + """Penalize foot contact forces above threshold.""" + from mjlab.sensor import ContactSensor + + sensor: ContactSensor = env.scene[sensor_name] + data = sensor.data + if data.force_history is not None: + force_mag = torch.norm(data.force_history, dim=-1) + peak_force = torch.max(force_mag, dim=2)[0] + else: + peak_force = torch.norm(data.force, dim=-1) + reward = torch.sum(torch.clamp(peak_force - threshold, min=0.0), dim=1) + asset = env.scene["robot"] + reward *= torch.clamp(-asset.data.projected_gravity_b[:, 2], 0.0, 0.7) / 0.7 return reward @@ -705,6 +770,14 @@ def upward(env, asset_cfg=None): reward = torch.square(1 - asset.data.projected_gravity_b[:, 2]) return reward + +def joint_power(env: ManagerBasedRlEnv, asset_cfg: SceneEntityCfg | None = None) -> torch.Tensor: + """Penalty for total joint mechanical power: sum(|tau * dq|).""" + if asset_cfg is None: + asset_cfg = SceneEntityCfg("robot") + asset: Entity = env.scene[asset_cfg.name] + return torch.sum(torch.abs(asset.data.qfrc_actuator * asset.data.joint_vel), dim=1) + def upright_roll_only(env, asset_cfg=None): if asset_cfg is None: from mjlab.envs.manager_based_rl_env import SceneEntityCfg diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/__init__.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/__init__.py deleted file mode 100644 index d30d744..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -""" -HimLoco RSL-RL implementation with history-informed models. -""" - -# Export HIM implementations -from .algorithms.him_ppo import * -from .modules.him_actor_critic import * -from .modules.him_estimator import * -from .storage.him_rollout_storage import * -from .runners.him_on_policy_runner import * -from .wrappers import * \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/algorithms/__init__.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/algorithms/__init__.py deleted file mode 100644 index f66ae90..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/algorithms/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -from .him_ppo import HIMPPO \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/algorithms/him_ppo.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/algorithms/him_ppo.py deleted file mode 100644 index 08aff75..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/algorithms/him_ppo.py +++ /dev/null @@ -1,192 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -import torch -import torch.nn as nn -import torch.optim as optim - -from ..modules import HIMActorCritic -from ..storage import HIMRolloutStorage - -class HIMPPO: - actor_critic: HIMActorCritic - def __init__(self, - actor_critic, - num_learning_epochs=1, - num_mini_batches=1, - clip_param=0.2, - gamma=0.998, - lam=0.95, - value_loss_coef=1.0, - entropy_coef=0.0, - learning_rate=1e-3, - max_grad_norm=1.0, - use_clipped_value_loss=True, - schedule="fixed", - desired_kl=0.01, - device='cpu', - ): - - self.device = device - - self.desired_kl = desired_kl - self.schedule = schedule - self.learning_rate = learning_rate - - # PPO components - self.actor_critic = actor_critic - self.actor_critic.to(self.device) - self.storage = None # initialized later - self.optimizer = optim.Adam(self.actor_critic.parameters(), lr=learning_rate) - self.transition = HIMRolloutStorage.Transition() - - # PPO parameters - self.clip_param = clip_param - self.num_learning_epochs = num_learning_epochs - self.num_mini_batches = num_mini_batches - self.value_loss_coef = value_loss_coef - self.entropy_coef = entropy_coef - self.gamma = gamma - self.lam = lam - self.max_grad_norm = max_grad_norm - self.use_clipped_value_loss = use_clipped_value_loss - - def init_storage(self, num_envs, num_transitions_per_env, actor_obs_shape, critic_obs_shape, action_shape): - self.storage = HIMRolloutStorage(num_envs, num_transitions_per_env, actor_obs_shape, critic_obs_shape, action_shape, self.device) - - def test_mode(self): - self.actor_critic.test() - - def train_mode(self): - self.actor_critic.train() - - def act(self, obs, critic_obs): - # Compute the actions and values - self.transition.actions = self.actor_critic.act(obs).detach() - self.transition.values = self.actor_critic.evaluate(critic_obs).detach() - self.transition.actions_log_prob = self.actor_critic.get_actions_log_prob(self.transition.actions).detach() - self.transition.action_mean = self.actor_critic.action_mean.detach() - self.transition.action_sigma = self.actor_critic.action_std.detach() - # need to record obs and critic_obs before env.step() - self.transition.observations = obs - self.transition.critic_observations = critic_obs - return self.transition.actions - - def process_env_step(self, rewards, dones, infos, next_critic_obs): - self.transition.next_critic_observations = next_critic_obs.clone() - self.transition.rewards = rewards.clone() - self.transition.dones = dones - # Bootstrapping on time outs - if 'time_outs' in infos: - self.transition.rewards += self.gamma * torch.squeeze(self.transition.values * infos['time_outs'].unsqueeze(1).to(self.device), 1) - - # Record the transition - self.storage.add_transitions(self.transition) - self.transition.clear() - self.actor_critic.reset(dones) - - def compute_returns(self, last_critic_obs): - last_values= self.actor_critic.evaluate(last_critic_obs).detach() - self.storage.compute_returns(last_values, self.gamma, self.lam) - - def update(self): - mean_value_loss = 0 - mean_surrogate_loss = 0 - mean_estimation_loss = 0 - mean_swap_loss = 0 - - generator = self.storage.mini_batch_generator(self.num_mini_batches, self.num_learning_epochs) - - for obs_batch, critic_obs_batch, actions_batch, next_critic_obs_batch, target_values_batch, advantages_batch, returns_batch, old_actions_log_prob_batch, \ - old_mu_batch, old_sigma_batch in generator: - - self.actor_critic.act(obs_batch) - actions_log_prob_batch = self.actor_critic.get_actions_log_prob(actions_batch) - value_batch = self.actor_critic.evaluate(critic_obs_batch) - mu_batch = self.actor_critic.action_mean - sigma_batch = self.actor_critic.action_std - entropy_batch = self.actor_critic.entropy - - # KL - if self.desired_kl != None and self.schedule == 'adaptive': - with torch.inference_mode(): - kl = torch.sum( - torch.log(sigma_batch / old_sigma_batch + 1.e-5) + (torch.square(old_sigma_batch) + torch.square(old_mu_batch - mu_batch)) / (2.0 * torch.square(sigma_batch)) - 0.5, axis=-1) - kl_mean = torch.mean(kl) - - if kl_mean > self.desired_kl * 2.0: - self.learning_rate = max(1e-5, self.learning_rate / 1.5) - elif kl_mean < self.desired_kl / 2.0 and kl_mean > 0.0: - self.learning_rate = min(1e-2, self.learning_rate * 1.5) - - for param_group in self.optimizer.param_groups: - param_group['lr'] = self.learning_rate - - #Estimator Update - estimation_loss, swap_loss = self.actor_critic.estimator.update(obs_batch, next_critic_obs_batch, lr=self.learning_rate) - - # Surrogate loss - ratio = torch.exp(actions_log_prob_batch - torch.squeeze(old_actions_log_prob_batch)) - surrogate = -torch.squeeze(advantages_batch) * ratio - surrogate_clipped = -torch.squeeze(advantages_batch) * torch.clamp(ratio, 1.0 - self.clip_param, - 1.0 + self.clip_param) - surrogate_loss = torch.max(surrogate, surrogate_clipped).mean() - - # Value function loss - if self.use_clipped_value_loss: - value_clipped = target_values_batch + (value_batch - target_values_batch).clamp(-self.clip_param, - self.clip_param) - value_losses = (value_batch - returns_batch).pow(2) - value_losses_clipped = (value_clipped - returns_batch).pow(2) - value_loss = torch.max(value_losses, value_losses_clipped).mean() - else: - value_loss = (returns_batch - value_batch).pow(2).mean() - - loss = surrogate_loss + self.value_loss_coef * value_loss - self.entropy_coef * entropy_batch.mean() - - # Gradient step - self.optimizer.zero_grad() - loss.backward() - nn.utils.clip_grad_norm_(self.actor_critic.parameters(), self.max_grad_norm) - self.optimizer.step() - - mean_value_loss += value_loss.item() - mean_surrogate_loss += surrogate_loss.item() - mean_estimation_loss += estimation_loss - mean_swap_loss += swap_loss - - num_updates = self.num_learning_epochs * self.num_mini_batches - mean_value_loss /= num_updates - mean_surrogate_loss /= num_updates - mean_estimation_loss /= num_updates - mean_swap_loss /= num_updates - self.storage.clear() - - return mean_value_loss, mean_surrogate_loss, estimation_loss, swap_loss diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/config/__init__.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/config/__init__.py deleted file mode 100644 index ca5d081..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/config/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -from .rl_cfg import * \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/config/rl_cfg.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/config/rl_cfg.py deleted file mode 100644 index d48ba1e..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/config/rl_cfg.py +++ /dev/null @@ -1,180 +0,0 @@ -from dataclasses import MISSING -from isaaclab.utils import configclass -from typing import Literal - - -@configclass -class HIMBaseRunnerCfg: - """Base configuration of the runner.""" - - seed: int = 1 - """The seed for the experiment. Default is 1.""" - - device: str = "cuda:0" - """The device for the rl-agent. Default is cuda:0.""" - - num_steps_per_env: int = MISSING - """The number of steps per environment per update.""" - - max_iterations: int = MISSING - """The maximum number of iterations.""" - - empirical_normalization: bool | None = None - """This parameter is deprecated and will be removed in the future. - - Use `actor_obs_normalization` and `critic_obs_normalization` instead. - """ - - # obs_groups: dict[str, list[str]] = MISSING - # """A mapping from observation groups to observation sets. - - # The keys of the dictionary are predefined observation sets used by the underlying algorithm - # and values are lists of observation groups provided by the environment. - - # For instance, if the environment provides a dictionary of observations with groups "policy", "images", - # and "privileged", these can be mapped to algorithmic observation sets as follows: - - # .. code-block:: python - - # obs_groups = { - # "policy": ["policy", "images"], - # "critic": ["policy", "privileged"], - # } - - # This way, the policy will receive the "policy" and "images" observations, and the critic will - # receive the "policy" and "privileged" observations. - - # For more details, please check ``vec_env.py`` in the rsl_rl library. - # """ - - # clip_actions: float | None = None - # """The clipping value for actions. If None, then no clipping is done. Defaults to None. - - # .. note:: - # This clipping is performed inside the :class:`RslRlVecEnvWrapper` wrapper. - # """ - - save_interval: int = MISSING - """The number of iterations between saves.""" - - experiment_name: str = MISSING - """The experiment name.""" - - run_name: str = "" - """The run name. Default is empty string. - - The name of the run directory is typically the time-stamp at execution. If the run name is not empty, - then it is appended to the run directory's name, i.e. the logging directory's name will become - ``{time-stamp}_{run_name}``. - """ - - logger: Literal["tensorboard", "neptune", "wandb"] = "tensorboard" - """The logger to use. Default is tensorboard.""" - - neptune_project: str = "isaaclab" - """The neptune project name. Default is "isaaclab".""" - - wandb_project: str = "isaaclab" - """The wandb project name. Default is "isaaclab".""" - - resume: bool = False - """Whether to resume a previous training. Default is False. - - This flag will be ignored for distillation. - """ - - load_run: str = ".*" - """The run directory to load. Default is ".*" (all). - - If regex expression, the latest (alphabetical order) matching run will be loaded. - """ - - load_checkpoint: str = "model_.*.pt" - """The checkpoint file to load. Default is ``"model_.*.pt"`` (all). - - If regex expression, the latest (alphabetical order) matching file will be loaded. - """ - - -@configclass -class HIMPPOActorCriticCfg: - """Configuration of the HIM PPO actor-critic.""" - - actor_hidden_dims: list[int] = [512, 256, 128] - """The hidden dimensions of the actor network.""" - - critic_hidden_dims: list[int] = [512, 256, 128] - """The hidden dimensions of the critic network.""" - - activation: str = "elu" - """The activation function to use. Default is 'elu'.""" - - init_noise_std: float = 1.0 - """The initial noise standard deviation for the actor. Default is 1.0.""" - - normalize_obs: bool = False - """Whether to normalize observations. Default is False.""" - -@configclass -class HIMPPPOAlgorithmCfg: - """Configuration of the HIM PPO algorithm.""" - num_learning_epochs: int = 1 - """The number of learning epochs per update. Default is 1.""" - - num_mini_batches: int = 1 - """The number of mini-batches per update. Default is 1.""" - - clip_param: float = 0.2 - """The clipping parameter for PPO. Default is 0.2.""" - - gamma: float = 0.998 - """The discount factor. Default is 0.998.""" - - lam: float = 0.95 - """The GAE lambda parameter. Default is 0.95.""" - - value_loss_coef: float = 1.0 - """The coefficient for the value loss. Default is 1.0.""" - - entropy_coef: float = 0.0 - """The coefficient for the entropy bonus. Default is 0.0.""" - - learning_rate: float = 1.0e-3 - """The learning rate. Default is 1.0e-3.""" - - max_grad_norm: float = 1.0 - """The maximum gradient norm for clipping. Default is 1.0.""" - - use_clipped_value_loss: bool = True - """Whether to use clipped value loss. Default is True.""" - - schedule: str = "fixed" - """The learning rate schedule. Default is 'fixed'.""" - - desired_kl: float = 0.01 - """The desired KL divergence for adaptive learning rate. Default is 0.01.""" - -@configclass -class HIMOnPolicyRunnerCfg(HIMBaseRunnerCfg): - """Configuration of the runner for on-policy algorithms.""" - - class_name: str = "HIMOnPolicyRunner" - """The runner class name. Default is OnPolicyRunner.""" - - policy_class_name: str = "HIMActorCritic" - """The policy class name. Default is HIMActorCritic.""" - - algorithm_class_name: str = "HIMPPO" - """The algorithm class name. Default is HIMPPO.""" - - policy: HIMPPOActorCriticCfg = MISSING - """The policy configuration.""" - - algorithm: HIMPPPOAlgorithmCfg = MISSING - """The algorithm configuration.""" - - history_length: int = 0 - """Number of historical time steps to stack with current observation (0 means current only). Default is 0.""" - - privileged_history_length: int = 0 - """Number of historical time steps to stack with current privileged observation. Default is 0.""" \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/env/__init__.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/env/__init__.py deleted file mode 100644 index 9539b9f..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/env/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -from .vec_env import VecEnv \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/env/vec_env.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/env/vec_env.py deleted file mode 100644 index 6a7ef1b..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/env/vec_env.py +++ /dev/null @@ -1,60 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -from abc import ABC, abstractmethod -import torch -from typing import Tuple, Union - -# minimal interface of the environment -class VecEnv(ABC): - num_envs: int - num_obs: int - num_privileged_obs: int - num_actions: int - max_episode_length: int - privileged_obs_buf: torch.Tensor - obs_buf: torch.Tensor - rew_buf: torch.Tensor - reset_buf: torch.Tensor - episode_length_buf: torch.Tensor # current episode duration - extras: dict - device: torch.device - @abstractmethod - def step(self, actions: torch.Tensor) -> Tuple[torch.Tensor, Union[torch.Tensor, None], torch.Tensor, torch.Tensor, dict]: - pass - @abstractmethod - def reset(self, env_ids: Union[list, torch.Tensor]): - pass - @abstractmethod - def get_observations(self) -> torch.Tensor: - pass - @abstractmethod - def get_privileged_observations(self) -> Union[torch.Tensor, None]: - pass \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/modules/__init__.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/modules/__init__.py deleted file mode 100644 index 5ce53f0..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/modules/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -from .him_actor_critic import HIMActorCritic -from .him_estimator import HIMEstimator \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/modules/him_actor_critic.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/modules/him_actor_critic.py deleted file mode 100644 index b7d50ef..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/modules/him_actor_critic.py +++ /dev/null @@ -1,236 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -import numpy as np - -import torch -import torch.nn as nn -from torch.distributions import Normal -from ..modules.him_estimator import HIMEstimator - -class RunningMeanStd: - # Dynamically calculate mean and std - def __init__(self, shape, device): # shape:the dimension of input data - self.n = 1e-4 - self.uninitialized = True - self.mean = torch.zeros(shape, device=device) - self.var = torch.ones(shape, device=device) - - def update(self, x): - count = self.n - batch_count = x.size(0) - tot_count = count + batch_count - - old_mean = self.mean.clone() - delta = torch.mean(x, dim=0) - old_mean - - self.mean = old_mean + delta * batch_count / tot_count - m_a = self.var * count - m_b = x.var(dim=0) * batch_count - M2 = m_a + m_b + torch.square(delta) * count * batch_count / tot_count - self.var = M2 / tot_count - self.n = tot_count - -class Normalization: - def __init__(self, shape, device='cuda:0'): - self.running_ms = RunningMeanStd(shape=shape, device=device) - - def __call__(self, x, update=False): - # Whether to update the mean and std,during the evaluating,update=Flase - if update: - self.running_ms.update(x) - x = (x - self.running_ms.mean) / (torch.sqrt(self.running_ms.var) + 1e-4) - - return x - -class HIMActorCritic(nn.Module): - is_recurrent = False - def __init__(self, num_actor_obs, - num_critic_obs, - num_one_step_obs, - num_actions, - actor_hidden_dims=[512, 256, 128], - critic_hidden_dims=[512, 256, 128], - activation='elu', - init_noise_std=1.0, - **kwargs): - if kwargs: - print("ActorCritic.__init__ got unexpected arguments, which will be ignored: " + str([key for key in kwargs.keys()])) - super(HIMActorCritic, self).__init__() - - activation = get_activation(activation) - - self.history_size = int(num_actor_obs/num_one_step_obs) - self.num_actor_obs = num_actor_obs - self.num_actions = num_actions - self.num_one_step_obs = num_one_step_obs - - mlp_input_dim_a = num_one_step_obs + 3 + 16 - mlp_input_dim_c = num_critic_obs - - # Estimator - self.estimator = HIMEstimator(temporal_steps=self.history_size, num_one_step_obs=num_one_step_obs) - - # Policy - actor_layers = [] - actor_layers.append(nn.Linear(mlp_input_dim_a, actor_hidden_dims[0])) - actor_layers.append(activation) - for l in range(len(actor_hidden_dims)): - if l == len(actor_hidden_dims) - 1: - actor_layers.append(nn.Linear(actor_hidden_dims[l], num_actions)) - # actor_layers.append(nn.Tanh()) - else: - actor_layers.append(nn.Linear(actor_hidden_dims[l], actor_hidden_dims[l + 1])) - actor_layers.append(activation) - self.actor = nn.Sequential(*actor_layers) - - # Value function - critic_layers = [] - critic_layers.append(nn.Linear(mlp_input_dim_c, critic_hidden_dims[0])) - critic_layers.append(activation) - for l in range(len(critic_hidden_dims)): - if l == len(critic_hidden_dims) - 1: - critic_layers.append(nn.Linear(critic_hidden_dims[l], 1)) - else: - critic_layers.append(nn.Linear(critic_hidden_dims[l], critic_hidden_dims[l + 1])) - critic_layers.append(activation) - self.critic = nn.Sequential(*critic_layers) - - print(f"Actor MLP: {self.actor}") - print(f"Critic MLP: {self.critic}") - print(f'Estimator: {self.estimator.encoder}') - - # Action noise - self.std = nn.Parameter(init_noise_std * torch.ones(num_actions)) - self.distribution = None - # disable args validation for speedup - Normal.set_default_validate_args = False - - # seems that we get better performance without init - # self.init_memory_weights(self.memory_a, 0.001, 0.) - # self.init_memory_weights(self.memory_c, 0.001, 0.) - - @staticmethod - # not used at the moment - def init_weights(sequential, scales): - [torch.nn.init.orthogonal_(module.weight, gain=scales[idx]) for idx, module in - enumerate(mod for mod in sequential if isinstance(mod, nn.Linear))] - - - def reset(self, dones=None): - pass - - def forward(self): - raise NotImplementedError - - @property - def action_mean(self): - return self.distribution.mean - - @property - def action_std(self): - return self.distribution.stddev - - @property - def entropy(self): - return self.distribution.entropy().sum(dim=-1) - - def update_distribution(self, obs_history): - with torch.no_grad(): - vel, latent = self.estimator(obs_history) - actor_input = torch.cat((obs_history[:,:self.num_one_step_obs], vel, latent), dim=-1) - - # Check for NaN/Inf in inputs - if torch.isnan(actor_input).any() or torch.isinf(actor_input).any(): - print(f"[ERROR] NaN/Inf detected in actor_input before normalization!") - print(f" - obs_history stats: min={obs_history.min():.4f}, max={obs_history.max():.4f}, mean={obs_history.mean():.4f}, has_nan={torch.isnan(obs_history).any()}") - print(f" - vel stats: min={vel.min():.4f}, max={vel.max():.4f}, mean={vel.mean():.4f}, has_nan={torch.isnan(vel).any()}") - print(f" - latent stats: min={latent.min():.4f}, max={latent.max():.4f}, mean={latent.mean():.4f}, has_nan={torch.isnan(latent).any()}") - raise ValueError("NaN/Inf in actor_input before normalization") - - mean = self.actor(actor_input) - - # Check for NaN/Inf in actor output - if torch.isnan(mean).any() or torch.isinf(mean).any(): - print(f"[ERROR] NaN/Inf in actor output (mean)!") - print(f" - actor_input stats: min={actor_input.min():.4f}, max={actor_input.max():.4f}, mean={actor_input.mean():.4f}") - print(f" - Batch size: {actor_input.shape[0]}") - - # Check actor network weights AFTER forward (they should still be OK) - for name, param in self.actor.named_parameters(): - if torch.isnan(param).any(): - print(f" - NaN in actor weight: {name}") - if torch.isinf(param).any(): - print(f" - Inf in actor weight: {name}") - - raise ValueError("NaN or Inf detected in actor network output!") - - self.distribution = Normal(mean, mean*0. + self.std) - - def act(self, obs_history=None, **kwargs): - self.update_distribution(obs_history) - return self.distribution.sample() - - def get_actions_log_prob(self, actions): - return self.distribution.log_prob(actions).sum(dim=-1) - - def act_inference(self, obs_history, observations=None): - vel, latent = self.estimator(obs_history) - actions_mean = self.actor(torch.cat((obs_history[:,:self.num_one_step_obs], vel, latent), dim=-1)) - return actions_mean - - def test_inference(self, obs_history, observations=None): - vel, latent = self.estimator(obs_history) - actions_mean = self.actor(torch.cat((obs_history[:,:self.num_one_step_obs], vel, latent), dim=-1)) - estimator_output = torch.cat((vel, latent), dim=-1) - return actions_mean, estimator_output - - def evaluate(self, critic_observations, **kwargs): - value = self.critic(critic_observations) - return value - -def get_activation(act_name): - if act_name == "elu": - return nn.ELU() - elif act_name == "selu": - return nn.SELU() - elif act_name == "relu": - return nn.ReLU() - elif act_name == "crelu": - return nn.ReLU() - elif act_name == "lrelu": - return nn.LeakyReLU() - elif act_name == "tanh": - return nn.Tanh() - elif act_name == "sigmoid": - return nn.Sigmoid() - else: - print("invalid activation function!") - return None \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/modules/him_estimator.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/modules/him_estimator.py deleted file mode 100644 index 4bd920a..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/modules/him_estimator.py +++ /dev/null @@ -1,155 +0,0 @@ -import copy -import math -import torch -import torch.nn as nn -import torch.optim as optim -import torch.nn.functional as F -import torch.distributions as torchd -from torch.distributions import Normal, Categorical - - -class HIMEstimator(nn.Module): - def __init__(self, - temporal_steps, - num_one_step_obs, - enc_hidden_dims=[128, 64, 16], - tar_hidden_dims=[128, 64], - activation='elu', - learning_rate=1e-3, - max_grad_norm=10.0, - num_prototype=32, - temperature=3.0, - **kwargs): - if kwargs: - print("Estimator_CL.__init__ got unexpected arguments, which will be ignored: " + str( - [key for key in kwargs.keys()])) - super(HIMEstimator, self).__init__() - activation = get_activation(activation) - - self.temporal_steps = temporal_steps - self.num_one_step_obs = num_one_step_obs - self.num_latent = enc_hidden_dims[-1] - self.max_grad_norm = max_grad_norm - self.temperature = temperature - - # Encoder - enc_input_dim = self.temporal_steps * self.num_one_step_obs - enc_layers = [] - for l in range(len(enc_hidden_dims) - 1): - enc_layers += [nn.Linear(enc_input_dim, enc_hidden_dims[l]), activation] - enc_input_dim = enc_hidden_dims[l] - enc_layers += [nn.Linear(enc_input_dim, enc_hidden_dims[-1] + 3)] - self.encoder = nn.Sequential(*enc_layers) - - # Target - tar_input_dim = self.num_one_step_obs - tar_layers = [] - for l in range(len(tar_hidden_dims)): - tar_layers += [nn.Linear(tar_input_dim, tar_hidden_dims[l]), activation] - tar_input_dim = tar_hidden_dims[l] - tar_layers += [nn.Linear(tar_input_dim, enc_hidden_dims[-1])] - self.target = nn.Sequential(*tar_layers) - - # Prototype - self.proto = nn.Embedding(num_prototype, enc_hidden_dims[-1]) - - # Optimizer - self.learning_rate = learning_rate - self.optimizer = optim.Adam(self.parameters(), lr=self.learning_rate) - - def get_latent(self, obs_history): - vel, z = self.encode(obs_history) - return vel.detach(), z.detach() - - def forward(self, obs_history): - parts = self.encoder(obs_history.detach()) - vel, z = parts[..., :3], parts[..., 3:] - z = F.normalize(z, dim=-1, p=2) - return vel.detach(), z.detach() - - def encode(self, obs_history): - parts = self.encoder(obs_history.detach()) - vel, z = parts[..., :3], parts[..., 3:] - z = F.normalize(z, dim=-1, p=2) - return vel, z - - def update(self, obs_history, next_critic_obs, lr=None): - if lr is not None: - self.learning_rate = lr - for param_group in self.optimizer.param_groups: - param_group['lr'] = self.learning_rate - - vel = next_critic_obs[:, self.num_one_step_obs:self.num_one_step_obs+3].detach() - next_obs = next_critic_obs.detach()[:, 3:self.num_one_step_obs+3] - - z_s = self.encoder(obs_history) - z_t = self.target(next_obs) - pred_vel, z_s = z_s[..., :3], z_s[..., 3:] - - z_s = F.normalize(z_s, dim=-1, p=2) - z_t = F.normalize(z_t, dim=-1, p=2) - - with torch.no_grad(): - w = self.proto.weight.data.clone() - w = F.normalize(w, dim=-1, p=2) - self.proto.weight.copy_(w) - - score_s = z_s @ self.proto.weight.T - score_t = z_t @ self.proto.weight.T - - with torch.no_grad(): - q_s = sinkhorn(score_s) - q_t = sinkhorn(score_t) - - log_p_s = F.log_softmax(score_s / self.temperature, dim=-1) - log_p_t = F.log_softmax(score_t / self.temperature, dim=-1) - - swap_loss = -0.5 * (q_s * log_p_t + q_t * log_p_s).mean() - estimation_loss = F.mse_loss(pred_vel, vel) - losses = estimation_loss + swap_loss - - self.optimizer.zero_grad() - losses.backward() - nn.utils.clip_grad_norm_(self.parameters(), self.max_grad_norm) - self.optimizer.step() - - return estimation_loss.item(), swap_loss.item() - - -@torch.no_grad() -def sinkhorn(out, eps=0.05, iters=3): - Q = torch.exp(out / eps).T - K, B = Q.shape[0], Q.shape[1] - Q /= Q.sum() - - for it in range(iters): - # normalize each row: total weight per prototype must be 1/K - Q /= torch.sum(Q, dim=1, keepdim=True) - Q /= K - - # normalize each column: total weight per sample must be 1/B - Q /= torch.sum(Q, dim=0, keepdim=True) - Q /= B - return (Q * B).T - - -def get_activation(act_name): - if act_name == "elu": - return nn.ELU() - elif act_name == "selu": - return nn.SELU() - elif act_name == "relu": - return nn.ReLU() - elif act_name == "crelu": - return nn.ReLU() - elif act_name == "silu": - return nn.SiLU() - elif act_name == "lrelu": - return nn.LeakyReLU() - elif act_name == "tanh": - return nn.Tanh() - elif act_name == "sigmoid": - return nn.Sigmoid() - else: - print("invalid activation function!") - return None \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/runners/__init__.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/runners/__init__.py deleted file mode 100644 index 1afaad9..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/runners/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -from .him_on_policy_runner import HIMOnPolicyRunner \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/runners/him_on_policy_runner.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/runners/him_on_policy_runner.py deleted file mode 100644 index df216b3..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/runners/him_on_policy_runner.py +++ /dev/null @@ -1,362 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -import time -import os -from collections import deque -import statistics - -import torch - -from ..algorithms import HIMPPO -from ..modules import HIMActorCritic -from ..env import VecEnv - - -class HIMOnPolicyRunner: - - def __init__(self, - env: VecEnv, - train_cfg, - log_dir=None, - device='cpu'): - """Initialize HimLoco on-policy runner. - - Args: - env: Vectorized environment. - train_cfg: Training configuration dictionary. - log_dir: Directory for logging. - device: Device to run on. - """ - # Store configuration - self.cfg = train_cfg # Full training config (for logging compatibility) - self.alg_cfg = train_cfg["algorithm"] - self.policy_cfg = train_cfg["policy"] - self.device = device - self.env = env - - # Determine observation dimensions - if self.env.num_privileged_obs is not None: - num_critic_obs = self.env.num_privileged_obs - else: - num_critic_obs = self.env.num_obs - self.num_actor_obs = self.env.num_obs - self.num_critic_obs = num_critic_obs - - # Initialize policy network - actor_critic_class = eval(train_cfg["policy_class_name"]) # HIMActorCritic - actor_critic: HIMActorCritic = actor_critic_class( - self.env.num_obs, # historical obs - num_critic_obs, # historical privileged obs - self.env.num_one_step_obs, - self.env.num_actions, - **self.policy_cfg - ).to(self.device) - - # Initialize algorithm - alg_class = eval(train_cfg["algorithm_class_name"]) # HIMPPO - self.alg: HIMPPO = alg_class(actor_critic, device=self.device, **self.alg_cfg) - - # Training configuration - self.num_steps_per_env = train_cfg["num_steps_per_env"] - self.save_interval = train_cfg["save_interval"] - - # Initialize storage and model - self.alg.init_storage( - self.env.num_envs, - self.num_steps_per_env, - [self.env.num_obs], - [self.env.num_privileged_obs], - [self.env.num_actions] - ) - - # Logging - self.log_dir = log_dir - self.writer = None - self.tot_timesteps = 0 - self.tot_time = 0 - self.current_learning_iteration = 0 - self.logger_type = self.cfg["logger"].lower() - - # _, _ = self.env.reset() we call this in wrapper - - def learn(self, num_learning_iterations, init_at_random_ep_len=False): - """Train the policy using HimLoco PPO algorithm. - - Args: - num_learning_iterations: Number of policy updates. - init_at_random_ep_len: Whether to randomize initial episode lengths. - """ - # Initialize writer (support multiple logger types like RSL-RL) - if self.log_dir is not None and self.writer is None: - # Launch either Tensorboard, WandB, or Neptune summary writer(s) - if self.logger_type == "neptune": - from rsl_rl.utils.neptune_utils import NeptuneSummaryWriter - self.writer = NeptuneSummaryWriter(log_dir=self.log_dir, flush_secs=10, cfg=self.alg_cfg) - # TODO: Add config logging support - # self.writer.log_config(self.env.cfg, train_cfg, self.alg_cfg, self.policy_cfg) - elif self.logger_type == "wandb": - from rsl_rl.utils.wandb_utils import WandbSummaryWriter - self.writer = WandbSummaryWriter(log_dir=self.log_dir, flush_secs=10, cfg=self.alg_cfg) - # TODO: Add config logging support - elif self.logger_type == "tensorboard": - from torch.utils.tensorboard import SummaryWriter - self.writer = SummaryWriter(log_dir=self.log_dir, flush_secs=10) - else: - raise ValueError("Logger type not found. Please choose 'neptune', 'wandb' or 'tensorboard'.") - - if init_at_random_ep_len: - self.env.episode_length_buf = torch.randint_like(self.env.episode_length_buf, high=int(self.env.max_episode_length)) - obs = self.env.get_observations() - privileged_obs = self.env.get_privileged_observations() - critic_obs = privileged_obs if privileged_obs is not None else obs - obs, critic_obs = obs.to(self.device), critic_obs.to(self.device) - self.alg.actor_critic.train() # switch to train mode (for dropout for example) - - ep_infos = [] - rewbuffer = deque(maxlen=100) - lenbuffer = deque(maxlen=100) - cur_reward_sum = torch.zeros(self.env.num_envs, dtype=torch.float, device=self.device) - cur_episode_length = torch.zeros(self.env.num_envs, dtype=torch.float, device=self.device) - - start_iter = self.current_learning_iteration - tot_iter = start_iter + num_learning_iterations - for it in range(start_iter, tot_iter): - start = time.time() - # Rollout - with torch.inference_mode(): - for i in range(self.num_steps_per_env): - actions = self.alg.act(obs, critic_obs) - obs, privileged_obs, rewards, dones, infos, termination_ids, termination_privileged_obs = self.env.step(actions) - - critic_obs = privileged_obs if privileged_obs is not None else obs - obs, critic_obs, rewards, dones = obs.to(self.device), critic_obs.to(self.device), rewards.to(self.device), dones.to(self.device) - termination_ids = termination_ids.to(self.device) - termination_privileged_obs = termination_privileged_obs.to(self.device) - - next_critic_obs = critic_obs.clone().detach() - next_critic_obs[termination_ids] = termination_privileged_obs.clone().detach() - - self.alg.process_env_step(rewards, dones, infos, next_critic_obs) - # print("+++++++++++++++++++++++++") - # print(obs[0]) - # print(next_critic_obs[0, :self.env.num_one_step_obs]) - # print("+++++++++++++++++++++++++") - if self.log_dir is not None: - # Book keeping - if 'episode' in infos: - ep_infos.append(infos['episode']) - elif 'log' in infos: - ep_infos.append(infos['log']) - # Update rewards - cur_reward_sum += rewards - # Update episode length - cur_episode_length += 1 - # Clear data for completed episodes - new_ids = (dones > 0).nonzero(as_tuple=False) - rewbuffer.extend(cur_reward_sum[new_ids][:, 0].cpu().numpy().tolist()) - lenbuffer.extend(cur_episode_length[new_ids][:, 0].cpu().numpy().tolist()) - cur_reward_sum[new_ids] = 0 - cur_episode_length[new_ids] = 0 - - stop = time.time() - collection_time = stop - start - - # Learning step - start = stop - self.alg.compute_returns(critic_obs) - - mean_value_loss, mean_surrogate_loss, mean_estimation_loss, mean_swap_loss = self.alg.update() - stop = time.time() - learn_time = stop - start - self.current_learning_iteration = it - # log info - if self.log_dir is not None: - self.log(locals()) - # Save model - if it % self.save_interval == 0: - self.save(os.path.join(self.log_dir, 'model_{}.pt'.format(it))) - # Clear episode infos - ep_infos.clear() - - # Save the final model after training - if self.log_dir is not None: - self.save(os.path.join(self.log_dir, 'model_{}.pt'.format(self.current_learning_iteration))) - - def log(self, locs, width=80, pad=35): - """Log training information to console and TensorBoard. - - Enhanced version compatible with RSL-RL style logging, supporting: - - Dynamic episode info processing - - Loss dictionary iteration - - Detailed performance metrics - """ - self.tot_timesteps += self.num_steps_per_env * self.env.num_envs - self.tot_time += locs['collection_time'] + locs['learn_time'] - iteration_time = locs['collection_time'] + locs['learn_time'] - - # -- Episode info - ep_string = "" - if locs['ep_infos']: - for key in locs['ep_infos'][0]: - infotensor = torch.tensor([], device=self.device) - for ep_info in locs['ep_infos']: - # skip missing keys - if key not in ep_info: - continue - # handle scalar and zero dimensional tensor infos - if not isinstance(ep_info[key], torch.Tensor): - ep_info[key] = torch.Tensor([ep_info[key]]) - if len(ep_info[key].shape) == 0: - ep_info[key] = ep_info[key].unsqueeze(0) - infotensor = torch.cat((infotensor, ep_info[key].to(self.device))) - - if infotensor.numel() > 0: - value = torch.mean(infotensor) - # log to tensorboard with proper namespace - if "/" in key: - self.writer.add_scalar(key, value, locs['it']) - ep_string += f"""{f'{key}:':>{pad}} {value:.4f}\n""" - else: - self.writer.add_scalar('Episode/' + key, value, locs['it']) - ep_string += f"""{f'Mean episode {key}:':>{pad}} {value:.4f}\n""" - - # -- Policy metrics - mean_std = self.alg.actor_critic.std.mean() - fps = int(self.num_steps_per_env * self.env.num_envs / (locs['collection_time'] + locs['learn_time'])) - - # -- Losses - loss_dict = { - 'value_function': locs['mean_value_loss'], - 'surrogate': locs['mean_surrogate_loss'], - 'estimation': locs['mean_estimation_loss'], - 'swap': locs['mean_swap_loss'], - } - for key, value in loss_dict.items(): - self.writer.add_scalar(f'Loss/{key}', value, locs['it']) - - self.writer.add_scalar('Loss/learning_rate', self.alg.learning_rate, locs['it']) - self.writer.add_scalar('Policy/mean_noise_std', mean_std.item(), locs['it']) - - # -- Performance - self.writer.add_scalar('Perf/total_fps', fps, locs['it']) - self.writer.add_scalar('Perf/collection_time', locs['collection_time'], locs['it']) - self.writer.add_scalar('Perf/learning_time', locs['learn_time'], locs['it']) - - # -- Training metrics - if len(locs['rewbuffer']) > 0: - self.writer.add_scalar('Train/mean_reward', statistics.mean(locs['rewbuffer']), locs['it']) - self.writer.add_scalar('Train/mean_episode_length', statistics.mean(locs['lenbuffer']), locs['it']) - if self.logger_type != "wandb": # wandb does not support non-integer x-axis logging - self.writer.add_scalar('Train/mean_reward/time', statistics.mean(locs['rewbuffer']), self.tot_time) - self.writer.add_scalar('Train/mean_episode_length/time', statistics.mean(locs['lenbuffer']), self.tot_time) - - # -- Console output - str_header = f" \033[1m Learning iteration {locs['it']}/{locs['tot_iter']} \033[0m " - - if len(locs['rewbuffer']) > 0: - log_string = ( - f"""{'#' * width}\n""" - f"""{str_header.center(width, ' ')}\n\n""" - f"""{'Computation:':>{pad}} {fps:.0f} steps/s (collection: {locs['collection_time']:.3f}s, learning {locs['learn_time']:.3f}s)\n""" - f"""{'Mean action noise std:':>{pad}} {mean_std.item():.2f}\n""" - ) - # Add losses - for key, value in loss_dict.items(): - log_string += f"""{f'Mean {key} loss:':>{pad}} {value:.4f}\n""" - # Add rewards - log_string += ( - f"""{'Mean reward:':>{pad}} {statistics.mean(locs['rewbuffer']):.2f}\n""" - f"""{'Mean episode length:':>{pad}} {statistics.mean(locs['lenbuffer']):.2f}\n""" - ) - else: - log_string = ( - f"""{'#' * width}\n""" - f"""{str_header.center(width, ' ')}\n\n""" - f"""{'Computation:':>{pad}} {fps:.0f} steps/s (collection: {locs['collection_time']:.3f}s, learning {locs['learn_time']:.3f}s)\n""" - f"""{'Mean action noise std:':>{pad}} {mean_std.item():.2f}\n""" - ) - for key, value in loss_dict.items(): - log_string += f"""{f'{key}:':>{pad}} {value:.4f}\n""" - - # Add episode info - log_string += ep_string - - # Add summary - log_string += ( - f"""{'-' * width}\n""" - f"""{'Total timesteps:':>{pad}} {self.tot_timesteps}\n""" - f"""{'Iteration time:':>{pad}} {iteration_time:.2f}s\n""" - f"""{'Time elapsed:':>{pad}} {time.strftime("%H:%M:%S", time.gmtime(self.tot_time))}\n""" - f"""{'ETA:':>{pad}} {time.strftime( - "%H:%M:%S", - time.gmtime( - self.tot_time / (locs['it'] - locs['start_iter'] + 1) - * (locs['start_iter'] + locs['num_learning_iterations'] - locs['it']) - ) - )}\n""" - ) - print(log_string) - - def save(self, path, infos=None): - """Save model checkpoint. - - Args: - path: Path to save the model. - infos: Additional information to save. - """ - saved_dict = { - 'model_state_dict': self.alg.actor_critic.state_dict(), - 'optimizer_state_dict': self.alg.optimizer.state_dict(), - 'estimator_optimizer_state_dict': self.alg.actor_critic.estimator.optimizer.state_dict(), - 'iter': self.current_learning_iteration, - 'infos': infos, - } - torch.save(saved_dict, path) - - # Upload model to external logging service - if hasattr(self, 'logger_type') and self.logger_type in ["neptune", "wandb"]: - if hasattr(self.writer, 'save_model'): - self.writer.save_model(path, self.current_learning_iteration) - - def load(self, path, load_optimizer=True): - loaded_dict = torch.load(path) - self.alg.actor_critic.load_state_dict(loaded_dict['model_state_dict']) - if load_optimizer: - self.alg.optimizer.load_state_dict(loaded_dict['optimizer_state_dict']) - self.alg.actor_critic.estimator.optimizer.load_state_dict(loaded_dict['estimator_optimizer_state_dict']) - self.current_learning_iteration = loaded_dict['iter'] - return loaded_dict['infos'] - - def get_inference_policy(self, device=None): - self.alg.actor_critic.eval() # switch to evaluation mode (dropout for example) - if device is not None: - self.alg.actor_critic.to(device) - return self.alg.actor_critic.act_inference diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/storage/__init__.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/storage/__init__.py deleted file mode 100644 index d04c429..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/storage/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright 2021 ETH Zurich, NVIDIA CORPORATION -# SPDX-License-Identifier: BSD-3-Clause - -from .him_rollout_storage import HIMRolloutStorage \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/storage/him_rollout_storage.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/storage/him_rollout_storage.py deleted file mode 100644 index 8cebaf1..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/storage/him_rollout_storage.py +++ /dev/null @@ -1,167 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -import torch -import numpy as np - -# from ..utils import split_and_pad_trajectories - -class HIMRolloutStorage: - class Transition: - def __init__(self): - self.observations = None - self.critic_observations = None - self.actions = None - self.rewards = None - self.dones = None - self.values = None - self.actions_log_prob = None - self.action_mean = None - self.action_sigma = None - self.next_critic_observations = None - - def clear(self): - self.__init__() - - def __init__(self, num_envs, num_transitions_per_env, obs_shape, privileged_obs_shape, actions_shape, device='cpu'): - - self.device = device - - self.obs_shape = obs_shape - self.privileged_obs_shape = privileged_obs_shape - self.actions_shape = actions_shape - - # Core - self.observations = torch.zeros(num_transitions_per_env, num_envs, *obs_shape, device=self.device) - if privileged_obs_shape[0] is not None: - self.privileged_observations = torch.zeros(num_transitions_per_env, num_envs, *privileged_obs_shape, device=self.device) - self.next_privileged_observations = torch.zeros(num_transitions_per_env, num_envs, *privileged_obs_shape, device=self.device) - else: - self.privileged_observations = None - self.next_privileged_observations = None - self.rewards = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device) - self.actions = torch.zeros(num_transitions_per_env, num_envs, *actions_shape, device=self.device) - self.dones = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device).byte() - - # For PPO - self.actions_log_prob = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device) - self.values = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device) - self.returns = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device) - self.advantages = torch.zeros(num_transitions_per_env, num_envs, 1, device=self.device) - self.mu = torch.zeros(num_transitions_per_env, num_envs, *actions_shape, device=self.device) - self.sigma = torch.zeros(num_transitions_per_env, num_envs, *actions_shape, device=self.device) - - self.num_transitions_per_env = num_transitions_per_env - self.num_envs = num_envs - - self.step = 0 - - def add_transitions(self, transition: Transition): - if self.step >= self.num_transitions_per_env: - raise AssertionError("Rollout buffer overflow") - self.observations[self.step].copy_(transition.observations) - if self.privileged_observations is not None: self.privileged_observations[self.step].copy_(transition.critic_observations) - if self.next_privileged_observations is not None: self.next_privileged_observations[self.step].copy_(transition.next_critic_observations) - self.actions[self.step].copy_(transition.actions) - self.rewards[self.step].copy_(transition.rewards.view(-1, 1)) - self.dones[self.step].copy_(transition.dones.view(-1, 1)) - self.values[self.step].copy_(transition.values) - self.actions_log_prob[self.step].copy_(transition.actions_log_prob.view(-1, 1)) - self.mu[self.step].copy_(transition.action_mean) - self.sigma[self.step].copy_(transition.action_sigma) - self.step += 1 - - def clear(self): - self.step = 0 - - def compute_returns(self, last_values, gamma, lam): - advantage = 0 - for step in reversed(range(self.num_transitions_per_env)): - if step == self.num_transitions_per_env - 1: - next_values = last_values - else: - next_values = self.values[step + 1] - next_is_not_terminal = 1.0 - self.dones[step].float() - delta = self.rewards[step] + next_is_not_terminal * gamma * next_values - self.values[step] - advantage = delta + next_is_not_terminal * gamma * lam * advantage - self.returns[step] = advantage + self.values[step] - - # Compute and normalize the advantages - self.advantages = self.returns - self.values - self.advantages = (self.advantages - self.advantages.mean()) / (self.advantages.std() + 1e-8) - - def get_statistics(self): - done = self.dones - done[-1] = 1 - flat_dones = done.permute(1, 0, 2).reshape(-1, 1) - done_indices = torch.cat((flat_dones.new_tensor([-1], dtype=torch.int64), flat_dones.nonzero(as_tuple=False)[:, 0])) - trajectory_lengths = (done_indices[1:] - done_indices[:-1]) - return trajectory_lengths.float().mean(), self.rewards.mean() - - def mini_batch_generator(self, num_mini_batches, num_epochs=8): - batch_size = self.num_envs * self.num_transitions_per_env - mini_batch_size = batch_size // num_mini_batches - indices = torch.randperm(num_mini_batches*mini_batch_size, requires_grad=False, device=self.device) - - observations = self.observations.flatten(0, 1) - if self.privileged_observations is not None: - critic_observations = self.privileged_observations.flatten(0, 1) - next_critic_observations = self.next_privileged_observations.flatten(0, 1) - else: - critic_observations = observations - next_critic_observations = observations - - actions = self.actions.flatten(0, 1) - values = self.values.flatten(0, 1) - returns = self.returns.flatten(0, 1) - old_actions_log_prob = self.actions_log_prob.flatten(0, 1) - advantages = self.advantages.flatten(0, 1) - old_mu = self.mu.flatten(0, 1) - old_sigma = self.sigma.flatten(0, 1) - - for epoch in range(num_epochs): - for i in range(num_mini_batches): - - start = i*mini_batch_size - end = (i+1)*mini_batch_size - batch_idx = indices[start:end] - - obs_batch = observations[batch_idx] - next_critic_observations_batch = next_critic_observations[batch_idx] - critic_observations_batch = critic_observations[batch_idx] - actions_batch = actions[batch_idx] - target_values_batch = values[batch_idx] - returns_batch = returns[batch_idx] - old_actions_log_prob_batch = old_actions_log_prob[batch_idx] - advantages_batch = advantages[batch_idx] - old_mu_batch = old_mu[batch_idx] - old_sigma_batch = old_sigma[batch_idx] - yield obs_batch, critic_observations_batch, actions_batch, next_critic_observations_batch, target_values_batch, advantages_batch, returns_batch, \ - old_actions_log_prob_batch, old_mu_batch, old_sigma_batch diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/utils/__init__.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/utils/__init__.py deleted file mode 100644 index 1b505f3..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/utils/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -from .utils import split_and_pad_trajectories, unpad_trajectories \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/utils/utils.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/utils/utils.py deleted file mode 100644 index b6affab..0000000 --- a/05_software/train/rc_mjlab/src/robot/rl/himloco/utils/utils.py +++ /dev/null @@ -1,71 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2021 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: BSD-3-Clause -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -# Copyright (c) 2021 ETH Zurich, Nikita Rudin - -import torch - -def split_and_pad_trajectories(tensor, dones): - """ Splits trajectories at done indices. Then concatenates them and padds with zeros up to the length og the longest trajectory. - Returns masks corresponding to valid parts of the trajectories - Example: - Input: [ [a1, a2, a3, a4 | a5, a6], - [b1, b2 | b3, b4, b5 | b6] - ] - - Output:[ [a1, a2, a3, a4], | [ [True, True, True, True], - [a5, a6, 0, 0], | [True, True, False, False], - [b1, b2, 0, 0], | [True, True, False, False], - [b3, b4, b5, 0], | [True, True, True, False], - [b6, 0, 0, 0] | [True, False, False, False], - ] | ] - - Assumes that the inputy has the following dimension order: [time, number of envs, aditional dimensions] - """ - dones = dones.clone() - dones[-1] = 1 - # Permute the buffers to have order (num_envs, num_transitions_per_env, ...), for correct reshaping - flat_dones = dones.transpose(1, 0).reshape(-1, 1) - - # Get length of trajectory by counting the number of successive not done elements - done_indices = torch.cat((flat_dones.new_tensor([-1], dtype=torch.int64), flat_dones.nonzero()[:, 0])) - trajectory_lengths = done_indices[1:] - done_indices[:-1] - trajectory_lengths_list = trajectory_lengths.tolist() - # Extract the individual trajectories - trajectories = torch.split(tensor.transpose(1, 0).flatten(0, 1),trajectory_lengths_list) - padded_trajectories = torch.nn.utils.rnn.pad_sequence(trajectories) - - - trajectory_masks = trajectory_lengths > torch.arange(0, tensor.shape[0], device=tensor.device).unsqueeze(1) - return padded_trajectories, trajectory_masks - -def unpad_trajectories(trajectories, masks): - """ Does the inverse operation of split_and_pad_trajectories() - """ - # Need to transpose before and after the masking to have proper reshaping - return trajectories.transpose(1, 0)[masks.transpose(1, 0)].view(-1, trajectories.shape[0], trajectories.shape[-1]).transpose(1, 0) \ No newline at end of file diff --git a/05_software/train/rc_mjlab/src/robot/rl/himloco/wrappers/__init__.py b/05_software/train/rc_mjlab/src/robot/rl/himloco/wrappers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/05_software/train/rc_mjlab/src/robot/robot_cfg.py b/05_software/train/rc_mjlab/src/robot/robot_cfg.py index 8c4d38c..5d7403e 100644 --- a/05_software/train/rc_mjlab/src/robot/robot_cfg.py +++ b/05_software/train/rc_mjlab/src/robot/robot_cfg.py @@ -27,13 +27,13 @@ def get_spec() -> mujoco.MjSpec: # All 16 actuators have identical physical specs: effort limit = 17 Nm, max velocity = 13 rad/s. -# Leg joints: Position PD control (referenced from HIMLoco Go2W: kp = 40, kd = 1). -STIFFNESS_LEG = 40.0 -DAMPING_LEG = 1.0 +# Leg joints: Position PD control aligned with current real deployment. +STIFFNESS_LEG = 50.0 +DAMPING_LEG = 1.5 EFFORT_LEG = 17.0 -# Wheel joints: Velocity control with damping = 0.5 and effort limit = 17 Nm. -DAMPING_WHEEL = 0.5 +# Wheel joints: Velocity control aligned with current real deployment. +DAMPING_WHEEL = 1.0 EFFORT_WHEEL = 17.0 # Maximum joint speed for all actuators (rad/s) @@ -110,4 +110,4 @@ def get_robot_crawl_cfg() -> EntityCfg: # Action scale: Leg target position is ±0.25 rad, wheel target velocity is ±10.0 rad/s. # (Policy output range ±1 maps to wheel speeds ±10 rad/s, staying well within the max velocity of 13 rad/s). LEG_POS_SCALE = 0.25 -WHEEL_VEL_SCALE = 10.0 +WHEEL_VEL_SCALE = 5.0 diff --git a/05_software/train/rc_mjlab/src/robot/terrains/competition_terrains.py b/05_software/train/rc_mjlab/src/robot/terrains/competition_terrains.py index 9fa73e4..ac65bf5 100644 --- a/05_software/train/rc_mjlab/src/robot/terrains/competition_terrains.py +++ b/05_software/train/rc_mjlab/src/robot/terrains/competition_terrains.py @@ -24,9 +24,9 @@ _COLOR_PURPLE = (0.60, 0.20, 0.80) @dataclass(kw_only=True) class RCWallTerrainCfg(SubTerrainCfg): - """Transverse wall obstacle terrain representing the race high wall. + """Triple transverse wall obstacle terrain representing repeated race high walls. - The robot must sprint from the flat platform, vault over the wall, and proceed. + The robot must sprint from the flat platform, vault over three walls, and proceed. As difficulty scales from 0 to 1, the wall height increases linearly from wall_height_range[0] to wall_height_range[1]. @@ -42,6 +42,8 @@ class RCWallTerrainCfg(SubTerrainCfg): """Wall length fraction of the terrain width (leaving gaps for visualization/debugging).""" platform_width: float = 1.5 """Sprint platform width (m).""" + wall_centers_x: tuple[float, float, float] = (2.9, 4.45, 6.0) + """Wall center positions along x, spaced to keep a short sprint, two recovery gaps, and exit room.""" def function( self, @@ -67,26 +69,26 @@ class RCWallTerrainCfg(SubTerrainCfg): origin = np.array([self.size[0] / 2, self.size[1] / 2, 0.0]) return TerrainOutput(origin=origin, geometries=geometries) - # -- Wall geometry: centered on x-midline, oriented along y-axis -- + # -- Wall geometry: three transverse walls oriented along y-axis -- wall_length = self.wall_length_frac * self.size[1] - cx = self.size[0] / 2 cy = self.size[1] / 2 wall_color = brand_ramp(_COLOR_ORANGE, difficulty) - wall_geom = body.add_geom( - type=mujoco.mjtGeom.mjGEOM_BOX, - size=( - self.wall_thickness / 2, # half-size x - wall_length / 2, # half-size y - wall_height / 2, # half-size z - ), - pos=(cx, cy, wall_height / 2), - ) - geometries.append(TerrainGeometry(geom=wall_geom, color=wall_color)) + for cx in self.wall_centers_x: + wall_geom = body.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + size=( + self.wall_thickness / 2, # half-size x + wall_length / 2, # half-size y + wall_height / 2, # half-size z + ), + pos=(cx, cy, wall_height / 2), + ) + geometries.append(TerrainGeometry(geom=wall_geom, color=wall_color)) - # Spawn origin is set to the left platform area (e.g., 1.5m) to allow - # the robot ample room to sprint/accelerate instead of spawning directly inside the obstacle. + # Spawn origin is set to the left platform area to allow a short sprint + # before the first wall and limited recovery space between subsequent walls. origin = np.array([1.5, cy, 0.0]) return TerrainOutput(origin=origin, geometries=geometries) diff --git a/05_software/train/rc_mjlab/uv.lock b/05_software/train/rc_mjlab/uv.lock index 80f47d9..b3323a5 100644 --- a/05_software/train/rc_mjlab/uv.lock +++ b/05_software/train/rc_mjlab/uv.lock @@ -41,20 +41,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "anyio" -version = "4.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, -] - [[package]] name = "asttokens" version = "3.0.1" @@ -64,15 +50,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - [[package]] name = "bcrypt" version = "5.0.0" @@ -369,18 +346,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "colorlog" -version = "6.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, -] - [[package]] name = "contourpy" version = "1.3.2" @@ -724,41 +689,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] -[[package]] -name = "embreex" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/89/692237d6a60f58f87f7bffdfe8c30008efd8811411e692f96d2cb1df0818/embreex-4.4.0-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:b685ed766ef86dbd302ae9e92ab20156007c31cbd2ea5465542b50eea99c6da5", size = 5098460, upload-time = "2026-04-22T19:46:30.276Z" }, - { url = "https://files.pythonhosted.org/packages/14/fe/cedef264696768248f8139c569e10796ffb76627383adb5a60e1eaf28a20/embreex-4.4.0-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:daf8b1848798523d7d71cc22f2610401ab02ec93ea063f9cbb90dcb9abda2ccf", size = 13215487, upload-time = "2026-04-22T19:46:32.769Z" }, - { url = "https://files.pythonhosted.org/packages/11/f5/460b7f79689ac5e6ceb3ec2a1194176a0a66d6c4e010dae68ba899a1c927/embreex-4.4.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f548cbebc550624ef08530ed9dcd147eeddcd181fd8f32cf3378800b39b21034", size = 14438524, upload-time = "2026-04-22T19:46:35.533Z" }, - { url = "https://files.pythonhosted.org/packages/9f/c2/aef3606d7ca2b4d2d18e57c8f65762b94d253e678a05c946649bb1913f5e/embreex-4.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:58921ef7ad488dbd514b3053e7c4a9fdcd2f7008426b3185b9f1bd394c608edd", size = 13119003, upload-time = "2026-04-22T19:46:38.442Z" }, - { url = "https://files.pythonhosted.org/packages/d8/e1/84b02da29deac092349b12fae21a3cbf4b64104ef78e0d0c1bca5d268112/embreex-4.4.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b2e69b777c0b7878e13ad8c31f4fecaddb5cd8633a5814c1ac11da2efe1065dc", size = 5098210, upload-time = "2026-04-22T19:46:41.149Z" }, - { url = "https://files.pythonhosted.org/packages/4c/0c/1bcdcb8cb09713d40c3cc6d303a4e456113df859dacb28a1b7af8a19a718/embreex-4.4.0-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:96a187753f578cb685051f8fd679dc7986f887fcb922bb81a9924f5b89e941d8", size = 13214875, upload-time = "2026-04-22T19:46:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/bed6f27a57b89ad028c8ec5adf6f1877a1ef92d983d703abb7a70717f0d9/embreex-4.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8415d14c8956afec7eb05749f1e0bf396bb51efd566054ca169e10e4089e7bb7", size = 14474358, upload-time = "2026-04-22T19:46:46.056Z" }, - { url = "https://files.pythonhosted.org/packages/21/f4/c3515fde7bacab245673988398ef40928ce0b9fb54e2b51e90a4a4535479/embreex-4.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfa54fb71cbb3a41c2366cabd09bb2dbbc8700843872f2c8655a3215a73459a7", size = 13119132, upload-time = "2026-04-22T19:46:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/f6/04/b3413ba4f1c17f2374cc39b5b86404221aedc632c8b6cdb484697eeffcd8/embreex-4.4.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:3bb261a25c21d50bc7f046401e7256e59e43a500b229fb2a00b6393c61e5293d", size = 5097518, upload-time = "2026-04-22T19:46:51.379Z" }, - { url = "https://files.pythonhosted.org/packages/6d/78/8cc0960cc0f2d60d581869a66c8013e1bf1c73bf5bf9609bd8aa79e0f721/embreex-4.4.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:8e93bce7cf905365117dea2d0726d19262c88a010044d00631db6bb7dc145612", size = 13214768, upload-time = "2026-04-22T19:46:54.088Z" }, - { url = "https://files.pythonhosted.org/packages/79/b0/05a5b4d49749602b12e13d1871f8e6d1fe6db806eda75f6f57bb4f1acf6f/embreex-4.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb0872f5bc231f465b840b122847acbaf468ac48f49bfaff127c5347ec0db94f", size = 14529899, upload-time = "2026-04-22T19:46:56.824Z" }, - { url = "https://files.pythonhosted.org/packages/b5/96/625e035f3433071c91de07e66265a261be7bb708367f785000f93d7a992a/embreex-4.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6c092ee1adf5c48b7430c7ae3902943863745e54b4aef4327ecb3473e0a299d7", size = 13119305, upload-time = "2026-04-22T19:46:59.329Z" }, - { url = "https://files.pythonhosted.org/packages/9b/58/56b0ef72a128da86892f89f61f0c91bf60c6ea20c2ab952cb88e05c4e89b/embreex-4.4.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:096cffa6be78966b0214b26f09fc22cb5ebcfbd4fe9fdc27caf6400869058a51", size = 5094712, upload-time = "2026-04-22T19:47:02.112Z" }, - { url = "https://files.pythonhosted.org/packages/36/d2/9bbde8d9d520c2a7bcad892631165b9676d9dcbde381f25bfda06d0f6e42/embreex-4.4.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:078bae4dcebb2a64c8dde6b3c178f258f966c4514e265608620033a6c907e21b", size = 13212105, upload-time = "2026-04-22T19:47:04.511Z" }, - { url = "https://files.pythonhosted.org/packages/03/5d/3e5ca5dea1c2c5b4604f3bedb67ea4beaa465398d9c04d8124ca3d657b05/embreex-4.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd305875e2cd7c51004a423fe7da9881fa266fa4a50e61dd546978e10f020e66", size = 14486730, upload-time = "2026-04-22T19:47:07.229Z" }, - { url = "https://files.pythonhosted.org/packages/45/00/26741306e557129d398744601cd9bca4069d52cebe146ba99e535f9f2c65/embreex-4.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:433de9063843804d70bb3c1680619bb28bc6403b79d3d94560ec9acc3df96eda", size = 13117246, upload-time = "2026-04-22T19:47:10.08Z" }, - { url = "https://files.pythonhosted.org/packages/62/70/4ed53f33ab0f0dca32f8017b903f5dcad25df32f7068854e529902aa97da/embreex-4.4.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:1ef2650f1ced083d433d46389a187fd57f7bf795106ff79a2671e95f2e6a4c4c", size = 5095722, upload-time = "2026-04-22T19:47:12.774Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ac/638a6b4a6cb67125a3c2676a108fb73d75e67b3ce3813f25b6056d0b77cd/embreex-4.4.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:ab3c50ecd3aa6c39491928d975b00c9f5eeaa1b39b74bab87170c17e2df1bfde", size = 13212439, upload-time = "2026-04-22T19:47:15.009Z" }, - { url = "https://files.pythonhosted.org/packages/39/1c/567194e9f5bdbb5099144dae3202452821319ff348e328b50c0fbacc3828/embreex-4.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b0bd41b10dc2f1ad4995faef08d924d5f33a5d47afb97ba5801c820f305db6d", size = 14480771, upload-time = "2026-04-22T19:47:17.681Z" }, - { url = "https://files.pythonhosted.org/packages/d1/8d/a46fb6ca4cc898e8d06449a4b46a8c22a28971f1e6d9bb6c99459bbb96d1/embreex-4.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:80f938291832ab11dc7a604d609bce2587d574b4fe862be91d117562629f1b94", size = 13373573, upload-time = "2026-04-22T19:47:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/d2/71/4e21eff598840060229cc502034737d04d2a42dee8118c7d5acf6ee6f28a/embreex-4.4.0-cp314-cp314t-macosx_13_0_arm64.whl", hash = "sha256:670470a53fdde1aeb52c4d8db600f17bc0dc7c34dc6f90233909db9c6fe1e88a", size = 5102482, upload-time = "2026-04-22T19:47:23.67Z" }, - { url = "https://files.pythonhosted.org/packages/61/5e/da7c1448c209f406a5b43a8feb07489e8652a64c48450b5642d3f34cf9e7/embreex-4.4.0-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:ea332cfe60f3b242ecb557ef7b5fcbffec5edd0f3127241bed343d090ac735e2", size = 13218445, upload-time = "2026-04-22T19:47:25.847Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d4/8cb8a41b25138999a98286ae1562e5903c7579ec71becc05bfa1c10d571f/embreex-4.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e3481b76df80f23128173486ac0efe55e9199fc311bc74707452b4f19951eff", size = 14589303, upload-time = "2026-04-22T19:47:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/8b393b35016909143ecac7bf7bf418f79236e1f83faf3867b5c5cb50c97f/embreex-4.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a2fedc756a36729da6803425398aa4987b27d1d89e9fad403d8ef371fad5ca01", size = 13389585, upload-time = "2026-04-22T19:47:31.398Z" }, -] - [[package]] name = "etils" version = "1.13.0" @@ -1046,43 +976,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, ] -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - [[package]] name = "idna" version = "3.13" @@ -1251,33 +1144,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - [[package]] name = "kiwisolver" version = "1.5.0" @@ -1402,236 +1268,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] -[[package]] -name = "lxml" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/6e/ee8fc0e01202eb3dd2b9e1ea4f0910d72425d35c66187c63931d7a3ea73f/lxml-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:41dcc4c7b10484257cbd6c37b83ddb26df2b0e5aff5ac00d095689015af868ec", size = 8540733, upload-time = "2026-04-18T04:27:33.185Z" }, - { url = "https://files.pythonhosted.org/packages/54/e8/325fe9b942824c773dffe1baf0c35b046a763851fdff4393af4450bceeb7/lxml-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a31286dbb5e74c8e9a5344465b77ab4c5bd511a253b355b5ca2fae7e579fafec", size = 4602805, upload-time = "2026-04-18T04:27:36.097Z" }, - { url = "https://files.pythonhosted.org/packages/2d/81/221aa3ea4a40370bb0358fa454cbe7e5a837e522f7630c24dfef3f9a73b0/lxml-6.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1bc4cc83fb7f66ffb16f74d6dd0162e144333fc36ebcce32246f80c8735b2551", size = 5002652, upload-time = "2026-04-18T04:27:30.603Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e1/fdbfb9019542f1875c093576df7f37adc2983c8ba7ecf17e5f14490bc107/lxml-6.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:20cf4d0651987c906a2f5cba4e3a8d6ba4bfdf973cfe2a96c0d6053888ea2ecd", size = 5155332, upload-time = "2026-04-18T04:27:33.507Z" }, - { url = "https://files.pythonhosted.org/packages/56/b1/4087c782fff397cd03abf9c551069be59bb04a7e548c50fb7b9c4cdaca28/lxml-6.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb34ea45a82dd637c2c97ae1bbb920850c1e59bcae79ce1c15af531d83e7215", size = 5057226, upload-time = "2026-04-18T04:27:37.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/66/516c79dec8417f3a972327330254c0b5fac93d5c3ecfd8a5b43650a5a4d9/lxml-6.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d9b99e5b2597e4f5aed2484fef835256fa1b68a19e4265c97628ef4bf8bcf4", size = 5287588, upload-time = "2026-04-18T04:27:41.4Z" }, - { url = "https://files.pythonhosted.org/packages/94/1d/e578f4cbeb42b9df9f29b0d44a45a7cdfa3a5ae300dd59ec68e3602d29bb/lxml-6.1.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:d43aa26dcda363f21e79afa0668f5029ed7394b3bb8c92a6927a3d34e8b610ea", size = 5412438, upload-time = "2026-04-18T04:27:45.589Z" }, - { url = "https://files.pythonhosted.org/packages/47/5b/2aa68307d6d15959e84d4882f9c04f2da63127eac463e1594166f681ef77/lxml-6.1.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:6262b87f9e5c1e5fe501d6c153247289af42eb44ad7660b9b3de17baaf92d6f6", size = 4770997, upload-time = "2026-04-18T04:27:49.853Z" }, - { url = "https://files.pythonhosted.org/packages/ae/c9/3e51fc1228310a836b4eb32595ae00154ab12197fca944676a3ab3b163ea/lxml-6.1.0-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1392c569c032f78a11a25d1de1c43fff13294c793b39e19d84fade3045cbbc3", size = 5359678, upload-time = "2026-04-18T04:31:56.184Z" }, - { url = "https://files.pythonhosted.org/packages/b5/91/ab8bc834f977fbbd310e697b120787c153db026f9151e02a88d2645d4e5b/lxml-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:045e387d1f4f42a418380930fa3f45c73c9b392faf67e495e58902e68e8f44a7", size = 5107890, upload-time = "2026-04-18T04:32:00.387Z" }, - { url = "https://files.pythonhosted.org/packages/bb/10/8a143cfa3ac99cb5b0523ff6d0429a9c9dddf25ffeae09caa3866c7964d9/lxml-6.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9f93d5b8b07f73e8c77e3c6556a3db269918390c804b5e5fcdd4858232cc8f16", size = 4803977, upload-time = "2026-04-18T04:32:05.099Z" }, - { url = "https://files.pythonhosted.org/packages/45/fd/ee02faf52fa39c2fe32f824628958b9aa86dff21343dc3161f0e3c6ccd15/lxml-6.1.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:de550d129f18d8ab819651ffe4f38b1b713c7e116707de3c0c6400d0ef34fbc1", size = 5350277, upload-time = "2026-04-18T04:32:09.176Z" }, - { url = "https://files.pythonhosted.org/packages/85/8c/b3481364b8554b5d36d540189a87fc71e94b0b01c24f8f152bd662dd2e45/lxml-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c08da09dc003c9e8c70e06b53a11db6fb3b250c21c4236b03c7d7b443c318e7a", size = 5309717, upload-time = "2026-04-18T04:32:13.303Z" }, - { url = "https://files.pythonhosted.org/packages/74/e8/a6b21927077a9127afa17473b6576b322616f34ac50ee4f577e763b75ec0/lxml-6.1.0-cp310-cp310-win32.whl", hash = "sha256:37448bf9c7d7adfc5254763901e2bbd6bb876228dfc1fc7f66e58c06368a7544", size = 3598491, upload-time = "2026-04-18T04:27:24.288Z" }, - { url = "https://files.pythonhosted.org/packages/ea/82/14dea800d041274d96c07d49ff9191f011d1427450850de19bf541e2cc12/lxml-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:2593a0a6621545b9095b71ad74ed4226eba438a7d9fc3712a99bdb15508cf93a", size = 4020906, upload-time = "2026-04-18T04:27:27.53Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ba/d3539aaf4d9d21456b9a7b902816623227d05d63e7c5aafd8834c4b9bed6/lxml-6.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80807d72f96b96ad5588cb85c75616e4f2795a7737d4630784c51497beb7776", size = 3667787, upload-time = "2026-04-18T04:27:29.407Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5d/3bccad330292946f97962df9d5f2d3ae129cce6e212732a781e856b91e07/lxml-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cec05be8c876f92a5aa07b01d60bbb4d11cfbdd654cad0561c0d7b5c043a61b9", size = 8526232, upload-time = "2026-04-18T04:27:40.389Z" }, - { url = "https://files.pythonhosted.org/packages/a7/51/adc8826570a112f83bb4ddb3a2ab510bbc2ccd62c1b9fe1f34fae2d90b57/lxml-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9c03e048b6ce8e77b09c734e931584894ecd58d08296804ca2d0b184c933ce50", size = 4595448, upload-time = "2026-04-18T04:27:44.208Z" }, - { url = "https://files.pythonhosted.org/packages/54/84/5a9ec07cbe1d2334a6465f863b949a520d2699a755738986dcd3b6b89e3f/lxml-6.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:942454ff253da14218f972b23dc72fa4edf6c943f37edd19cd697618b626fac5", size = 4923771, upload-time = "2026-04-18T04:32:17.402Z" }, - { url = "https://files.pythonhosted.org/packages/a7/23/851cfa33b6b38adb628e45ad51fb27105fa34b2b3ba9d1d4aa7a9428dfe0/lxml-6.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d036ee7b99d5148072ac7c9b847193decdfeac633db350363f7bce4fff108f0e", size = 5068101, upload-time = "2026-04-18T04:32:21.437Z" }, - { url = "https://files.pythonhosted.org/packages/b0/38/41bf99c2023c6b79916ba057d83e9db21d642f473cac210201222882d38b/lxml-6.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ae5d8d5427f3cc317e7950f2da7ad276df0cfa37b8de2f5658959e618ea8512", size = 5002573, upload-time = "2026-04-18T04:32:25.373Z" }, - { url = "https://files.pythonhosted.org/packages/c2/20/053aa10bdc39747e1e923ce2d45413075e84f70a136045bb09e5eaca41d3/lxml-6.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:363e47283bde87051b821826e71dde47f107e08614e1aa312ba0c5711e77738c", size = 5202816, upload-time = "2026-04-18T04:32:29.393Z" }, - { url = "https://files.pythonhosted.org/packages/9a/da/bc710fad8bf04b93baee752c192eaa2210cd3a84f969d0be7830fea55802/lxml-6.1.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:f504d861d9f2a8f94020130adac88d66de93841707a23a86244263d1e54682f5", size = 5329999, upload-time = "2026-04-18T04:32:34.019Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/bf035dedbdf7fab49411aa52e4236f3445e98d38647d85419e6c0d2806b9/lxml-6.1.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:23a5dc68e08ed13331d61815c08f260f46b4a60fdd1640bbeb82cf89a9d90289", size = 4659643, upload-time = "2026-04-18T04:32:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4f/22be31f33727a5e4c7b01b0a874503026e50329b259d3587e0b923cf964b/lxml-6.1.0-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f15401d8d3dbf239e23c818afc10c7207f7b95f9a307e092122b6f86dd43209a", size = 5265963, upload-time = "2026-04-18T04:32:41.881Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2b/d44d0e5c79226017f4ab8c87a802ebe4f89f97e6585a8e4166dffcdd7b6e/lxml-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fcf3da95e93349e0647d48d4b36a12783105bcc74cb0c416952f9988410846a3", size = 5045444, upload-time = "2026-04-18T04:32:44.512Z" }, - { url = "https://files.pythonhosted.org/packages/d3/c3/3f034fec1594c331a6dbf9491238fdcc9d66f68cc529e109ec75b97197e1/lxml-6.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0d082495c5fcf426e425a6e28daaba1fcb6d8f854a4ff01effb1f1f381203eb9", size = 4712703, upload-time = "2026-04-18T04:32:47.16Z" }, - { url = "https://files.pythonhosted.org/packages/12/16/0b83fccc158218aca75a7aa33e97441df737950734246b9fffa39301603d/lxml-6.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e3c4f84b24a1fcba435157d111c4b755099c6ff00a3daee1ad281817de75ed11", size = 5252745, upload-time = "2026-04-18T04:32:50.427Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ee/12e6c1b39a77666c02eaa77f94a870aaf63c4ac3a497b2d52319448b01c6/lxml-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976a6b39b1b13e8c354ad8d3f261f3a4ac6609518af91bdb5094760a08f132c4", size = 5226822, upload-time = "2026-04-18T04:32:53.437Z" }, - { url = "https://files.pythonhosted.org/packages/34/20/c7852904858b4723af01d2fc14b5d38ff57cb92f01934a127ebd9a9e51aa/lxml-6.1.0-cp311-cp311-win32.whl", hash = "sha256:857efde87d365706590847b916baff69c0bc9252dc5af030e378c9800c0b10e3", size = 3594026, upload-time = "2026-04-18T04:27:31.903Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/d60c732b56da5085175c07c74b2df4e6d181b0c9a61e1691474f06ef4b39/lxml-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:183bfb45a493081943be7ea2b5adfc2b611e1cf377cefa8b8a8be404f45ef9a7", size = 4025114, upload-time = "2026-04-18T04:27:34.077Z" }, - { url = "https://files.pythonhosted.org/packages/c2/df/c84dcc175fd690823436d15b41cb920cd5ba5e14cd8bfb00949d5903b320/lxml-6.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:19f4164243fc206d12ed3d866e80e74f5bc3627966520da1a5f97e42c32a3f39", size = 3667742, upload-time = "2026-04-18T04:27:38.45Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, - { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, - { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, - { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, - { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, - { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, - { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, - { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, - { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, - { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, - { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, - { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, - { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, - { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, - { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, - { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, - { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, - { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, - { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, - { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, - { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, - { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, - { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, - { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, - { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, - { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, - { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, - { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" }, - { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" }, - { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" }, - { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" }, - { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" }, - { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" }, - { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" }, - { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" }, - { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" }, - { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" }, - { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" }, - { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" }, - { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" }, - { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" }, - { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" }, - { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" }, - { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" }, - { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" }, - { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" }, - { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" }, - { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" }, - { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" }, - { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" }, - { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" }, - { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, - { url = "https://files.pythonhosted.org/packages/f2/88/55143966481409b1740a3ac669e611055f49efd68087a5ce41582325db3e/lxml-6.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:546b66c0dd1bb8d9fa89d7123e5fa19a8aff3a1f2141eb22df96112afb17b842", size = 3930134, upload-time = "2026-04-18T04:32:35.008Z" }, - { url = "https://files.pythonhosted.org/packages/b5/97/28b985c2983938d3cb696dd5501423afb90a8c3e869ef5d3c62569282c0f/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfa1a34df366d9dc0d5eaf420f4cf2bb1e1bebe1066d1c2fc28c179f8a4004c", size = 4210749, upload-time = "2026-04-18T04:36:03.626Z" }, - { url = "https://files.pythonhosted.org/packages/29/67/dfab2b7d58214921935ccea7ce9b3df9b7d46f305d12f0f532ac7cf6b804/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db88156fcf544cdbf0d95588051515cfdfd4c876fc66444eb98bceb5d6db76de", size = 4318463, upload-time = "2026-04-18T04:36:06.309Z" }, - { url = "https://files.pythonhosted.org/packages/32/a2/4ac7eb32a4d997dd352c32c32399aae27b3f268d440e6f9cfa405b575d2f/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07f98f5496f96bf724b1e3c933c107f0cbf2745db18c03d2e13a291c3afd2635", size = 4251124, upload-time = "2026-04-18T04:36:09.056Z" }, - { url = "https://files.pythonhosted.org/packages/33/ef/d6abd850bb4822f9b720cfe36b547a558e694881010ff7d012191e8769c6/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4642e04449a1e164b5ff71ffd901ddb772dfabf5c9adf1b7be5dffe1212bc037", size = 4401758, upload-time = "2026-04-18T04:36:11.803Z" }, - { url = "https://files.pythonhosted.org/packages/40/44/3ee09a5b60cb44c4f2fbc1c9015cfd6ff5afc08f991cab295d3024dcbf2d/lxml-6.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7da13bb6fbadfafb474e0226a30570a3445cfd47c86296f2446dafbd77079ace", size = 3508860, upload-time = "2026-04-18T04:32:48.619Z" }, -] - -[[package]] -name = "manifold3d" -version = "3.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b0/fd/4dfc246e076e3912c45a821764f4de8b6c8117fa36fc67f8e44bf9dfe59b/manifold3d-3.4.1.tar.gz", hash = "sha256:b517927e2c15dc52169fff0cd12e1949eceb4ca49f3a5b8c0568b1116a561ab1", size = 269275, upload-time = "2026-03-24T06:22:40.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/43/9ce593ad180d69f802e7b9837da95567c30e6af3cb1b66f3c254d0eb4445/manifold3d-3.4.1-cp310-cp310-macosx_10_14_universal2.whl", hash = "sha256:2ec55079d4c4ada4aaa3708559a473b41817ed9d226c61e1a6d43b0ef17390c5", size = 1739369, upload-time = "2026-03-24T06:21:35.762Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/547b4098cffecedc5e4dc5d8eb753d8ede90bed5b325e75476ae5edb7340/manifold3d-3.4.1-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:6cf5abadc56c094fbaf135e2474db79ade3dba6210bb9b7aff90d48361c328b1", size = 942685, upload-time = "2026-03-24T06:21:37.512Z" }, - { url = "https://files.pythonhosted.org/packages/4e/94/f8baf4269dcd34d4d1a4ef42c4724231f5fc57a81f0ad65fcbade2cdf00d/manifold3d-3.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:274d87ead558f0e29eec874e588789200f578b6132b3bd8e4ce4e1da21fe10a8", size = 828147, upload-time = "2026-03-24T06:21:39.172Z" }, - { url = "https://files.pythonhosted.org/packages/94/c9/8a634c6304c0a9f4d0ca1c59cf798f90a3ad174d32d9390a7b938b0f7bf5/manifold3d-3.4.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0002baedc42cb1562cf723d0362ef99a9675bf5455dabd1d9ba893868a132ad0", size = 1238708, upload-time = "2026-03-24T06:21:40.623Z" }, - { url = "https://files.pythonhosted.org/packages/a9/62/3df433dce9f87e7a4d76bbdbb6339a70a674fd8e350055bf88b4085c1e80/manifold3d-3.4.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:308e3dda79236b9258dd0d13cfc56f932a372ca5f56677bb06ed65585a447045", size = 1340136, upload-time = "2026-03-24T06:21:42.013Z" }, - { url = "https://files.pythonhosted.org/packages/cc/65/462e5422e39f7e57a28281bb5c5c55b7a1a50f71bcbbc34730ade5f3fb33/manifold3d-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ef8d5bb14f827738179d472f9deeb43323e82b92afe9ee359c175120ce5194d0", size = 970295, upload-time = "2026-03-24T06:21:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/af/14/42f1d0fbe0dbbaf7abd9ffb83596a84cea7b3d84c40f0c20a3474a23e397/manifold3d-3.4.1-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:5b4ba7b71ffd6bf16682acff89cb72ade3eba54ea05d58d956c3a95d982ed8e4", size = 1753383, upload-time = "2026-03-24T06:21:45.602Z" }, - { url = "https://files.pythonhosted.org/packages/35/47/3d5369c2485c48c2510eab4e803b29296edecbce78c9069995731bff9635/manifold3d-3.4.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:095b072e89485f00af8aeed0a88e72c19f9740467824d6653b329851c3928c14", size = 956296, upload-time = "2026-03-24T06:21:47.257Z" }, - { url = "https://files.pythonhosted.org/packages/75/d4/d01a5579b636e49a106071365f82d0c7a695226cbc6dc077304e51bfc86f/manifold3d-3.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:539c2467abc58754b74dc9bb3422ddd4e73a5b11b006f27c0ba6302bab6ecfe9", size = 841995, upload-time = "2026-03-24T06:21:48.529Z" }, - { url = "https://files.pythonhosted.org/packages/d8/63/32c64efc0a34f23776dc339f8b1cdbcc61b12a80ade261f2e7a358af52d9/manifold3d-3.4.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42c0c8ab2495acd514e8f46e5acd9465937c46c8c06e45e2f02da983b849bcea", size = 1252454, upload-time = "2026-03-24T06:21:50.317Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f2/6c7ec5986bc3cfc9251878ade6e65354538a8bc569c5e51c483aa4310063/manifold3d-3.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa75de877ccda5482493f65659de240d4bb7c96e3cc4ce96509ecd6ef9f91fbc", size = 1353505, upload-time = "2026-03-24T06:21:52.428Z" }, - { url = "https://files.pythonhosted.org/packages/94/61/9e8ab3a9ce6e7e6099794afd25abe1e4e7934f31529c55331bf8019bc736/manifold3d-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:2aff8707558e2fa5ff241185fe71da18ac8bd4156fb2811401df3366e461c562", size = 983641, upload-time = "2026-03-24T06:21:53.876Z" }, - { url = "https://files.pythonhosted.org/packages/d9/59/def4c589dd55aa32026a720f8a31d71aa2162fef8e3963b6241a7945ef4e/manifold3d-3.4.1-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:967c89daf24ec9ff863323d593cce98e4c130abbaaa9504df6789f9d8c780d0d", size = 1752517, upload-time = "2026-03-24T06:21:55.203Z" }, - { url = "https://files.pythonhosted.org/packages/b1/a9/377800999cc8421ce8bfa40787d09570bb635e0099f44959170fee751dd7/manifold3d-3.4.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:c29db9a1bda414ecaa56dd2cd274f06bbbe740e463133c5b69943d82c3dcfb96", size = 956343, upload-time = "2026-03-24T06:21:57.134Z" }, - { url = "https://files.pythonhosted.org/packages/6a/72/7f988a0deae9b3fbed3a6b2e9285e96fd9105e95f6755f5457e3a80e103e/manifold3d-3.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ae6855a6f652acd89e228f1981e5b710d4b10e06d7c06e5bada3b3fb31904a3", size = 840924, upload-time = "2026-03-24T06:21:58.462Z" }, - { url = "https://files.pythonhosted.org/packages/bf/46/787ad4b53a35ccf1d31fbd3d2ffe0653dab67057b1f561db51d2edc494ba/manifold3d-3.4.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a8068f85416034e290d23b424f2bea15d2f1da1c5ccb79b442bdb50ed4e1a4b6", size = 1253014, upload-time = "2026-03-24T06:21:59.734Z" }, - { url = "https://files.pythonhosted.org/packages/e2/45/29d2380ac477b11629a72483b21dd544861caccaedbc87043bf315a15a50/manifold3d-3.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe9ff5ce3d949c72b21120318121eb926ddba4299eab0e8bab2c6784a9843ffb", size = 1355512, upload-time = "2026-03-24T06:22:01.518Z" }, - { url = "https://files.pythonhosted.org/packages/7f/7f/310688a725a5a23d00e9f29e614a2b7906b399df27731b1aa6e153e4f465/manifold3d-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:1bd6fa1b20238603ec3df7f6060ddba1181cf9464104e82b746747b487d12092", size = 983293, upload-time = "2026-03-24T06:22:03.273Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d0/b066b476242dddfad98db51425a28ac41ab008a4e7697f6d6bca21a24881/manifold3d-3.4.1-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:210ec6918870611d9e3f888c00657aad842cfa89a7967e94546a568bf8dfc2f1", size = 1752343, upload-time = "2026-03-24T06:22:04.731Z" }, - { url = "https://files.pythonhosted.org/packages/11/2c/10b5cb142b00bb7b14bb5698c584ce7722c68c3ed58ae4173693a35d2108/manifold3d-3.4.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d4e1dd76a3c5fe935bc14eb62f98ce2361e0e505fcfca08abb2f9d8a1d01e0db", size = 956241, upload-time = "2026-03-24T06:22:06.404Z" }, - { url = "https://files.pythonhosted.org/packages/05/a7/af84e5f6e6af2d07d800355345ffb303c4e8de96dbf3194633322f3d8335/manifold3d-3.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7e1bf4857f64311fb5113ff286898c47efc0f199e4d860cfc663b5f69ce90ede", size = 840900, upload-time = "2026-03-24T06:22:07.974Z" }, - { url = "https://files.pythonhosted.org/packages/80/1c/f274d6e35652c3fb72b54c5fcafc5fc474e1a93d3fd17fb8df3c9c765873/manifold3d-3.4.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b23435456d5ed64e48a34a281869c3b626da8ccd8872e54637b77f420716f9a", size = 1252521, upload-time = "2026-03-24T06:22:09.259Z" }, - { url = "https://files.pythonhosted.org/packages/97/90/82081bcbffc68e36f9f34c36f041d6e0176cbb462e9041683d82ff17b626/manifold3d-3.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6871adaf9e5081303d2c9446de5a76a9af84bcf938949fa198cc5f0ae9cad19f", size = 1355366, upload-time = "2026-03-24T06:22:10.892Z" }, - { url = "https://files.pythonhosted.org/packages/0d/db/26df1d96a2c61a4d79aeb0ca2f8bfbfd4af94fdb944469dda38ced240f2f/manifold3d-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:0a93e8202cccea16c76a6c3a7d02300755cccea6536874ccfc160f8c4d8948c4", size = 983317, upload-time = "2026-03-24T06:22:12.257Z" }, - { url = "https://files.pythonhosted.org/packages/26/50/d62f9fed01bdeaea50d3dd821498573c0bf1c286e54e5a632f47cef8fffa/manifold3d-3.4.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:632525867f23a5d34ab4a7b9129dc440a6ed2b4a0444d9feddb6069361107555", size = 1752191, upload-time = "2026-03-24T06:22:13.521Z" }, - { url = "https://files.pythonhosted.org/packages/1c/2e/464f3898f8f1b727a248d4b2bedc310d60efed1a0f43f9977f95f122fcf4/manifold3d-3.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c4e755c0a1d808603185fb5a562c045c71918b4e64a498c489eba658df49692b", size = 956239, upload-time = "2026-03-24T06:22:14.864Z" }, - { url = "https://files.pythonhosted.org/packages/0e/ec/da4bdba9ae1fe9049be9e2f42336ebf700dfd839795b561fb0cee9cb03f6/manifold3d-3.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2c9c20b479e15ec5f0710951d3ae4f95635d0bbf6502f03e43ed87e310e69230", size = 840646, upload-time = "2026-03-24T06:22:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e0/c476d79d5940da31cf6d0aa9353e56d70fe709fefbadd3c99804594aeb4d/manifold3d-3.4.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:145b94b7bb73b425fa42bb795d5c8eaea5de5e45cba9731f2542c7311f3a73c3", size = 1253522, upload-time = "2026-03-24T06:22:17.734Z" }, - { url = "https://files.pythonhosted.org/packages/89/fa/2d5838248950b8cb41ba22496dfc7e95222582761ec83e473a210a0be059/manifold3d-3.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1093c002a5f5c012219208bfb0b49f4821974f887cbd357316b9dbab74b7fa5b", size = 1356044, upload-time = "2026-03-24T06:22:19.375Z" }, - { url = "https://files.pythonhosted.org/packages/95/a7/2b8e4b88a613b0057b871ca71342d7237289c5eccf2f75ce10afa04e080e/manifold3d-3.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:04c99b94fbb92572051288d3c6163baf9c81a647dab33d1fdce418457b0a1a44", size = 1014216, upload-time = "2026-03-24T06:22:20.977Z" }, - { url = "https://files.pythonhosted.org/packages/4f/06/c8c5e38b5f93ce2268aa0fc2e4e995a04c8722045868dc75021a7c2a5bc9/manifold3d-3.4.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:22bb8c202c88072fd5cd3fb24243715e7b200151a8aa3da78661b3611e07924f", size = 1760559, upload-time = "2026-03-24T06:22:22.701Z" }, - { url = "https://files.pythonhosted.org/packages/7e/93/5defaadef6a57bb864a51f68c824435929a7c7de1205f95e166325cba55e/manifold3d-3.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9ade2f14cf906271604ccfa5c49bb5704f0c14b08367e6d3aa0c4ed6ed56f919", size = 961249, upload-time = "2026-03-24T06:22:24.364Z" }, - { url = "https://files.pythonhosted.org/packages/fd/fa/5705986493097e268f26d87b3cfce8e966f6c62a1b8f38fa4086b704cf4d/manifold3d-3.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b4b24047cf423b024f477c09e2c44fed5991cbca4906abf34bf6ced62f37ef93", size = 846070, upload-time = "2026-03-24T06:22:25.946Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/925d96698fbd90e9990a7a4fdd9a7a8b038e166de696673cfd141bf54e85/manifold3d-3.4.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e02108ee43c6dcf1f40b208569ad49dcf1a6ceed21fd6d5fd6e8f09c02a8b60", size = 1260343, upload-time = "2026-03-24T06:22:27.266Z" }, - { url = "https://files.pythonhosted.org/packages/07/0a/f2d1c6390ebd565fa44357cddc9e6aa783fbccb0cc952996217bcbc69699/manifold3d-3.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b1ac2b29c6ca1412f63a1ac2c240ae067fe03f666bb12a21729012275fbbde85", size = 1361860, upload-time = "2026-03-24T06:22:28.945Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9b/5d0cf29530e31c2ef66cc9b2780031a82955002ad924b0eb23ac5e3dd90f/manifold3d-3.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4d3f795cfcaa857f4bd9bf62bd3f15061bae502fb4cea87e820f4bba67045ff8", size = 1022928, upload-time = "2026-03-24T06:22:30.285Z" }, -] - -[[package]] -name = "mapbox-earcut" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/7b/bbf6b00488662be5d2eb7a188222c264b6f713bac10dc4a77bf37a4cb4b6/mapbox_earcut-2.0.0.tar.gz", hash = "sha256:81eab6b86cf99551deb698b98e3f7502c57900e5c479df15e1bdaf1a57f0f9d6", size = 39934, upload-time = "2025-11-16T18:41:27.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/d3/5222339a8fad091bf64f2e3041e48606d69d69f0609a7632ca17a8a05d5a/mapbox_earcut-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:c9a1dab7529f8e54bdb377f908e56f1e2b9a7e27ed168c64d3c7c38ed04ac201", size = 55920, upload-time = "2025-11-16T18:40:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/19/e4/88d06e83ab75db2f4ae140a1e03ad8f84b02ac8af585dd61108aba73b8ed/mapbox_earcut-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e5953098ea198253c8a40e2f282ca5b04d50ec2b9661e20c4cd2b2be39f0bb0", size = 52557, upload-time = "2025-11-16T18:40:10.536Z" }, - { url = "https://files.pythonhosted.org/packages/22/88/abefd244ea049e42334c5f7a9e3b58f4ec3c84d063119ba3c8d27ff31932/mapbox_earcut-2.0.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efe5fd5de409e3b6d13907e73f295c8f1d63bdb6b8ca155dde4c93865796eafe", size = 56950, upload-time = "2025-11-16T18:40:11.905Z" }, - { url = "https://files.pythonhosted.org/packages/3c/e2/11122fddd086b930502eb4a954735da0f75e9d658fdab2d9e5914b9ebd2a/mapbox_earcut-2.0.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd04da6edbca1dd68ddbfac2398a95c763f35d7317fed227fde5b3aff1253b18", size = 59618, upload-time = "2025-11-16T18:40:13.017Z" }, - { url = "https://files.pythonhosted.org/packages/e8/fd/e62195729daa3111fe95404a99c7a6b3aa174800373d10111b7e7278a789/mapbox_earcut-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bdb8881e857d6d9277df696e9cfb8749c00d6162021d9359cba9da58dfdd4f5", size = 153021, upload-time = "2025-11-16T18:40:14.294Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6a/d39ebaaa9010ea6c9f4d468f8812b1a1b31a40fba4f02ff29bc1bf321c30/mapbox_earcut-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6e2d1bf5af90d5857955775b4d8ea15b02e172f2a8f194bba50ff95f8ff3e80e", size = 157736, upload-time = "2025-11-16T18:40:16.344Z" }, - { url = "https://files.pythonhosted.org/packages/20/00/6a59cdb8d8c1bf7e3cc92f0404f68fdb1a3cb0bbb0837af0dbb93d6290a6/mapbox_earcut-2.0.0-cp310-cp310-win32.whl", hash = "sha256:5b0aa63dd890d712343095b05eb7b60e071912ad3ced1fc4187d6a6a739677bc", size = 51564, upload-time = "2025-11-16T18:40:17.852Z" }, - { url = "https://files.pythonhosted.org/packages/bc/7b/af69669c959d8f7fd1bd49c15deace2360bf6a79dad7bf9f7a7f1c137da6/mapbox_earcut-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b1355f13af89ea815b32f59a5455db295c965d51ab501bde0459cddc010a7149", size = 56793, upload-time = "2025-11-16T18:40:18.953Z" }, - { url = "https://files.pythonhosted.org/packages/07/9f/fbd15d9e348e75e986d6912c4eab99888106b7e5fb0a01e765422f7cd464/mapbox_earcut-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:9b5040e79e3783295e99c90277f31c1cbaddd3335297275331995ba5680e3649", size = 55773, upload-time = "2025-11-16T18:40:20.045Z" }, - { url = "https://files.pythonhosted.org/packages/72/40/be761298704fbbaa81c5618bb306f1510fb068e482f6a1c8b3b6c1b31479/mapbox_earcut-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf43baafec3ef1e967319d9b5da96bc6ddf3dbb204b6f3535275eda4b519a72", size = 52444, upload-time = "2025-11-16T18:40:21.501Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0b/0c0c08db9663238ffb82c48259582dc0047a3255d98c0ac83c48026b7544/mapbox_earcut-2.0.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a283531847f603dd9d69afb75b21bd009d385ca9485fcd3e5a7fa5db1ccd913", size = 56803, upload-time = "2025-11-16T18:40:22.891Z" }, - { url = "https://files.pythonhosted.org/packages/f0/4a/86796859383d7d11fa5d4bcf1983f94c6cbb9eeb60fb3bab527fec4b32fa/mapbox_earcut-2.0.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab697676f4cec4572d4e941b7a3429a6687bf2ac6e8db3f3781024e3239ae3a0", size = 59403, upload-time = "2025-11-16T18:40:24.021Z" }, - { url = "https://files.pythonhosted.org/packages/6c/db/adaf981ab3bcfcf993ef317636b1f27210d6834bb1e8d63db6ad7c08214a/mapbox_earcut-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1bdac76e048f4299accf4eaf797079ddfc330442e7231c15535ed198100d6c5", size = 152876, upload-time = "2025-11-16T18:40:25.588Z" }, - { url = "https://files.pythonhosted.org/packages/d2/83/86417974039e7554c9e1e55c852a7e9c2a1390d64675eb85d70e5fa7eb37/mapbox_earcut-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a6945b23f859bef11ce3194303d17bd371c86b637e7029f81b1feaff3db3758", size = 157548, upload-time = "2025-11-16T18:40:27.202Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4c/c82a292bb21e5c651d81334123db2d654c5c9d19b2197080d3429dc1e49a/mapbox_earcut-2.0.0-cp311-cp311-win32.whl", hash = "sha256:8e119524c29406afb5eaa15e933f297d35679293a3ca62ced22f97a14c484cb5", size = 51424, upload-time = "2025-11-16T18:40:28.415Z" }, - { url = "https://files.pythonhosted.org/packages/30/57/6c39d7db81f72a3e4814ef152c8fb8dfe275dc4b03c9bfa073d251e3755f/mapbox_earcut-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:378bbbb3304e446023752db8f44ecd6e7ef965bcbda36541d2ae64442ba94254", size = 56662, upload-time = "2025-11-16T18:40:29.863Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d6/a1ef6e196b3d6968bf6546d4f7e54c559f9cff8991fdb880df0ba1618f52/mapbox_earcut-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:6d249a431abd6bbff36f1fd0493247a86de962244cc4081b4d5050b02ed48fb1", size = 50505, upload-time = "2025-11-16T18:40:30.992Z" }, - { url = "https://files.pythonhosted.org/packages/8d/93/846804029d955c3c841d8efff77c2b0e8d9aab057d3a077dc8e3f88b5ea4/mapbox_earcut-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db55ce18e698bc9d90914ee7d4f8c3e4d23827456ece7c5d7a1ec91e90c7122b", size = 55623, upload-time = "2025-11-16T18:40:32.113Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f6/cc9ece104bc3876b350dba6fef7f34fb7b20ecc028d2cdbdbecb436b1ed1/mapbox_earcut-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01dd6099d16123baf582a11b2bd1d59ce848498cf0cdca3812fd1f8b20ff33b7", size = 52028, upload-time = "2025-11-16T18:40:33.516Z" }, - { url = "https://files.pythonhosted.org/packages/88/6e/230da4aabcc56c99e9bddb4c43ce7d4ba3609c0caf2d316fb26535d7c60c/mapbox_earcut-2.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d5a098aae26a52282bc981a38e7bf6b889d2ea7442f2cd1903d2ba842f4ff07", size = 56351, upload-time = "2025-11-16T18:40:35.217Z" }, - { url = "https://files.pythonhosted.org/packages/1a/f7/5cdd3752526e91d91336c7263af7767b291d21e63c89d7190a60051f0f87/mapbox_earcut-2.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de35f241d0b9110ad9260f295acedd9d7cc0d7acfe30d36b1b3ee8419c2caba1", size = 59209, upload-time = "2025-11-16T18:40:36.634Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a2/b7781416cb93b37b95d0444e03f87184de8815e57ff202ce4105fa921325/mapbox_earcut-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cb63ab85e2e430c350f93e75c13f8b91cb8c8a045f3cd714c390b69a720368a", size = 152316, upload-time = "2025-11-16T18:40:38.147Z" }, - { url = "https://files.pythonhosted.org/packages/c1/74/396338e3d345e4e36fb23a0380921098b6a95ce7fb19c4777f4185a5974e/mapbox_earcut-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fb3c9f069fc3795306db87f8139f70c4f047532f897a3de05f54dc1faebc97f6", size = 157268, upload-time = "2025-11-16T18:40:39.753Z" }, - { url = "https://files.pythonhosted.org/packages/56/2c/66fd137ea86c508f6cd7247f7f6e2d1dabffc9f0e9ccf14c71406b197af1/mapbox_earcut-2.0.0-cp312-cp312-win32.whl", hash = "sha256:eb290e6676217707ed238dd55e07b0a8ca3ab928f6a27c4afefb2ff3af08d7cb", size = 51226, upload-time = "2025-11-16T18:40:41.018Z" }, - { url = "https://files.pythonhosted.org/packages/b8/84/7b78e37b0c2109243c0dad7d9ba9774b02fcee228bf61cf727a5aa1702e2/mapbox_earcut-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:5ef5b3319a43375272ad2cad9333ed16e569b5102e32a4241451358897e6f6ee", size = 56417, upload-time = "2025-11-16T18:40:42.173Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/cd7195aa27c1c8f2b9d38025a5a8663f32cd01c07b648a54b1308ab26c15/mapbox_earcut-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:a4a3706feb5cc8c782d8f68bb0110c8d551304043f680a87a54b0651a2c208c3", size = 50111, upload-time = "2025-11-16T18:40:43.334Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7c/c5dd5b255b9828ba5df729e62fdd470a322c938f07ef392ca03c0592bb3a/mapbox_earcut-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:582329a81bd36cf0f82e443c395bcb8cfdb10caddafec76acaebac7c20bf1c31", size = 55619, upload-time = "2025-11-16T18:40:44.44Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3f/03f23eac9831e7d0d8da3d6993695a9a3724659c94e9997f6b7aaccc199d/mapbox_earcut-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d2ac5f610b3e44a3a0c4df06b5552d503b4f1c2c409eeca20dbe05112bd60955", size = 52023, upload-time = "2025-11-16T18:40:45.857Z" }, - { url = "https://files.pythonhosted.org/packages/39/f3/a92ccee494b3e437e4bd81ecd358e39d231dc90af010d6c43930506c10ad/mapbox_earcut-2.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58cc88513b87734b243d86f0d3fb87e96e0a78d9abd8fd615c55f766dd63f949", size = 56357, upload-time = "2025-11-16T18:40:47.27Z" }, - { url = "https://files.pythonhosted.org/packages/03/30/e54ececd0403a5495c340b693075abec92a6d17dc44283b6cb059534f7ed/mapbox_earcut-2.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40218d887798451932f3c335992834aa807c35cd497c6e0733470fdbd77f9521", size = 59215, upload-time = "2025-11-16T18:40:48.682Z" }, - { url = "https://files.pythonhosted.org/packages/6e/e1/8fbff13a074c1fbf702b30ce7ec4d878bc664d659c1c2b1697831f4ea3a8/mapbox_earcut-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:39fa5cfa0e855b028ec9b0200c88ebfa252448f343ce2f67b6fc07fe1f22a3ae", size = 152304, upload-time = "2025-11-16T18:40:49.85Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/c757030b3cb3a9f2278ded6f7312d2b9d3761db6f3da8d395f7f7303dd66/mapbox_earcut-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:476b558473b8a43f238d46e819bc0f830c427842ec5feb19e23b4dcac8ad2455", size = 157270, upload-time = "2025-11-16T18:40:51.093Z" }, - { url = "https://files.pythonhosted.org/packages/96/63/589c6decb1f032d8811f1066da552f0a718830f592e6d6539fa4c3c766b8/mapbox_earcut-2.0.0-cp313-cp313-win32.whl", hash = "sha256:8c2d125c182acbc490b39503c0dec4f937bae180d0849a26bcea0ee4a76024bd", size = 51207, upload-time = "2025-11-16T18:40:52.285Z" }, - { url = "https://files.pythonhosted.org/packages/76/75/a79a6020c46d4f07731e88ec5cc9324f6b43343aba835def1dc0bf59fecf/mapbox_earcut-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e049e6a37c228d7a9cb2f54ae405aa21d35c5175d849530fb32064ddb38ad5ab", size = 56416, upload-time = "2025-11-16T18:40:53.474Z" }, - { url = "https://files.pythonhosted.org/packages/ce/5f/83e878c2b3e9e6db1f60b598a2cc5ed4c2b5bc8d281575c964869414a159/mapbox_earcut-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:8a833d73d63d4b6291bbd8b4d2f551e87f663282cdc547ecbbd9b423849ee996", size = 50103, upload-time = "2025-11-16T18:40:54.954Z" }, - { url = "https://files.pythonhosted.org/packages/96/fc/f1b74324c83f510213ff91eb8b1d2697ad5a12418c5fba966e80f1104a5f/mapbox_earcut-2.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad1dc141797037b7d4c9d8d2e52b9665b36294913a8ec31008b282d1a95b9bdc", size = 55728, upload-time = "2025-11-16T18:40:56.098Z" }, - { url = "https://files.pythonhosted.org/packages/7b/59/053c04e29c4bd22157d3b6255f1e5c19c46cb7a594c4314298bdcbca723f/mapbox_earcut-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0f0f5c6f5ed8ffdce8efe6a003ba598089d0ee07eabd41868db183be50484f9f", size = 52063, upload-time = "2025-11-16T18:40:57.227Z" }, - { url = "https://files.pythonhosted.org/packages/a6/77/acc2d553c3bb8c769535a280545bb7d9608141e90511a2e6215a54611776/mapbox_earcut-2.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82cd92775f37fd1e4b8464c5e74a00e87130eecc55ee3df2492b8ca2bdf6ef3e", size = 56522, upload-time = "2025-11-16T18:40:58.349Z" }, - { url = "https://files.pythonhosted.org/packages/1a/f5/627dd6defd3c1a2b3069e9e27482aa04d268c841735e576c1e22848a34f6/mapbox_earcut-2.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:626ffc1310e0cc8910283e4ac3139e5fb0458f18f2c4874162f66159951933ff", size = 59204, upload-time = "2025-11-16T18:41:00.095Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3e/819185542ab095ba1244ad65ececb3edcde6fd0111248a0f9318d695bfcf/mapbox_earcut-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ea951d764a356cad95b23fef950d8aa3b44b933795ad09d977fea7d4dbe377c3", size = 152550, upload-time = "2025-11-16T18:41:01.233Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ad/85e0f815e4774b90ad6761bce55c80d13ee21b2a24014b0be0d5010b0049/mapbox_earcut-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:df1f217624abb5e02ecabcbd84369de970b8d8bc1e4e7c164c1cfcaddad76ca3", size = 157322, upload-time = "2025-11-16T18:41:02.866Z" }, - { url = "https://files.pythonhosted.org/packages/27/4c/0f56369e7a000d2f3177d17baf34263559b206ae524fcd0c4c5d1d960dab/mapbox_earcut-2.0.0-cp314-cp314-win32.whl", hash = "sha256:6fa61307d38b50fc9bd5449c00dbae46d270a32b372c6fc3b8af4b85c85746e4", size = 52916, upload-time = "2025-11-16T18:41:04.122Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9d/8c557dd9b3d9fe2344f5bd5ff3bb0b2a42ed6addb7e43ca4358051743b04/mapbox_earcut-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:0da20ed3c81b240450118773bcedfac34e70a56998f66147222c46f4356fff67", size = 57713, upload-time = "2025-11-16T18:41:05.204Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ec/678c5553938d3a29d02dd41dd898672267f054afc4e2821958dee6ec86ce/mapbox_earcut-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:847e74bd5878e4c64793dc100f9288f5443f87c55c3fe391fd90509029136ff6", size = 51872, upload-time = "2025-11-16T18:41:06.323Z" }, - { url = "https://files.pythonhosted.org/packages/18/37/94f2d973669cbfef811e536713fe56ec012ba74e5f8795a832337b1866a3/mapbox_earcut-2.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ddc9e7175fc903185c64afbbf91febee56b50787dd0962fce2bfb4f20cf80d27", size = 56447, upload-time = "2025-11-16T18:41:07.443Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1c/e0afcc82659cc1727a7e59c4f9e9880bbc3f048a4a5325772b44d4a91dfd/mapbox_earcut-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6dc8a7568066af9a858018d6d92b7e77e164578f9fcd79093f1cbe4ec203461b", size = 53154, upload-time = "2025-11-16T18:41:08.618Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2d/9845281c8c35da2bea733b8c2df5b9fe694e73e7b05fe8a1d4c3c439a1bc/mapbox_earcut-2.0.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6abc5340edd9b433ab2dab2ee033082a199d5c51cce445124626c0040ec0d81b", size = 56285, upload-time = "2025-11-16T18:41:09.728Z" }, - { url = "https://files.pythonhosted.org/packages/97/8e/eeea762a519490662b8f480e2b35bf03701b0bcc5a446b62a4c5a1500b06/mapbox_earcut-2.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df7afdd8078a9aa28f469d9242531d304e09a4b14e514f048e021a949f3777b4", size = 58601, upload-time = "2025-11-16T18:41:10.872Z" }, - { url = "https://files.pythonhosted.org/packages/b9/67/932f80aa6af9bc1a317b6119052c74f327d81e00b457003a049e324b810c/mapbox_earcut-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a286f73e612a46cafd6d6c843365265090517af16823e2f37277c13cd8b6f09", size = 154924, upload-time = "2025-11-16T18:41:12.104Z" }, - { url = "https://files.pythonhosted.org/packages/87/38/5db4a91f9f90cbb447be61da5468a2955fad3a840ae4c7dbde789b09d45a/mapbox_earcut-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8d081fe1d00dc553e3e68c02fc395324aad0d8ed955f3ff59289264c9b21ace4", size = 159194, upload-time = "2025-11-16T18:41:13.364Z" }, - { url = "https://files.pythonhosted.org/packages/6b/03/de3843b13fe854a010fb2f8b25551d4d5fe1c879ff2e7c8d7d8d7d735a8e/mapbox_earcut-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:13049ca96431bbc7ef7fd7780dd1872209ca11a5c1977f7aa91a1b574a8af863", size = 54143, upload-time = "2025-11-16T18:41:14.564Z" }, - { url = "https://files.pythonhosted.org/packages/9a/89/fbdee5a56ba51df9be6098b5428636ad75aa994e98d8bec6113d5cba401e/mapbox_earcut-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ace78e4fdba3b8cbb7768d44d77a981698305862a07f94bbb6f5cc16659adb4", size = 60833, upload-time = "2025-11-16T18:41:15.694Z" }, -] - [[package]] name = "markdown" version = "3.10.2" @@ -1853,7 +1489,7 @@ wheels = [ [[package]] name = "mjlab" -version = "1.3.0" +version = "1.4.0" source = { editable = "mjlab" } dependencies = [ { name = "imageio-ffmpeg" }, @@ -1864,6 +1500,8 @@ dependencies = [ { name = "onnxscript" }, { name = "prettytable" }, { name = "rsl-rl-lib" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "tensorboard" }, { name = "tensordict" }, { name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, @@ -1887,12 +1525,13 @@ cu128 = [ requires-dist = [ { name = "imageio-ffmpeg" }, { name = "mediapy", specifier = ">=1.2.6" }, - { name = "mjviser", git = "https://github.com/mujocolab/mjviser?rev=1bdfd6fe79066b847a5f430000fcfbb53ec31a6f" }, - { name = "mujoco", specifier = ">=3.8.0", index = "https://py.mujoco.org/" }, - { name = "mujoco-warp", git = "https://github.com/google-deepmind/mujoco_warp?rev=6f235d4" }, + { name = "mjviser", specifier = ">=0.0.14" }, + { name = "mujoco", specifier = "~=3.8.0", index = "https://py.mujoco.org/" }, + { name = "mujoco-warp", git = "https://github.com/google-deepmind/mujoco_warp?rev=88b55fc2696960b927bc12584994bb8412b36558" }, { name = "onnxscript", specifier = ">=0.5.4" }, { name = "prettytable" }, - { name = "rsl-rl-lib", specifier = "==5.2.0" }, + { name = "rsl-rl-lib", specifier = "==5.4.0" }, + { name = "scipy", specifier = ">=1.15" }, { name = "tensorboard", specifier = ">=2.20.0" }, { name = "tensordict" }, { name = "torch", specifier = ">=2.7.0" }, @@ -1904,7 +1543,7 @@ requires-dist = [ { name = "tqdm" }, { name = "trimesh", specifier = ">=4.8.3" }, { name = "tyro", specifier = ">=1.0.1" }, - { name = "viser", specifier = ">=1.0.26" }, + { name = "viser", specifier = ">=1.0.27" }, { name = "wandb", specifier = ">=0.22.3" }, { name = "warp-lang", marker = "sys_platform != 'darwin'", specifier = ">=1.12.0", index = "https://pypi.nvidia.com/" }, { name = "warp-lang", marker = "sys_platform == 'darwin'", specifier = ">=1.12.0" }, @@ -1939,8 +1578,8 @@ docs = [ [[package]] name = "mjviser" -version = "0.0.13" -source = { git = "https://github.com/mujocolab/mjviser?rev=1bdfd6fe79066b847a5f430000fcfbb53ec31a6f#1bdfd6fe79066b847a5f430000fcfbb53ec31a6f" } +version = "0.0.14" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mujoco" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1949,6 +1588,10 @@ dependencies = [ { name = "trimesh" }, { name = "viser" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/1d/e4/eef89b279fb1811b5f120a99ac9284c32ff2ca4fad5e6f5c93035f72ba9a/mjviser-0.0.14.tar.gz", hash = "sha256:ebde2203dab89959a13ae549b4d3e5e5cf9eb69de11a1a2fd759cbe8f8c641f3", size = 29576, upload-time = "2026-05-07T03:35:13.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/c2/4534d678ad1b3f7dee6fd83110112800287be21ba007d988abb9ddc8e0ac/mjviser-0.0.14-py3-none-any.whl", hash = "sha256:4b09f8e90506fc4a71d76fc628872147157947e9292428213ab78cfe137c9a26", size = 32296, upload-time = "2026-05-07T03:35:14.252Z" }, +] [[package]] name = "ml-dtypes" @@ -2099,8 +1742,8 @@ wheels = [ [[package]] name = "mujoco-warp" -version = "3.8.0" -source = { git = "https://github.com/google-deepmind/mujoco_warp?rev=6f235d4#6f235d46cb2ecf8f37c8f967f8dd9c87d0ca5807" } +version = "3.8.0.2" +source = { git = "https://github.com/google-deepmind/mujoco_warp?rev=88b55fc2696960b927bc12584994bb8412b36558#88b55fc2696960b927bc12584994bb8412b36558" } dependencies = [ { name = "absl-py" }, { name = "etils", version = "1.13.0", source = { registry = "https://pypi.org/simple" }, extra = ["epath"], marker = "python_full_version < '3.11'" }, @@ -2854,20 +2497,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] -[[package]] -name = "pycollada" -version = "0.9.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/8d/52a5364a17eb96129962cae8d3ee7658775e085ad0ba38388684ad5944e9/pycollada-0.9.3.tar.gz", hash = "sha256:c34d6dcf0fe2eba5896f71c96d37a1c0fe1a61f08440fa0cfcec3dc2895d3302", size = 110826, upload-time = "2026-01-24T15:45:23.625Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/86/f1f61b7a0701f9d1299e5293d083318019f91021a4d449f94d59dbe024e4/pycollada-0.9.3-py3-none-any.whl", hash = "sha256:636e6496f60987586db82455ea7bbd9ade775e8181c6590c83b698b6cd53a9f5", size = 129206, upload-time = "2026-01-24T15:45:22.182Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -3276,20 +2905,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - [[package]] name = "requests" version = "2.33.1" @@ -3342,131 +2957,9 @@ requires-dist = [ ] provides-extras = ["cu128"] -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, - { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, - { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, - { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, - { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, - { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, - { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, - { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, - { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, - { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, - { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, - { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, - { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, -] - [[package]] name = "rsl-rl-lib" -version = "5.2.0" +version = "5.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitpython" }, @@ -3474,30 +2967,15 @@ dependencies = [ { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "onnx" }, { name = "onnxscript" }, + { name = "tensorboard" }, { name = "tensordict" }, { name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform != 'darwin'" }, { name = "torchvision" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/a8/fb9aae0573a83dd510e228085f5e6ff9076a91f2bff6699270f07d31854c/rsl_rl_lib-5.2.0.tar.gz", hash = "sha256:cbb4eee96af9574495208381115d45d68ad1c0403710a2bc6512a2ee5bf57124", size = 60558, upload-time = "2026-04-23T12:40:54.259Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/51/2d4c95b3642c0f659fbcddcd17fa0401903733250fa382f51f9b913bb85f/rsl_rl_lib-5.4.0.tar.gz", hash = "sha256:e1aa5cd5771f2d9a9e7a7ba5456b942ab588410cfd397b3c63e32d00a0744f0d", size = 65902, upload-time = "2026-05-27T10:42:39.656Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/3a/3e8f39049cc5a8994bfdb2dd09d727f90a2781b9755adf59e49fa509500e/rsl_rl_lib-5.2.0-py3-none-any.whl", hash = "sha256:fc767059f329a184527dd10766c3382354f6deca4b049f23febc8140c3dc6029", size = 86451, upload-time = "2026-04-23T12:40:52.762Z" }, -] - -[[package]] -name = "rtree" -version = "1.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/09/7302695875a019514de9a5dd17b8320e7a19d6e7bc8f85dcfb79a4ce2da3/rtree-1.4.1.tar.gz", hash = "sha256:c6b1b3550881e57ebe530cc6cffefc87cd9bf49c30b37b894065a9f810875e46", size = 52425, upload-time = "2025-08-13T19:32:01.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/d9/108cd989a4c0954e60b3cdc86fd2826407702b5375f6dfdab2802e5fed98/rtree-1.4.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d672184298527522d4914d8ae53bf76982b86ca420b0acde9298a7a87d81d4a4", size = 468484, upload-time = "2025-08-13T19:31:50.593Z" }, - { url = "https://files.pythonhosted.org/packages/f3/cf/2710b6fd6b07ea0aef317b29f335790ba6adf06a28ac236078ed9bd8a91d/rtree-1.4.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7e48d805e12011c2cf739a29d6a60ae852fb1de9fc84220bbcef67e6e595d7d", size = 436325, upload-time = "2025-08-13T19:31:52.367Z" }, - { url = "https://files.pythonhosted.org/packages/55/e1/4d075268a46e68db3cac51846eb6a3ab96ed481c585c5a1ad411b3c23aad/rtree-1.4.1-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa8c4496e31e9ad58ff6c7df89abceac7022d906cb64a3e18e4fceae6b77f65", size = 459789, upload-time = "2025-08-13T19:31:53.926Z" }, - { url = "https://files.pythonhosted.org/packages/d1/75/e5d44be90525cd28503e7f836d077ae6663ec0687a13ba7810b4114b3668/rtree-1.4.1-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12de4578f1b3381a93a655846900be4e3d5f4cd5e306b8b00aa77c1121dc7e8c", size = 507644, upload-time = "2025-08-13T19:31:55.164Z" }, - { url = "https://files.pythonhosted.org/packages/fd/85/b8684f769a142163b52859a38a486493b05bafb4f2fb71d4f945de28ebf9/rtree-1.4.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b558edda52eca3e6d1ee629042192c65e6b7f2c150d6d6cd207ce82f85be3967", size = 1454478, upload-time = "2025-08-13T19:31:56.808Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a4/c2292b95246b9165cc43a0c3757e80995d58bc9b43da5cb47ad6e3535213/rtree-1.4.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f155bc8d6bac9dcd383481dee8c130947a4866db1d16cb6dff442329a038a0dc", size = 1555140, upload-time = "2025-08-13T19:31:58.031Z" }, - { url = "https://files.pythonhosted.org/packages/74/25/5282c8270bfcd620d3e73beb35b40ac4ab00f0a898d98ebeb41ef0989ec8/rtree-1.4.1-py3-none-win_amd64.whl", hash = "sha256:efe125f416fd27150197ab8521158662943a40f87acab8028a1aac4ad667a489", size = 389358, upload-time = "2025-08-13T19:31:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/3f/50/0a9e7e7afe7339bd5e36911f0ceb15fed51945836ed803ae5afd661057fd/rtree-1.4.1-py3-none-win_arm64.whl", hash = "sha256:3d46f55729b28138e897ffef32f7ce93ac335cb67f9120125ad3742a220800f0", size = 355253, upload-time = "2025-08-13T19:32:00.296Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dd/6797be77ce0cc12881f29cd9363b791f15276fde6a136a73443b6b6c2ce3/rsl_rl_lib-5.4.0-py3-none-any.whl", hash = "sha256:b30a0e59dac0ef7236f8f793c9bedfa8f2b8f8d29c2965140feeb1439153ebab", size = 92871, upload-time = "2026-05-27T10:42:38.415Z" }, ] [[package]] @@ -3669,74 +3147,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, ] -[[package]] -name = "shapely" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/89/c3548aa9b9812a5d143986764dededfa48d817714e947398bdda87c77a72/shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f", size = 1825959, upload-time = "2025-09-24T13:50:00.682Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8a/7ebc947080442edd614ceebe0ce2cdbd00c25e832c240e1d1de61d0e6b38/shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea", size = 1629196, upload-time = "2025-09-24T13:50:03.447Z" }, - { url = "https://files.pythonhosted.org/packages/c8/86/c9c27881c20d00fc409e7e059de569d5ed0abfcec9c49548b124ebddea51/shapely-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f", size = 2951065, upload-time = "2025-09-24T13:50:05.266Z" }, - { url = "https://files.pythonhosted.org/packages/50/8a/0ab1f7433a2a85d9e9aea5b1fbb333f3b09b309e7817309250b4b7b2cc7a/shapely-2.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142", size = 3058666, upload-time = "2025-09-24T13:50:06.872Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c6/5a30ffac9c4f3ffd5b7113a7f5299ccec4713acd5ee44039778a7698224e/shapely-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4", size = 3966905, upload-time = "2025-09-24T13:50:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/e92f3035ba43e53959007f928315a68fbcf2eeb4e5ededb6f0dc7ff1ecc3/shapely-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0", size = 4129260, upload-time = "2025-09-24T13:50:11.183Z" }, - { url = "https://files.pythonhosted.org/packages/42/24/605901b73a3d9f65fa958e63c9211f4be23d584da8a1a7487382fac7fdc5/shapely-2.1.2-cp310-cp310-win32.whl", hash = "sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e", size = 1544301, upload-time = "2025-09-24T13:50:12.521Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/6db795b8dd3919851856bd2ddd13ce434a748072f6fdee42ff30cbd3afa3/shapely-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f", size = 1722074, upload-time = "2025-09-24T13:50:13.909Z" }, - { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" }, - { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" }, - { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" }, - { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" }, - { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" }, - { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" }, - { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" }, - { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, - { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, - { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, - { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, - { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, - { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, - { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, - { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, - { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, - { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, - { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, - { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, - { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, - { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, - { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, - { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, - { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, - { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, - { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, - { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, - { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, - { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, - { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -3769,15 +3179,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] -[[package]] -name = "svg-path" -version = "7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/b9/649abbe870842c185b12920e937e9b95d4c2b18de50af98d2c140df3e179/svg_path-7.0.tar.gz", hash = "sha256:9037486957cb1dcf4375ef42206499a47c111b8ffcbac6e3e55f9d079d875bb0", size = 23552, upload-time = "2025-07-06T15:20:40.823Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/83/4f5b250220e1a5acd31345a5ec1c95a7769725d0d8135276f399f44062f8/svg_path-7.0-py2.py3-none-any.whl", hash = "sha256:447cb1e16a95acea2dd867fe737fa99cb75d587b4fc64dbee709a8dd6891ad9c", size = 18208, upload-time = "2025-07-06T15:20:39.59Z" }, -] - [[package]] name = "sympy" version = "1.14.0" @@ -4050,29 +3451,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/05/8d9dd4a8cbaf4ff5af7a2161c26c29cc5d816fa50a5a6a3476b67587eea1/trimesh-4.12.1-py3-none-any.whl", hash = "sha256:2efa261d93edad91c82ecebd9bfb709e211ad99589b29b8f14868e6009386c08", size = 741040, upload-time = "2026-04-24T20:59:15.308Z" }, ] -[package.optional-dependencies] -easy = [ - { name = "charset-normalizer" }, - { name = "colorlog" }, - { name = "embreex" }, - { name = "httpx" }, - { name = "jsonschema" }, - { name = "lxml" }, - { name = "manifold3d", marker = "python_full_version < '3.14'" }, - { name = "mapbox-earcut" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pillow" }, - { name = "pycollada" }, - { name = "rtree" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "shapely" }, - { name = "svg-path" }, - { name = "vhacdx" }, - { name = "xxhash" }, -] - [[package]] name = "triton" version = "3.6.0" @@ -4150,63 +3528,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] -[[package]] -name = "vhacdx" -version = "0.0.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/15/e3/d2abc3dc4c1cb216c2efdc70b36f80efeb1bdbd7d420a676ddc9d9d980e1/vhacdx-0.0.10.tar.gz", hash = "sha256:fcc23201e319d79fe25e064847efc254bd39ac30af28cc761409e1f9142dd033", size = 58125, upload-time = "2025-12-02T20:58:45.358Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/f4/da308d86daaa9c636851357cbd928715d47963beecd525b3749d2d5c9537/vhacdx-0.0.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc7be82fab608cb7231e95a0a10700be1e9422a36b21e7d49c782a598c8d37c", size = 222760, upload-time = "2025-12-02T20:57:30.778Z" }, - { url = "https://files.pythonhosted.org/packages/e0/8a/e3462a43ec6712b74d921e4af9d5a2998752378c5554bde9a594dbb0cf0c/vhacdx-0.0.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4b63d1c5ad0e64c300a3a9d9404f4778df367b8c545639dfb932db4b76704ff3", size = 208812, upload-time = "2025-12-02T20:57:33.486Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d1/b717275adb108431f1404193542fab7ecf4c5bae221f1552bbd570fe0e5d/vhacdx-0.0.10-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9bcf3fe1c555598e41348108b55a0fc67534e7fef2367452c301014518c1476", size = 236999, upload-time = "2025-12-02T20:57:34.971Z" }, - { url = "https://files.pythonhosted.org/packages/bf/84/97e2305f6bd4a4de3d40bb234c38282cbcf2fa30653ff5ae4f7df9d8f3ec/vhacdx-0.0.10-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9506ca89289da63e5a3d1ac97aa7413aece47d65cbaa4b0c409469555add0e06", size = 250035, upload-time = "2025-12-02T20:57:36.037Z" }, - { url = "https://files.pythonhosted.org/packages/9d/66/eb1d8d64742b9e73557e075cea6ee7e4976dd89b84c7d3197ca3621d5a85/vhacdx-0.0.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06faf9caa0abceddd5fa505e4299e2ebf14bc26c58a1e521013717cbf37bea61", size = 1224134, upload-time = "2025-12-02T20:57:37.217Z" }, - { url = "https://files.pythonhosted.org/packages/47/db/e829b21b071db94f45079c4ace2f967c684f08b10ea285919a95e9d5fe21/vhacdx-0.0.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3a6b43b42290697e2bd04087977d1e3841d287c528e414c765581ecec62e66f6", size = 1284300, upload-time = "2025-12-02T20:57:38.78Z" }, - { url = "https://files.pythonhosted.org/packages/ff/aa/b401565542b927ce3e0a6d5e72acef79343a449ee1a7ad94a5c7266bab26/vhacdx-0.0.10-cp310-cp310-win_amd64.whl", hash = "sha256:27eb3b293ccef1332d477346d564bb4c474bb451e9b753e3ce9cac01cbb90a0c", size = 193069, upload-time = "2025-12-02T20:57:40.318Z" }, - { url = "https://files.pythonhosted.org/packages/b7/2c/d49df6fec3294cef3c8c88c54784162bd8350c427fecd9b16335772b760f/vhacdx-0.0.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8584f33ed020b6cce678b8febcf84af22bced617ef31c85bf31fd7e2b4bba9fe", size = 224113, upload-time = "2025-12-02T20:57:41.59Z" }, - { url = "https://files.pythonhosted.org/packages/68/1d/bd2456baa6b16977c106adc2386b6e7a34c3e57ade6aeeab68bb61ceb16f/vhacdx-0.0.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a9b63cdb5f34dfee386b64a01f7e1571ef0c2555244ea3d83a09d78273123bce", size = 210118, upload-time = "2025-12-02T20:57:42.749Z" }, - { url = "https://files.pythonhosted.org/packages/49/ab/15adb78489b51c2a898642755be727ecd7c3de37cac6e434ce420b8ce27c/vhacdx-0.0.10-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915eab6c19fdf63ab256855331db546575284786a480aa2d67437db0e86b0d17", size = 238276, upload-time = "2025-12-02T20:57:43.95Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f1/464c761dbe24f58d6fc354bf51729342981fb7a621e170e0d3512fadbec8/vhacdx-0.0.10-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e335bb9af540e6867ff051166a399075823fdd8fc1fc27e9530995cc1bda1eb", size = 251383, upload-time = "2025-12-02T20:57:45.246Z" }, - { url = "https://files.pythonhosted.org/packages/b2/22/c7b4117c5431189a6a019e8fc2cf590df3ab196c38b4b7c3622292205d9b/vhacdx-0.0.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c3ddbaa38eb65c3aec9b0e39a822223474c931c0e18d3e93a3a499870ffa45ad", size = 1225200, upload-time = "2025-12-02T20:57:46.639Z" }, - { url = "https://files.pythonhosted.org/packages/6c/62/c679ad28ce7854771913255e1abc588b3643c2147fb5c51a8553224aa1dd/vhacdx-0.0.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d398fcc13e330ed1fd2540a7d572aeca0be9621411def78e10c7ea4132f959ee", size = 1285935, upload-time = "2025-12-02T20:57:48.51Z" }, - { url = "https://files.pythonhosted.org/packages/de/c8/a8260b780e4578d7ef19b70343f9717f74ff48f9950138c96c78f209ec01/vhacdx-0.0.10-cp311-cp311-win_amd64.whl", hash = "sha256:c9665a3ef887babcac8b5822f01288e8f06b4a949fadbbe1861670b358f111ee", size = 194137, upload-time = "2025-12-02T20:57:50.207Z" }, - { url = "https://files.pythonhosted.org/packages/cf/9c/66375e65634c80f6efb46e81915126bf3e55dc9d6615217590cbc8316d2e/vhacdx-0.0.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dd17d697d6d4d7cf66f1e947e0530041913981e05f7025236bec28a350b1a33", size = 224998, upload-time = "2025-12-02T20:57:51.639Z" }, - { url = "https://files.pythonhosted.org/packages/4e/e3/fc2644d3e7d0b2b52e2f681eb2878c0e1b9cafc53946f66736d0f01e237c/vhacdx-0.0.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:189ded39b709436cb732cdf694d4cf22e877aefb97e2ab2b55bf7ada9c030f93", size = 211130, upload-time = "2025-12-02T20:57:53.018Z" }, - { url = "https://files.pythonhosted.org/packages/e3/93/0b0f1977f5b3c2e1bbea5ef85e37a808ff73f1b7daf42950c57090e90dc7/vhacdx-0.0.10-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3b03d35ab56a93beee338175dbe0b87552353e5dfb3ff37467e88f56cedf7cc", size = 239661, upload-time = "2025-12-02T20:57:54.144Z" }, - { url = "https://files.pythonhosted.org/packages/94/98/d2a6aeb1c6570a1fc1be29ee03db795f643ab03c6df7635522f23796b39d/vhacdx-0.0.10-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8c54ed193fa0db0248928fbf5d438b3872d615a506889d5b89fc6467d6411a", size = 252938, upload-time = "2025-12-02T20:57:55.275Z" }, - { url = "https://files.pythonhosted.org/packages/94/2e/1e678efc161a0d7fe1806f5e037ce11cc5964db7e08ccfc220ef63951863/vhacdx-0.0.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5c898104140c72e4dc789e6125812671eee5e412916e83eff24a6148248ff5e", size = 1226696, upload-time = "2025-12-02T20:57:56.438Z" }, - { url = "https://files.pythonhosted.org/packages/90/5b/b302a0420a241c4910f4870eb9f39e6ada59858db441cc35bda511c17982/vhacdx-0.0.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abdd0ba17786e578206594731df15c90e2751b6884220c8673124f47fd7ac620", size = 1287794, upload-time = "2025-12-02T20:57:57.694Z" }, - { url = "https://files.pythonhosted.org/packages/73/e9/f9729603ac75047a257f1b4ddac60cbde72b0abfd49ffed305751ba630a2/vhacdx-0.0.10-cp312-cp312-win_amd64.whl", hash = "sha256:79e7db59b4042295b21b79d55ba486a9a480550f696d466f158a30ed920dd0ec", size = 195033, upload-time = "2025-12-02T20:57:58.95Z" }, - { url = "https://files.pythonhosted.org/packages/0e/54/c2fc08d9324bbd92735caf9207cbabada3a8dd9d270d6e46ca69eb7f883d/vhacdx-0.0.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0599bc2a96de8fc9aeff460b3e88b8572e84ae95b8fc6c9888ef4b92023c22d5", size = 225014, upload-time = "2025-12-02T20:58:00.938Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9e/42adb642a12915acc9cb2bfab21710a6aabf045c26967ba0ff0e08a872d0/vhacdx-0.0.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dc648829a1e973f34ee11393a4f334ab55e3e0e9c4b9f6d6349af966fdf1895a", size = 211127, upload-time = "2025-12-02T20:58:02.107Z" }, - { url = "https://files.pythonhosted.org/packages/51/3d/63e090cd966817b89643d7e52e13df45043b22a42c7fbf702866bdd75bc0/vhacdx-0.0.10-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74c03f7315a434ec83cd0bff1e6bce6af4c01df61d677f48f3ffb36800606ee7", size = 239471, upload-time = "2025-12-02T20:58:03.173Z" }, - { url = "https://files.pythonhosted.org/packages/b8/b4/07ab1c828bae0eb5c72cd9a4cbe8b0376d374509be3c7055e1a399bf85c3/vhacdx-0.0.10-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fcd02acc3733ec3a0a0d28ca7f526d4c56f14a3ceb4b12fce45acf72c09054a", size = 253019, upload-time = "2025-12-02T20:58:04.318Z" }, - { url = "https://files.pythonhosted.org/packages/05/cb/bc8a8858b300d2c092da11096ae0586ece446b4c41cb26620bf00d1d8232/vhacdx-0.0.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4b9f8a80ca4c54d7fa76419a2ebd9e9386cd177dc4d2b97f2208ac57c9a7e8aa", size = 1226933, upload-time = "2025-12-02T20:58:05.907Z" }, - { url = "https://files.pythonhosted.org/packages/15/52/213230883874615f1661903bce1ace5013d03b34696efce8d53c662a3358/vhacdx-0.0.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:847bd43e82afb439dd3fa972618d786d0b98d8ef04a8e8a6381f6945204d2505", size = 1288871, upload-time = "2025-12-02T20:58:07.432Z" }, - { url = "https://files.pythonhosted.org/packages/32/25/f0e6806824f88d47ab8bc1c9bf6f11634fd7b382d635d0696825f3b5672f/vhacdx-0.0.10-cp313-cp313-win_amd64.whl", hash = "sha256:ab300c5f3fe4e54f99af92f9ea27c977b09df5f59190b0a3e025161110f71ce7", size = 195091, upload-time = "2025-12-02T20:58:08.783Z" }, - { url = "https://files.pythonhosted.org/packages/b7/b8/5137c048728fddd3315a79c94ba8663f3707f9268af9af15b15e1ef3cd85/vhacdx-0.0.10-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:147030c7683be4f21a3cdfb202b121c01716694b61ddad794345fcd9fa43d0ec", size = 225247, upload-time = "2025-12-02T20:58:09.918Z" }, - { url = "https://files.pythonhosted.org/packages/1b/08/5c731863db402e9878380f68be8722fabbcaf8cfe8d06237aaf15f116d95/vhacdx-0.0.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:069eb4381917b790921a33d4cc6ed07f7ed5362474232110baf8dd3dadcd768d", size = 211339, upload-time = "2025-12-02T20:58:10.951Z" }, - { url = "https://files.pythonhosted.org/packages/04/3a/e93ce9b653a9f435c530df8d5ad68a80b8bdc2b8518abc225fef9e7f349a/vhacdx-0.0.10-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7702892008b1150619c1f66a62ef88d1cb8f92b09d9c39a0bfb87d147f1c5ae2", size = 239974, upload-time = "2025-12-02T20:58:12.101Z" }, - { url = "https://files.pythonhosted.org/packages/77/dc/ef34f97a65385bc1f8ed4718fa5f7d96313e299e76761f1b69efaf597797/vhacdx-0.0.10-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4d550dfc377471b36f11065fc12cfbbd1750d63b10a336644bfdcbf27aa8382", size = 253245, upload-time = "2025-12-02T20:58:13.303Z" }, - { url = "https://files.pythonhosted.org/packages/f4/6c/57051066bd0589b7fe68c32061821180f520b6a7ef4efa072b755dde63d3/vhacdx-0.0.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edce8f0ff516a111b6f1d644782a1d496b3e9e34ff4ce09849c9b8071627bca5", size = 1227432, upload-time = "2025-12-02T20:58:14.73Z" }, - { url = "https://files.pythonhosted.org/packages/1d/49/3488f2bd991027bd86f072cf776935c80b4e630bd3bc43c3289bc6eeeba0/vhacdx-0.0.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4c463abbdce73d5d0b94eab2c9f43f2b55a4d0e788d87af659cc78029b960bf9", size = 1289126, upload-time = "2025-12-02T20:58:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/2f4506382a1133bf441cba2010017064e8f7af940d100141799d7e867e58/vhacdx-0.0.10-cp314-cp314-win_amd64.whl", hash = "sha256:b93c834f2bf1fa6630da5d3f77e94ea8e542fca31e385244a7ec905a32155549", size = 198706, upload-time = "2025-12-02T20:58:17.378Z" }, - { url = "https://files.pythonhosted.org/packages/db/f6/4fabfe65f3123abda09adc416a396caf8c2ad1b29c34a5178ec71754a163/vhacdx-0.0.10-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4c0f1bafc53e156472b0367533c2d3ec7a96b676b6d57aa92dd3e37519331b07", size = 228276, upload-time = "2025-12-02T20:58:18.545Z" }, - { url = "https://files.pythonhosted.org/packages/dc/70/bdc742628adcf9966cea81be7a651300bc399b492d10a763781af6d27041/vhacdx-0.0.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b0f8643dcb1f0774320fc1389ead0d0da4536e4c0fecfd4c8133baec0b6fa556", size = 214287, upload-time = "2025-12-02T20:58:19.696Z" }, - { url = "https://files.pythonhosted.org/packages/84/6a/f2e37ad333d3f671e1d59ba76bb61edc5520146539d52ee29e555becb4ac/vhacdx-0.0.10-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4547f3e55eb935d163d89c10ffdcadf8797c3b435a9dc82be4e0e27b1e3abff0", size = 240923, upload-time = "2025-12-02T20:58:21.105Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7a/f0325cd7ece95dbbc10d0c3f6d241d47beb3b99ae4dafe2e450082cd7bd9/vhacdx-0.0.10-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee09c4f2b6385546001b5e8609f428417fac147cfd3ea020fbc7dec0f11c489b", size = 254257, upload-time = "2025-12-02T20:58:22.176Z" }, - { url = "https://files.pythonhosted.org/packages/ac/56/53347b910351eb4cf32a797e177f18b8d82b1ef4e4325607254cfe88ad2a/vhacdx-0.0.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b94d198e4716f9220985523f374617432ef5530910f3730051f3e7fcba71798", size = 1228434, upload-time = "2025-12-02T20:58:23.302Z" }, - { url = "https://files.pythonhosted.org/packages/be/f5/f86c63da38b0446ef6652e8e72b84451e440418eaac0f554737e159ae36e/vhacdx-0.0.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:39c6d31ed27e3f33e9411927e1567ba37a18ba7ce9295efd1b24414b7313b503", size = 1288854, upload-time = "2025-12-02T20:58:24.46Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d1/b30dec954a24b41358297a3fbe7c30d8e2e818831f552cb34c904baa04e4/vhacdx-0.0.10-cp314-cp314t-win_amd64.whl", hash = "sha256:fc6a613082ec522a020e4f6a09f39ed42546de9aebe99548aa84938b1440871c", size = 204896, upload-time = "2025-12-02T20:58:25.825Z" }, -] - [[package]] name = "viser" -version = "1.0.26" +version = "1.0.29" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "imageio" }, @@ -4219,12 +3543,11 @@ dependencies = [ { name = "trimesh" }, { name = "typing-extensions" }, { name = "websockets" }, - { name = "yourdfpy" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/ce/82a0e50fae21f5e02fcc5d9aff2ab59dccb9c319b6c4cf528f2228049b05/viser-1.0.26.tar.gz", hash = "sha256:dc08c6f505e70324b0603bdddf9714c00ac828c259ee49abd8ad094bfc90c91c", size = 4828261, upload-time = "2026-03-30T11:43:19.513Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/ef/118c59772bad5409ab1ffa234f5c429eb823b76c5f9f086c40158833d00d/viser-1.0.29.tar.gz", hash = "sha256:6229a985720a5da3f427647f04bccfdd437c3b21aafa5d2818bcd5f73cc1ad2b", size = 4907233, upload-time = "2026-05-20T10:28:50.522Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/f7/762a2d5fab509d0c632b271e21e634462397cc02cca649771c3e9d2e0bcc/viser-1.0.26-py3-none-any.whl", hash = "sha256:03b177b4ef584f58f7b74fdf44cccb165b8a220ffd90728ef5c1e3d1b1fcf258", size = 4922888, upload-time = "2026-03-30T11:43:21.355Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/b3461e44c999ad8e8819ac191e5437b0ed3444e9b1d6f2b22bc0bcc66b21/viser-1.0.29-py3-none-any.whl", hash = "sha256:807834e166c9156d08b2997cd992bef0c0892e780a1700ae5e1d65523978d780", size = 5009573, upload-time = "2026-05-20T10:28:48.548Z" }, ] [[package]] @@ -4446,179 +3769,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ] -[[package]] -name = "xxhash" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/2f/e183a1b407002f5af81822bee18b61cdb94b8670208ef34734d8d2b8ebe9/xxhash-3.7.0.tar.gz", hash = "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", size = 82022, upload-time = "2026-04-25T11:10:32.553Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/49/e4b575b4ed170a7f640c8bd69cfadfa81c7b700191fde5e72228762b9f73/xxhash-3.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cd8ab85c916a58d5c8656ea15e3ce9df836fe2f120a74c296e01d69fab2614b4", size = 33426, upload-time = "2026-04-25T11:05:15.702Z" }, - { url = "https://files.pythonhosted.org/packages/07/61/40f0155b0b09988eb6cdbfc52652f2f371810b0c58163208cb05667757bd/xxhash-3.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:85f5c0e26d945b5bb475e0a3d95193117498130baa7619357bdc7869c2391b5a", size = 30859, upload-time = "2026-04-25T11:05:17.708Z" }, - { url = "https://files.pythonhosted.org/packages/12/bd/2902b7aad574e43cd85fd84849cfbce48c52cb02c7d6902b8a2b3f6e668e/xxhash-3.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7ffeaada9f8699be63d639536b0b60dff73b7d3325b7475c5bc8fdbf4eed47f", size = 193839, upload-time = "2026-04-25T11:05:19.364Z" }, - { url = "https://files.pythonhosted.org/packages/48/df/343ce8fd09e47ba8fba43b3bad3283ddf0deca799d5a27b084c3aa2ce502/xxhash-3.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee88dfaa6b1b2bfadd3c031fa5f05584870e62fb05dc500942e9900c44fcfda", size = 212896, upload-time = "2026-04-25T11:05:21.131Z" }, - { url = "https://files.pythonhosted.org/packages/79/cf/703e8422a8b52407864281fb4eb52c605e9f33180413b4458f05de110eba/xxhash-3.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7426ff0dfa76eb47efc2cc59d4a717bfa9dc9938bff5e49e748bca749f6aa616", size = 235896, upload-time = "2026-04-25T11:05:22.988Z" }, - { url = "https://files.pythonhosted.org/packages/ed/bc/d4b039edbd426575add5f217abeeb2bf870e2c510d35445df81b4f457901/xxhash-3.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8ff6ec73110f610425caef3ea875afbfc34caa542f01df3a80f45aadeb9f906", size = 211665, upload-time = "2026-04-25T11:05:24.799Z" }, - { url = "https://files.pythonhosted.org/packages/42/24/c6f81361796814b92399a88bf079d3b65e617f531819128fcf1bd6ef0571/xxhash-3.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d23fd49fdc5c8af61fb7104f1ad247954499140f6cb6045b3aa5c99dadbbf28", size = 444929, upload-time = "2026-04-25T11:05:26.245Z" }, - { url = "https://files.pythonhosted.org/packages/a4/db/268012153eb7f6bf2c8a0491fdcde11e093f166990821a2ab754fe95537d/xxhash-3.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c249621af6d50a05d9f10af894b404157b15819878e18f75fcbb0213a77d07", size = 193271, upload-time = "2026-04-25T11:05:28.282Z" }, - { url = "https://files.pythonhosted.org/packages/0a/86/1d0d905d659850dad7f59c807c130249fdb204dc6f71f1fb36268f3f3e61/xxhash-3.7.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6741564a923f082f3c2941c8bb920462ed5b25eaebdd1e161f162233c9a10bc5", size = 284580, upload-time = "2026-04-25T11:05:30.116Z" }, - { url = "https://files.pythonhosted.org/packages/1f/52/fc01ca7ff425a9bdb38d9e3a17f2630447ce3b45d45a929a6cd94d469334/xxhash-3.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4fd8acc6e32596350619896feb372033c0920975992d29837c32853bb1feacd", size = 210193, upload-time = "2026-04-25T11:05:31.969Z" }, - { url = "https://files.pythonhosted.org/packages/ec/96/122e0c6a3537a54b30752031dca557182576bae1a4171c0be8c532c84496/xxhash-3.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:646a69b56d8145d85f7fd2289d14fba07880c8a5bda406aa256b407481a61f35", size = 241094, upload-time = "2026-04-25T11:05:33.651Z" }, - { url = "https://files.pythonhosted.org/packages/d8/17/92e33338db8c18add33a46b56c2b7d5dcc6cc2ac076c45389f6017b1bf37/xxhash-3.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:11dd69b1a34b7b9af29012f390825b0cdb0617c0966560e227ca74daa7478ba9", size = 197721, upload-time = "2026-04-25T11:05:35.387Z" }, - { url = "https://files.pythonhosted.org/packages/c7/04/fd4114a0820913f336bef5c82ef851bde8d06270982ebd7b2a859961bbf2/xxhash-3.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:01cf5c5333aed26cc8d5eea33b8d6398e085e365a704b7372fabdf7ab06441a9", size = 210073, upload-time = "2026-04-25T11:05:37.405Z" }, - { url = "https://files.pythonhosted.org/packages/dd/eb/a2472b8b81cd576a9af3a4889ad8ba5784e8c5a04592587056cdaededd6c/xxhash-3.7.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f1e65d52c2d526734abecb98372c256b7eacce8fdc42e0df8570417fb39e2772", size = 274960, upload-time = "2026-04-25T11:05:39.224Z" }, - { url = "https://files.pythonhosted.org/packages/3d/d3/493afc544aae50b5fb2844ceaeb3697283bb59695db1a7cb40448636de05/xxhash-3.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8ff00fcc3eb436617ed8556cf15daf76c2b501248361a065625a588af78a0a02", size = 413113, upload-time = "2026-04-25T11:05:40.669Z" }, - { url = "https://files.pythonhosted.org/packages/50/6a/002800845a22bff32bcf5fd09caceb4d3f5c3da6b754c46edb9743ce908b/xxhash-3.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b5cd29840505631c6f7dbb8a5d34b742b5e6bbda38fe0b9f54e825f3ea6b61dc", size = 190677, upload-time = "2026-04-25T11:05:42.403Z" }, - { url = "https://files.pythonhosted.org/packages/f4/0f/86ee514622a381c0dc49167c8d431a22aa93518a4063559c3e36e4b82bc8/xxhash-3.7.0-cp310-cp310-win32.whl", hash = "sha256:5bf2f1940499839b39fef1561b5ecb6ede9ac34ef4457474e1337fc7ef07c2f3", size = 30627, upload-time = "2026-04-25T11:05:44.022Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/2ef2310803efb4a2d07844e8098d797e25702024793aa2e85858623a43b5/xxhash-3.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:d41fcda2fa8ca682ebca134a2f2dc02575ba549267585597e73061565795f475", size = 31463, upload-time = "2026-04-25T11:05:45.218Z" }, - { url = "https://files.pythonhosted.org/packages/9e/75/40dbf8f142baf8993c38cd988c8d8f51fe0c51e6c84c5769a3c0280a651d/xxhash-3.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:a845a59664d5c531525a467470220f8edc37959e0a6f8e734ffb6654da5c4bee", size = 27747, upload-time = "2026-04-25T11:05:46.422Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f4/7bd35089ff1f8e2c96baa2dce05775a122aacd2e3830a73165e27a4d0848/xxhash-3.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fdc7d06929ae28dda98297a18eef7b0fd38991a3b405d8d7b55c9ef24c296958", size = 33423, upload-time = "2026-04-25T11:05:47.628Z" }, - { url = "https://files.pythonhosted.org/packages/a3/26/4e00c88a6a2c8a759cfb77d2a9a405f901e8aa66e60ef1fd0aeb35edda48/xxhash-3.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea6daa712f4e094a30830cf01e9b47d03b24d05cc9dab8609f0d9a9db8454712", size = 30857, upload-time = "2026-04-25T11:05:49.189Z" }, - { url = "https://files.pythonhosted.org/packages/82/2f/eeb942c17a5a761a8f01cb9180a0b76bfb62a2c39e6f46b1f9001899027a/xxhash-3.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9e6c0d843f1daf85ea23aeb053579135552bde575b7b98af20bfc667b6e4548d", size = 194702, upload-time = "2026-04-25T11:05:50.457Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/96f132c08b1e5951c68691d3b9ec351ec2edc028f6a01fcd294f46b9d9f0/xxhash-3.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:363c139bf15e1ac5f136b981d3c077eb551299b1effede7f12faa010b8590a60", size = 213613, upload-time = "2026-04-25T11:05:52.571Z" }, - { url = "https://files.pythonhosted.org/packages/82/89/d4e92b796c5ed052d29ed324dbfc1dc1188e0c4bf64bebbf0f8fc20698df/xxhash-3.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a778b25874cb0f862eaab5986bff4ca49ffb0def7c0a34c237b948b3c6c775b2", size = 236726, upload-time = "2026-04-25T11:05:54.395Z" }, - { url = "https://files.pythonhosted.org/packages/40/f1/81fc4361921dc6e557a9c60cb3712f36d244d06eeeb71cd2f4252ac42678/xxhash-3.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e1860f1e43d40e9d904cf22d93e587ea42e010ebce4160877e46bcab4bc232a", size = 212443, upload-time = "2026-04-25T11:05:56.334Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d0/afeddd4cff50a332f50d4b8a2e8857673153ab0564ef472fcdeb0b5430df/xxhash-3.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9122ad6f867c4a0f5e655f5c3bdf89103852009dbb442a3d23e688b9e699e800", size = 445793, upload-time = "2026-04-25T11:05:58.953Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d0/3c91e4e6a05ca4d7df8e39ec3a75b713609258ec84705ab34be6430826a1/xxhash-3.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d9110d0c3fb02679972837a033251fd186c529aa62f19c132fc909c74052b8", size = 193937, upload-time = "2026-04-25T11:06:00.546Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3a/a6b0772d9801dd4bea4ca4fd34734d6e9b51a711c8a611a24a79de26a878/xxhash-3.7.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:347a93f2b4ce67ce61959665e32a7447c380f8347e55e100daa23766baacf0e5", size = 285188, upload-time = "2026-04-25T11:06:01.96Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f8/cf8e31fd7282230fe7367cd501a2e75b4b67b222bfc7eacccfc20d2652cb/xxhash-3.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:acbb48679ddf3852c45280c10ff10d52ca2cd1da2e552fb81db1ff786c75d0e4", size = 210966, upload-time = "2026-04-25T11:06:03.453Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f0/fd36cc4a81bf52ee5633275daae2b93dd958aace67fd4f5d466ec83b5f35/xxhash-3.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fe14c356f8b23ad811dc026077a6d4abccdaa7bce5ca98579605550657b6fcfb", size = 241994, upload-time = "2026-04-25T11:06:05.264Z" }, - { url = "https://files.pythonhosted.org/packages/08/e1/67f5d9c9369be42eaf99ba02c01bf14c5ecd67087b02567960bfcee43b63/xxhash-3.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f420ad3d41e38194353a498bbc9561fd5a9973a27b536ce46d8583479cf44335", size = 198707, upload-time = "2026-04-25T11:06:07.044Z" }, - { url = "https://files.pythonhosted.org/packages/50/17/a4c865ca22d2da6b1bc7d739bf88cab209533cf52ba06ca9da27c3039bee/xxhash-3.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:693d02c6dc7d1aa0a45921d54cd8c1ff629e09dfdc2238471507af1f7a1c6f04", size = 210917, upload-time = "2026-04-25T11:06:08.853Z" }, - { url = "https://files.pythonhosted.org/packages/49/8b/453b35810d697abac3c96bde3528bece685869227da274eb80a4a4d4a119/xxhash-3.7.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:14bf7a54e43825ec131ee7fe3c60e142e7c2c1e676ad0f93fc893432d15414af", size = 275772, upload-time = "2026-04-25T11:06:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ad/4eed7eab07fd3ee6678f416190f0413d097ab5d7c1278906bf1e9549d789/xxhash-3.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ae3a39a4d96bdb6f8d154fd7f490c4ad06f0532fcd2bb656052a9a7762cf5d31", size = 414068, upload-time = "2026-04-25T11:06:12.511Z" }, - { url = "https://files.pythonhosted.org/packages/d3/4e/fd6f8a680ba248fdb83054fa71a8bfa3891225200de1708b888ef2c49829/xxhash-3.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1cc07c639e3a77ef1d32987464d3e408565b8a3be57b545d3542b191054d9923", size = 191459, upload-time = "2026-04-25T11:06:14.07Z" }, - { url = "https://files.pythonhosted.org/packages/50/7c/8cb34b3bed4f44ca6827a534d50833f9bc6c006e83b0eb410ac9fa0793bd/xxhash-3.7.0-cp311-cp311-win32.whl", hash = "sha256:3281ba1d1e60ee7a382a7b958513ba03c2c0d5fcbd9a6f7517c0a81251a23422", size = 30628, upload-time = "2026-04-25T11:06:15.802Z" }, - { url = "https://files.pythonhosted.org/packages/0b/47/a49767bd7b40782bedae9ff0721bfe1d7e4dd9dc1585dea684e57ba67c20/xxhash-3.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:a7f25baec4c5d851d40718d6fae52285b31683093d4ff5207e63ab306ccf14a5", size = 31461, upload-time = "2026-04-25T11:06:17.104Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c6/3957bfacfb706bd687be246dfa8dd60f8df97c44186d229f7fd6e26c4b7e/xxhash-3.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:4c2454448ce847c72635827bb75c15c5a3434b03ee1afd28cb6dc6fb2597d830", size = 27746, upload-time = "2026-04-25T11:06:18.716Z" }, - { url = "https://files.pythonhosted.org/packages/f2/8a/51a14cdef4728c6c2337db8a7d8704422cc65676d9199d77215464c880af/xxhash-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", size = 33357, upload-time = "2026-04-25T11:06:20.44Z" }, - { url = "https://files.pythonhosted.org/packages/b9/1b/0c2c933809421ffd9bf42b59315552c143c755db5d9a816b2f1ae273e884/xxhash-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", size = 30869, upload-time = "2026-04-25T11:06:21.989Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/89d5fdd6ee12d70ba99451de46dd0e8010167468dcd913ec855653f4dd50/xxhash-3.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", size = 194100, upload-time = "2026-04-25T11:06:23.586Z" }, - { url = "https://files.pythonhosted.org/packages/87/ee/2f9f2ed993e77206d1e66991290a1ebe22e843351ca3ebec8e49e01ba186/xxhash-3.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", size = 212977, upload-time = "2026-04-25T11:06:25.019Z" }, - { url = "https://files.pythonhosted.org/packages/de/60/5a91644615a9e9d4e42c2e9925f1908e3a24e4e691d9de7340d565bea024/xxhash-3.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", size = 236373, upload-time = "2026-04-25T11:06:26.482Z" }, - { url = "https://files.pythonhosted.org/packages/22/c0/f3a9384eaaed9d14d4d062a5d953aa0da489bfe9747877aa994caa87cd0b/xxhash-3.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", size = 212229, upload-time = "2026-04-25T11:06:28.065Z" }, - { url = "https://files.pythonhosted.org/packages/2e/67/02f07a9fd79726804190f2172c4894c3ed9a4ebccaca05653c84beb58025/xxhash-3.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", size = 445462, upload-time = "2026-04-25T11:06:30.048Z" }, - { url = "https://files.pythonhosted.org/packages/40/37/558f5a90c0672fc9b4402dc25d87ac5b7406616e8969430c9ca4e52ee74d/xxhash-3.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", size = 193932, upload-time = "2026-04-25T11:06:31.857Z" }, - { url = "https://files.pythonhosted.org/packages/d5/90/aaa09cd58661d32044dbbad7df55bbe22a623032b810e7ed3b8c569a2a6f/xxhash-3.7.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", size = 284807, upload-time = "2026-04-25T11:06:33.697Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f3/53df3719ab127a02c174f0c1c74924fcd110866e89c966bc7909cfa8fa84/xxhash-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", size = 210445, upload-time = "2026-04-25T11:06:35.488Z" }, - { url = "https://files.pythonhosted.org/packages/72/33/d219975c0e8b6fa2eb9ccd486fe47e21bf1847985b878dd2fbc3126e0d5c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", size = 241273, upload-time = "2026-04-25T11:06:37.24Z" }, - { url = "https://files.pythonhosted.org/packages/3e/50/49b1afe610eb3964cedcb90a4d4c3d46a261ee8669cbd4f060652619ae3c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", size = 197950, upload-time = "2026-04-25T11:06:39.148Z" }, - { url = "https://files.pythonhosted.org/packages/c6/75/5f42a1a4c78717d906a4b6a140c6dbf837ab1f547a54d23c4e2903310936/xxhash-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", size = 210709, upload-time = "2026-04-25T11:06:40.958Z" }, - { url = "https://files.pythonhosted.org/packages/8a/85/237e446c25abced71e9c53d269f2cef5bab8a82b3f88a12e00c5368e7368/xxhash-3.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", size = 275345, upload-time = "2026-04-25T11:06:42.525Z" }, - { url = "https://files.pythonhosted.org/packages/62/34/c2c26c0a6a9cc739bc2a5f0ae03ba8b87deb12b8bce35f7ac495e790dc6d/xxhash-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", size = 414056, upload-time = "2026-04-25T11:06:44.343Z" }, - { url = "https://files.pythonhosted.org/packages/a0/aa/5c58e9bc8071b8afd8dcf297ff362f723c4892168faba149f19904132bf4/xxhash-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", size = 191485, upload-time = "2026-04-25T11:06:46.262Z" }, - { url = "https://files.pythonhosted.org/packages/d4/69/a929cf9d1e2e65a48b818cdce72cb6b69eab2e6877f21436d0a1942aff43/xxhash-3.7.0-cp312-cp312-win32.whl", hash = "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", size = 30671, upload-time = "2026-04-25T11:06:48.039Z" }, - { url = "https://files.pythonhosted.org/packages/b9/1b/104b41a8947f4e1d4a66ce1e628eea752f37d1890bfd7453559ca7a3d950/xxhash-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", size = 31514, upload-time = "2026-04-25T11:06:49.279Z" }, - { url = "https://files.pythonhosted.org/packages/98/a0/1fd0ea1f1b886d9e7c73f0397571e22333a7d79e31da6d7127c2a4a71d75/xxhash-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", size = 27761, upload-time = "2026-04-25T11:06:50.448Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ca/d5174b4c36d10f64d4ca7050563138c5a599efb01a765858ddefc9c1202a/xxhash-3.7.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", size = 36813, upload-time = "2026-04-25T11:06:51.73Z" }, - { url = "https://files.pythonhosted.org/packages/41/d0/abc6c9d347ba1f1e1e1d98125d0881a0452c7f9a76a9dd03a7b5d2197f23/xxhash-3.7.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", size = 35121, upload-time = "2026-04-25T11:06:53.048Z" }, - { url = "https://files.pythonhosted.org/packages/bf/11/4cc834eb3d79f2f2b3a6ef7324195208bcdfbdcf7534d2b17267aa5f3a8f/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", size = 29624, upload-time = "2026-04-25T11:06:54.311Z" }, - { url = "https://files.pythonhosted.org/packages/23/83/e97d3e7b635fe73a1dfb1e91f805324dd6d930bb42041cbf18f183bc0b6d/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", size = 30638, upload-time = "2026-04-25T11:06:55.864Z" }, - { url = "https://files.pythonhosted.org/packages/f4/40/d84951d80c35db1f4c40a29a64a8520eea5d56e764c603906b4fe763580f/xxhash-3.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", size = 33323, upload-time = "2026-04-25T11:06:57.336Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/c7dc6558d97e9ab023f663d69ab28b340ed9bf4d2d94f2c259cf896bb354/xxhash-3.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", size = 33362, upload-time = "2026-04-25T11:06:58.656Z" }, - { url = "https://files.pythonhosted.org/packages/2a/6e/46b84017b1301d54091430353d4ad5901654a3e0871649877a416f7f1644/xxhash-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", size = 30874, upload-time = "2026-04-25T11:06:59.834Z" }, - { url = "https://files.pythonhosted.org/packages/df/5e/8f9158e3ab906ad3fec51e09b5ea0093e769f12207bfa42a368ca204e7ab/xxhash-3.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", size = 194185, upload-time = "2026-04-25T11:07:01.658Z" }, - { url = "https://files.pythonhosted.org/packages/f3/29/a804ded9f5d3d3758292678d23e7528b08fda7b7e750688d08b052322475/xxhash-3.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", size = 213033, upload-time = "2026-04-25T11:07:03.606Z" }, - { url = "https://files.pythonhosted.org/packages/8b/91/1ce5a7d2fdc975267320e2c78fc1cecfe7ab735ccbcf6993ec5dd541cb2c/xxhash-3.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", size = 236140, upload-time = "2026-04-25T11:07:05.396Z" }, - { url = "https://files.pythonhosted.org/packages/34/04/fd595a4fd8617b05fa27bd9b684ecb4985bfed27917848eea85d54036d06/xxhash-3.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", size = 212291, upload-time = "2026-04-25T11:07:06.966Z" }, - { url = "https://files.pythonhosted.org/packages/03/fb/f1a379cbc372ae5b9f4ab36154c48a849ca6ebe3ac477067a57865bf3bc6/xxhash-3.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", size = 445532, upload-time = "2026-04-25T11:07:08.525Z" }, - { url = "https://files.pythonhosted.org/packages/65/59/172424b79f8cfd4b6d8a122b2193e6b8ad4b11f7159bb3b6f9b3191329bb/xxhash-3.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", size = 193990, upload-time = "2026-04-25T11:07:10.315Z" }, - { url = "https://files.pythonhosted.org/packages/b9/19/aeac22161d953f139f07ba5586cb4a17c5b7b6dff985122803bb12933500/xxhash-3.7.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", size = 284876, upload-time = "2026-04-25T11:07:12.15Z" }, - { url = "https://files.pythonhosted.org/packages/77/d5/4fd0b59e7a02242953da05ff679fbb961b0a4368eac97a217e11dae110c1/xxhash-3.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", size = 210495, upload-time = "2026-04-25T11:07:13.952Z" }, - { url = "https://files.pythonhosted.org/packages/aa/fb/976a3165c728c7faf74aa1b5ab3cf6a85e6d731612894741840524c7d28c/xxhash-3.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", size = 241331, upload-time = "2026-04-25T11:07:15.557Z" }, - { url = "https://files.pythonhosted.org/packages/4a/2c/6763d5901d53ac9e6ba296e5717ae599025c9d268396e8faa8b4b0a8e0ac/xxhash-3.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", size = 198037, upload-time = "2026-04-25T11:07:17.563Z" }, - { url = "https://files.pythonhosted.org/packages/61/2b/876e722d533833f5f9a83473e6ba993e48745701096944e77bbecf29b2c3/xxhash-3.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", size = 210744, upload-time = "2026-04-25T11:07:19.055Z" }, - { url = "https://files.pythonhosted.org/packages/21/e6/d7e7baef7ce24166b4668d3c48557bb35a23b92ecadcac7e7718d099ab69/xxhash-3.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", size = 275406, upload-time = "2026-04-25T11:07:20.908Z" }, - { url = "https://files.pythonhosted.org/packages/92/fe/198b3763b2e01ca908f2154969a2352ec99bda892b574a11a9a151c5ede4/xxhash-3.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", size = 414125, upload-time = "2026-04-25T11:07:23.037Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6d/019a11affd5a5499137cacca53808659964785439855b5aa40dfd3412916/xxhash-3.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", size = 191555, upload-time = "2026-04-25T11:07:24.991Z" }, - { url = "https://files.pythonhosted.org/packages/76/21/b96d58568df2d01533244c3e0e5cbdd0c8b2b25c4bec4d72f19259a292d7/xxhash-3.7.0-cp313-cp313-win32.whl", hash = "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", size = 30668, upload-time = "2026-04-25T11:07:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/99/57/d849a8d3afa1f8f4bc6a831cd89f49f9706fbbad94d2975d6140a171988c/xxhash-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", size = 31524, upload-time = "2026-04-25T11:07:27.92Z" }, - { url = "https://files.pythonhosted.org/packages/81/52/bacc753e92dee78b058af8dcef0a50815f5f860986c664a92d75f965b6a5/xxhash-3.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", size = 27768, upload-time = "2026-04-25T11:07:29.113Z" }, - { url = "https://files.pythonhosted.org/packages/1c/47/ddbd683b7fc7e592c1a8d9d65f73ce9ab513f082b3967eee2baf549b8fc6/xxhash-3.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", size = 33576, upload-time = "2026-04-25T11:07:30.469Z" }, - { url = "https://files.pythonhosted.org/packages/07/f2/36d3310161db7f72efb4562aadde0ed429f1d0531782dd6345b12d2da527/xxhash-3.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", size = 31123, upload-time = "2026-04-25T11:07:31.989Z" }, - { url = "https://files.pythonhosted.org/packages/0d/3f/75937a5c69556ed213021e43cbedd84c8e0279d0d74e7d41a255d84ba4b1/xxhash-3.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", size = 196491, upload-time = "2026-04-25T11:07:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/f10d7ff8c7a733d4403a43b9de18c8fabc005f98cec054644f04418659ee/xxhash-3.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", size = 215793, upload-time = "2026-04-25T11:07:34.919Z" }, - { url = "https://files.pythonhosted.org/packages/8b/fd/778f60aa295f58907938f030a8b514611f391405614a525cccd2ffc00eb5/xxhash-3.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", size = 237993, upload-time = "2026-04-25T11:07:36.638Z" }, - { url = "https://files.pythonhosted.org/packages/70/f5/736db5de387b4a540e37a05b84b40dc58a1ce974bfd2b4e5754ce29b68c3/xxhash-3.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", size = 214887, upload-time = "2026-04-25T11:07:38.564Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/09a095f22fdb9a27fbb716841fbff52119721f9ca4261952d07a912f7839/xxhash-3.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", size = 448407, upload-time = "2026-04-25T11:07:40.552Z" }, - { url = "https://files.pythonhosted.org/packages/74/8a/b745efeeca9e34a91c26fdc97ad8514c43d5a81ac78565cba80a1353870a/xxhash-3.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", size = 196119, upload-time = "2026-04-25T11:07:42.101Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5c/0cfceb024af90c191f665c7933b1f318ee234f4797858383bebd1881d52f/xxhash-3.7.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", size = 286751, upload-time = "2026-04-25T11:07:43.568Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0a/0793e405dc3cf8f4ebe2c1acec1e4e4608cd9e7e50ea691dabbc2a95ccbb/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", size = 212961, upload-time = "2026-04-25T11:07:45.388Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7e/721118ffc63bfff94aa565bcf2555a820f9f4bdb0f001e0d609bdfad70de/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", size = 243703, upload-time = "2026-04-25T11:07:47.053Z" }, - { url = "https://files.pythonhosted.org/packages/6e/18/16f6267160488b8276fd3d449d425712512add292ba545c1b6946bfdb7dd/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", size = 200894, upload-time = "2026-04-25T11:07:48.657Z" }, - { url = "https://files.pythonhosted.org/packages/2d/94/80ba841287fd97e3e9cac1d228788c8ef623746f570404961eec748ecb5c/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", size = 213357, upload-time = "2026-04-25T11:07:50.257Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/106d4067130c59f1e18a55ffadcd876d8c68534883a1e02685b29d3d8153/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", size = 277600, upload-time = "2026-04-25T11:07:51.745Z" }, - { url = "https://files.pythonhosted.org/packages/c5/86/a081dd30da71d720b2612a792bfd55e45fa9a07ac76a0507f60487473c25/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", size = 416980, upload-time = "2026-04-25T11:07:53.504Z" }, - { url = "https://files.pythonhosted.org/packages/35/29/1a95221a029a3c1293773869e1ab47b07cbbdd82444a42809e8c60156626/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", size = 193840, upload-time = "2026-04-25T11:07:55.103Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/db909dd0823285de2286f67e10ee4d81e96ad35d7d8e964ecb07fccd8af9/xxhash-3.7.0-cp313-cp313t-win32.whl", hash = "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", size = 30966, upload-time = "2026-04-25T11:07:56.524Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ff/d705b15b22f21ee106adce239cb65d35067a158c630b240270f09b17c2e6/xxhash-3.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", size = 31784, upload-time = "2026-04-25T11:07:57.758Z" }, - { url = "https://files.pythonhosted.org/packages/a2/1f/b2cf83c3638fd0588e0b17f22e5a9400bdfb1a3e3755324ac0aee2250b88/xxhash-3.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", size = 27932, upload-time = "2026-04-25T11:07:59.109Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cc/431db584f6fbb9312e40a173af027644e5580d39df1f73603cbb9dca4d6b/xxhash-3.7.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:8c5fcfd806c335bfa2adf1cd0b3110a44fc7b6995c3a648c27489bae85801465", size = 36644, upload-time = "2026-04-25T11:08:00.658Z" }, - { url = "https://files.pythonhosted.org/packages/bc/01/255ec513e0a705d1f9a61413e78dfce4e3235203f0ed525a24c2b4b56345/xxhash-3.7.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:506a0b488f190f0a06769575e30caf71615c898ed93ab18b0dbcb6dec5c3713c", size = 35003, upload-time = "2026-04-25T11:08:02.338Z" }, - { url = "https://files.pythonhosted.org/packages/68/70/c55fc33c93445b44d8fc5a17b41ed99e3cebe92bcf8396809e63fc9a1165/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ec68dbba21532c0173a9872298e65c89749f7c9d21538c3a78b5bb6105871568", size = 29655, upload-time = "2026-04-25T11:08:03.701Z" }, - { url = "https://files.pythonhosted.org/packages/c2/72/ff8de73df000d74467d12a59ce6d6e2b2a368b978d41ab7b1fba5ed442be/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa77e7ec1450d415d20129961814787c9abd9a07f98872f070b1fe96c5084611", size = 30664, upload-time = "2026-04-25T11:08:05.011Z" }, - { url = "https://files.pythonhosted.org/packages/b6/91/08416d9bd9bc3bf39d831abe8a5631ac2db5141dfd6fe81c3fe59a1f9264/xxhash-3.7.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fe32736295ea38e43e7d9424053c8c47c9f64fecfc7c895fb3da9b30b131c9ee", size = 33317, upload-time = "2026-04-25T11:08:06.413Z" }, - { url = "https://files.pythonhosted.org/packages/0e/3b/86b1caa4dee10a99f4bf9521e623359341c5e50d05158fa10c275b2bd079/xxhash-3.7.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ab9dd2c83c4bbd63e422181a76f13502d049d3ddcac9a1bdc29196263d692bb8", size = 33457, upload-time = "2026-04-25T11:08:08.099Z" }, - { url = "https://files.pythonhosted.org/packages/ed/38/98ea14ad1517e1461292a65906951458d520689782bfbae111050145bdba/xxhash-3.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3afec3a336a2286601a437cb07562ab0227685e6fbb9ec17e8c18457ff348ecf", size = 30894, upload-time = "2026-04-25T11:08:09.429Z" }, - { url = "https://files.pythonhosted.org/packages/61/a2/074654d0b893606541199993c7db70067d9fc63b748e0d60020a52a1bd36/xxhash-3.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:565df64437a9390f84465dcca33e7377114c7ede8d05cd2cf20081f831ea788e", size = 194409, upload-time = "2026-04-25T11:08:10.91Z" }, - { url = "https://files.pythonhosted.org/packages/e2/26/6d2a1afc468189f77ca28c32e1c83e1b9da1178231e05641dbc1b350e332/xxhash-3.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12eca820a5d558633d423bf8bb78ce72a55394823f64089247f788a7e0ae691e", size = 213135, upload-time = "2026-04-25T11:08:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0e/d8aecf95e09c42547453137be74d2f7b8b14e08f5177fa2fab6144a19061/xxhash-3.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f262b8f7599516567e070abf607b9af649052b2c4bd6f9be02b0cb41b7024805", size = 236379, upload-time = "2026-04-25T11:08:14.206Z" }, - { url = "https://files.pythonhosted.org/packages/f2/74/8140e8210536b3dd0cc816c4faaeb5ba6e63e8125ab25af4bcddd6a037b3/xxhash-3.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1598916cb197681e03e601901e4ab96a9a963de398c59d0964f8a6f44a2b361", size = 212447, upload-time = "2026-04-25T11:08:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d2/462001d2903b4bee5a5689598a0a55e5e7cd1ac7f4247a5545cff10d3ebb/xxhash-3.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:322b2f0622230f526aeb1738149948a7ae357a9e2ceb1383c6fd1fdaecdafa16", size = 445660, upload-time = "2026-04-25T11:08:17.441Z" }, - { url = "https://files.pythonhosted.org/packages/23/09/2bd1ed7f8689b20e51727952cac8329d50c694dc32b2eba06ba5bc742b37/xxhash-3.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cc22070880cc57b830a65cde4e65fa884c6d9b28ae4803b5ee05911e7bafba", size = 194076, upload-time = "2026-04-25T11:08:19.134Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6e/692302cd0a5f4ac4e6289f37fa888dc2e1e07750b68fe3e4bfe939b8cea3/xxhash-3.7.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb5a888a968b2434abf9ecda357b5d43f10d7b5a6da6fdbbe036208473aff0e2", size = 284990, upload-time = "2026-04-25T11:08:20.618Z" }, - { url = "https://files.pythonhosted.org/packages/05/d9/e54b159b3d9df7999d2a7c676ce7b323d1b5588a64f8f51ed8172567bd87/xxhash-3.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a999771ff97bec27d18341be4f3a36b163bb1ac41ec17bef6d2dabd84acd33c7", size = 210590, upload-time = "2026-04-25T11:08:22.24Z" }, - { url = "https://files.pythonhosted.org/packages/50/93/0e0df1a3a196ced4ca71de76d65ead25d8e87bbfb87b64306ea47a40c00d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ed4a6efe2dee1655adb73e7ad40c6aa955a6892422b1e3b95de6a34de56e3cbb", size = 241442, upload-time = "2026-04-25T11:08:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a9/d917a7a814e90b218f8a0d37967105eea91bf752c3303683c99a1f7bfc1f/xxhash-3.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fd17f14ac0faa12126c2f9ca774a8cf342957265ec3c8669c144e5e6cdb478c", size = 198356, upload-time = "2026-04-25T11:08:25.99Z" }, - { url = "https://files.pythonhosted.org/packages/89/5e/f2ba1877c39469abbefc72991d6ebdcbd4c0880db01ae8cb1f553b0c537d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:05fd1254268c59b5cb2a029dfc204275e9fc52de2913f1e53aa8d01442c96b4d", size = 210898, upload-time = "2026-04-25T11:08:27.608Z" }, - { url = "https://files.pythonhosted.org/packages/90/c6/be56b58e73de531f39a10de1355bb77ceb663900dc4bf2d6d3002a9c3f9e/xxhash-3.7.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a2eae53197c6276d5b317f75a1be226bbf440c20b58bf525f36b5d0e1f657ca6", size = 275519, upload-time = "2026-04-25T11:08:29.301Z" }, - { url = "https://files.pythonhosted.org/packages/92/e2/17ddc85d5765b9c709f192009ed8f5a1fc876f4eb35bba7c307b5b1169f9/xxhash-3.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bfe6f92e3522dcbe8c4281efd74fa7542a336cb00b0e3272c4ec0edabeaeaf67", size = 414191, upload-time = "2026-04-25T11:08:31.16Z" }, - { url = "https://files.pythonhosted.org/packages/9c/42/85f5b79f4bf1ec7ba052491164adfd4f4e9519f5dc7246de4fbd64a1bd56/xxhash-3.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7ab9a49c410d8c6c786ab99e79c529938d894c01433130353dd0fe999111077a", size = 191604, upload-time = "2026-04-25T11:08:32.862Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d0/6127b623aa4cca18d8b7743592b048d689fd6c6e37ff26a22cddf6cd9d7f/xxhash-3.7.0-cp314-cp314-win32.whl", hash = "sha256:040ea63668f9185b92bc74942df09c7e65703deed71431333678fc6e739a9955", size = 31271, upload-time = "2026-04-25T11:08:34.651Z" }, - { url = "https://files.pythonhosted.org/packages/64/4f/44fc4788568004c43921701cbc127f48218a1eede2c9aea231115323564d/xxhash-3.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2a61e2a3fb23c892496d587b470dee7fa1b58b248a187719c65ea8e94ec13257", size = 32284, upload-time = "2026-04-25T11:08:35.987Z" }, - { url = "https://files.pythonhosted.org/packages/6d/77/18bb895eb60a49453d16e17d67990e5caff557c78eafc90ad4e2eabf4570/xxhash-3.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:c7741c7524961d8c0cb4d4c21b28957ff731a3fd5b5cd8b856dc80a40e9e5acc", size = 28701, upload-time = "2026-04-25T11:08:37.767Z" }, - { url = "https://files.pythonhosted.org/packages/45/a0/46f72244570c550fbbb7db1ef554183dd5ebe9136385f30e032b781ae8f6/xxhash-3.7.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc84bf7aa7592f31ec63a3e7b11d624f468a3f19f5238cec7282a42e838ab1d7", size = 33646, upload-time = "2026-04-25T11:08:39.109Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3a/453846a7eceea11e75def361eed01ec6a0205b9822c19927ed364ccae7cc/xxhash-3.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f1563fdc8abfc389748e6932c7e4e99c89a53e4ec37d4563c24fc06f5e5644b", size = 31125, upload-time = "2026-04-25T11:08:40.467Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3e/49434aba738885d512f9e486db1bdd19db28dfa40372b56da26ef7a4e738/xxhash-3.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d415f18becf6f153046ab6adc97da77e3643a0ee205dae61c4012604113a020", size = 196633, upload-time = "2026-04-25T11:08:41.943Z" }, - { url = "https://files.pythonhosted.org/packages/a4/e9/006cb6127baeb9f8abe6d15e62faa01349f09b34e2bfd65175b2422d026b/xxhash-3.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb16aa13ed175bc9be5c2491ba031b85a9b51c4ed90e0b3d4ebe63cf3fb54f8e", size = 215899, upload-time = "2026-04-25T11:08:43.645Z" }, - { url = "https://files.pythonhosted.org/packages/27/e4/cc57d72e66df0ae29b914335f1c6dcf61e8f3746ddf0ae3c471aa4f15e00/xxhash-3.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9fd595f1e5941b3d7863e4774e4b30caa6731fc34b9277da032295aa5656ee5", size = 238116, upload-time = "2026-04-25T11:08:45.698Z" }, - { url = "https://files.pythonhosted.org/packages/af/78/3531d4a3fd8a0038cc6be1f265a69c1b3587f557a10b677dd736de2202c1/xxhash-3.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1295325c5a98d552333fa53dc2b026b0ef0ec9c8e73ca3a952990b4c7d65d459", size = 215012, upload-time = "2026-04-25T11:08:47.355Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f6/259fb1eaaec921f59b17203b0daee69829761226d3b980d5191d7723dd83/xxhash-3.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3573a651d146912da9daa9e29e5fbc45994420daaa9ef1e2fa5823e1dc485513", size = 448534, upload-time = "2026-04-25T11:08:49.149Z" }, - { url = "https://files.pythonhosted.org/packages/7b/16/a66d0eaf6a7e68532c07714361ddc904c663ec940f3b028c1ae4a21a7b9d/xxhash-3.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ec1e080a3d02d94ea9335bfab0e3374b877e25411422c18f51a943fa4b46381", size = 196217, upload-time = "2026-04-25T11:08:50.805Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ef/d2efc7fc51756dc52509109d1a25cefc859d74bc4b19a167b12dbd8c2786/xxhash-3.7.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84415265192072d8638a3afc3c1bc5995e310570cd9acb54dc46d3939e364fe0", size = 286906, upload-time = "2026-04-25T11:08:52.418Z" }, - { url = "https://files.pythonhosted.org/packages/fc/67/25decd1d4a4018582ec4db2a868a2b7e40640f4adb20dfeb19ac923aa825/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d4dea659b57443989ef32f4295104fd6912c73d0bf26d1d148bb88a9f159b02", size = 213057, upload-time = "2026-04-25T11:08:54.105Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5d/17651eb29d06786cdc40c60ae3d27d645aa5d61d2eca6237a7ba0b94789b/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05ece0fe4d9c9c2728912d1981ae1566cfc83a011571b24732cbf76e1fb70dca", size = 243886, upload-time = "2026-04-25T11:08:56.109Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d4/174d9cf7502243d586e6a9ae842b1ae23026620995114f85f1380e588bc9/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fd880353cf1ffaf321bc18dd663e111976dbd0d3bbd8a66d58d2b470dfa7f396", size = 201015, upload-time = "2026-04-25T11:08:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/91/8c/2254e2d06c3ac5e6fe22eaf3da791b87ea823ae9f2c17b4af66755c5752d/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4e15cc9e2817f6481160f930c62842b3ff419e20e13072bcbab12230943092bc", size = 213457, upload-time = "2026-04-25T11:08:59.826Z" }, - { url = "https://files.pythonhosted.org/packages/79/a2/e3daa762545921173e3360f3b4ff7fc63c2d27359f7230ec1a7a74e117f6/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:90b9d1a8bd37d768ffc92a1f651ec69afc532a96fa1ac2ea7abbed5d630b3237", size = 277738, upload-time = "2026-04-25T11:09:01.423Z" }, - { url = "https://files.pythonhosted.org/packages/e1/4c/e186da2c46b87f5204640e008d42730bf3c1ee9f0efb71ae1ebcdfeac681/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:157c49475b34ecea8809e51123d9769a534e139d1247942f7a4bc67710bb2533", size = 417127, upload-time = "2026-04-25T11:09:03.592Z" }, - { url = "https://files.pythonhosted.org/packages/17/28/3798e15007a3712d0da3d3fe70f8e11916569858b5cc371053bc26270832/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a6ddec83325685e729ca119d1f5c518ec39294212ecd770e60693cdc5f7eb79", size = 193962, upload-time = "2026-04-25T11:09:06.228Z" }, - { url = "https://files.pythonhosted.org/packages/ad/95/a26baa93b5241fd7630998816a4ec47a5a0bad193b3f8fc8f3593e1a4a67/xxhash-3.7.0-cp314-cp314t-win32.whl", hash = "sha256:a04a6cab47e2166435aaf5b9e5ee41d1532cc8300efdef87f2a4d0acb7db19ed", size = 31643, upload-time = "2026-04-25T11:09:08.153Z" }, - { url = "https://files.pythonhosted.org/packages/44/36/5454f13c447e395f9b06a3e91274c59f503d31fad84e1836efe3bdb71f6a/xxhash-3.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8653dd7c2eda020545bb2c71c7f7039b53fe7434d0fc1a0a9deb79ab3f1a4fc1", size = 32522, upload-time = "2026-04-25T11:09:09.534Z" }, - { url = "https://files.pythonhosted.org/packages/74/35/698e7e3ff38e22992ea24870a511d8762474fb6783627a2910ff22a185c2/xxhash-3.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:468f0fc114faaa4b36699f8e328bbc3bb11dc418ba94ac52c26dd736d4b6c637", size = 28807, upload-time = "2026-04-25T11:09:11.234Z" }, - { url = "https://files.pythonhosted.org/packages/54/c1/e57ac7317b1f58a92bab692da6d497e2a7ce44735b224e296347a7ecc754/xxhash-3.7.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ad3aa71e12ee634f22b39a0ff439357583706e50765f17f05550f92dbf128a23", size = 31232, upload-time = "2026-04-25T11:10:21.51Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4e/075559bd712bc62e84915ea46bbee859f935d285659082c129bdbff679dd/xxhash-3.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5de686e73690cdaf72b96d4fa083c230ec9020bcc2627ce6316138e2cf2fe2d1", size = 28553, upload-time = "2026-04-25T11:10:23.1Z" }, - { url = "https://files.pythonhosted.org/packages/92/ca/a9c78cb384d4b033b0c58196bd5c8509873cabe76389e195127b0302a741/xxhash-3.7.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7fbec49f5341bbdea0c471f7d1e2fb41ae8925af9b6f28025c28defd8eb94274", size = 41109, upload-time = "2026-04-25T11:10:25.022Z" }, - { url = "https://files.pythonhosted.org/packages/bd/b1/dfe2629f7c77eb2fa234c72ff537cdd64939763df704e256446ed364a16d/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b542c347c2089f43dc5a6db31d2a6f3cdb04ee33505ec6e9f653834dbb0bde", size = 36307, upload-time = "2026-04-25T11:10:26.949Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f7/5a484afce0f48dd8083208b42e4911f290a82c7b52458ef2927e4d421a45/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a169a036bed0995e090d1493b283cc2cc8a6f5046821086b843abefff80643bc", size = 32534, upload-time = "2026-04-25T11:10:29.01Z" }, - { url = "https://files.pythonhosted.org/packages/0f/5f/4acfcd490db9780cf36c58534d828003c564cde5350220a1c783c4d10776/xxhash-3.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ec101643395d7f21405b640f728f6f627e6986557027d740f2f9b220955edafe", size = 31552, upload-time = "2026-04-25T11:10:30.727Z" }, -] - -[[package]] -name = "yourdfpy" -version = "0.0.60" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lxml" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "six" }, - { name = "trimesh", extra = ["easy"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/19/20c50861f30aff7720f9a601f386d73760c2df9961de1f98d0dbf3b85e69/yourdfpy-0.0.60.tar.gz", hash = "sha256:2af2d8bdeea1b85b642590a3b4236fdb35746d7b3e38ce460a169c18d9c4f868", size = 538238, upload-time = "2026-01-23T07:32:47.856Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/60/4ea0d6df0b497d51bf2ef87eaab0eb26f8bc3b3313c012da5df3383cced9/yourdfpy-0.0.60-py3-none-any.whl", hash = "sha256:8a187a8b18c98db87c76e9a950581b3c875b761e00df83942526c17ea693166c", size = 22194, upload-time = "2026-01-23T07:32:46.481Z" }, -] - [[package]] name = "zipp" version = "3.23.1" diff --git a/README.md b/README.md index e0b056d..d98e2e1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ RC_WheelLeg 是山东华宇工学院 16DOF 串联轮足机器人项目。 -当前 `16dof` 分支用于整理 16DOF 机械、强化学习训练、Sim2Sim、Sim2Real、ROS 2 部署和比赛版本。机械资料和第一代软件闭环已经完成首轮整理。 +当前 `16dof` 分支用于整理 16DOF 机械、强化学习训练、Sim2Sim、Sim2Real、ROS 2 部署和比赛版本。机械资料、第一代软件闭环和第一份新版 MJCF/训练框架已经完成整理。 ## 平台概览 @@ -33,13 +33,14 @@ RC_WheelLeg/ - [x] 整理第一代 mjlab 训练任务和本地依赖 - [x] 整理第一代 MuJoCo、Sim2Sim 和 MJCF - [x] 整理 IK 真机控制与第一代 Python Sim2Real +- [x] 整理第一份新版 MJCF 与 mjlab 训练框架 - [ ] 核对比赛机械与仿真模型参数 - [ ] 整理 URDF/MJCF 机器人描述 - [ ] 整理后续统一训练、ROS 2 和比赛版本 机械资料入口见 [`02_mechanical/README.md`](02_mechanical/README.md)。 -早期软件版本入口见 [`05_software/README.md`](05_software/README.md)。 +软件版本入口见 [`05_software/README.md`](05_software/README.md)。 ## 早期真机验证 @@ -49,6 +50,8 @@ RC_WheelLeg/ ## 版本管理 +版本里程碑与主要变化见 [`01_doc/version_history.md`](01_doc/version_history.md)。 + - `8dof` 分支和 `v0.1.0` Tag 保存第一代 8DOF 实机版本。 - `16dof` 分支承载第二代轮足平台的完整演进。 - 线性演进阶段使用 Commit、Tag 和 Release 保存,不复制 `old`、`final` 或 `v2` 目录。