From e23e2150bdaeb6f537f72b1cb514794def799bd7 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 17:20:40 +0800 Subject: [PATCH 01/13] feat: define fixed model variant task ownership --- docs/sphinx/source/adr/ADR-0000-index.md | 1 + ...-fixed-model-variant-ownership-boundary.md | 127 +++++++ docs/sphinx/source/adr/README.md | 1 + .../5-domain_randomization/0-index.md | 45 ++- .../5-domain_randomization/0-index.md | 42 ++- src/unilab/base/base.py | 11 + src/unilab/base/config_materialization.py | 8 +- src/unilab/base/variants.py | 318 ++++++++++++++++++ src/unilab/envs/manager_based_rl_env.py | 12 + tests/base/test_fixed_model_variants.py | 218 ++++++++++++ 10 files changed, 756 insertions(+), 27 deletions(-) create mode 100644 docs/sphinx/source/adr/ADR-0010-fixed-model-variant-ownership-boundary.md create mode 100644 src/unilab/base/variants.py create mode 100644 tests/base/test_fixed_model_variants.py diff --git a/docs/sphinx/source/adr/ADR-0000-index.md b/docs/sphinx/source/adr/ADR-0000-index.md index 21d227aa0..109736c68 100644 --- a/docs/sphinx/source/adr/ADR-0000-index.md +++ b/docs/sphinx/source/adr/ADR-0000-index.md @@ -23,6 +23,7 @@ orphan: true | [ADR-0007 UniSim Extraction Boundary](ADR-0007-unisim-extraction-boundary.md) | Physics package extraction | Accepted | | [ADR-0008 Debug Overlay Primitive Contract And Playback Session](ADR-0008-debug-overlay-primitive-contract-and-playback-session.md) | Debug overlay / playback session | Accepted | | [ADR-0009 SuperDex Native C++ Scene Batch Executor](ADR-0009-superdex-persistent-cpu-workers.md) | Backend CPU scene execution | Accepted | +| [ADR-0010 Fixed Model Variant Ownership Boundary](ADR-0010-fixed-model-variant-ownership-boundary.md) | Fixed variants / cross-repository boundary | Proposed | ## ADR Governance diff --git a/docs/sphinx/source/adr/ADR-0010-fixed-model-variant-ownership-boundary.md b/docs/sphinx/source/adr/ADR-0010-fixed-model-variant-ownership-boundary.md new file mode 100644 index 000000000..228b7ac46 --- /dev/null +++ b/docs/sphinx/source/adr/ADR-0010-fixed-model-variant-ownership-boundary.md @@ -0,0 +1,127 @@ +--- +orphan: true +--- + +# ADR-0010 Fixed Model Variant Ownership Boundary + +语言: 简体中文 + +- Status: Accepted +- Date: 2026-09-13 +- Owners: Env / Config / Backend maintainers +- Supersedes: None +- Superseded by: None + +## Context + +[Discussion #1541](https://github.com/Motphys/UniLab/discussions/1541) +指出,legacy domain-randomization provider 与 SimToolReal 大量工具模型瓶颈有共同根因: +backend 缺少 per-env model identity / model-field indirection。因此 task 曾被迫在 +UniLab 侧编译多个 engine model,或在 reset 协议中扩展模型变更语义。 + +当前分层已经明确: + +- `mjbatch-uni` 拥有 CPU MuJoCo batch executor、same-layout mesh pooling 与 + topology-affine routing; +- UniSim 拥有 `SimBackend`、DR capability、backend adapters 与 engine-native + realization; +- UniLab 拥有 Hydra task owner config、EventManager/reset transaction 与 task identity。 + +本决策固定 fixed model/tool variant 的跨仓边界,避免后续 contract child 把 +executor 细节或 live engine objects 上移到 task 层。 + +## Decision + +### Ownership + +UniLab 只拥有任务选择语义: + +- 声明 named fixed model/tool variant source catalog; +- 在冷路径生成最终 env-to-variant assignment; +- 保持 assignment 在 backend construction/materialization 后不可变; +- 不打开、解析或编译 variant source,不持有 `MjSpec`、`MjModel`、mjbatch object、 + Warp array 或任何 backend-private handle。 + +UniSim 拥有唯一 public negotiation/realization contract: + +- 在既有 `DomainRandomizationCapabilities` 上声明 fixed-variant capability; +- 定义 pickle-safe 的 construction-time variant plan; +- 将 UniLab source descriptor 翻译为 backend-family preparation input; +- MuJoCo/MJWarp adapter 分别选择 executor realization; +- 声明 per-env playback 语义。 + +mjbatch-uni 与 MJWarp/mjlab 路径只作为 UniSim adapter 的 engine implementation。 +same-layout compiler coherence、mesh dedup、per-world arrays、CUDA graph capture 前 +初始化、derived constants 与 per-env playback 均不得成为 UniLab API。 + +### Task Configuration And Assignment + +`EnvCfg.fixed_model_variants` 是 task owner 的声明性 catalog。每个 entry 只有 +name 与 source path descriptor;assignment 支持 deterministic `round_robin` 或 +task 已经展开的 explicit names。materialization 输出 `int32`、形状 `(num_envs,)` +的 final index array,并标记 read-only。backend-local copies 可以存在,但不能改写 +task final identity。 + +Assignment 是 construction-time task identity,不在 reset 时重采样。reset event terms +只能在既有 model identity 内提交 curated model-field payload;mesh/tool identity 的 +变更必须重新构造 backend。 + +当前 schema 只承诺 same-public-layout variants:`nq`、`nv`、actuator/action shape、 +sensor layout 与 observation contract 必须一致。无法投影到统一 public layout 的 +heterogeneous topology 在本决策中 fail closed,等待独立 contract。 + +### Capability Negotiation + +fixed-variant 支持必须由 UniSim 的 DR capability object 显式声明为 +`supports_fixed_variants`。缺失与 `False` 等价并且 fail closed;UniLab 不得根据 +backend 名称、可选 package import、executor introspection 或异常降级推断支持。 + +在 UniSim U1/U2/U3 落地前,配置了 fixed variants 的 Manager-Based env 在创建 env +前失败,并清理已创建的 unsupported backend。legacy +`DomainRandomizationManager` / provider protocol 不获得该能力。 + +## Stable Contracts + +- Task catalog/final assignment: `src/unilab/base/variants.py` +- Owner config entry: `EnvCfg.fixed_model_variants` +- Hydra typed materialization: `src/unilab/base/config_materialization.py` +- Fail-closed Manager lifecycle guard: `src/unilab/envs/manager_based_rl_env.py` +- Contract tests: `tests/base/test_fixed_model_variants.py` +- UniSim extraction boundary: [ADR-0007](ADR-0007-unisim-extraction-boundary.md) + +## Alternatives Considered + +- 让 task config 直接持有或返回 `MjSpec`。拒绝原因:task YAML 变成 MuJoCo-specific, + UniSim 难以保持 MJWarp 兼容,且 public plan 不再 pickle-safe。 +- 在 UniLab 为每个 env 编译完整 model。拒绝原因:这正是 SimToolReal 内存和冷启动 + 瓶颈,并把 engine realization 上移到错误 owner。 +- 在 reset provider 中切换 model identity。拒绝原因:破坏派生常量、CUDA graph 和 + playback 的生命周期假设,也会延长 legacy DR 协议共存。 +- 暴露通用 dict/field-name mutation API。拒绝原因:engine schema 与 executor details + 会泄漏成 public contract,难以跨 MuJoCo/MJWarp 保持版本兼容。 + +## Consequences + +- Task owner 可以描述 600 个固定工具及最终 assignment,但实现无需把 600 个 live model + 带入 UniLab。 +- UniSim contract child 必须先提供 capability 与 construction-time plan,再让 task + rollout child 消费;不能要求 UniLab import `mjbatch`。 +- MuJoCo CPU 与 MJWarp 可以使用不同 realization,但必须接受同一个 neutral source + descriptor/final assignment,并保留各自 capability 差异。 +- Fixed identity 与 reset-time model-field DR 分离:前者 immutable,后者由 + Manager-Based reset transaction 一次性提交。 + +## Evidence In Repo + +- `src/unilab/base/variants.py` +- `src/unilab/base/base.py` +- `src/unilab/base/config_materialization.py` +- `src/unilab/envs/manager_based_rl_env.py` +- `tests/base/test_fixed_model_variants.py` + +## Related Documents + +- {doc}`ADR Index ` +- {doc}`ADR-0007 UniSim Extraction Boundary ` +- {doc}`Domain Randomization ` +- [Roadmap #1563](https://github.com/Motphys/UniLab/issues/1563) diff --git a/docs/sphinx/source/adr/README.md b/docs/sphinx/source/adr/README.md index 21d227aa0..109736c68 100644 --- a/docs/sphinx/source/adr/README.md +++ b/docs/sphinx/source/adr/README.md @@ -23,6 +23,7 @@ orphan: true | [ADR-0007 UniSim Extraction Boundary](ADR-0007-unisim-extraction-boundary.md) | Physics package extraction | Accepted | | [ADR-0008 Debug Overlay Primitive Contract And Playback Session](ADR-0008-debug-overlay-primitive-contract-and-playback-session.md) | Debug overlay / playback session | Accepted | | [ADR-0009 SuperDex Native C++ Scene Batch Executor](ADR-0009-superdex-persistent-cpu-workers.md) | Backend CPU scene execution | Accepted | +| [ADR-0010 Fixed Model Variant Ownership Boundary](ADR-0010-fixed-model-variant-ownership-boundary.md) | Fixed variants / cross-repository boundary | Proposed | ## ADR Governance diff --git a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md index 823bca27b..933c28b33 100644 --- a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md +++ b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md @@ -16,8 +16,8 @@ The unified entry point of the legacy provider path lives in `NpEnv._init_domain These three paths correspond to three lifecycle classes: -- **init-lifecycle DR**: items that change the model identity or model geometry; can only take effect during env/backend initialization and materialization, e.g. object `geom_size` scaling via model variants. -- **reset-lifecycle DR**: items that do not change model identity, only change parameters or reset state within the same model, e.g. `base_mass_delta`, `base_com_offset`, `gravity`, `kp`, `kd`. +- **construction-lifecycle identity**: fixed model/tool variants and their immutable env assignment take effect only during backend construction/materialization. +- **reset-lifecycle DR**: items that do not change model identity, only change parameters or reset state within the same model, e.g. `base_mass_delta`, `base_com_offset`, `gravity`, `kp`, `kd`, and backend-declared geometry/model fields. - **interval-lifecycle DR**: external perturbations between steps, e.g. push. ## Status Conclusions @@ -27,7 +27,7 @@ These three paths correspond to three lifecycle classes: 3. What is "unified" today is mainly the entry point and execution flow, not every randomization item itself. The legacy path's shared helper `build_common_reset_randomization()` currently generates `base_mass_delta`, `base_com_offset`, `gravity`, `kp`, `kd`. 4. `ResetRandomizationPayload` can already express `gravity`, `body_iquat`, `body_inertia`, `kp`, `kd`, and `MuJoCoBackend` has declared support. Whether these are actually used still depends on whether the task provider samples and dispatches them. 5. `MotrixBackend` currently supports `base_mass_delta`, `base_com_offset`, `kp`, `kd`, and interval push; and it requires all model actuators to be position actuators during initialization. -6. `geom_size` is not a reset-lifecycle field; object geom scale is handled by init-lifecycle model materialization. +6. Fixed mesh/tool identity is declared by `env.fixed_model_variants`; reset-time geometry fields remain behind backend capability declarations and never change that identity. ## Uniformity Assessment Table @@ -159,15 +159,38 @@ uv run train --algo ppo --task go1_joystick_flat --sim mujoco \ 'env.events.push_robot.interval_range_s=[10.0,10.0]' ``` -## `geom_size` Lifecycle Boundary +## Fixed Model/Tool Variant Boundary -`geom_size` is explicitly not part of `ResetRandomizationPayload`, and must not be modified on the hot path via `BatchEnvPool.reset(..., randomization=...)`. +Manager-Based owners declare fixed variants on the environment config. The task +owns names, source descriptors, and the final assignment; it does not compile a +model: -The reason is that `geom_size` changes model geometry and model identity; the correct lifecycle is: +```yaml +env: + fixed_model_variants: + variants: + - name: tool_a + source_model_file: tools/tool_a.xml + - name: tool_b + source_model_file: tools/tool_b.xml + assignment: + mode: round_robin +``` + +`materialize_fixed_model_variants(...)` turns the declaration into a read-only +`int32` assignment with shape `(num_envs,)`. An owner may instead provide every +name with `mode: explicit`. The assignment is task identity: it is fixed after +backend construction and is not resampled by reset events. -1. The task provider generates the model variants and env-to-model assignment in `build_init_randomization_plan(...)`. -2. The MuJoCo backend modifies geom size on the cold path using `MjSpec` and compiles scale-specific `MjModel`s. -3. The backend constructs `BatchEnvPool` with a model sequence of length `num_envs`. +UniLab does not open, parse, or compile `source_model_file`, and does not hold +`MjSpec`, `MjModel`, mjbatch, or Warp objects. UniSim adapters own source +realization and must declare `supports_fixed_variants`. Until that +contract lands, a task configured with fixed variants fails closed before env +construction. Heterogeneous variants that cannot expose one public +state/action/sensor layout also fail closed. + +The ownership boundary and MJWarp/CPU executor split are recorded in +{doc}`ADR-0010 `. ```{toctree} :hidden: @@ -175,10 +198,6 @@ The reason is that `geom_size` changes model geometry and model identity; the co 1-configuration 2-writing_providers ``` -4. The reset stage only performs state and parameter perturbations within the same model identity; it does not handle `geom_size`. - -This boundary exists to honor the cold-path asset/model-metadata access principle: `step()`, `reset()`, and hot-path DR do not parse XML, do not read assets, and do not branch at runtime based on asset metadata. - ## Related Tasks - {doc}`G1 Motion Tracking <../4-tasks/2-motion_tracking>`: confirm motion assets and replay first before enabling DR. diff --git a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md index 3cc68e0f6..b33696fa8 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md @@ -16,8 +16,8 @@ legacy provider 路径的统一入口点位于 `NpEnv._init_domain_randomization 这三条路径对应三个生命周期类别: -- **init 生命周期 DR**:改变模型 identity 或模型几何的项;只能在 env/backend 初始化和 materialization 期间生效,例如通过模型变体进行的物体 `geom_size` 缩放。 -- **reset 生命周期 DR**:不改变模型 identity,只在同一模型内改变参数或 reset 状态的项,例如 `base_mass_delta`、`base_com_offset`、`gravity`、`kp`、`kd`。 +- **construction 生命周期 identity**:固定 model/tool variant 及其 immutable env assignment,只在 backend construction/materialization 期间生效。 +- **reset 生命周期 DR**:不改变模型 identity,只在同一模型内改变参数或 reset 状态的项,例如 `base_mass_delta`、`base_com_offset`、`gravity`、`kp`、`kd`,以及 backend 显式声明支持的 geometry/model 字段。 - **interval 生命周期 DR**:step 之间的外部扰动,例如 push。 ## 状态结论 @@ -27,7 +27,7 @@ legacy provider 路径的统一入口点位于 `NpEnv._init_domain_randomization 3. 今天所"统一"的主要是入口点和执行流程,而不是每一个随机化项本身。legacy 路径的共享辅助函数 `build_common_reset_randomization()` 目前生成 `base_mass_delta`、`base_com_offset`、`gravity`、`kp`、`kd`。 4. `ResetRandomizationPayload` 已经可以表达 `gravity`、`body_iquat`、`body_inertia`、`kp`、`kd`,并且 `MuJoCoBackend` 已声明支持。这些是否实际被使用,仍取决于 task provider 是否对它们进行采样和 dispatch。 5. `MotrixBackend` 目前支持 `base_mass_delta`、`base_com_offset`、`kp`、`kd` 和 interval push;并且它要求在初始化期间所有模型 actuator 都是 position actuator。 -6. `geom_size` 不是 reset 生命周期字段;物体 geom 缩放由 init 生命周期的模型 materialization 处理。 +6. 固定 mesh/tool identity 由 `env.fixed_model_variants` 声明;reset-time geometry 字段仍位于 backend capability 声明之后,且不会改变该 identity。 ## 统一性评估表 @@ -155,15 +155,35 @@ uv run train --algo ppo --task go1_joystick_flat --sim mujoco \ 'env.events.push_robot.interval_range_s=[10.0,10.0]' ``` -## `geom_size` 生命周期边界 +## 固定 Model/Tool Variant 边界 -`geom_size` 明确不属于 `ResetRandomizationPayload`,并且不得在热路径上通过 `BatchEnvPool.reset(..., randomization=...)` 修改。 +Manager-Based owner 在 environment config 中声明 fixed variants。Task 拥有 +名称、source descriptor 和最终 assignment,但不编译模型: -原因在于 `geom_size` 会改变模型几何和模型 identity;正确的生命周期是: +```yaml +env: + fixed_model_variants: + variants: + - name: tool_a + source_model_file: tools/tool_a.xml + - name: tool_b + source_model_file: tools/tool_b.xml + assignment: + mode: round_robin +``` + +`materialize_fixed_model_variants(...)` 会把它物化为形状 `(num_envs,)`、只读的 +`int32` assignment。Owner 也可以用 `mode: explicit` 提供全部名称。Assignment 是 +task identity:backend construction 后固定,reset event 不会重新采样。 -1. task provider 在 `build_init_randomization_plan(...)` 中生成模型变体以及 env 到模型的分配。 -2. MuJoCo 后端在冷路径上使用 `MjSpec` 修改 geom size,并编译 scale 专属的 `MjModel`。 -3. 后端使用长度为 `num_envs` 的模型序列构造 `BatchEnvPool`。 +UniLab 不打开、解析或编译 `source_model_file`,也不持有 `MjSpec`、`MjModel`、 +mjbatch 或 Warp object。UniSim adapter 负责 source realization,并必须声明 +`supports_fixed_variants`。在该 contract 落地前,配置 fixed variants 的 +task 会在 env 构造前 fail closed。无法投影到统一 public +state/action/sensor layout 的 heterogeneous variants 同样 fail closed。 + +所有权边界以及 MJWarp/CPU executor 分工记录在 +{doc}`ADR-0010 `。 ```{toctree} :hidden: @@ -171,10 +191,6 @@ uv run train --algo ppo --task go1_joystick_flat --sim mujoco \ 1-configuration 2-writing_providers ``` -4. reset 阶段只在同一模型 identity 内执行状态和参数扰动;它不处理 `geom_size`。 - -这条边界存在的目的是遵循冷路径 asset/model-metadata 访问原则:`step()`、`reset()` 和热路径 DR 不解析 XML、不读取 asset,也不在运行时基于 asset 元数据进行分支。 - ## 相关任务 - {doc}`G1 Motion Tracking <../4-tasks/2-motion_tracking>`:开启 DR 前先确认 motion 资产和 replay。 diff --git a/src/unilab/base/base.py b/src/unilab/base/base.py index 91c4205d2..225743fe7 100644 --- a/src/unilab/base/base.py +++ b/src/unilab/base/base.py @@ -10,6 +10,7 @@ from unisim.backend.base import BackendPlayRenderPlan, CameraCfg, DebugOverlayGetter from .scene import SceneCfg +from .variants import FixedModelVariantCatalogCfg OnPlaybackFrameFn = Callable[[int, np.ndarray], "np.ndarray | None"] @@ -33,6 +34,9 @@ class EnvCfg: """ scene: SceneCfg | None = None + # Task-owned identity selected once during backend construction. The + # descriptor remains engine-neutral; UniLab never compiles or opens it. + fixed_model_variants: FixedModelVariantCatalogCfg | None = None sim_dt: float = 0.01 max_episode_seconds: Optional[float] = None ctrl_dt: float = 0.01 @@ -118,6 +122,13 @@ def validate(self): """ if self.sim_dt > self.ctrl_dt: raise ValueError("sim_dt must be less than or equal to ctrl_dt") + if self.fixed_model_variants is not None and not isinstance( + self.fixed_model_variants, FixedModelVariantCatalogCfg + ): + raise TypeError( + "fixed_model_variants must be FixedModelVariantCatalogCfg or None, " + f"got {type(self.fixed_model_variants).__name__}" + ) if ( isinstance(self.superdex_num_workers, bool) or not isinstance(self.superdex_num_workers, int) diff --git a/src/unilab/base/config_materialization.py b/src/unilab/base/config_materialization.py index 1fe694952..4a070a48b 100644 --- a/src/unilab/base/config_materialization.py +++ b/src/unilab/base/config_materialization.py @@ -140,8 +140,14 @@ def _prepare_value(value: Any, *, annotation: Any, path: str) -> Any: for key, item in values.items() } if isinstance(value, list): + origin = get_origin(annotation) + args = get_args(annotation) + if origin in (list, tuple) and args: + item_annotation = args[0] + else: + item_annotation = Any return [ - _prepare_value(item, annotation=Any, path=f"{path}[{index}]") + _prepare_value(item, annotation=item_annotation, path=f"{path}[{index}]") for index, item in enumerate(value) ] return value diff --git a/src/unilab/base/variants.py b/src/unilab/base/variants.py new file mode 100644 index 000000000..e4e12451b --- /dev/null +++ b/src/unilab/base/variants.py @@ -0,0 +1,318 @@ +"""Task-owned fixed model variants and immutable assignment materialization. + +UniLab owns *which* variant each environment uses. It does not compile engine +models or select an executor representation; those responsibilities stay behind +the UniSim backend contract. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +import numpy as np +from unisim.dr.types import ( + DomainRandomizationCapabilities, + FixedVariantLayout, + FixedVariantPlan, + ModelSourceDescriptor, +) + + +@dataclass(frozen=True) +class FixedModelVariantCfg: + """One named, pickle-safe source descriptor for a fixed model variant.""" + + name: str + source_model_file: str + + +@dataclass(frozen=True) +class FixedModelVariantAssignmentCfg: + """Declare how a task maps environments to named fixed variants.""" + + mode: Literal["round_robin", "explicit"] = "round_robin" + explicit_variant_names: tuple[str, ...] = field(default_factory=tuple) + + def __post_init__(self) -> None: + object.__setattr__( + self, "explicit_variant_names", _string_tuple(self.explicit_variant_names) + ) + if self.mode not in ("round_robin", "explicit"): + raise ValueError( + "FixedModelVariantAssignmentCfg.mode must be 'round_robin' or 'explicit'; " + f"got {self.mode!r}" + ) + for index, name in enumerate(self.explicit_variant_names): + if not name.strip(): + raise ValueError( + "FixedModelVariantAssignmentCfg.explicit_variant_names" + f"[{index}] must be a non-empty string" + ) + + +@dataclass(frozen=True) +class FixedModelVariantCatalogCfg: + """Task-owned catalog of same-public-layout model/tool sources.""" + + variants: tuple[FixedModelVariantCfg, ...] = field(default_factory=tuple) + assignment: FixedModelVariantAssignmentCfg = field( + default_factory=FixedModelVariantAssignmentCfg + ) + + def __post_init__(self) -> None: + if not isinstance(self.variants, (list, tuple)): + raise TypeError( + "FixedModelVariantCatalogCfg.variants must be a sequence of " + f"FixedModelVariantCfg, got {type(self.variants).__name__}" + ) + object.__setattr__(self, "variants", tuple(self.variants)) + if not isinstance(self.assignment, FixedModelVariantAssignmentCfg): + raise TypeError( + "FixedModelVariantCatalogCfg.assignment must be " + f"FixedModelVariantAssignmentCfg, got {type(self.assignment).__name__}" + ) + _validate_catalog(self) + + +@dataclass(frozen=True) +class FixedModelVariantMaterialization: + """The final immutable variant selection handed to the owner boundary. + + ``model_assignments`` is an ``int32`` array with shape ``(num_envs,)`` and + is marked read-only. Consumers that need a writable projection must call + :meth:`copy_model_assignments`; they must never mutate the final task + identity in place. + """ + + variants: tuple[FixedModelVariantCfg, ...] + model_assignments: np.ndarray + + def __post_init__(self) -> None: + if not isinstance(self.variants, (list, tuple)): + raise TypeError( + "FixedModelVariantMaterialization.variants must be a sequence of " + f"FixedModelVariantCfg, got {type(self.variants).__name__}" + ) + object.__setattr__(self, "variants", tuple(self.variants)) + if not isinstance(self.model_assignments, np.ndarray): + raise TypeError( + "Fixed model variant model_assignments must be np.ndarray, " + f"got {type(self.model_assignments).__name__}" + ) + if self.model_assignments.dtype.kind not in "iu": + raise ValueError( + "Fixed model variant model_assignments must have an integer dtype; " + f"got {self.model_assignments.dtype}" + ) + object.__setattr__( + self, + "model_assignments", + np.ascontiguousarray(self.model_assignments, dtype=np.int32), + ) + self.model_assignments.setflags(write=False) + + @property + def variant_names(self) -> tuple[str, ...]: + return tuple(variant.name for variant in self.variants) + + def copy_model_assignments(self) -> np.ndarray: + """Return a writable backend-local copy of the final assignment.""" + + return np.array(self.model_assignments, dtype=np.int32, copy=True) + + +def _validate_catalog(catalog: FixedModelVariantCatalogCfg) -> None: + if not catalog.variants: + raise ValueError("FixedModelVariantCatalogCfg.variants must not be empty") + _validate_variants(catalog.variants, "FixedModelVariantCatalogCfg.variants") + if catalog.assignment.mode == "round_robin" and catalog.assignment.explicit_variant_names: + raise ValueError( + "FixedModelVariantAssignmentCfg.explicit_variant_names must be empty " + "when assignment mode is 'round_robin'" + ) + + +def _validate_variants(variants: tuple[FixedModelVariantCfg, ...], label_prefix: str) -> None: + if not variants: + raise ValueError(f"{label_prefix} must not be empty") + + names: set[str] = set() + for index, variant in enumerate(variants): + label = f"{label_prefix}[{index}]" + if not isinstance(variant, FixedModelVariantCfg): + raise TypeError(f"{label} must be FixedModelVariantCfg, got {type(variant).__name__}") + if not isinstance(variant.name, str) or not variant.name.strip(): + raise ValueError(f"{label}.name must be a non-empty string") + if not isinstance(variant.source_model_file, str) or not variant.source_model_file.strip(): + raise ValueError( + f"{label}('{variant.name}').source_model_file must be a non-empty string" + ) + if variant.name in names: + raise ValueError( + "Fixed model variant names must be unique; duplicate " + f"{variant.name!r} was declared more than once" + ) + names.add(variant.name) + + +def _string_tuple(values: object) -> tuple[str, ...]: + if isinstance(values, str) or not isinstance(values, (list, tuple)): + raise TypeError(f"Expected a sequence of strings, got {type(values).__name__}") + result = tuple(values) + if any(not isinstance(value, str) for value in result): + kinds = sorted({type(value).__name__ for value in result if not isinstance(value, str)}) + raise TypeError(f"Expected a sequence of strings, got {kinds}") + return result + + +def materialize_fixed_model_variants( + catalog: FixedModelVariantCatalogCfg, num_envs: int +) -> FixedModelVariantMaterialization: + """Materialize a final assignment without touching an engine object. + + This is deliberately a cold-path operation: it resolves only task names and + integer indices. It never opens or compiles ``source_model_file``. + """ + + _validate_catalog(catalog) + if isinstance(num_envs, bool) or not isinstance(num_envs, (int, np.integer)): + raise TypeError(f"num_envs must be a positive integer, got {num_envs!r}") + if num_envs <= 0: + raise ValueError(f"num_envs must be positive, got {num_envs}") + + variant_indices = {variant.name: index for index, variant in enumerate(catalog.variants)} + if catalog.assignment.mode == "round_robin": + if catalog.assignment.explicit_variant_names: + raise ValueError( + "FixedModelVariantAssignmentCfg.explicit_variant_names must be empty " + "when assignment mode is 'round_robin'" + ) + assignments = np.arange(num_envs, dtype=np.int32) % np.int32(len(catalog.variants)) + else: + requested = catalog.assignment.explicit_variant_names + if len(requested) != num_envs: + raise ValueError( + "Explicit fixed-variant assignment must contain exactly num_envs names; " + f"expected {num_envs}, got {len(requested)}" + ) + unknown = [name for name in requested if name not in variant_indices] + if unknown: + available = [variant.name for variant in catalog.variants] + raise ValueError( + f"Explicit fixed-variant assignment references unknown variants {unknown}; " + f"available variants are {available}" + ) + assignments = np.fromiter( + (variant_indices[name] for name in requested), + dtype=np.int32, + count=num_envs, + ) + + materialization = FixedModelVariantMaterialization(catalog.variants, assignments) + validate_fixed_model_variant_materialization(materialization, num_envs) + return materialization + + +def prepare_fixed_model_variants( + catalog: FixedModelVariantCatalogCfg, + num_envs: int, + capabilities: DomainRandomizationCapabilities, +) -> FixedModelVariantMaterialization: + """Materialize a task assignment after negotiating the single DR contract. + + This owner-layer helper is the integration seam used by env construction. + It intentionally accepts only the already-materialized backend capability + object; it never probes a backend type or optional engine package. + """ + + require_fixed_model_variant_support(capabilities) + return materialize_fixed_model_variants(catalog, num_envs) + + +def build_fixed_variant_plan( + materialization: FixedModelVariantMaterialization, +) -> FixedVariantPlan: + """Translate the final task selection into UniSim's neutral variant plan.""" + + num_envs = int(materialization.model_assignments.size) + validate_fixed_model_variant_materialization(materialization, num_envs) + return FixedVariantPlan( + assignment=materialization.model_assignments, + variants=tuple( + ModelSourceDescriptor(model_file=variant.source_model_file) + for variant in materialization.variants + ), + layout=FixedVariantLayout.SAME_LAYOUT, + ) + + +def validate_fixed_model_variant_materialization( + materialization: FixedModelVariantMaterialization, + num_envs: int, + *, + require_immutable_assignment: bool = True, +) -> None: + """Validate the final assignment shape, range, and immutable state.""" + + if not isinstance(materialization, FixedModelVariantMaterialization): + raise TypeError( + "Fixed model variant materialization must be " + f"FixedModelVariantMaterialization, got {type(materialization).__name__}" + ) + if isinstance(num_envs, bool) or not isinstance(num_envs, (int, np.integer)) or num_envs <= 0: + raise ValueError(f"num_envs must be positive, got {num_envs!r}") + _validate_variants(materialization.variants, "FixedModelVariantMaterialization.variants") + assignments = materialization.model_assignments + if not isinstance(assignments, np.ndarray): + raise TypeError( + "Fixed model variant model_assignments must be np.ndarray, " + f"got {type(assignments).__name__}" + ) + if assignments.shape != (num_envs,): + raise ValueError( + f"Fixed model variant model_assignments must have shape ({num_envs},); " + f"got {assignments.shape}" + ) + if assignments.dtype.kind not in "iu": + raise ValueError( + "Fixed model variant model_assignments must have an integer dtype; " + f"got {assignments.dtype}" + ) + if np.any(assignments < 0) or np.any(assignments >= len(materialization.variants)): + raise ValueError("Fixed model variant model_assignments contains an out-of-range index") + if require_immutable_assignment and assignments.flags.writeable: + raise ValueError( + "Final fixed model variant model_assignments must be read-only after materialization" + ) + + +def require_fixed_model_variant_support( + capabilities: DomainRandomizationCapabilities, +) -> None: + """Fail closed unless UniSim explicitly declares fixed-variant support. + + The authoritative declaration remains UniSim's DR capability object. This + helper intentionally does not infer support from a backend type, installed + engine, or optional import. + """ + + declared = getattr(capabilities, "supports_fixed_variants", False) + if declared is not True: + raise NotImplementedError( + f"{type(capabilities).__name__} does not support fixed model variants " + f"(supports_fixed_variants={declared!r})" + ) + + +__all__ = [ + "FixedModelVariantAssignmentCfg", + "FixedModelVariantCatalogCfg", + "FixedModelVariantCfg", + "FixedModelVariantMaterialization", + "build_fixed_variant_plan", + "materialize_fixed_model_variants", + "prepare_fixed_model_variants", + "require_fixed_model_variant_support", + "validate_fixed_model_variant_materialization", +] diff --git a/src/unilab/envs/manager_based_rl_env.py b/src/unilab/envs/manager_based_rl_env.py index d938118ef..4b605f313 100644 --- a/src/unilab/envs/manager_based_rl_env.py +++ b/src/unilab/envs/manager_based_rl_env.py @@ -27,6 +27,11 @@ from unilab.base.np_env import NpEnv, NpEnvState from unilab.base.reset_state import ResetStateTransaction from unilab.base.scene import SceneCfg, resolve_scene_default_qpos +from unilab.base.variants import ( + build_fixed_variant_plan, + materialize_fixed_model_variants, + require_fixed_model_variant_support, +) from unilab.dtype_config import get_global_dtype from unilab.managers import ( ActionManager, @@ -779,6 +784,11 @@ def make_manager_based_rl_env( ) cfg.validate() + if cfg.fixed_model_variants is not None: + # Validate the complete task identity before allocating backend resources. + cfg.scene.fixed_variant_plan = build_fixed_variant_plan( + materialize_fixed_model_variants(cfg.fixed_model_variants, num_envs) + ) # Constrain the process before backend materialization so native pools size # themselves from the rank-owned CPU block. apply_env_cpu_runtime(cfg.cpu_ids) @@ -796,6 +806,8 @@ def make_manager_based_rl_env( **backend_kwargs, ) try: + if cfg.scene.fixed_variant_plan is not None: + require_fixed_model_variant_support(backend.get_dr_capabilities()) return ManagerBasedRlEnv(cfg, backend, num_envs) except Exception: backend.cleanup_scene_assets() diff --git a/tests/base/test_fixed_model_variants.py b/tests/base/test_fixed_model_variants.py new file mode 100644 index 000000000..53eb2a1ab --- /dev/null +++ b/tests/base/test_fixed_model_variants.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import numpy as np +import pytest +from omegaconf import OmegaConf +from unisim.dr.types import DomainRandomizationCapabilities + +from unilab.base.base import EnvCfg +from unilab.base.config_materialization import apply_cfg_overrides +from unilab.base.entity import EntityCfg +from unilab.base.scene import SceneCfg +from unilab.base.variants import ( + FixedModelVariantAssignmentCfg, + FixedModelVariantCatalogCfg, + FixedModelVariantCfg, + FixedModelVariantMaterialization, + build_fixed_variant_plan, + materialize_fixed_model_variants, + prepare_fixed_model_variants, + require_fixed_model_variant_support, + validate_fixed_model_variant_materialization, +) +from unilab.envs import manager_based_rl_env +from unilab.envs.manager_based_rl_env import ManagerBasedRlEnvCfg, make_manager_based_rl_env + + +def _catalog( + mode: Literal["round_robin", "explicit"] = "round_robin", + names: tuple[str, ...] = (), +) -> FixedModelVariantCatalogCfg: + return FixedModelVariantCatalogCfg( + variants=( + FixedModelVariantCfg("tool_a", "tools/a.xml"), + FixedModelVariantCfg("tool_b", "tools/b.xml"), + ), + assignment=FixedModelVariantAssignmentCfg(mode=mode, explicit_variant_names=names), + ) + + +def test_hydra_materializes_typed_catalog_and_assignment() -> None: + cfg = EnvCfg() + + apply_cfg_overrides( + cfg, + OmegaConf.create( + { + "fixed_model_variants": { + "variants": [ + {"name": "tool_a", "source_model_file": "tools/a.xml"}, + {"name": "tool_b", "source_model_file": "tools/b.xml"}, + ], + "assignment": {"mode": "explicit", "explicit_variant_names": ["tool_b"]}, + } + } + ), + ) + cfg.validate() + + assert cfg.fixed_model_variants is not None + assert isinstance(cfg.fixed_model_variants, FixedModelVariantCatalogCfg) + assert cfg.fixed_model_variants.variants == ( + FixedModelVariantCfg("tool_a", "tools/a.xml"), + FixedModelVariantCfg("tool_b", "tools/b.xml"), + ) + assert cfg.fixed_model_variants.assignment == FixedModelVariantAssignmentCfg( + mode="explicit", explicit_variant_names=("tool_b",) + ) + + +def test_round_robin_materialization_is_final_and_immutable() -> None: + materialization = materialize_fixed_model_variants(_catalog(), num_envs=5) + + assert materialization.variant_names == ("tool_a", "tool_b") + np.testing.assert_array_equal( + materialization.model_assignments, np.array([0, 1, 0, 1, 0], dtype=np.int32) + ) + assert not materialization.model_assignments.flags.writeable + with pytest.raises(ValueError, match="assignment destination is read-only"): + materialization.model_assignments[0] = 1 + copied = materialization.copy_model_assignments() + assert copied.flags.writeable + np.testing.assert_array_equal(copied, materialization.model_assignments) + + +def test_explicit_assignment_uses_names_not_engine_objects() -> None: + materialization = materialize_fixed_model_variants( + _catalog(mode="explicit", names=("tool_b", "tool_a", "tool_b")), num_envs=3 + ) + + np.testing.assert_array_equal( + materialization.model_assignments, np.array([1, 0, 1], dtype=np.int32) + ) + validate_fixed_model_variant_materialization(materialization, num_envs=3) + + +@pytest.mark.parametrize( + ("names", "num_envs", "match"), + [ + (("tool_a",), 2, "exactly num_envs"), + (("tool_a", "missing", "tool_b"), 3, "unknown variants"), + ], +) +def test_explicit_assignment_fail_closed(names: tuple[str, ...], num_envs: int, match: str) -> None: + with pytest.raises(ValueError, match=match): + materialize_fixed_model_variants(_catalog(mode="explicit", names=names), num_envs=num_envs) + + +def test_catalog_rejects_duplicate_and_empty_sources() -> None: + with pytest.raises(ValueError, match="duplicate 'tool_a'"): + FixedModelVariantCatalogCfg( + variants=( + FixedModelVariantCfg("tool_a", "a.xml"), + FixedModelVariantCfg("tool_a", "b.xml"), + ) + ) + with pytest.raises(ValueError, match="must not be empty"): + FixedModelVariantCatalogCfg() + with pytest.raises(ValueError, match="source_model_file must be a non-empty string"): + FixedModelVariantCatalogCfg(variants=(FixedModelVariantCfg("tool_a", " "),)) + + +def test_final_assignment_validation_rejects_a_writable_array() -> None: + materialization = materialize_fixed_model_variants(_catalog(), num_envs=2) + writable = np.arange(2, dtype=np.int32) + object.__setattr__(materialization, "model_assignments", writable) + + with pytest.raises(ValueError, match="must be read-only"): + validate_fixed_model_variant_materialization(materialization, num_envs=2) + + +def test_fixed_variant_support_fails_closed_on_one_capability_contract() -> None: + catalog = _catalog() + + with pytest.raises(NotImplementedError, match="does not support"): + require_fixed_model_variant_support(DomainRandomizationCapabilities()) + + materialization = prepare_fixed_model_variants( + catalog, + 2, + DomainRandomizationCapabilities(supports_fixed_variants=True), + ) + validate_fixed_model_variant_materialization(materialization, num_envs=2) + + +def test_fixed_variant_materialization_builds_unisim_plan() -> None: + materialization = materialize_fixed_model_variants(_catalog(), num_envs=2) + plan = build_fixed_variant_plan(materialization) + + assert plan.layout.value == "same_layout" + np.testing.assert_array_equal(plan.assignment, materialization.model_assignments) + assert tuple(variant.model_file for variant in plan.variants) == ( + "tools/a.xml", + "tools/b.xml", + ) + assert not plan.assignment.flags.writeable + + +def test_variant_owner_module_does_not_reference_engine_internals() -> None: + repo_root = Path(__file__).parents[2] + source = (repo_root / "src" / "unilab" / "base" / "variants.py").read_text(encoding="utf-8") + manager_source = (repo_root / "src" / "unilab" / "envs" / "manager_based_rl_env.py").read_text( + encoding="utf-8" + ) + + assert "mjbatch" not in source + assert "MjSpec" not in source + assert "mujoco" not in source + assert "mjbatch" not in manager_source + assert "MjSpec" not in manager_source + + +def test_manager_env_fails_closed_before_variant_consumption( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class UnsupportedBackend: + backend_type = "test" + num_envs = 2 + cleaned = False + + def get_dr_capabilities(self) -> DomainRandomizationCapabilities: + return DomainRandomizationCapabilities() + + def cleanup_scene_assets(self) -> None: + self.cleaned = True + + backend = UnsupportedBackend() + cfg = ManagerBasedRlEnvCfg( + scene=SceneCfg( + model_file="scene.xml", + entities={"robot": EntityCfg(root_body_name="base")}, + ), + max_episode_seconds=1.0, + fixed_model_variants=_catalog(), + ) + monkeypatch.setattr(manager_based_rl_env, "env_backend_kwargs", lambda _cfg: {}) + monkeypatch.setattr( + manager_based_rl_env, + "create_backend", + lambda *_args, **_kwargs: backend, + ) + + def _fail_if_env_is_constructed(*_args: object, **_kwargs: object) -> None: + raise AssertionError("unsupported variants must fail before env construction") + + monkeypatch.setattr(manager_based_rl_env, "ManagerBasedRlEnv", _fail_if_env_is_constructed) + + with pytest.raises(NotImplementedError, match="does not support"): + make_manager_based_rl_env(cfg, num_envs=2, backend_type="mujoco") + + assert backend.cleaned is True + assert cfg.scene is not None + assert cfg.scene.fixed_variant_plan is not None + np.testing.assert_array_equal( + cfg.scene.fixed_variant_plan.assignment, np.array([0, 1], dtype=np.int32) + ) From 3d767a85b25e90419086ec48f78244c37d53f4cf Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 18:15:35 +0800 Subject: [PATCH 02/13] feat: consume per-world reset defaults --- ...-fixed-model-variant-ownership-boundary.md | 5 + .../5-domain_randomization/0-index.md | 7 + .../2-contracts/4-dr_contract.md | 22 + .../5-domain_randomization/0-index.md | 5 + .../2-contracts/4-dr_contract.md | 21 + src/unilab/base/entity.py | 12 +- src/unilab/base/reset_state.py | 393 +++++++++++------- src/unilab/envs/manager_based_rl_env.py | 2 +- src/unilab/envs/mdp/events.py | 164 +++++--- src/unilab/tasks/locomotion/go2/footstand.py | 12 +- tests/base/test_entity_facade.py | 61 ++- tests/base/test_fixed_model_variants.py | 1 - tests/base/test_reset_state.py | 122 +++++- tests/envs/mdp/test_events.py | 270 +++++++++--- 14 files changed, 805 insertions(+), 292 deletions(-) diff --git a/docs/sphinx/source/adr/ADR-0010-fixed-model-variant-ownership-boundary.md b/docs/sphinx/source/adr/ADR-0010-fixed-model-variant-ownership-boundary.md index 228b7ac46..95d4010ad 100644 --- a/docs/sphinx/source/adr/ADR-0010-fixed-model-variant-ownership-boundary.md +++ b/docs/sphinx/source/adr/ADR-0010-fixed-model-variant-ownership-boundary.md @@ -110,6 +110,9 @@ backend 名称、可选 package import、executor introspection 或异常降级 descriptor/final assignment,并保留各自 capability 差异。 - Fixed identity 与 reset-time model-field DR 分离:前者 immutable,后者由 Manager-Based reset transaction 一次性提交。 +- Manager model-field 默认值只能来自 UniSim 的 + `SimBackend.get_reset_term_default(term)`;canonical 与 per-world 表由 backend + 权威返回,UniLab 不再为了 `body_inertia` 重新编译 MuJoCo scene。 ## Evidence In Repo @@ -117,6 +120,8 @@ backend 名称、可选 package import、executor introspection 或异常降级 - `src/unilab/base/base.py` - `src/unilab/base/config_materialization.py` - `src/unilab/envs/manager_based_rl_env.py` +- `src/unilab/base/reset_state.py` +- `src/unilab/base/entity.py` - `tests/base/test_fixed_model_variants.py` ## Related Documents diff --git a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md index 933c28b33..6b8cb4cec 100644 --- a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md +++ b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md @@ -189,6 +189,13 @@ contract lands, a task configured with fixed variants fails closed before env construction. Heterogeneous variants that cannot expose one public state/action/sensor layout also fail closed. +Reset-time model-field DR stays inside the selected identity. Its canonical or +per-env baselines come from the backend's declared +`get_reset_term_default(term)` contract; Manager terms do not recompile the +scene or apply a canonical baseline to every tool. When only part of a model +table is randomized, unwritten columns retain the selected environment's +variant baseline. + The ownership boundary and MJWarp/CPU executor split are recorded in {doc}`ADR-0010 `. diff --git a/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md b/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md index c7f15979b..8d4557722 100644 --- a/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md +++ b/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md @@ -43,6 +43,28 @@ DR item when three pieces exist together: MuJoCo and Motrix differences stay in backend capability declarations, backend implementations, and owner YAMLs. +## Manager Reset Defaults + +Manager-Based model-field terms do not compile engine assets or call legacy +per-field getters. During cold-path binding, `ResetStateTransaction` asks +UniSim for `SimBackend.get_reset_term_default(term)` and validates the result +before exposing immutable columns to an `Entity`. + +The returned table is authoritative and has one of two layouts: + +- canonical model table, such as `(nbody,)` for `body_mass`; +- per-environment fixed-variant table, such as `(num_envs, nbody)`. + +For a selected reset subset, event terms use the corresponding per-env rows as +their baseline. A write to a subset of model columns fills every unwritten +column from that same env row before the transaction builds one dense payload. +Missing capabilities, unsupported terms, non-floating tables, invalid tails, and +per-env tables whose first dimension is not `num_envs` fail closed. + +This boundary removes the former UniLab-side MuJoCo recompilation used to +obtain `body_inertia` defaults; inertial identity and defaults belong to the +backend that realized the fixed model variant. + ## Interval Term Descriptors Interval plans are term-descriptor based: `IntervalRandomizationPlan.ops` diff --git a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md index b33696fa8..5abfcc438 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md @@ -182,6 +182,11 @@ mjbatch 或 Warp object。UniSim adapter 负责 source realization,并必须 task 会在 env 构造前 fail closed。无法投影到统一 public state/action/sensor layout 的 heterogeneous variants 同样 fail closed。 +Reset-time model-field DR 保持在已选 identity 内。其 canonical 或 per-env +基线来自 backend 声明的 `get_reset_term_default(term)` contract;Manager term +不会重新编译场景,也不会把 canonical 基线套到每个工具上。只随机化模型表的一 +部分时,未写入的列保留所选环境的 variant 基线。 + 所有权边界以及 MJWarp/CPU executor 分工记录在 {doc}`ADR-0010 `。 diff --git a/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md b/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md index aa7f118f5..a03361691 100644 --- a/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md +++ b/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md @@ -40,6 +40,27 @@ Backend 支持是显式的。只有当以下三个部分同时存在时,一个 MuJoCo 与 Motrix 的差异保留在 backend 能力声明、backend 实现与 owner YAML 中。 +## Manager Reset 默认值 + +Manager-Based model-field term 不编译 engine asset,也不调用旧版按字段拆分的 +getter。冷路径绑定时,`ResetStateTransaction` 通过 UniSim 的 +`SimBackend.get_reset_term_default(term)` 获取默认值,先做校验,再向 +`Entity` 暴露不可变列绑定。 + +返回表是权威默认值,只有两种布局: + +- canonical model table,例如 `body_mass` 的 `(nbody,)`; +- per-environment fixed-variant table,例如 `(num_envs, nbody)`。 + +对一次 reset 的 selected rows,event term 使用对应 env row 作为基线。只写模型 +列子集时,transaction 会先用同一 env row 的默认值补齐未写列,然后构造一次 +dense payload。能力缺失、term 不支持、非浮点表、tail 形状错误,以及首维不是 +`num_envs` 的 per-env 表都会 fail closed。 + +该边界移除了此前 UniLab 为获取 `body_inertia` 默认值而重新编译 MuJoCo 场景的 +路径;惯量 identity 与默认值由完成 fixed model variant realization 的 backend +拥有。 + ## Interval Term 描述符 Interval plan 基于 term 描述符:`IntervalRandomizationPlan.ops` 携带一个 diff --git a/src/unilab/base/entity.py b/src/unilab/base/entity.py index 86e0de0fb..436a58a1f 100644 --- a/src/unilab/base/entity.py +++ b/src/unilab/base/entity.py @@ -1803,16 +1803,12 @@ def bind_body_inertia_write( self, body_ids: np.ndarray | Sequence[int] | slice | None = None, *, - default: np.ndarray, - default_mass: np.ndarray, term_name: str, ) -> tuple[np.ndarray, np.ndarray]: - """Bind entity-local body columns and caller-compiled default inertias. + """Bind entity-local body columns and authoritative default inertias. - ``default`` / ``default_mass`` are the full backend-width inertial - tables compiled from the scene model on the cold path; the transaction - cross-validates ``default_mass`` against the backend's authoritative - body-mass table before trusting the inertia rows. + The backend returns either canonical or per-environment default rows; + entity-local columns are selected without compiling a model in UniLab. """ reset_state, local_ids, backend_ids = self._bind_body_randomization( body_ids, @@ -1820,8 +1816,6 @@ def bind_body_inertia_write( ) _, defaults = reset_state.bind_body_inertia_write( backend_ids, - default=default, - default_mass=default_mass, term_name=f"{term_name}:{self.name}", ) return self._readonly_local_binding(local_ids, defaults) diff --git a/src/unilab/base/reset_state.py b/src/unilab/base/reset_state.py index 05e32cd2d..f883a6ed7 100644 --- a/src/unilab/base/reset_state.py +++ b/src/unilab/base/reset_state.py @@ -33,6 +33,29 @@ from unilab.utils.rotation import np_quat_apply_inverse +_RANDOMIZATION_TERM_TAILS: dict[str, tuple[int, ...]] = { + RESET_TERM_BODY_INERTIA: (3,), + RESET_TERM_BODY_MASS: (), + RESET_TERM_BODY_IPOS: (3,), + RESET_TERM_DOF_ARMATURE: (), + RESET_TERM_DOF_DAMPING: (), + RESET_TERM_DOF_FRICTIONLOSS: (), + RESET_TERM_GEOM_FRICTION: (3,), + RESET_TERM_GEOM_SIZE: (3,), + RESET_TERM_GEOM_SOLIMP: (5,), + RESET_TERM_GEOM_SOLREF: (2,), + RESET_TERM_GRAVITY: (), + RESET_TERM_KD: (), + RESET_TERM_KP: (), +} + + +def _randomization_term_tail(field: str) -> tuple[int, ...]: + try: + return _RANDOMIZATION_TERM_TAILS[field] + except KeyError as exc: + raise ValueError(f"unknown reset randomization term {field!r}") from exc + class ResetStateTransaction: """Reusable, fail-closed transaction for reset-mode state mutation.""" @@ -131,17 +154,21 @@ def bind_geom_size_write( """Bind immutable geom_size defaults through the declared backend capability.""" default = self._materialize_randomization_default( RESET_TERM_GEOM_SIZE, - getter=self._backend.get_geom_sizes, expected_tail=(3,), term_name=term_name, ) columns = self._validate_columns( column_ids, - width=default.shape[0], + width=self._randomization_default_width(default, field=RESET_TERM_GEOM_SIZE), capability="geom_size IDs", term_name=term_name, ) - return self._readonly_binding(columns, default[columns]) + selected = self._select_randomization_default_columns( + default, + columns, + field=RESET_TERM_GEOM_SIZE, + ) + return self._readonly_binding(columns, selected) def write_geom_size( self, @@ -170,17 +197,21 @@ def bind_geom_solref_write( """Bind immutable geom_solref defaults through the declared backend capability.""" default = self._materialize_randomization_default( RESET_TERM_GEOM_SOLREF, - getter=self._backend.get_geom_solref, expected_tail=(2,), term_name=term_name, ) columns = self._validate_columns( column_ids, - width=default.shape[0], + width=self._randomization_default_width(default, field=RESET_TERM_GEOM_SOLREF), capability="geom_solref IDs", term_name=term_name, ) - return self._readonly_binding(columns, default[columns]) + selected = self._select_randomization_default_columns( + default, + columns, + field=RESET_TERM_GEOM_SOLREF, + ) + return self._readonly_binding(columns, selected) def write_geom_solref( self, @@ -209,17 +240,21 @@ def bind_geom_solimp_write( """Bind immutable geom_solimp defaults through the declared backend capability.""" default = self._materialize_randomization_default( RESET_TERM_GEOM_SOLIMP, - getter=self._backend.get_geom_solimp, expected_tail=(5,), term_name=term_name, ) columns = self._validate_columns( column_ids, - width=default.shape[0], + width=self._randomization_default_width(default, field=RESET_TERM_GEOM_SOLIMP), capability="geom_solimp IDs", term_name=term_name, ) - return self._readonly_binding(columns, default[columns]) + selected = self._select_randomization_default_columns( + default, + columns, + field=RESET_TERM_GEOM_SOLIMP, + ) + return self._readonly_binding(columns, selected) def write_geom_solimp( self, @@ -248,17 +283,21 @@ def bind_dof_damping_write( """Bind immutable dof_damping defaults through the declared backend capability.""" default = self._materialize_randomization_default( RESET_TERM_DOF_DAMPING, - getter=self._backend.get_dof_damping, - expected_tail=None, + expected_tail=(), term_name=term_name, ) columns = self._validate_columns( column_ids, - width=default.shape[0], + width=self._randomization_default_width(default, field=RESET_TERM_DOF_DAMPING), capability="dof_damping IDs", term_name=term_name, ) - return self._readonly_binding(columns, default[columns]) + selected = self._select_randomization_default_columns( + default, + columns, + field=RESET_TERM_DOF_DAMPING, + ) + return self._readonly_binding(columns, selected) def write_dof_damping( self, @@ -287,17 +326,24 @@ def bind_dof_frictionloss_write( """Bind immutable dof_frictionloss defaults through the declared backend capability.""" default = self._materialize_randomization_default( RESET_TERM_DOF_FRICTIONLOSS, - getter=self._backend.get_dof_frictionloss, - expected_tail=None, + expected_tail=(), term_name=term_name, ) columns = self._validate_columns( column_ids, - width=default.shape[0], + width=self._randomization_default_width( + default, + field=RESET_TERM_DOF_FRICTIONLOSS, + ), capability="dof_frictionloss IDs", term_name=term_name, ) - return self._readonly_binding(columns, default[columns]) + selected = self._select_randomization_default_columns( + default, + columns, + field=RESET_TERM_DOF_FRICTIONLOSS, + ) + return self._readonly_binding(columns, selected) def write_dof_frictionloss( self, @@ -326,17 +372,21 @@ def bind_body_mass_write( """Bind body-mass columns and immutable backend defaults on the cold path.""" default = self._materialize_randomization_default( RESET_TERM_BODY_MASS, - getter=self._backend.get_body_mass, - expected_tail=None, + expected_tail=(), term_name=term_name, ) columns = self._validate_columns( body_ids, - width=default.shape[0], + width=self._randomization_default_width(default, field=RESET_TERM_BODY_MASS), capability="body mass IDs", term_name=term_name, ) - return self._readonly_binding(columns, default[columns]) + selected = self._select_randomization_default_columns( + default, + columns, + field=RESET_TERM_BODY_MASS, + ) + return self._readonly_binding(columns, selected) def bind_body_ipos_write( self, @@ -347,106 +397,60 @@ def bind_body_ipos_write( """Bind body inertial-position columns and immutable backend defaults.""" default = self._materialize_randomization_default( RESET_TERM_BODY_IPOS, - getter=self._backend.get_body_ipos, expected_tail=(3,), term_name=term_name, ) columns = self._validate_columns( body_ids, - width=default.shape[0], + width=self._randomization_default_width(default, field=RESET_TERM_BODY_IPOS), capability="body ipos IDs", term_name=term_name, ) - return self._readonly_binding(columns, default[columns]) + selected = self._select_randomization_default_columns( + default, + columns, + field=RESET_TERM_BODY_IPOS, + ) + return self._readonly_binding(columns, selected) def bind_body_inertia_write( self, body_ids: np.ndarray, *, - default: np.ndarray, - default_mass: np.ndarray, term_name: str, ) -> tuple[np.ndarray, np.ndarray]: - """Bind body-inertia columns with caller-supplied cold-path defaults. - - ``SimBackend`` has no body-inertia getter, so the caller compiles the - scene model on the cold path and supplies the full ``(nbody, 3)`` - principal-inertia table in backend body-id order. ``default_mass`` is - the full ``(nbody,)`` table from the same compile and is - cross-validated against the backend's authoritative body-mass table, - which fail-closed pins the body set and row ordering. + """Bind body-inertia columns and authoritative backend defaults. + + UniSim returns either a canonical ``(nbody, 3)`` table or a per-world + ``(num_envs, nbody, 3)`` table. No caller-side model compilation or + body-order cross-check is needed. """ - mass_default = self._materialize_randomization_default( - RESET_TERM_BODY_MASS, - getter=self._backend.get_body_mass, - expected_tail=None, - term_name=term_name, - ) - try: - capabilities = self._backend.get_dr_capabilities() - except (AttributeError, NotImplementedError) as exc: - raise self._capability_error(term_name, "body_inertia randomization", exc) from exc - unsupported = capabilities.get_unsupported_reset_terms( - frozenset((RESET_TERM_BODY_INERTIA,)) - ) - if unsupported: - raise self._capability_error( - term_name, - "body_inertia randomization", - NotImplementedError(f"unsupported reset payload field: {RESET_TERM_BODY_INERTIA}"), - ) - reference = self._validate_randomization_default_table( - default_mass, - expected_shape=mass_default.shape, - capability="default body_mass cross-check", - term_name=term_name, - ) - if not np.allclose(reference, mass_default, rtol=1e-4, atol=1e-9): - raise ValueError( - f"EventManager term '{term_name}' caller-compiled body_mass table does not " - f"match backend '{self._backend.backend_type}' defaults; the cold-path scene " - "compile diverges from the backend model (e.g. fragments adding bodies)" - ) - inertia = self._validate_randomization_default_table( - default, - expected_shape=(mass_default.shape[0], 3), - capability="default body_inertia", + inertia = self._materialize_randomization_default( + RESET_TERM_BODY_INERTIA, + expected_tail=(3,), term_name=term_name, ) if np.any(inertia < 0.0): raise ValueError( f"EventManager term '{term_name}' default body_inertia contains negative values" ) - cached = self._randomization_defaults.get(RESET_TERM_BODY_INERTIA) - if cached is None: - inertia.setflags(write=False) - self._randomization_defaults[RESET_TERM_BODY_INERTIA] = inertia - self._randomization_values[RESET_TERM_BODY_INERTIA] = np.empty( - (self._num_envs, *inertia.shape), - dtype=inertia.dtype, - ) - self._randomization_dirty_masks[RESET_TERM_BODY_INERTIA] = np.zeros( - self._num_envs, dtype=np.bool_ - ) - elif not np.array_equal(cached, inertia): - raise ValueError( - f"EventManager term '{term_name}' supplied a body_inertia default table that " - "differs from the table already bound on this transaction" - ) - default_table = self._randomization_defaults[RESET_TERM_BODY_INERTIA] columns = self._validate_columns( body_ids, - width=default_table.shape[0], + width=self._randomization_default_width(default=inertia, field=RESET_TERM_BODY_INERTIA), capability="body inertia IDs", term_name=term_name, ) - return self._readonly_binding(columns, default_table[columns]) + selected = self._select_randomization_default_columns( + inertia, + columns, + field=RESET_TERM_BODY_INERTIA, + ) + return self._readonly_binding(columns, selected) def bind_gravity_write(self, *, term_name: str) -> np.ndarray: """Bind the immutable backend gravity vector on the cold path.""" return self._materialize_randomization_default( RESET_TERM_GRAVITY, - getter=self._backend.get_gravity, expected_tail=(), term_name=term_name, ) @@ -460,17 +464,21 @@ def bind_dof_armature_write( """Bind DOF-armature columns and immutable backend defaults.""" default = self._materialize_randomization_default( RESET_TERM_DOF_ARMATURE, - getter=self._backend.get_dof_armature, - expected_tail=None, + expected_tail=(), term_name=term_name, ) columns = self._validate_columns( dof_ids, - width=default.shape[0], + width=self._randomization_default_width(default, field=RESET_TERM_DOF_ARMATURE), capability="DOF armature IDs", term_name=term_name, ) - return self._readonly_binding(columns, default[columns]) + selected = self._select_randomization_default_columns( + default, + columns, + field=RESET_TERM_DOF_ARMATURE, + ) + return self._readonly_binding(columns, selected) def bind_geom_friction_write( self, @@ -481,17 +489,21 @@ def bind_geom_friction_write( """Bind geom-friction rows and immutable backend defaults.""" default = self._materialize_randomization_default( RESET_TERM_GEOM_FRICTION, - getter=self._backend.get_geom_friction, expected_tail=(3,), term_name=term_name, ) columns = self._validate_columns( geom_ids, - width=default.shape[0], + width=self._randomization_default_width(default, field=RESET_TERM_GEOM_FRICTION), capability="geom friction IDs", term_name=term_name, ) - return self._readonly_binding(columns, default[columns]) + selected = self._select_randomization_default_columns( + default, + columns, + field=RESET_TERM_GEOM_FRICTION, + ) + return self._readonly_binding(columns, selected) def write_body_mass( self, @@ -571,7 +583,11 @@ def write_gravity( mask = self._randomization_dirty_masks[RESET_TERM_GRAVITY] uninitialized = ids[~mask[ids]] if uninitialized.size: - buffer[uninitialized] = default + buffer[uninitialized] = self._randomization_default_rows( + default, + uninitialized, + field=RESET_TERM_GRAVITY, + ) buffer[ids] = gravity mask[ids] = True self._dirty_mask[ids] = True @@ -628,8 +644,22 @@ def bind_actuator_gain_write( self._materialize_default_actuator_gains(term_name) assert self._default_kp is not None assert self._default_kd is not None - selected_kp = np.array(self._default_kp[columns], copy=True) - selected_kd = np.array(self._default_kd[columns], copy=True) + selected_kp = np.array( + self._select_randomization_default_columns( + self._default_kp, + columns, + field=RESET_TERM_KP, + ), + copy=True, + ) + selected_kd = np.array( + self._select_randomization_default_columns( + self._default_kd, + columns, + field=RESET_TERM_KD, + ), + copy=True, + ) selected_kp.setflags(write=False) selected_kd.setflags(write=False) bound_columns = np.array(columns, copy=True) @@ -677,8 +707,16 @@ def write_actuator_gains( assert self._kd is not None uninitialized = ids[~self._gain_dirty_mask[ids]] if uninitialized.size: - self._kp[uninitialized] = self._default_kp - self._kd[uninitialized] = self._default_kd + self._kp[uninitialized] = self._randomization_default_rows( + self._default_kp, + uninitialized, + field=RESET_TERM_KP, + ) + self._kd[uninitialized] = self._randomization_default_rows( + self._default_kd, + uninitialized, + field=RESET_TERM_KD, + ) if ids.size and columns.size: self._kp[ids[:, None], columns[None, :]] = kp_values self._kd[ids[:, None], columns[None, :]] = kd_values @@ -1000,12 +1038,24 @@ def _materialize_default_actuator_gains(self, term_name: str) -> None: "actuator gain randomization", NotImplementedError(f"unsupported reset payload fields: {detail}"), ) - try: - kp, kd = self._backend.get_actuator_gains() - except (AttributeError, NotImplementedError) as exc: - raise self._capability_error(term_name, "default actuator gains", exc) from exc - default_kp = self._validate_gain_vector(kp, "default actuator kp", term_name) - default_kd = self._validate_gain_vector(kd, "default actuator kd", term_name) + default_kp = self._fetch_reset_term_default( + RESET_TERM_KP, + expected_tail=(), + term_name=term_name, + ) + default_kd = self._fetch_reset_term_default( + RESET_TERM_KD, + expected_tail=(), + term_name=term_name, + ) + for name, default in ((RESET_TERM_KP, default_kp), (RESET_TERM_KD, default_kd)): + width = self._randomization_default_width(default, field=name) + if width != self._backend.num_actuators: + raise ValueError( + f"EventManager term '{term_name}' default actuator {name} on backend " + f"'{self._backend.backend_type}' has model width {width}; expected " + f"{self._backend.num_actuators}" + ) self._default_kp = default_kp self._default_kd = default_kd self._kp = np.empty( @@ -1021,8 +1071,7 @@ def _materialize_randomization_default( self, field: str, *, - getter, - expected_tail: tuple[int, ...] | None, + expected_tail: tuple[int, ...], term_name: str, ) -> np.ndarray: cached = self._randomization_defaults.get(field) @@ -1039,8 +1088,28 @@ def _materialize_randomization_default( f"{field} randomization", NotImplementedError(f"unsupported reset payload field: {field}"), ) + default = self._fetch_reset_term_default( + field, + expected_tail=expected_tail, + term_name=term_name, + ) + self._randomization_defaults[field] = default + self._randomization_values[field] = np.empty( + (self._num_envs, *self._canonical_default_shape(default, field=field)), + dtype=default.dtype, + ) + self._randomization_dirty_masks[field] = np.zeros(self._num_envs, dtype=np.bool_) + return default + + def _fetch_reset_term_default( + self, + field: str, + *, + expected_tail: tuple[int, ...], + term_name: str, + ) -> np.ndarray: try: - value = getter() + value = self._backend.get_reset_term_default(field) except (AttributeError, NotImplementedError) as exc: raise self._capability_error(term_name, f"default {field}", exc) from exc if not isinstance(value, np.ndarray): @@ -1049,14 +1118,21 @@ def _materialize_randomization_default( f"'{self._backend.backend_type}' must return np.ndarray, got " f"{type(value).__name__}" ) - expected_ndim = 1 if expected_tail is None else 1 + len(expected_tail) - if value.ndim != expected_ndim: + canonical_ndim = 1 + len(expected_tail) + canonical_shape = value.ndim == canonical_ndim + per_env_shape = value.ndim == canonical_ndim + 1 and value.shape[0] == self._num_envs + if field == RESET_TERM_GRAVITY: + canonical_shape = value.shape == (3,) + per_env_shape = value.shape == (self._num_envs, 3) + if not canonical_shape and not per_env_shape: raise ValueError( f"EventManager term '{term_name}' capability 'default {field}' on backend " f"'{self._backend.backend_type}' returned shape {value.shape}; expected " - f"{expected_ndim}-D" + f"a canonical {canonical_ndim}-D table or a per-environment " + f"({self._num_envs}, *canonical) table" ) - if expected_tail is not None and value.shape[1:] != expected_tail: + tail_slice = 2 if per_env_shape else 1 + if value.shape[tail_slice:] != expected_tail: raise ValueError( f"EventManager term '{term_name}' capability 'default {field}' on backend " f"'{self._backend.backend_type}' returned shape {value.shape}; expected tail " @@ -1074,12 +1150,6 @@ def _materialize_randomization_default( ) default = np.array(value, copy=True) default.setflags(write=False) - self._randomization_defaults[field] = default - self._randomization_values[field] = np.empty( - (self._num_envs, *default.shape), - dtype=default.dtype, - ) - self._randomization_dirty_masks[field] = np.zeros(self._num_envs, dtype=np.bool_) return default def _require_randomization_default(self, field: str, term_name: str) -> np.ndarray: @@ -1091,32 +1161,61 @@ def _require_randomization_default(self, field: str, term_name: str) -> np.ndarr "during manager construction before writing it" ) from exc - def _validate_randomization_default_table( + def _default_is_per_env( self, - value: np.ndarray, + default: np.ndarray, *, - expected_shape: tuple[int, ...], - capability: str, - term_name: str, + field: str, + ) -> bool: + canonical_ndim = 1 + len(_randomization_term_tail(field)) + if default.ndim == canonical_ndim: + return False + if default.ndim == canonical_ndim + 1 and default.shape[0] == self._num_envs: + return True + raise RuntimeError( + f"Reset transaction cached an invalid '{field}' default table with shape " + f"{default.shape}" + ) + + def _canonical_default_shape( + self, + default: np.ndarray, + *, + field: str, + ) -> tuple[int, ...]: + shape = ( + default.shape[1:] if self._default_is_per_env(default, field=field) else default.shape + ) + return tuple(int(value) for value in shape) + + def _randomization_default_width( + self, + default: np.ndarray, + *, + field: str, + ) -> int: + axis = 1 if self._default_is_per_env(default, field=field) else 0 + return int(default.shape[axis]) + + def _select_randomization_default_columns( + self, + default: np.ndarray, + columns: np.ndarray, + *, + field: str, ) -> np.ndarray: - """Validate a caller-supplied cold-path default table and detach a copy.""" - if not isinstance(value, np.ndarray): - raise TypeError( - f"EventManager term '{term_name}' {capability} must be np.ndarray, got " - f"{type(value).__name__}" - ) - if value.shape != expected_shape: - raise ValueError( - f"EventManager term '{term_name}' {capability} has shape {value.shape}; " - f"expected {expected_shape}" - ) - if not np.issubdtype(value.dtype, np.floating): - raise TypeError( - f"EventManager term '{term_name}' {capability} must be floating, got {value.dtype}" - ) - if not np.isfinite(value).all(): - raise ValueError(f"EventManager term '{term_name}' {capability} contains NaN or Inf") - return np.array(value, copy=True) + if self._default_is_per_env(default, field=field): + return default[:, columns] + return default[columns] + + def _randomization_default_rows( + self, + default: np.ndarray, + env_ids: np.ndarray, + *, + field: str, + ) -> np.ndarray: + return default[env_ids] if self._default_is_per_env(default, field=field) else default def _readonly_binding( self, @@ -1147,7 +1246,7 @@ def _write_selected_randomization( default = self._require_randomization_default(field, term_name) columns = self._validate_columns( column_ids, - width=default.shape[0], + width=self._randomization_default_width(default, field=field), capability=f"{field} column IDs", term_name=term_name, ) @@ -1161,7 +1260,11 @@ def _write_selected_randomization( mask = self._randomization_dirty_masks[field] uninitialized = ids[~mask[ids]] if uninitialized.size: - buffer[uninitialized] = default + buffer[uninitialized] = self._randomization_default_rows( + default, + uninitialized, + field=field, + ) if ids.size and columns.size: buffer[ids[:, None], columns[None, :]] = selected mask[ids] = True diff --git a/src/unilab/envs/manager_based_rl_env.py b/src/unilab/envs/manager_based_rl_env.py index 4b605f313..c8e6ae935 100644 --- a/src/unilab/envs/manager_based_rl_env.py +++ b/src/unilab/envs/manager_based_rl_env.py @@ -784,7 +784,7 @@ def make_manager_based_rl_env( ) cfg.validate() - if cfg.fixed_model_variants is not None: + if cfg.fixed_model_variants is not None and cfg.scene is not None: # Validate the complete task identity before allocating backend resources. cfg.scene.fixed_variant_plan = build_fixed_variant_plan( materialize_fixed_model_variants(cfg.fixed_model_variants, num_envs) diff --git a/src/unilab/envs/mdp/events.py b/src/unilab/envs/mdp/events.py index 8020ff3d7..aa96bfada 100644 --- a/src/unilab/envs/mdp/events.py +++ b/src/unilab/envs/mdp/events.py @@ -10,7 +10,6 @@ import math import re -from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast import numpy as np @@ -22,7 +21,6 @@ if TYPE_CHECKING: from unilab.base.entity import Entity - from unilab.envs.manager_based_rl_env import ManagerBasedRlEnv as ManagerBasedRlEnvImpl from unilab.managers._types import ManagerBasedRlEnv @@ -32,7 +30,6 @@ _PD_GAIN_PARAM_NAMES = frozenset(("kp_range", "kd_range", "asset_cfg", "distribution", "operation")) _DISTRIBUTIONS = ("uniform", "log_uniform", "gaussian") _OPERATIONS = ("add", "scale", "abs") -_REPO_ROOT = Path(__file__).resolve().parents[4] def _gain_range( @@ -249,6 +246,24 @@ def resolve_env_ids(env: ManagerBasedRlEnv, env_ids: np.ndarray | None) -> np.nd return env_ids +def _selected_reset_defaults( + defaults: np.ndarray, + env_ids: np.ndarray, + *, + canonical_ndim: int, +) -> np.ndarray: + """Select canonical or per-environment reset defaults for concrete rows.""" + if defaults.ndim == canonical_ndim: + return np.broadcast_to(defaults, (env_ids.size, *defaults.shape)) + if defaults.ndim == canonical_ndim + 1: + return defaults[env_ids] + raise ValueError( + f"Reset default table has shape {defaults.shape}; expected a canonical " + f"{canonical_ndim}-D table or a per-environment " + f"({env_ids.size}, *canonical) table" + ) + + class _ModelFieldRandomizer(ManagerTermBase): """Cold-path-bound NumPy adapter for pinned mjlab model-field DR terms.""" @@ -364,9 +379,19 @@ def _select_string_ranges( [index for index, value in enumerate(assigned) if value is not None], dtype=np.intp, ) + canonical_ndim = 1 if self._field_width == 1 else 2 + if defaults.ndim == canonical_ndim: + selected_defaults = defaults[selected] + elif defaults.ndim == canonical_ndim + 1: + selected_defaults = defaults[:, selected] + else: + raise ValueError( + f"EventManager term '{self._term_name}' bound an invalid default table with " + f"shape {defaults.shape}" + ) return ( local_ids[selected], - defaults[selected], + selected_defaults, tuple(names[index] for index in selected), [assigned[index] for index in selected], ) @@ -502,25 +527,32 @@ def __call__( ) -> None: del ranges, asset_cfg, distribution, operation, axes, shared_random ids = resolve_env_ids(env, env_ids) - defaults = self._defaults - scalar = defaults.ndim == 1 - default_values = defaults[:, None] if scalar else defaults - values = np.broadcast_to( - default_values, - (len(ids), *default_values.shape), - ).copy() + canonical_ndim = 1 if self._field_width == 1 else 2 + default_values = _selected_reset_defaults( + self._defaults, + ids, + canonical_ndim=canonical_ndim, + ) + values = np.array(default_values, copy=True) for axis in self._axes: samples = self._sample_axis(env, axis, len(ids)) - values[:, :, axis] = _apply_randomization_operation( - default_values[None, :, axis], - samples, - self._operation, - ) + if self._field_width == 1: + values[...] = _apply_randomization_operation( + default_values, + samples, + self._operation, + ) + else: + values[..., axis] = _apply_randomization_operation( + default_values[..., axis], + samples, + self._operation, + ) if np.any(values < 0.0) or not np.isfinite(values).all(): raise ValueError( f"EventManager term '{self._term_name}' produced negative, NaN, or Inf values" ) - self._write(values[:, :, 0] if scalar else values, ids) + self._write(values, ids) class GeomFriction(_ModelFieldRandomizer): @@ -657,8 +689,16 @@ def __call__( kp = _sample_gain_range(env.rng, self._kp_range, shape, self._distribution) kd = _sample_gain_range(env.rng, self._kd_range, shape, self._distribution) if self._operation == "scale": - kp *= self._default_kp[None, :] - kd *= self._default_kd[None, :] + kp = kp * _selected_reset_defaults( + self._default_kp, + ids, + canonical_ndim=1, + ) + kd = kd * _selected_reset_defaults( + self._default_kd, + ids, + canonical_ndim=1, + ) self._entity.write_actuator_gains_to_sim( kp, kd, @@ -768,8 +808,13 @@ def __call__( (ids.size, self._body_ids.size), self._distribution, ) + default_mass = _selected_reset_defaults( + self._default_mass, + ids, + canonical_ndim=1, + ) values = _apply_randomization_operation( - self._default_mass[None, :], + default_mass, samples, self._operation, ) @@ -785,45 +830,6 @@ def __call__( randomize_rigid_body_mass = RandomizeRigidBodyMass -def _scene_inertial_defaults( - env: ManagerBasedRlEnv, - *, - term_name: str, -) -> tuple[np.ndarray, np.ndarray]: - """Compile the configured MJCF scene on the cold path for inertial defaults. - - Returns the full ``(nbody,)`` body-mass and ``(nbody, 3)`` principal-inertia - tables in model body order. ``SimBackend`` exposes no body-inertia getter, - so the defaults come from the same scene file the MuJoCo-family backends - compile; the reset transaction cross-validates the mass table against the - backend's authoritative values before trusting the inertia rows. - """ - try: - import mujoco - except ImportError as exc: - raise NotImplementedError( - f"EventManager term '{term_name}' requires the mujoco package to compile " - "the scene model for inertial defaults" - ) from exc - scene = cast("ManagerBasedRlEnvImpl", env).cfg.scene - if scene is None: - raise ValueError(f"EventManager term '{term_name}' requires a configured scene model file") - model_file = str(scene.model_file) - candidates = [Path(model_file)] - if not Path(model_file).is_absolute(): - candidates.append(_REPO_ROOT / model_file) - path = next((candidate for candidate in candidates if candidate.is_file()), None) - if path is None: - raise ValueError( - f"EventManager term '{term_name}' cannot locate scene model file " - f"{model_file!r} (tried {', '.join(str(candidate) for candidate in candidates)})" - ) - model = mujoco.MjModel.from_xml_path(str(path)) - mass = np.asarray(model.body_mass, dtype=np.float64) - inertia = np.asarray(model.body_inertia, dtype=np.float64) - return mass, inertia - - class RandomizeBodyMassInertia(ManagerTermBase): """Startup-style mass+inertia scaling via one shared per-env factor. @@ -837,8 +843,8 @@ class RandomizeBodyMassInertia(ManagerTermBase): UniLab startup events have no reset-transaction write path, so this term runs in reset mode but samples the factor only once — at the first reset — and reapplies the cached per-env values on every later reset. Both writes - re-derive from immutable compile-time defaults, so reapplication is - idempotent and non-accumulating. + re-derive from immutable backend-authoritative defaults, so reapplication + is idempotent and non-accumulating. """ _PARAMS = frozenset(("asset_cfg", "scale_range")) @@ -868,15 +874,12 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedRlEnv): self._scale_lo = float(bounds[0]) self._scale_hi = float(bounds[1]) self._entity = cast("Entity", env.scene[asset_cfg.name]) - default_mass, default_inertia = _scene_inertial_defaults(env, term_name=term_name) self._body_ids, self._default_mass = self._entity.bind_body_mass_write( asset_cfg.body_ids, term_name=term_name, ) inertia_ids, self._default_inertia = self._entity.bind_body_inertia_write( asset_cfg.body_ids, - default=default_inertia, - default_mass=default_mass, term_name=term_name, ) if not np.array_equal(self._body_ids, inertia_ids): @@ -884,6 +887,11 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedRlEnv): f"EventManager term '{term_name}' mass/inertia body bindings diverged: " f"{self._body_ids.tolist()} != {inertia_ids.tolist()}" ) + if self._default_mass.ndim != self._default_inertia.ndim - 1: + raise ValueError( + f"EventManager term '{term_name}' mass and inertia default tables use " + "incompatible canonical/per-environment layouts" + ) self._scales: np.ndarray | None = None def __call__( @@ -903,14 +911,24 @@ def __call__( ) self._scales = np.exp(2.0 * alpha) scales = self._scales[ids] + default_mass = _selected_reset_defaults( + self._default_mass, + ids, + canonical_ndim=1, + ) + default_inertia = _selected_reset_defaults( + self._default_inertia, + ids, + canonical_ndim=2, + ) self._entity.write_body_mass_to_sim( - self._default_mass[None, :] * scales, + default_mass * scales, body_ids=self._body_ids, env_ids=ids, term_name="randomize_body_mass_inertia", ) self._entity.write_body_inertia_to_sim( - self._default_inertia[None, :, :] * scales[:, :, None], + default_inertia * scales[:, :, None], body_ids=self._body_ids, env_ids=ids, term_name="randomize_body_mass_inertia", @@ -978,7 +996,12 @@ def __call__( ranges[:, 1], size=(ids.size, 3), ) - values = self._default_ipos[None, :, :] + offsets[:, None, :] + default_ipos = _selected_reset_defaults( + self._default_ipos, + ids, + canonical_ndim=2, + ) + values = default_ipos + offsets[:, None, :] self._entity.write_body_ipos_to_sim( values, body_ids=self._body_ids, @@ -1040,8 +1063,13 @@ def __call__( (ids.size, 3), self._distribution, ) + default_gravity = _selected_reset_defaults( + self._default_gravity, + ids, + canonical_ndim=1, + ) values = _apply_randomization_operation( - self._default_gravity[None, :], + default_gravity, samples, self._operation, ) diff --git a/src/unilab/tasks/locomotion/go2/footstand.py b/src/unilab/tasks/locomotion/go2/footstand.py index 011485c41..4cbbb6fa8 100644 --- a/src/unilab/tasks/locomotion/go2/footstand.py +++ b/src/unilab/tasks/locomotion/go2/footstand.py @@ -1033,8 +1033,16 @@ def __call__( ) -> None: del params ids = _env_ids(env, env_ids) - scale = env.rng.uniform(*self._scale_range, size=(ids.size, self._default_mass.size)) - mass = self._default_mass[None, :] * scale + default_mass = ( + self._default_mass[ids] if self._default_mass.ndim == 2 else self._default_mass[None, :] + ) + if default_mass.shape[0] != ids.size: + default_mass = np.broadcast_to( + default_mass, + (ids.size, *default_mass.shape[1:]), + ) + scale = env.rng.uniform(*self._scale_range, size=default_mass.shape) + mass = default_mass * scale mass[:, self._torso_index] += env.rng.uniform(*self._added_range, size=ids.size) if np.any(mass <= 0.0): raise ValueError("FootstandMassRandomization produced a non-positive body mass") diff --git a/tests/base/test_entity_facade.py b/tests/base/test_entity_facade.py index e4d7e1ef8..10e2c06e8 100644 --- a/tests/base/test_entity_facade.py +++ b/tests/base/test_entity_facade.py @@ -202,6 +202,14 @@ def get_dof_damping(self): self._check("damping defaults") return np.ones(9) + def get_reset_term_default(self, term): + self._check(f"{term} defaults") + if term == "geom_size": + return np.ones((3, 3)) + if term == "dof_damping": + return np.ones(9) + raise NotImplementedError(term) + def get_joint_dof_indices(self, names): self._check("model DOF IDs") return np.array([{"hip": 7}[name] for name in names], dtype=np.int32) @@ -248,11 +256,62 @@ def set_state(self, env_ids, qpos, qvel, randomization=None): np.testing.assert_array_equal(backend.payload.geom_size[0, :2], 1.0) assert backend.payload.dof_damping[0, 7] == 0.2 np.testing.assert_array_equal(hand.read_mocap_pose()[ids], pose) - for name in ("geom size defaults", "damping defaults", "model DOF IDs", "mocap binding"): + for name in ("geom_size defaults", "dof_damping defaults", "model DOF IDs", "mocap binding"): assert backend.calls[name] == calls[name] == 1 assert backend.calls["root-state layout"] == 0 +def test_entity_binding_selects_per_world_default_columns() -> None: + class Backend(_StrictBackendProfile): + def __init__(self): + super().__init__("mjwarp") + self.payload = None + + def get_dr_capabilities(self): + return DomainRandomizationCapabilities(supported_reset_terms=frozenset(("body_mass",))) + + def get_reset_term_default(self, term): + self._check("body_mass defaults") + if term != "body_mass": + raise NotImplementedError(term) + return np.asarray( + [[1.0 + env_id] * 10 for env_id in range(self.num_envs)], + dtype=np.float64, + ) + + def set_state(self, env_ids, qpos, qvel, randomization=None): + self.set_state_calls.append((env_ids, qpos, qvel)) + self.payload = randomization + + backend = Backend() + transaction = ResetStateTransaction(cast(SimBackend, backend)) + entity = Entity( + "tool", + EntityCfg(body_names=("foot",)), + cast(SimBackend, backend), + reset_state=transaction, + ) + + body_ids, defaults = entity.bind_body_mass_write(term_name="variant_mass") + assert defaults.shape == (backend.num_envs, body_ids.size) + np.testing.assert_allclose(defaults[:, 0], [1.0, 2.0, 3.0]) + + ids = np.array([1], dtype=np.int32) + with transaction.scoped(ids): + entity.write_body_mass_to_sim( + np.asarray([[7.0]]), + body_ids, + ids, + term_name="variant_mass", + ) + + assert backend.payload is not None + expected_payload = np.full(10, 2.0) + expected_payload[backend.body_ids["foot"]] = 7.0 + np.testing.assert_allclose(backend.payload.body_mass, expected_payload[None, :]) + assert backend.calls["body_mass defaults"] == 1 + + def _scene(backend_type: str = "mujoco") -> tuple[_StrictBackendProfile, EntityScene]: backend = _StrictBackendProfile(backend_type) cfg = SceneCfg( diff --git a/tests/base/test_fixed_model_variants.py b/tests/base/test_fixed_model_variants.py index 53eb2a1ab..2a1c6ca5d 100644 --- a/tests/base/test_fixed_model_variants.py +++ b/tests/base/test_fixed_model_variants.py @@ -16,7 +16,6 @@ FixedModelVariantAssignmentCfg, FixedModelVariantCatalogCfg, FixedModelVariantCfg, - FixedModelVariantMaterialization, build_fixed_variant_plan, materialize_fixed_model_variants, prepare_fixed_model_variants, diff --git a/tests/base/test_reset_state.py b/tests/base/test_reset_state.py index d407295c7..bd6d6028f 100644 --- a/tests/base/test_reset_state.py +++ b/tests/base/test_reset_state.py @@ -8,6 +8,7 @@ import pytest from unisim.backend.base import BackendMocapPoseBinding, BackendRootStateLayout, SimBackend from unisim.dr.types import ( + RESET_TERM_BODY_MASS, RESET_TERM_KD, RESET_TERM_KP, DomainRandomizationCapabilities, @@ -55,6 +56,13 @@ def get_dr_capabilities(self) -> DomainRandomizationCapabilities: def get_actuator_gains(self) -> tuple[np.ndarray, np.ndarray]: return self.default_kp.copy(), self.default_kd.copy() + def get_reset_term_default(self, term: str) -> np.ndarray: + if term == RESET_TERM_KP: + return self.default_kp.copy() + if term == RESET_TERM_KD: + return self.default_kd.copy() + raise NotImplementedError(term) + def set_state( self, env_ids: np.ndarray, @@ -97,20 +105,17 @@ def _defaults(self, width): self.default_calls += 1 return np.ones((3, width)) if width else np.ones(2) - def get_geom_sizes(self): - return self._defaults(3) - - def get_geom_solref(self): - return self._defaults(2) - - def get_geom_solimp(self): - return self._defaults(5) - - def get_dof_damping(self): - return self._defaults(0) - - def get_dof_frictionloss(self): - return self._defaults(0) + def get_reset_term_default(self, term: str) -> np.ndarray: + widths = { + "geom_size": 3, + "geom_solref": 2, + "geom_solimp": 5, + "dof_damping": 0, + "dof_frictionloss": 0, + } + if term not in widths: + raise NotImplementedError(term) + return self._defaults(widths[term]) def set_state(self, env_ids, qpos, qvel, randomization=None): self.events.append("state") @@ -132,6 +137,70 @@ def write(ids, poses): ) +class _PerWorldBodyMassBackend(_Backend): + def __init__(self) -> None: + super().__init__() + self.default_calls = 0 + + def get_dr_capabilities(self): + return DomainRandomizationCapabilities( + supported_reset_terms=frozenset((RESET_TERM_BODY_MASS,)) + ) + + def get_reset_term_default(self, term: str) -> np.ndarray: + if term != RESET_TERM_BODY_MASS: + raise NotImplementedError(term) + self.default_calls += 1 + return np.asarray( + [ + [1.0, 10.0, 100.0], + [2.0, 20.0, 200.0], + [3.0, 30.0, 300.0], + [4.0, 40.0, 400.0], + ] + ) + + +class _InvalidDefaultBackend(_Backend): + def __init__(self, field: str, value: Any) -> None: + super().__init__() + self.field = field + self.value = value + + def get_dr_capabilities(self) -> DomainRandomizationCapabilities: + return DomainRandomizationCapabilities(supported_reset_terms=frozenset((self.field,))) + + def get_reset_term_default(self, term: str) -> np.ndarray: + if term != self.field: + raise NotImplementedError(term) + return self.value + + +@pytest.mark.parametrize( + ("field", "value", "match"), + [ + ("body_mass", np.ones((2, 3), dtype=np.float64), "canonical 1-D table"), + ( + "body_mass", + np.ones((3, 2), dtype=np.float64), + "canonical 1-D table or a per-environment \\(4, \\*canonical\\)", + ), + ("body_mass", np.ones(2, dtype=np.int32), "must be floating"), + ("gravity", np.ones(2, dtype=np.float64), "canonical 1-D table"), + ], +) +def test_reset_term_default_shapes_fail_closed(field: str, value: Any, match: str) -> None: + transaction = _transaction(_InvalidDefaultBackend(field, value)) + with pytest.raises((TypeError, ValueError), match=match): + if field == "gravity": + transaction.bind_gravity_write(term_name="bad_default") + else: + transaction.bind_body_mass_write( + np.array([0], dtype=np.int32), + term_name="bad_default", + ) + + @pytest.mark.parametrize( "field,width", [ @@ -160,6 +229,31 @@ def test_manipulation_fields_preserve_unselected_columns_and_bind_once(field, wi np.testing.assert_array_equal(getattr(payload, field)[:, 0], 1.0) +def test_per_world_body_mass_preserves_each_selected_rows_baseline() -> None: + backend = _PerWorldBodyMassBackend() + transaction = _transaction(backend) + columns = np.array([1, 2], dtype=np.int32) + _, defaults = transaction.bind_body_mass_write(columns, term_name="variant_mass") + + assert defaults.shape == (backend.num_envs, columns.size) + np.testing.assert_allclose(defaults[:, 0], [10.0, 20.0, 30.0, 40.0]) + np.testing.assert_allclose(defaults[:, 1], [100.0, 200.0, 300.0, 400.0]) + + ids = np.array([2, 0], dtype=np.int32) + with transaction.scoped(ids): + transaction.write_body_mass( + ids, + columns[:1], + np.full((ids.size, 1), 0.5), + term_name="variant_mass", + ) + + assert backend.default_calls == 1 + payload = backend.randomization_calls[-1] + assert payload is not None and payload.body_mass is not None + np.testing.assert_allclose(payload.body_mass, [[1.0, 0.5, 100.0], [3.0, 0.5, 300.0]]) + + def test_mocap_pose_is_staged_then_committed_after_generalized_state(): backend = _ManipulationBackend() transaction = _transaction(backend) diff --git a/tests/envs/mdp/test_events.py b/tests/envs/mdp/test_events.py index ef41f1cbf..aa8dcc537 100644 --- a/tests/envs/mdp/test_events.py +++ b/tests/envs/mdp/test_events.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect from types import SimpleNamespace from typing import Any, cast @@ -170,6 +171,7 @@ def __init__( interval_angular_velocity_supported: bool = False, interval_force_supported: bool = False, interval_torque_supported: bool = False, + per_world_defaults: bool = False, ) -> None: self.root_layout_supported = root_layout_supported self.gain_supported = gain_supported @@ -178,6 +180,7 @@ def __init__( self.interval_angular_velocity_supported = interval_angular_velocity_supported self.interval_force_supported = interval_force_supported self.interval_torque_supported = interval_torque_supported + self.per_world_defaults = per_world_defaults self.default_qpos = np.asarray([0.0, 0.0, 0.5, 1.0, 0.0, 0.0, 0.0]) self.init_qvel = np.zeros(6) self.set_state_calls: list[tuple[np.ndarray, np.ndarray, np.ndarray]] = [] @@ -186,11 +189,42 @@ def __init__( self.body_quat = np.zeros((self.num_envs, 1, 4)) self.body_quat[:, :, 0] = 1.0 self.body_velocity = np.zeros((self.num_envs, 1, 3)) - self.body_mass = np.array([10.0]) - self.body_ipos = np.array([[0.0, 0.0, 0.0]]) - self.gravity = np.array([0.0, 0.0, -9.81]) - self.dof_armature = np.array([0.0] * 6 + [1.0, 2.0, 3.0]) - self.geom_friction = np.array([[0.5, 0.01, 0.001], [0.7, 0.02, 0.002], [0.9, 0.03, 0.003]]) + canonical_friction = np.array([[0.5, 0.01, 0.001], [0.7, 0.02, 0.002], [0.9, 0.03, 0.003]]) + canonical_armature = np.array([0.0] * 6 + [1.0, 2.0, 3.0]) + if per_world_defaults: + self.body_mass = np.asarray([[10.0 + env_id] for env_id in range(self.num_envs)]) + self.body_ipos = np.asarray( + [[0.01 * env_id, 0.0, 0.0] for env_id in range(self.num_envs)] + )[:, None, :] + self.gravity = np.asarray( + [[0.0, 0.0, -9.81 - 0.1 * env_id] for env_id in range(self.num_envs)] + ) + self.dof_armature = np.stack( + [canonical_armature * (1.0 + 0.1 * env_id) for env_id in range(self.num_envs)] + ) + self.geom_friction = np.stack( + [canonical_friction * (1.0 + 0.1 * env_id) for env_id in range(self.num_envs)] + ) + self.default_kp = np.stack( + [ + np.asarray([10.0, 20.0, 30.0]) * (1.0 + 0.1 * env_id) + for env_id in range(self.num_envs) + ] + ) + self.default_kd = np.stack( + [ + np.asarray([1.0, 2.0, 3.0]) * (1.0 + 0.1 * env_id) + for env_id in range(self.num_envs) + ] + ) + else: + self.body_mass = np.array([10.0]) + self.body_ipos = np.array([[0.0, 0.0, 0.0]]) + self.gravity = np.array([0.0, 0.0, -9.81]) + self.dof_armature = canonical_armature + self.geom_friction = canonical_friction + self.default_kp = np.asarray([10.0, 20.0, 30.0]) + self.default_kd = np.asarray([1.0, 2.0, 3.0]) self.interval_plans: list[IntervalRandomizationPlan] = [] def get_body_ids(self, names) -> np.ndarray: @@ -266,23 +300,22 @@ def get_dr_capabilities(self) -> DomainRandomizationCapabilities: supports_interval_body_torque=self.interval_torque_supported, ) - def get_actuator_gains(self) -> tuple[np.ndarray, np.ndarray]: - return np.array([10.0, 20.0, 30.0]), np.array([1.0, 2.0, 3.0]) - - def get_body_mass(self) -> np.ndarray: - return self.body_mass.copy() - - def get_body_ipos(self) -> np.ndarray: - return self.body_ipos.copy() - - def get_gravity(self) -> np.ndarray: - return self.gravity.copy() - - def get_dof_armature(self) -> np.ndarray: - return self.dof_armature.copy() - - def get_geom_friction(self) -> np.ndarray: - return self.geom_friction.copy() + def get_reset_term_default(self, term: str) -> np.ndarray: + if not self.get_dr_capabilities().supports_reset_term(term): + raise NotImplementedError(term) + values = { + RESET_TERM_KP: self.default_kp, + RESET_TERM_KD: self.default_kd, + RESET_TERM_BODY_MASS: self.body_mass, + RESET_TERM_BODY_IPOS: self.body_ipos, + RESET_TERM_DOF_ARMATURE: self.dof_armature, + RESET_TERM_GEOM_FRICTION: self.geom_friction, + RESET_TERM_GRAVITY: self.gravity, + } + try: + return np.array(values[term], copy=True) + except KeyError as exc: + raise NotImplementedError(term) from exc def apply_interval_randomization(self, plan: IntervalRandomizationPlan) -> None: self.interval_plans.append(plan) @@ -325,6 +358,7 @@ def _transaction_env( interval_angular_velocity_supported: bool = False, interval_force_supported: bool = False, interval_torque_supported: bool = False, + per_world_defaults: bool = False, body_names: tuple[str, ...] | None = ("base",), rng_seed: int = 5, step_dt: float = 0.02, @@ -337,6 +371,7 @@ def _transaction_env( interval_angular_velocity_supported=interval_angular_velocity_supported, interval_force_supported=interval_force_supported, interval_torque_supported=interval_torque_supported, + per_world_defaults=per_world_defaults, ) transaction = ResetStateTransaction(cast(SimBackend, backend)) scene = EntityScene( @@ -449,6 +484,40 @@ def test_pd_gains_event_supports_log_uniform_absolute_sampling() -> None: assert np.unique(payload.kp[0]).size > 1 +def test_pd_gains_event_uses_selected_per_world_default_rows() -> None: + env, backend, transaction = _transaction_env( + per_world_defaults=True, + rng_seed=11, + ) + manager = EventManager( + { + "randomize_pd": EventTermCfg( + func=mdp.pd_gains, + mode="reset", + params={ + "kp_range": (2.0, 2.0), + "kd_range": (3.0, 3.0), + "asset_cfg": SceneEntityCfg( + "robot", + actuator_names=["a2", "a0"], + preserve_order=True, + ), + }, + ) + }, + env, + ) + ids = np.array([0, 2], dtype=np.int32) + + with transaction.scoped(ids): + manager.apply(mode="reset", env_ids=ids, global_env_step_count=0) + + payload = backend.randomization_calls[-1] + assert payload is not None + np.testing.assert_allclose(payload.kp, [[20.0, 20.0, 60.0], [24.0, 24.0, 72.0]]) + np.testing.assert_allclose(payload.kd, [[3.0, 2.0, 9.0], [3.6, 2.4, 10.8]]) + + @pytest.mark.parametrize( ("cfg_kwargs", "match"), [ @@ -604,6 +673,46 @@ def test_model_field_terms_use_cached_selectors_and_one_dense_reset_payload() -> np.testing.assert_allclose(payload.geom_friction, [expected_friction] * 2) +def test_model_field_term_uses_selected_per_world_default_rows() -> None: + env, backend, transaction = _transaction_env( + per_world_defaults=True, + rng_seed=23, + ) + manager = EventManager( + { + "armature": EventTermCfg( + func=mdp.joint_armature, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg( + "robot", + joint_names=("j2", "j0"), + preserve_order=True, + ), + "ranges": (2.0, 2.0), + "operation": "scale", + }, + ) + }, + env, + ) + ids = np.array([0, 2], dtype=np.int32) + + with transaction.scoped(ids): + mdp.reset_scene_to_default(env, ids) + manager.apply(mode="reset", env_ids=ids, global_env_step_count=0) + + payload = backend.randomization_calls[-1] + assert payload is not None and payload.dof_armature is not None + np.testing.assert_allclose( + payload.dof_armature, + [ + [0.0] * 6 + [2.0, 2.0, 6.0], + [0.0] * 6 + [2.4, 2.4, 7.2], + ], + ) + + def test_model_field_aliases_are_identical_and_capability_gaps_fail_cold() -> None: assert mdp.dof_armature is mdp.joint_armature env, backend, _ = _transaction_env(randomization_supported=False) @@ -1267,14 +1376,48 @@ def test_rigid_body_com_event_consumes_live_params_between_applies() -> None: np.testing.assert_allclose(second, [[[0.5, 0.0, -0.2]]] * 2) +def test_manager_model_field_terms_do_not_import_engine_packages() -> None: + source = inspect.getsource(mdp.events) + assert "import mujoco" not in source + assert "import mjbatch" not in source + + class _InertiaBackend(_Backend): - """Fake backend with a world-body row so MJCF-compiled inertial defaults align.""" + """Fake backend with authoritative world/body inertial default rows.""" - def __init__(self, *, inertia_supported: bool = True) -> None: - super().__init__() + def __init__( + self, + *, + inertia_supported: bool = True, + per_world_defaults: bool = False, + ) -> None: + super().__init__(per_world_defaults=per_world_defaults) self.inertia_supported = inertia_supported # Row 0 is the world body, matching the compiled MJCF body table. - self.body_mass = np.array([0.0, 10.0]) + self.body_mass = ( + np.asarray([[0.0, 10.0 + 0.5 * env_id] for env_id in range(self.num_envs)]) + if per_world_defaults + else np.array([0.0, 10.0]) + ) + if per_world_defaults: + self.body_inertia = np.stack( + [ + np.asarray( + [ + [0.0, 0.0, 0.0], + [ + 0.1 + 0.01 * env_id, + 0.2 + 0.01 * env_id, + 0.3 + 0.01 * env_id, + ], + ] + ) + for env_id in range(self.num_envs) + ], + axis=0, + ) + else: + self.body_inertia = np.asarray([[0.0, 0.0, 0.0], [0.1, 0.2, 0.3]]) self.body_pos = np.zeros((self.num_envs, 2, 3)) self.body_quat = np.zeros((self.num_envs, 2, 4)) self.body_quat[:, :, 0] = 1.0 @@ -1304,25 +1447,22 @@ def get_dr_capabilities(self) -> DomainRandomizationCapabilities: supports_interval_body_torque=capabilities.supports_interval_body_torque, ) - -_MASS_INERTIA_SCENE_XML = ( - '' - "" - '' - '' - "" -) + def get_reset_term_default(self, term: str) -> np.ndarray: + if term == RESET_TERM_BODY_INERTIA and self.inertia_supported: + return self.body_inertia.copy() + return super().get_reset_term_default(term) def _mass_inertia_env( - tmp_path, *, inertia_supported: bool = True, + per_world_defaults: bool = False, rng_seed: int = 5, ) -> tuple[ManagerBasedRlEnv, _InertiaBackend, ResetStateTransaction]: - model_file = tmp_path / "mass_inertia_scene.xml" - model_file.write_text(_MASS_INERTIA_SCENE_XML, encoding="utf-8") - backend = _InertiaBackend(inertia_supported=inertia_supported) + backend = _InertiaBackend( + inertia_supported=inertia_supported, + per_world_defaults=per_world_defaults, + ) transaction = ResetStateTransaction(cast(SimBackend, backend)) scene = EntityScene( { @@ -1344,7 +1484,6 @@ def _mass_inertia_env( rng=np.random.default_rng(rng_seed), scene=scene, step_dt=0.02, - cfg=SimpleNamespace(scene=SimpleNamespace(model_file=str(model_file))), ), ) return env, backend, transaction @@ -1366,8 +1505,8 @@ def _mass_inertia_manager(env: ManagerBasedRlEnv) -> EventManager: ) -def test_randomize_body_mass_inertia_scales_once_and_replays_cached_factor(tmp_path) -> None: - env, backend, transaction = _mass_inertia_env(tmp_path, rng_seed=11) +def test_randomize_body_mass_inertia_scales_once_and_replays_cached_factor() -> None: + env, backend, transaction = _mass_inertia_env(rng_seed=11) manager = _mass_inertia_manager(env) ids = np.array([0, 2], dtype=np.int32) @@ -1407,27 +1546,56 @@ def test_randomize_body_mass_inertia_scales_once_and_replays_cached_factor(tmp_p np.testing.assert_allclose(replay.body_inertia[0, 1], payload.body_inertia[1, 1]) -def test_randomize_body_mass_inertia_capability_gap_fails_during_construction(tmp_path) -> None: - env, backend, _ = _mass_inertia_env(tmp_path, inertia_supported=False) +def test_randomize_body_mass_inertia_capability_gap_fails_during_construction() -> None: + env, backend, _ = _mass_inertia_env(inertia_supported=False) with pytest.raises(NotImplementedError, match="body_inertia randomization.*unsupported"): _mass_inertia_manager(env) assert backend.set_state_calls == [] -def test_randomize_body_mass_inertia_cross_check_fails_on_model_divergence(tmp_path) -> None: - env, backend, _ = _mass_inertia_env(tmp_path) - backend.body_mass = np.array([0.0, 11.0]) # backend model drifted from the scene file - with pytest.raises(ValueError, match="does not match backend"): - _mass_inertia_manager(env) - assert backend.set_state_calls == [] +def test_randomize_body_mass_inertia_uses_selected_per_world_baselines() -> None: + env, backend, transaction = _mass_inertia_env( + per_world_defaults=True, + rng_seed=11, + ) + manager = EventManager( + { + "mass_inertia": EventTermCfg( + func=mdp.randomize_body_mass_inertia, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=("base",)), + "scale_range": (2.0, 2.0), + }, + ) + }, + env, + ) + ids = np.array([0, 2], dtype=np.int32) + + with transaction.scoped(ids): + mdp.reset_scene_to_default(env, ids) + manager.apply(mode="reset", env_ids=ids, global_env_step_count=0) + + payload = backend.randomization_calls[-1] + assert payload is not None + assert payload.body_mass is not None + assert payload.body_inertia is not None + np.testing.assert_allclose(payload.body_mass[:, 1], [20.0, 22.0]) + np.testing.assert_allclose(payload.body_mass[:, 0], 0.0) + np.testing.assert_allclose( + payload.body_inertia[:, 1, :], + [[0.2, 0.4, 0.6], [0.24, 0.44, 0.64]], + ) + np.testing.assert_allclose(payload.body_inertia[:, 0, :], 0.0) @pytest.mark.parametrize( "scale_range", [(0.9, 0.1), (0.0, 1.05)], ) -def test_randomize_body_mass_inertia_rejects_invalid_scale_range(tmp_path, scale_range) -> None: - env, backend, _ = _mass_inertia_env(tmp_path) +def test_randomize_body_mass_inertia_rejects_invalid_scale_range(scale_range) -> None: + env, backend, _ = _mass_inertia_env() with pytest.raises(ValueError, match="scale_range"): EventManager( { From 92e9d42717558d7f36344889f2fc2164e9d075cf Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 17:40:33 +0800 Subject: [PATCH 03/13] refactor: retire legacy domain randomization protocol --- docs/sphinx/source/api_reference/dr/index.md | 2 +- docs/sphinx/source/changelog.md | 18 + .../5-domain_randomization/0-index.md | 129 +---- .../5-domain_randomization/1-configuration.md | 46 +- .../2-writing_providers.md | 67 --- .../3-deployment/1-sim_to_real/1-overview.md | 2 +- .../1-sim_to_real/6-domain_randomization.md | 8 +- .../1-sim_to_real/8-latency_budget.md | 2 +- .../2-sim_to_sim/7-config_guard.md | 2 +- .../5-task_config_translation.md | 6 +- .../2-contracts/4-dr_contract.md | 185 ++----- .../5-domain_randomization/0-index.md | 118 +---- .../5-domain_randomization/1-configuration.md | 41 +- .../2-writing_providers.md | 64 --- .../3-deployment/1-sim_to_real/1-overview.md | 2 +- .../1-sim_to_real/6-domain_randomization.md | 6 +- .../1-sim_to_real/8-latency_budget.md | 2 +- .../2-sim_to_sim/7-config_guard.md | 2 +- .../5-task_config_translation.md | 6 +- .../2-contracts/4-dr_contract.md | 179 ++----- src/unilab/base/np_env.py | 33 +- src/unilab/dr/__init__.py | 17 +- src/unilab/dr/dr_utils.py | 189 ------- src/unilab/dr/manager.py | 114 ----- src/unilab/dr/provider.py | 46 -- src/unilab/utils/sim2sim.py | 1 - tests/base/test_dr_legacy_removed.py | 33 ++ tests/base/test_sim_backend.py | 5 +- tests/dr/test_manager.py | 467 ------------------ tests/envs/test_manager_based_rl_env.py | 2 +- 30 files changed, 259 insertions(+), 1535 deletions(-) delete mode 100644 docs/sphinx/source/en/2-user_guide/5-domain_randomization/2-writing_providers.md delete mode 100644 docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/2-writing_providers.md delete mode 100644 src/unilab/dr/dr_utils.py delete mode 100644 src/unilab/dr/manager.py delete mode 100644 src/unilab/dr/provider.py create mode 100644 tests/base/test_dr_legacy_removed.py delete mode 100644 tests/dr/test_manager.py diff --git a/docs/sphinx/source/api_reference/dr/index.md b/docs/sphinx/source/api_reference/dr/index.md index a14d37899..1168c6c7a 100644 --- a/docs/sphinx/source/api_reference/dr/index.md +++ b/docs/sphinx/source/api_reference/dr/index.md @@ -1,6 +1,6 @@ # `unilab.dr` — Domain Randomization -The DR manager + provider lifecycle. See the contract document at +Manager-Based event terms and backend-owned plan types. See the contract document at {doc}`../../en/4-developer_guide/2-contracts/4-dr_contract` before adding randomization to a new task. diff --git a/docs/sphinx/source/changelog.md b/docs/sphinx/source/changelog.md index 15a24e307..f7bc01a02 100644 --- a/docs/sphinx/source/changelog.md +++ b/docs/sphinx/source/changelog.md @@ -13,6 +13,24 @@ UniLab 遵循[语义化版本](https://semver.org/)。本共享页面以中英 ## Unreleased / 未发布 +- Retire the legacy DomainRandomization provider protocol (roadmap + [#1563](https://github.com/Motphys/UniLab/issues/1563), + [#1567](https://github.com/Motphys/UniLab/issues/1567)). + `DomainRandomizationProvider`, `DomainRandomizationManager`, their NpEnv + hooks, and the provider-side payload helper are removed. Manager-Based event + terms are the sole DR lifecycle: fixed model identity is construction-time, + reset terms commit through `ResetStateTransaction`, and interval terms use the + public UniSim plan contract. `unilab.dr` remains only as a thin re-export of + backend-owned plan/capability types. The `env.domain_rand` sim2sim allowlist + entry and provider documentation are removed. + 移除 legacy DomainRandomization provider 协议(roadmap #1563、#1567)。 + `DomainRandomizationProvider`、`DomainRandomizationManager`、NpEnv hooks 和 + provider 侧 payload helper 已删除。Manager-Based event term 成为唯一 DR + lifecycle:固定模型 identity 位于 construction-time,reset term 通过 + `ResetStateTransaction` 提交,interval term 使用公开 UniSim plan contract。 + `unilab.dr` 仅保留 backend-owned plan/capability 类型的薄 re-export,并移除 + `env.domain_rand` sim2sim allowlist 与 provider 文档。 + - Replace the `mujoco-uni-runtime` dependency (`mujoco_uni` import) with the `mjbatch` native batch engine across the repository (roadmap [#1552](https://github.com/unilabsim/UniLab/issues/1552), diff --git a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md index 6b8cb4cec..629cb1e9b 100644 --- a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md +++ b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md @@ -3,16 +3,15 @@ This page only describes the current domain randomization status of registered tasks in the repo. All conclusions come from the code; nothing is inferred from design intent. -Two DR declaration paths exist today: +Manager-Based event terms are the only DR declaration path: - **Manager-Based (Compatible) tasks**: reset / interval randomization is declared through Hydra `events:` manager terms in the owner YAML; reset-lifecycle events sample at reset, interval-lifecycle events perturb between steps. See the `events:` block of `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml` for an example. -- **Task-level provider path**: custom tasks (including tasks hosted in external repos) may declare `env.domain_rand.*` configuration through a `DomainRandomizationProvider` + `DomainRandomizationManager`. No in-repo task currently uses this path. -The unified entry point of the legacy provider path lives in `NpEnv._init_domain_randomization()` and `DomainRandomizationManager`: +The Manager-Based lifecycle is: -- init path: the task provider produces an `InitRandomizationPlan`; the manager calls the backend's `apply_init_randomization(...)` during env initialization -- reset path: the task provider produces a `ResetPlan`; the manager validates capability and then calls the backend's `set_state(..., randomization=...)` -- interval path: the task provider produces an `IntervalRandomizationPlan`; the manager calls the backend's `apply_interval_randomization(...)` as needed before step +- construction path: fixed model/tool identity is attached to `SceneCfg` before backend construction +- reset path: event terms compose writes in `ResetStateTransaction`; the transaction calls `SimBackend.set_state(..., randomization=...)` once +- interval path: event terms submit backend-owned interval plans through the public contract These three paths correspond to three lifecycle classes: @@ -22,12 +21,11 @@ These three paths correspond to three lifecycle classes: ## Status Conclusions -1. Manager-Based tasks do not register a DR provider; their reset/interval randomization consists of `events:` manager terms in the owner YAML, executed uniformly by the manager lifecycle. Custom tasks on the provider path instead go through the `DomainRandomizationManager` unified entry point. -2. Provider-path owners define a `domain_rand` config dataclass, a `DomainRandomizationProvider`, and a `ResetPlan`; Manager-Based owners declare reset behavior through Hydra command/event terms. G1 motion reset perturbations belong to `MotionCommandCfg`, while WBT adds `EventTermCfg` reset and interval terms. -3. What is "unified" today is mainly the entry point and execution flow, not every randomization item itself. The legacy path's shared helper `build_common_reset_randomization()` currently generates `base_mass_delta`, `base_com_offset`, `gravity`, `kp`, `kd`. -4. `ResetRandomizationPayload` can already express `gravity`, `body_iquat`, `body_inertia`, `kp`, `kd`, and `MuJoCoBackend` has declared support. Whether these are actually used still depends on whether the task provider samples and dispatches them. -5. `MotrixBackend` currently supports `base_mass_delta`, `base_com_offset`, `kp`, `kd`, and interval push; and it requires all model actuators to be position actuators during initialization. -6. Fixed mesh/tool identity is declared by `env.fixed_model_variants`; reset-time geometry fields remain behind backend capability declarations and never change that identity. +1. Reset/interval randomization consists of `events:` manager terms in the owner YAML, executed uniformly by the manager lifecycle. +2. Manager-Based owners declare reset behavior through Hydra command/event terms. G1 motion reset perturbations belong to `MotionCommandCfg`, while WBT adds `EventTermCfg` reset and interval terms. +3. `ResetRandomizationPayload` expresses curated reset terms; a backend must advertise every requested term and own its derived-quantity obligation. +4. `MotrixBackend` currently supports `base_mass_delta`, `base_com_offset`, `kp`, `kd`, and interval push; and it requires all model actuators to be position actuators during initialization. +5. Fixed mesh/tool identity is declared by `env.fixed_model_variants`; reset-time geometry fields remain behind backend capability declarations and never change that identity. ## Uniformity Assessment Table @@ -55,98 +53,27 @@ These three paths correspond to three lifecycle classes: | `AllegroInhandRotation` | Entity-scoped hand/ball reset; an explicitly configured grasp cache is sampled, otherwise `null` explicitly selects the model home pose; optional `joint_noise`, `ball_velocity_noise`, and `ball_z_offset` | none | owner YAML explicitly selects the home pose and zero reset noise; a configured missing or malformed cache fails closed | | `AllegroInhandRotationGrasp` | Reuses the rotation reset with `joint_noise=0.25`; Manager-Based termination checks fingertip distance, contact count, and ball height; recorder stores successful timeout rows | none | generates the 50k-row Allegro grasp cache and raises `RunComplete` after a successful save | -## Current Unified DR Capabilities and Boundaries +## Current DR Capabilities and Boundaries -### 1. The Legacy Provider Entry Point Is Unified +The owner YAML declares event terms; `ResetStateTransaction` composes selected +rows and validates shapes; UniSim backends advertise and apply the curated +payload. Task-specific reset sampling remains owned by command/event terms: -The unified entry point of the legacy provider path is guaranteed by `NpEnv` -and `DomainRandomizationManager`: +- `G1MotionTracking` pose / velocity / joint noise is owned by its manager command. +- Allegro grasp / object initial-state sampling is task-specific event logic. +- Fixed model/tool identity is construction-time and never reset-time DR. -- Tasks only need to register a provider -- The manager uniformly performs capability validation -- The backend is uniformly responsible for actually applying the randomization payload - -So from an execution-path perspective, provider-path tasks are unified; -Manager-Based tasks instead execute the `events:` terms declared in the owner -YAML through the manager lifecycle. - -### 2. The Shared Helpers Are Still Narrow - -The legacy path's `dr_utils.py` builds and validates common reset payloads: - -- reset common payload: `base_mass_delta`, `base_com_offset`, `gravity`, `kp`, `kd` - -This means: - -- Provider-path tasks sample their task-specific state directly inside each provider -- `G1MotionTracking`'s pose / velocity / joint noise is owned by its manager command -- Allegro's grasp / object initial state sampling is entirely task-specific logic -- `geom_size` scale is init-lifecycle model materialization and is not part of the reset common payload - -So today's "uniformity" is more about the contract and the calling convention than "all tasks share the same set of randomization-item schemas". - -### 3. Backend Capabilities Already Exceed What Tasks Currently Use - -`ResetRandomizationPayload` now contains: - -- `base_mass_delta` -- `base_com_offset` -- `gravity` -- `body_iquat` -- `body_inertia` -- `kp` -- `kd` - -Backend capability today: - -- `MuJoCoBackend`: supports the 7 reset terms above, plus interval push, interval body velocity delta (linear and world-frame angular), and interval body force/torque -- `MotrixBackend`: supports `base_mass_delta`, `base_com_offset`, `kp`, `kd`, plus interval push; requires actuators to all be position actuators during initialization - -Notes: - -- The current `IntervalRandomizationPlan` supports `push_perturbation_limit`, `body_linear_velocity_delta`, `body_angular_velocity_delta`, `body_force`, and `body_torque`; among these, `body_force`/`body_torque` express hot-path direct external-wrench perturbations without exposing the backend-private `xfrc_applied` details. -- The current MuJoCo backend's interval push and interval body force are both dispatched through `xfrc_applied`. -- The Motrix backend currently still does not support direct body-force disturbance, so such owner configs must continue to be explicitly disabled. - -But on the task side, the current reality is: not every provider constructs these fields. The backend contract is the capability boundary; whether the task config and provider dispatch a payload is what determines whether a given task actually enables the corresponding DR item. +A requested backend capability that is not advertised fails closed; there is no +provider-side filtering fallback. ## Reset gravity Usage -`gravity` is a reset-lifecycle DR: on each reset, a full MuJoCo gravity vector `(gx, gy, gz)` is sampled per env subset and dispatched to the backend via `ResetRandomizationPayload.gravity`. This vector expresses both direction and magnitude: - -- Direction: determined by the direction of `(gx, gy, gz)`. -- Magnitude: determined by the vector norm `sqrt(gx^2 + gy^2 + gz^2)`. -- Lifecycle: only sampled and written at reset; the env retains that gravity until the next reset re-samples it. -- Backend: currently in UniLab, only the MuJoCo backend declares support for this reset term; the Motrix backend does not. Some tasks filter it by capability and skip it; others raise an error in the validate stage. - -The config entry lives under `env.domain_rand` in provider-path task owners; -Manager-Based tasks have no `env.domain_rand`: - -```yaml -env: - domain_rand: - randomize_gravity: true - gravity_range: - - [-0.2, -0.2, -10.5] - - [0.2, 0.2, -8.5] -``` - -Field semantics: - -- `randomize_gravity`: whether to enable gravity reset DR; defaults to `false`. -- `gravity_range`: a `(2, 3)`-shaped per-dimension sampling range; the first and second rows give the upper and lower bounds of each component. -- On each reset, each dimension is uniformly sampled within `[min(row0, row1), max(row0, row1)]`. The direction is not automatically normalized, and the gravity norm is not fixed. - -If you only want to randomize the magnitude while keeping the vertical-down direction, only open up the `z` component; to randomize both direction and magnitude, open up `x/y/z`. Enable it from the CLI with `env.domain_rand.randomize_gravity=true` and a `env.domain_rand.gravity_range=[...]` override on a provider-path task owner. - -Notes: - -- `gravity_range` must be convertible into a `(2, 3)` array; otherwise reset will raise an error when constructing the payload. -- This term does not call `mj_setConst`; MuJoCo step / forward reads `mjModel.opt.gravity` directly. -- Do not enable this term under the Motrix backend; the current Motrix capability does not include `gravity`. -- The MuJoCo backend writes gravity through the `mjbatch` per-simulation model - expansion (`expand("gravity")`), which the pinned `mjbatch` build ships. -- During training it is recommended to start from a small tilt range; otherwise sampling a too-large horizontal gravity early on may degrade the task into being unlearnable. +`gravity` is a reset-lifecycle DR: on each reset, a full MuJoCo gravity vector +`(gx, gy, gz)` is sampled per selected environment and dispatched through +`ResetRandomizationPayload.gravity`. Configure it with a reset `EventTermCfg` +that calls `randomize_physics_scene_gravity`; unsupported backends fail closed. +A small tilt range is recommended because large horizontal gravity can make an +early task unlearnable. ## Interval push Usage @@ -203,13 +130,11 @@ The ownership boundary and MJWarp/CPU executor split are recorded in :hidden: 1-configuration -2-writing_providers ``` ## Related Tasks - {doc}`G1 Motion Tracking <../4-tasks/2-motion_tracking>`: confirm motion assets and replay first before enabling DR. - {doc}`Go2 Rough Terrain <../4-tasks/1-locomotion>`: common items are mass, COM, friction, and push. -For configuration examples, see {doc}`1-configuration`. For the developer -provider interface and backend capability boundary, see -{doc}`2-writing_providers` and {doc}`Domain Randomization Contract `. +For the backend capability boundary, see +{doc}`Domain Randomization Contract `. diff --git a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/1-configuration.md b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/1-configuration.md index e0f2d0874..4768f342d 100644 --- a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/1-configuration.md +++ b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/1-configuration.md @@ -1,38 +1,25 @@ # Configuration -Domain randomization is configured inside the selected task owner YAML. Use -`--task` and `--sim` to select backend-specific behavior first, then override -fields inside that selected owner. +Domain randomization is configured inside the selected Manager-Based task owner +YAML. Use `--task` and `--sim` to select backend-specific behavior first, then +override fields inside that selected owner. -Two declaration paths exist today: +The lifecycle boundaries are: -- Manager-Based (Compatible) tasks declare reset / interval randomization - through Hydra `events:` manager terms in the owner YAML, for example - `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml`. -- Tasks may also attach a task-level provider and configure legacy provider - fields under `env.domain_rand`; no in-repo task currently uses this path. +- Fixed model/tool identity is declared once on `env.fixed_model_variants` and + realized during backend construction; it is not reset randomization. +- Reset-lifecycle event terms perturb state or curated model parameters through + one `ResetStateTransaction` and one backend payload. +- Interval-lifecycle event terms apply perturbations between steps. -Common lifecycle boundaries: - -- Init-lifecycle items change model identity or geometry and must run during - env/backend initialization. -- Reset-lifecycle items perturb state or model parameters at reset through a - backend-supported payload. -- Interval-lifecycle items apply perturbations between steps. - -The detailed task status and field semantics are in {doc}`0-index`. - -Domain randomization is split by lifecycle: init, reset, and interval. The -legacy path's manager is `src/unilab/dr/manager.py`; task providers live near -the env owners, and backend capabilities are declared through -`unisim.backend.base`. +Backend support is declared through `unisim.backend.base`. A requested term that +the selected backend does not advertise fails closed. ## Reset Gravity Use `--sim mujoco` when enabling gravity reset randomization; Motrix does not -advertise the same gravity capability in the current backend. This item is only -available on the task-level provider path (`env.domain_rand.randomize_gravity` -and `env.domain_rand.gravity_range`), which no in-repo task currently uses. +advertise the gravity reset capability. Configure gravity through a reset event +term that calls `randomize_physics_scene_gravity`. ## Interval Push @@ -48,9 +35,8 @@ uv run train --algo ppo --task go1_joystick_flat --sim mujoco \ ## Owner-Local Defaults Keep ranges in the task owner YAML when they are part of the task contract. For -example, the rough quadruped family's base mass, center-of-mass, kp/kd, and -push randomization are declared as event terms in the shared base -`src/unilab/conf/ppo/task/quadruped_joystick_rough/base.yaml` (the `go2_joystick_rough` -backend owners compose it through Hydra defaults). +example, the rough quadruped family's base mass, center-of-mass, kp/kd, and push +randomization are declared as event terms in the shared base +`src/unilab/conf/ppo/task/quadruped_joystick_rough/base.yaml`. For the full current inventory, see {doc}`0-index`. diff --git a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/2-writing_providers.md b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/2-writing_providers.md deleted file mode 100644 index 3fea0fbab..000000000 --- a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/2-writing_providers.md +++ /dev/null @@ -1,67 +0,0 @@ -# Writing Providers - -This page describes the task-level provider path: custom tasks (including -tasks hosted in external repos) may declare domain randomization through a -task-level `DomainRandomizationProvider`. Manager-Based tasks do not write -providers; they declare randomization through Hydra `events:` manager terms in -the owner YAML (see {doc}`0-index` and {doc}`1-configuration`). - -Task-level domain randomization providers live with the task env owner. They -sample task-specific state and return plans consumed by -`DomainRandomizationManager`. - -## Provider Shape - -Current provider examples define one or more of these plan methods: - -- Build an init plan for model variants or geometry materialization. -- Return a reset plan with state updates and a reset randomization payload. -- Return an interval plan for push or body-force perturbations. - -Interval plans are built from `IntervalTermOp` descriptors (term name, NumPy -payload, optional `body_ids`; see {doc}`../../4-developer_guide/2-contracts/4-dr_contract`): - -```python -from unilab.dr import INTERVAL_TERM_BODY_FORCE, IntervalRandomizationPlan, IntervalTermOp - - -def build_interval_randomization_plan(self, env, step_counter): - ... - return IntervalRandomizationPlan( - ops=( - IntervalTermOp( - INTERVAL_TERM_BODY_FORCE, - force, # shape (num_envs, len(body_ids), 3) - body_ids=body_ids, - ), - ), - ) -``` - -Migration note: returning interval plans via the legacy fields -(`push_perturbation_limit`, `body_ids`, `body_force`, ...) is deprecated. Such -plans are still adapted 1:1 through `IntervalRandomizationPlan.iter_ops()`, -but new providers should populate `ops`; the legacy fields will be removed in -the next unisim-core major release. - -The shared types live in `unisim.dr.types` (interval term descriptors in -`unisim.dr.interval`), re-exported from `src/unilab/dr/__init__.py`, and the -manager lives in `src/unilab/dr/manager.py`. - -## Rules - -- Keep XML, asset, and model metadata access on cold paths such as init, - materialization, or cache creation. -- Do not probe backend private methods from env hot paths. -- Dispatch only fields that the backend declares through its DR capabilities. -- Put task-specific sampling in the task provider, not in training scripts. - -## Evidence - -The provider interface and manager live in: - -- `src/unilab/dr/provider.py` -- `src/unilab/dr/manager.py` - -Developer contract details are in -{doc}`../../4-developer_guide/2-contracts/4-dr_contract`. diff --git a/docs/sphinx/source/en/3-deployment/1-sim_to_real/1-overview.md b/docs/sphinx/source/en/3-deployment/1-sim_to_real/1-overview.md index 8623bee0e..4a79329c8 100644 --- a/docs/sphinx/source/en/3-deployment/1-sim_to_real/1-overview.md +++ b/docs/sphinx/source/en/3-deployment/1-sim_to_real/1-overview.md @@ -35,7 +35,7 @@ flowchart LR | Stage | UniLab artefact | Page | |---|---|---| | Train | Task owner YAML + training script | {doc}`../../2-user_guide/1-training/1-cli_reference` | -| Curriculum + DR | `unilab.dr` + task-side providers | {doc}`6-domain_randomization` | +| Curriculum + DR | Manager-Based event terms | {doc}`6-domain_randomization` | | Cross-backend sanity | `--task --sim ` | {doc}`../2-sim_to_sim/1-backend_swap` | | ONNX export | Training playback scripts + deploy helpers | {doc}`5-onnx_runtime` | | Latency / obs lag | Task config flags and deploy-side logs | {doc}`8-latency_budget` | diff --git a/docs/sphinx/source/en/3-deployment/1-sim_to_real/6-domain_randomization.md b/docs/sphinx/source/en/3-deployment/1-sim_to_real/6-domain_randomization.md index bb49eb95f..4ad5adbd8 100644 --- a/docs/sphinx/source/en/3-deployment/1-sim_to_real/6-domain_randomization.md +++ b/docs/sphinx/source/en/3-deployment/1-sim_to_real/6-domain_randomization.md @@ -1,7 +1,7 @@ # Domain Randomization for Real-World Transfer This page is the deployment checklist for domain randomization. For the -**contract** layer (what a DR provider must implement), see +**contract** layer (what Manager-Based event terms and backends must implement), see {doc}`../../4-developer_guide/2-contracts/4-dr_contract`. ## What to randomize, in priority order @@ -46,10 +46,8 @@ Manager-Based tasks declare reset and interval randomization through `env.events` in their owner YAML, executed by the manager lifecycle. See `src/unilab/conf/ppo/task/quadruped_joystick_rough/base.yaml`. -Tasks may also attach a task-level provider (the -`DomainRandomizationProvider` interface in `src/unilab/dr/provider.py`) to -`src/unilab/dr/manager.py`; no in-repo task currently uses this path. The -capability boundary for both paths is described in +The legacy task-level provider protocol has been removed. The capability +boundary is described in {doc}`../../4-developer_guide/2-contracts/4-dr_contract`. ## Recipe: starting ranges diff --git a/docs/sphinx/source/en/3-deployment/1-sim_to_real/8-latency_budget.md b/docs/sphinx/source/en/3-deployment/1-sim_to_real/8-latency_budget.md index 9e94878fe..6cecd56ce 100644 --- a/docs/sphinx/source/en/3-deployment/1-sim_to_real/8-latency_budget.md +++ b/docs/sphinx/source/en/3-deployment/1-sim_to_real/8-latency_budget.md @@ -64,4 +64,4 @@ the deploy side. - {doc}`6-domain_randomization` - {doc}`7-safety_layers` -- `src/unilab/dr/manager.py` +- `src/unilab/managers/event_manager.py` diff --git a/docs/sphinx/source/en/3-deployment/2-sim_to_sim/7-config_guard.md b/docs/sphinx/source/en/3-deployment/2-sim_to_sim/7-config_guard.md index 60e696305..be3893ffc 100644 --- a/docs/sphinx/source/en/3-deployment/2-sim_to_sim/7-config_guard.md +++ b/docs/sphinx/source/en/3-deployment/2-sim_to_sim/7-config_guard.md @@ -29,7 +29,7 @@ Fields are classified by dotted path into three tiers (see `src/unilab/training/ |---|---|---| | **DENYLIST** | Mismatch → `CrossBackendIncompatibleError`, aborts | `algo.obs_groups`, legacy `env.control_config.action_scale`, Manager-Based `env.observations` / `env.actions` / policy and critic group mapping, `algo.policy.actor_hidden_dims` / `critic_hidden_dims`, `algo.empirical_normalization` / `algo.obs_normalization`, `env.sampling_mode` | | **WARNING_LIST** | Prints a warning, continues | `reward.*`, `env.control_config.simulate_action_latency`, `env.ctrl_dt` | -| **ALLOWLIST** | Free to override, not checked | `training.sim_backend`, `env.scene`, `training.play_steps`, `env.domain_rand`, `env.noise_config`, `env.commands.vel_limit` | +| **ALLOWLIST** | Free to override, not checked | `training.sim_backend`, `env.scene`, `training.play_steps`, `env.noise_config`, `env.commands.vel_limit` | ## When DENYLIST fields differ diff --git a/docs/sphinx/source/en/3-deployment/3-framework_migration/5-task_config_translation.md b/docs/sphinx/source/en/3-deployment/3-framework_migration/5-task_config_translation.md index 830d5cc5d..0745c5828 100644 --- a/docs/sphinx/source/en/3-deployment/3-framework_migration/5-task_config_translation.md +++ b/docs/sphinx/source/en/3-deployment/3-framework_migration/5-task_config_translation.md @@ -72,15 +72,15 @@ A side-by-side map of common config fields across Isaac Lab / Legged Gym * - Randomize friction - `EventTerm(...friction)` - `cfg.domain_rand.friction_range` - - `dr.friction.*` in owner YAML + - `env.events.` using `geom_friction` * - Push robot - `EventTerm(...push)` - `cfg.domain_rand.push_robots` - - `dr.push.*` + - `env.events.push_robot` * - PD gain DR - `EventTerm(...stiffness)` - `cfg.domain_rand.randomize_motor_strength` - - `dr.actuator.pd_kp_factor` + - `env.events.pd_gains` ``` ## Curriculum diff --git a/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md b/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md index 8d4557722..1ca4148d4 100644 --- a/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md +++ b/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md @@ -1,56 +1,44 @@ # Domain Randomization Contract -Domain randomization is an env-owner provider contract plus backend capability -application. User configuration examples live in -{doc}`../../2-user_guide/5-domain_randomization/0-index`. +Manager-Based event terms are the sole UniLab DR lifecycle. There is no task +provider protocol and `NpEnv` no longer carries a DR manager. -## Lifecycle Classes +## Lifecycle -- Init lifecycle: changes model identity or geometry. These changes run during - env/backend initialization, materialization, or cache construction. -- Reset lifecycle: changes state or parameters within the same model identity. - Providers dispatch a reset randomization payload through `ResetPlan`. -- Interval lifecycle: applies perturbations between steps, such as push or body - force plans. +- **Construction identity:** `env.fixed_model_variants` materializes a final + read-only assignment and attaches a UniSim `FixedVariantPlan` to `SceneCfg`. + Backends realize it before their first forward and before CUDA graph capture. +- **Reset:** event terms write through Entity bindings into + `ResetStateTransaction`; the transaction calls + `SimBackend.set_state(..., randomization=...)` once. +- **Interval:** event terms use backend-owned interval plans through the public + `SimBackend` contract. -Hot paths must not parse XML/assets or probe backend private methods with -`getattr` or `hasattr`. +## Capability Boundary -## Provider Minimum +Backend differences are explicit capabilities, not task-side branches: -A task that uses DR should define: +- `DomainRandomizationCapabilities.supported_reset_terms` +- `supported_interval_terms` +- fixed-variant layouts and source formats +- per-environment playback support +- curated reset-term contracts and derived-quantity obligations -1. A task-owned domain-randomization config dataclass. -2. A `DomainRandomizationProvider`. -3. Reset behavior returning `ResetPlan` state and randomization payloads. -4. Interval behavior through `IntervalRandomizationPlan` when needed. -5. Env construction that calls `self._init_domain_randomization(...)`. +An unadvertised requested term fails closed with the backend and term named. +Manager code never imports MuJoCo or mjbatch and never accesses a backend model +or pool. -Shared types live in `unisim.dr.types` (interval term descriptors in -`unisim.dr.interval`); both are re-exported from `src/unilab/dr/__init__.py`. -Manager behavior lives in `src/unilab/dr/manager.py`. +## Reset Payload And Defaults -## Backend Capability Boundary +`ResetRandomizationPayload` is a curated NumPy plan whose first dimension is the +selected row count. Supported terms include the body mass/COM/inertia family, +gravity, geometry friction/size/solver parameters, joint damping/armature/friction, +and actuator gains. Derived fields such as geometry bounds are backend-owned and +are never independently caller-supplied. -Backend support is explicit. A reset or interval item only counts as a unified -DR item when three pieces exist together: - -1. `ResetRandomizationPayload` has an explicit field, or - `IntervalRandomizationPlan.ops` carries an `IntervalTermOp` for the term. -2. The backend declares and implements the capability. -3. The task config/provider samples and dispatches that field or op. - -MuJoCo and Motrix differences stay in backend capability declarations, -backend implementations, and owner YAMLs. - -## Manager Reset Defaults - -Manager-Based model-field terms do not compile engine assets or call legacy -per-field getters. During cold-path binding, `ResetStateTransaction` asks -UniSim for `SimBackend.get_reset_term_default(term)` and validates the result -before exposing immutable columns to an `Entity`. - -The returned table is authoritative and has one of two layouts: +During cold-path binding, `ResetStateTransaction` asks UniSim for +`SimBackend.get_reset_term_default(term)`. The returned table is authoritative +and has one of two layouts: - canonical model table, such as `(nbody,)` for `body_mass`; - per-environment fixed-variant table, such as `(num_envs, nbody)`. @@ -59,103 +47,22 @@ For a selected reset subset, event terms use the corresponding per-env rows as their baseline. A write to a subset of model columns fills every unwritten column from that same env row before the transaction builds one dense payload. Missing capabilities, unsupported terms, non-floating tables, invalid tails, and -per-env tables whose first dimension is not `num_envs` fail closed. - -This boundary removes the former UniLab-side MuJoCo recompilation used to -obtain `body_inertia` defaults; inertial identity and defaults belong to the -backend that realized the fixed model variant. +per-env tables whose first dimension is not `num_envs` fail closed. This removes +the former UniLab-side MuJoCo recompilation used to obtain inertia defaults. -## Interval Term Descriptors +## Interval Terms Interval plans are term-descriptor based: `IntervalRandomizationPlan.ops` -carries a tuple of `IntervalTermOp` entries (term name, NumPy payload, -optional `body_ids`) from `unisim.dr.interval`, re-exported through -`unilab.dr`. - -- Builtin term names are the `INTERVAL_TERM_*` constants; their payload - contracts are pinned by `INTERVAL_TERM_SPECS` (`push`: payload shape `(3,)`, - no `body_ids`; the four body terms: payload shape - `(num_envs, len(body_ids), 3)` with required `body_ids`). - `IntervalTermOp.validate()` enforces these contracts for builtin terms; - unknown custom terms pass validation through untouched. -- Capability ownership stays with the backend: - `DomainRandomizationCapabilities.supported_interval_terms` is the - authoritative declaration, queried via `supports_interval_term` / - `get_unsupported_interval_terms`. -- `DomainRandomizationManager.apply_interval_randomization_if_due` is generic: - it contains no term names and no per-term branches, so a backend-owned - custom term needs no manager change. Terms missing from the capability set - fail closed with `NotImplementedError` naming the backend type and the - terms; on the backend side, `SimBackend.apply_interval_randomization` - routes each op through its handler table and fails closed with the backend - class and term name when no handler exists. -- Ops and plans must stay pickle-safe (protocol 4) across spawn-based - collector processes: stdlib + NumPy frozen dataclasses only. -- The legacy plan fields (`push_perturbation_limit`, `body_ids`, - `body_linear_velocity_delta`, `body_angular_velocity_delta`, `body_force`, - `body_torque`) and the legacy `supports_interval_*` capability bools are - deprecated: `IntervalRandomizationPlan.iter_ops()` still adapts set legacy - fields into ops 1:1, and the bools remain as capability fallbacks. New - providers should populate `ops`; the legacy fields will be removed in the - next unisim-core major release. - -## MuJoCo mjbatch Snapshot - -Current MuJoCo reset randomization writes the nine supported fields through -`mjbatch` per-simulation model views: the backend expands a field with -`Batch.expand(name)`, writes the targeted env rows, then refreshes derived -constants with `Batch.set_const(ids)` before the fused reset runs `mj_forward`. -This interface lives in the `mjbatch` package, not in this -repository; the reset-term constants that map onto it are in -`unisim.dr.types`. - -The supported reset fields and their per-env block shapes are below. The leading -dimension is always `len(env_ids)`; the trailing block size is the field's full -flat width in a single `mjModel`. - -| Field | Per-env block shape | -| --- | --- | -| `body_mass` | `nbody` | -| `body_ipos` | `3 * nbody` | -| `body_iquat` | `4 * nbody` | -| `body_inertia` | `3 * nbody` | -| `dof_armature` | `nv` | -| `gravity` | `3` | -| `geom_friction` | `3 * ngeom` | -| `kp` | `nu` | -| `kd` | `nu` | - -Refresh behavior is fixed by the backend: `body_mass`, `body_ipos`, -`body_iquat`, `body_inertia`, and `dof_armature` trigger an `mj_setConst` -refresh after the write, while `gravity`, `geom_friction`, `kp`, and `kd` do -not. - -Two caveats: - -- `geom_size` is not in the supported reset fields. Geometry size is expressed - through init-lifecycle model materialization (see `GeomSizeOverride` / - `ModelVariantSpec` in `unisim.dr.types`), not reset randomization. -- `gravity` reset randomization requires an `mjbatch` build that ships it - (`expand("gravity")` covers the `mjOption` vector). This repository depends - on the official `mujoco` package (`~=3.11.0`, with the default version pinned - by `uv.lock`) plus `mjbatch`, whose expandable fields include `gravity`. - -## Motor Control Extension - -Motor-actuator tasks that do not map policy output directly to backend position -actuators should keep conversion in the env owner layer. Register a pre-step -callback through `SimBackend.set_pre_step_control(...)`; the backend calls it -before physics substeps and refreshes sensors after stepping. - -Go2W is the current all-motor actuator example: its env owner combines leg -position targets and wheel torque, while kp/kd randomization stays in the env -owner cache rather than leaking MuJoCo position-actuator mechanics into shared -payloads. - -## Evidence In Repo - -- DR types: `unisim.dr.types` and `unisim.dr.interval`, re-exported by - `src/unilab/dr/__init__.py` -- DR manager: `src/unilab/dr/manager.py` -- Backend interface: `unisim.backend.base` -- Provider interface: `src/unilab/dr/provider.py` +carries `IntervalTermOp` entries from `unisim.dr.interval`, re-exported through +`unilab.dr`. Builtin payload contracts are enforced by `IntervalTermOp.validate`; +unknown backend-owned custom terms pass through to that backend's handler table. +Ops and plans remain pickle-safe stdlib/NumPy data across spawn collectors. + +## Evidence + +- Manager lifecycle: `src/unilab/managers/event_manager.py` +- Reset transaction: `src/unilab/base/reset_state.py` +- Entity bindings: `src/unilab/base/entity.py` +- Task-owned fixed variants: `src/unilab/base/variants.py` +- Backend contract/capability types: `unisim.backend.base`, `unisim.dr.types` +- ADR: {doc}`ADR-0010 Fixed Model Variant Ownership Boundary ` diff --git a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md index 5abfcc438..68c98f9fa 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md @@ -3,16 +3,11 @@ 本页仅描述仓库中已注册任务的域随机化现状。所有结论都来自代码;不从设计意图推断任何内容。 -当前存在两条 DR 声明路径: +Manager-Based event term 是唯一 DR 声明路径: - **Manager-Based(Compatible)任务**:reset / interval 随机化通过 owner YAML 中的 Hydra `events:` manager term 声明;reset 生命周期的 event 在 reset 时采样,interval 生命周期的 event 在 step 之间施加扰动。例如 `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml` 的 `events:` 段。 -- **任务级 provider 路径**:自定义任务(包括托管在外部仓库中的任务)可以通过 `DomainRandomizationProvider` + `DomainRandomizationManager` 声明 `env.domain_rand.*` 配置。当前仓库内没有任务使用该路径。 -legacy provider 路径的统一入口点位于 `NpEnv._init_domain_randomization()` 和 `DomainRandomizationManager`: -- init 路径:task provider 产生一个 `InitRandomizationPlan`;manager 在 env 初始化期间调用后端的 `apply_init_randomization(...)` -- reset 路径:task provider 产生一个 `ResetPlan`;manager 验证能力,然后调用后端的 `set_state(..., randomization=...)` -- interval 路径:task provider 产生一个 `IntervalRandomizationPlan`;manager 在 step 之前按需调用后端的 `apply_interval_randomization(...)` 这三条路径对应三个生命周期类别: @@ -22,12 +17,11 @@ legacy provider 路径的统一入口点位于 `NpEnv._init_domain_randomization ## 状态结论 -1. Manager-Based 任务不注册 DR provider;它们的 reset/interval 随机化是 owner YAML 中的 `events:` manager term,由 manager 生命周期统一执行。走 provider 路径的自定义任务则经过 `DomainRandomizationManager` 统一入口。 -2. provider 路径的 owner 定义 `domain_rand` 配置 dataclass、`DomainRandomizationProvider` 和 `ResetPlan`;Manager-Based owner 则通过 Hydra command/event term 声明 reset 行为。G1 motion reset 扰动归 `MotionCommandCfg` 所有,WBT 另加 `EventTermCfg` reset 与 interval term。 -3. 今天所"统一"的主要是入口点和执行流程,而不是每一个随机化项本身。legacy 路径的共享辅助函数 `build_common_reset_randomization()` 目前生成 `base_mass_delta`、`base_com_offset`、`gravity`、`kp`、`kd`。 -4. `ResetRandomizationPayload` 已经可以表达 `gravity`、`body_iquat`、`body_inertia`、`kp`、`kd`,并且 `MuJoCoBackend` 已声明支持。这些是否实际被使用,仍取决于 task provider 是否对它们进行采样和 dispatch。 -5. `MotrixBackend` 目前支持 `base_mass_delta`、`base_com_offset`、`kp`、`kd` 和 interval push;并且它要求在初始化期间所有模型 actuator 都是 position actuator。 -6. 固定 mesh/tool identity 由 `env.fixed_model_variants` 声明;reset-time geometry 字段仍位于 backend capability 声明之后,且不会改变该 identity。 +1. reset/interval 随机化由 owner YAML 中的 `events:` manager term 声明,并由 manager 生命周期统一执行。 +2. Manager-Based owner 通过 Hydra command/event term 声明 reset 行为。G1 motion reset 扰动归 `MotionCommandCfg` 所有,WBT 另加 `EventTermCfg` reset 与 interval term。 +3. `ResetRandomizationPayload` 表达 curated reset terms;backend 必须声明每个请求 term,并拥有其派生量重算义务。 +4. `MotrixBackend` 目前支持 `base_mass_delta`、`base_com_offset`、`kp`、`kd` 和 interval push;并且它要求在初始化期间所有模型 actuator 都是 position actuator。 +5. 固定 mesh/tool identity 由 `env.fixed_model_variants` 声明;reset-time geometry 字段仍位于 backend capability 声明之后,且不会改变该 identity。 ## 统一性评估表 @@ -55,94 +49,24 @@ legacy provider 路径的统一入口点位于 `NpEnv._init_domain_randomization | `AllegroInhandRotation` | entity 范围的手/球 reset;显式配置 grasp cache 时进行采样,否则以 `null` 显式选择模型 home pose;可选 `joint_noise`、`ball_velocity_noise` 与 `ball_z_offset` | 无 | owner YAML 显式选择 home pose 与零 reset 噪声;配置的 cache 缺失或格式错误时 fail-closed | | `AllegroInhandRotationGrasp` | 复用 rotation reset 并设置 `joint_noise=0.25`;Manager-Based termination 检查指尖距离、接触数和球高度;recorder 保存成功 timeout rows | 无 | 生成 5 万行 Allegro grasp cache,成功保存后抛出 `RunComplete` | -## 当前统一 DR 的能力与边界 +## 当前 DR 的能力与边界 -### 1. legacy provider 入口是统一的 +owner YAML 声明 event term;`ResetStateTransaction` 组合 selected rows 并校验 +shape;UniSim backend 声明并应用 curated payload。task-specific reset 采样仍由 +command/event term 拥有: -legacy provider 路径的统一入口点由 `NpEnv` 和 `DomainRandomizationManager` 保证: +- `G1MotionTracking` 的 pose / velocity / joint noise 归 manager command 所有。 +- Allegro grasp / object 初始状态采样是 task-specific event logic。 +- 固定 model/tool identity 是 construction-time,不是 reset-time DR。 -- 任务只需注册一个 provider -- manager 统一执行能力验证 -- 后端统一负责实际施加随机化 payload - -因此从执行路径的角度看,provider 路径的任务是统一的;Manager-Based 任务则由 manager 生命周期统一执行 owner YAML 声明的 `events:` term。 - -### 2. 共享辅助函数仍然较窄 - -legacy 路径的 `dr_utils.py` 构造并校验通用 reset payload: - -- reset common payload:`base_mass_delta`、`base_com_offset`、`gravity`、`kp`、`kd` - -这意味着: - -- provider 路径的任务直接在各自的 provider 内部采样 task 专属状态 -- `G1MotionTracking` 的 pose / velocity / joint 噪声由其 manager command 所有 -- Allegro 的 grasp / 物体初始状态采样完全是 task 专属逻辑 -- `geom_size` 缩放是 init 生命周期的模型 materialization,不属于 reset common payload - -所以今天的"统一性"更多是关于 contract 和调用约定,而不是"所有任务共享同一套随机化项 schema"。 - -### 3. 后端能力已经超出任务当前使用的范围 - -`ResetRandomizationPayload` 现在包含: - -- `base_mass_delta` -- `base_com_offset` -- `gravity` -- `body_iquat` -- `body_inertia` -- `kp` -- `kd` - -当前的后端能力: - -- `MuJoCoBackend`:支持上述 7 个 reset 项,外加 interval push、interval body velocity delta(线速度与世界系角速度)和 interval body force/torque -- `MotrixBackend`:支持 `base_mass_delta`、`base_com_offset`、`kp`、`kd`,外加 interval push;要求在初始化期间 actuator 全部为 position actuator - -说明: - -- 当前的 `IntervalRandomizationPlan` 支持 `push_perturbation_limit`、`body_linear_velocity_delta`、`body_angular_velocity_delta`、`body_force` 和 `body_torque`;其中 `body_force`/`body_torque` 表达热路径上的直接外力/力矩扰动,而不暴露后端私有的 `xfrc_applied` 细节。 -- 当前 MuJoCo 后端的 interval push 和 interval body force 都通过 `xfrc_applied` dispatch。 -- Motrix 后端目前仍不支持直接 body-force 扰动,因此这类 owner 配置必须继续显式禁用。 - -但在任务侧,当前的现实是:并非每个 provider 都构造这些字段。后端 contract 是能力边界;task 配置和 provider 是否 dispatch 一个 payload,才决定了某个任务是否实际启用对应的 DR 项。 +未显式声明支持的后端能力会 fail closed;不存在 provider 侧过滤回退。 ## Reset gravity 用法 -`gravity` 是一个 reset 生命周期 DR:在每次 reset 时,会按 env 子集采样一个完整的 MuJoCo gravity 向量 `(gx, gy, gz)`,并通过 `ResetRandomizationPayload.gravity` dispatch 到后端。该向量同时表达方向和大小: - -- 方向:由 `(gx, gy, gz)` 的方向决定。 -- 大小:由向量范数 `sqrt(gx^2 + gy^2 + gz^2)` 决定。 -- 生命周期:仅在 reset 时采样和写入;env 会保留该重力,直到下一次 reset 重新采样。 -- 后端:当前在 UniLab 中,只有 MuJoCo 后端声明支持该 reset 项;Motrix 后端不支持。一些任务按能力过滤并跳过它;另一些任务在 validate 阶段抛出错误。 - -配置入口位于 provider 路径任务 owner 的 `env.domain_rand` 下;Manager-Based 任务没有 `env.domain_rand`: - -```yaml -env: - domain_rand: - randomize_gravity: true - gravity_range: - - [-0.2, -0.2, -10.5] - - [0.2, 0.2, -8.5] -``` - -字段语义: - -- `randomize_gravity`:是否启用 gravity reset DR;默认为 `false`。 -- `gravity_range`:一个形状为 `(2, 3)` 的逐维采样范围;第一行和第二行给出每个分量的上界和下界。 -- 在每次 reset 时,每个维度在 `[min(row0, row1), max(row0, row1)]` 内均匀采样。方向不会自动归一化,重力范数也不固定。 - -如果你只想随机化大小而保持竖直向下的方向,只开放 `z` 分量;如果想同时随机化方向和大小,开放 `x/y/z`。在 provider 路径的任务 owner 上,可通过 CLI 以 `env.domain_rand.randomize_gravity=true` 与 `env.domain_rand.gravity_range=[...]` override 启用。 - -说明: - -- `gravity_range` 必须可转换为 `(2, 3)` 数组;否则 reset 在构造 payload 时会抛出错误。 -- 该项不调用 `mj_setConst`;MuJoCo step / forward 直接读取 `mjModel.opt.gravity`。 -- 不要在 Motrix 后端下启用该项;当前 Motrix 能力不包含 `gravity`。 -- MuJoCo 后端通过 `mjbatch` 的 per-simulation 模型展开(`expand("gravity")`) - 写入 gravity,钉住的 `mjbatch` 构建已包含该字段。 -- 在训练期间,建议从较小的倾斜范围开始;否则在早期采样到过大的水平重力,可能会使任务退化为不可学习。 +`gravity` 是 reset 生命周期 DR:每次 reset 按选中环境采样完整的 MuJoCo gravity +向量 `(gx, gy, gz)`,并通过 `ResetRandomizationPayload.gravity` 提交。请通过调用 +`randomize_physics_scene_gravity` 的 reset `EventTermCfg` 配置;不支持的后端 +fail closed。建议从较小倾斜范围开始,避免早期训练任务不可学习。 ## Interval push 用法 @@ -194,13 +118,11 @@ Reset-time model-field DR 保持在已选 identity 内。其 canonical 或 per-e :hidden: 1-configuration -2-writing_providers ``` ## 相关任务 - {doc}`G1 Motion Tracking <../4-tasks/2-motion_tracking>`:开启 DR 前先确认 motion 资产和 replay。 - {doc}`Go2 Rough Terrain <../4-tasks/1-locomotion>`:常见的是 mass、COM、friction、push。 -有关配置示例,请参阅 {doc}`1-configuration`。有关开发者 -provider 接口和后端能力边界,请参阅 -{doc}`2-writing_providers` 和 {doc}`Domain Randomization Contract `。 +有关后端能力边界,请参阅 +{doc}`Domain Randomization Contract `。 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/1-configuration.md b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/1-configuration.md index eb135258c..52c936234 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/1-configuration.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/1-configuration.md @@ -1,33 +1,23 @@ # 配置 -域随机化在所选的 task owner YAML 内部配置。先使用 `--task` 和 `--sim` 选择后端专属行为, -然后在所选的 owner 内部 override 字段。 +域随机化在所选 Manager-Based task owner YAML 内部配置。先使用 `--task` 和 +`--sim` 选择后端专属行为,然后在所选 owner 内部 override 字段。 -当前有两条声明路径: +生命周期边界: -- Manager-Based(Compatible)任务通过 owner YAML 的 `events:` manager term 声明 - reset / interval 随机化,例如 `src/unilab/conf/ppo/task/go1_joystick_flat/base.yaml`。 -- 任务也可以挂载任务级 provider,并在 `env.domain_rand` 下配置 legacy provider - 字段;目前仓内没有任务使用这条路径。 +- 固定 model/tool identity 在 `env.fixed_model_variants` 上声明一次,并在 + backend construction 期间 realization;它不是 reset 随机化。 +- reset 生命周期 event term 通过一个 `ResetStateTransaction` 和一个 backend + payload 扰动状态或 curated model parameters。 +- interval 生命周期 event term 在 step 之间施加扰动。 -常见的生命周期边界: - -- init 生命周期项会改变模型 identity 或几何,必须在 env/backend 初始化期间运行。 -- reset 生命周期项通过后端支持的 payload 在 reset 时扰动状态或模型参数。 -- interval 生命周期项在 step 之间施加扰动。 - -详细的任务状态和字段语义见 {doc}`0-index`。 - -域随机化按生命周期划分:init、reset 和 interval。legacy 路径的 manager 位于 -`src/unilab/dr/manager.py`;task provider 位于 env owner 附近, -后端能力通过 `unisim.backend.base` 声明。 +Backend 支持通过 `unisim.backend.base` 显式声明。所选 backend 未声明的能力 +会 fail closed。 ## Reset Gravity -在启用 gravity reset 随机化时使用 `--sim mujoco`;Motrix 在当前后端中 -未提供相同的 gravity 能力。该项只在任务级 provider 路径 -(`env.domain_rand.randomize_gravity` 与 `env.domain_rand.gravity_range`)上可用, -目前仓内没有任务使用这条路径。 +启用 gravity reset 随机化时使用 `--sim mujoco`;Motrix 未声明 gravity reset +能力。请通过调用 `randomize_physics_scene_gravity` 的 reset event term 配置。 ## Interval Push @@ -43,8 +33,7 @@ uv run train --algo ppo --task go1_joystick_flat --sim mujoco \ ## Owner 本地默认值 当取值范围是任务 contract 的一部分时,将其保留在 task owner YAML 中。例如, -rough 四足家族的 base mass、质心、kp/kd 和 push 随机化作为 event term 声明在共享 base -`src/unilab/conf/ppo/task/quadruped_joystick_rough/base.yaml`(`go2_joystick_rough` 的 backend -owner 通过 Hydra defaults 组合它)。 +rough 四足家族的 base mass、质心、kp/kd 和 push 随机化作为 event term 声明在 +共享 base `src/unilab/conf/ppo/task/quadruped_joystick_rough/base.yaml`。 -完整的当前清单见 {doc}`0-index`。 +完整当前清单见 {doc}`0-index`。 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/2-writing_providers.md b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/2-writing_providers.md deleted file mode 100644 index a6095beec..000000000 --- a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/2-writing_providers.md +++ /dev/null @@ -1,64 +0,0 @@ -# 编写 Provider - -本页描述任务级 provider 路径:自定义任务(包括托管在外部仓库中的任务) -可以通过任务级 `DomainRandomizationProvider` 声明域随机化。Manager-Based -任务不写 provider;它们在 owner YAML 中通过 Hydra `events:` manager term -声明随机化(见 {doc}`0-index` 与 {doc}`1-configuration`)。 - -任务级域随机化 provider 与 task env owner 放在一起。它们采样任务专属的 -状态,并返回由 `DomainRandomizationManager` 消费的 plan。 - -## Provider 形态 - -当前的 provider 示例定义了以下 plan 方法中的一个或多个: - -- 为模型变体或几何 materialization 构建 init plan。 -- 返回带有状态更新和 reset 随机化 payload 的 reset plan。 -- 返回用于 push 或 body-force 扰动的 interval plan。 - -Interval plan 由 `IntervalTermOp` 描述符构建(term 名称、NumPy payload、 -可选的 `body_ids`;见 {doc}`../../4-developer_guide/2-contracts/4-dr_contract`): - -```python -from unilab.dr import INTERVAL_TERM_BODY_FORCE, IntervalRandomizationPlan, IntervalTermOp - - -def build_interval_randomization_plan(self, env, step_counter): - ... - return IntervalRandomizationPlan( - ops=( - IntervalTermOp( - INTERVAL_TERM_BODY_FORCE, - force, # 形状 (num_envs, len(body_ids), 3) - body_ids=body_ids, - ), - ), - ) -``` - -迁移说明:通过旧版字段(`push_perturbation_limit`、`body_ids`、 -`body_force` 等)返回 interval plan 已废弃。这类 plan 仍会经 -`IntervalRandomizationPlan.iter_ops()` 1:1 适配,但新 provider 应填充 -`ops`;旧字段将在下一个 unisim-core major release 中移除。 - -共享类型位于 `unisim.dr.types`(interval term 描述符位于 -`unisim.dr.interval`),由 `src/unilab/dr/__init__.py` 再导出,manager 位于 -`src/unilab/dr/manager.py`。 - -## 规则 - -- 将 XML、asset 和模型元数据访问保留在冷路径上,例如 init、 - materialization 或 cache 创建。 -- 不要从 env 热路径探测后端私有方法。 -- 只 dispatch 后端通过其 DR 能力声明的字段。 -- 将任务专属采样放在 task provider 中,而不是训练脚本中。 - -## 证据 - -provider 接口与 manager 位于: - -- `src/unilab/dr/provider.py` -- `src/unilab/dr/manager.py` - -开发者 contract 详情见 -{doc}`../../4-developer_guide/2-contracts/4-dr_contract`。 diff --git a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/1-overview.md b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/1-overview.md index 45a378702..a90ed9097 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/1-overview.md +++ b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/1-overview.md @@ -31,7 +31,7 @@ flowchart LR | 阶段 | UniLab 产物 | 页面 | |---|---|---| | 训练 | 任务 owner YAML + 训练脚本 | {doc}`../../2-user_guide/1-training/1-cli_reference` | -| 课程 + DR | `unilab.dr` + 任务侧 provider | {doc}`6-domain_randomization` | +| 课程 + DR | Manager-Based event term | {doc}`6-domain_randomization` | | 跨后端健全性检查 | `--task --sim ` | {doc}`../2-sim_to_sim/1-backend_swap` | | ONNX 导出 | 训练回放脚本 + 部署辅助工具 | {doc}`5-onnx_runtime` | | 延迟 / 观测滞后 | 任务配置开关与部署侧日志 | {doc}`8-latency_budget` | diff --git a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/6-domain_randomization.md b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/6-domain_randomization.md index ab9a8d321..7724b62e9 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/6-domain_randomization.md +++ b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/6-domain_randomization.md @@ -1,6 +1,6 @@ # 面向真机迁移的域随机化 -本页是域随机化的部署检查清单。关于**契约**层(一个 DR provider 必须实现什么),见 +本页是域随机化的部署检查清单。关于**契约**层(Manager-Based event term 与 backend 必须实现什么),见 {doc}`../../4-developer_guide/2-contracts/4-dr_contract`。 ## 随机化什么,按优先级排序 @@ -44,9 +44,7 @@ Manager-Based 任务在 owner YAML 的 `env.events` 中声明 reset 与 interval 随机化,由 manager 生命周期执行。示例见 `src/unilab/conf/ppo/task/quadruped_joystick_rough/base.yaml`。 -任务也可以通过任务级 provider(`src/unilab/dr/provider.py` 中的 -`DomainRandomizationProvider` 接口)接入 `src/unilab/dr/manager.py`;目前仓内 -没有任务使用这条路径。两条路径的能力边界见 +legacy 任务级 provider 协议已移除。能力边界见 {doc}`../../4-developer_guide/2-contracts/4-dr_contract`。 ## 配方:起始范围 diff --git a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/8-latency_budget.md b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/8-latency_budget.md index 4b9611f94..b2acebb6c 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/8-latency_budget.md +++ b/docs/sphinx/source/zh_CN/3-deployment/1-sim_to_real/8-latency_budget.md @@ -57,4 +57,4 @@ G1 WBT owner,`history_length: 5` 让每个本体感受项携带 5 步历史并 - {doc}`6-domain_randomization` - {doc}`7-safety_layers` -- `src/unilab/dr/manager.py` +- `src/unilab/managers/event_manager.py` diff --git a/docs/sphinx/source/zh_CN/3-deployment/2-sim_to_sim/7-config_guard.md b/docs/sphinx/source/zh_CN/3-deployment/2-sim_to_sim/7-config_guard.md index 8055272bb..92ece0cad 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/2-sim_to_sim/7-config_guard.md +++ b/docs/sphinx/source/zh_CN/3-deployment/2-sim_to_sim/7-config_guard.md @@ -29,7 +29,7 @@ uv run eval --algo ppo --task go2_joystick_flat --sim motrix --load-run -1 |---|---|---| | **DENYLIST** | 差异即 `CrossBackendIncompatibleError`,中断 | `algo.obs_groups`、legacy `env.control_config.action_scale`、Manager-Based `env.observations` / `env.actions` / policy 与 critic group mapping、`algo.policy.actor_hidden_dims` / `critic_hidden_dims`、`algo.empirical_normalization` / `algo.obs_normalization`、`env.sampling_mode` | | **WARNING_LIST** | 仅打印 warning,继续 | `reward.*`、`env.control_config.simulate_action_latency`、`env.ctrl_dt` | -| **ALLOWLIST** | 自由覆盖,不检查 | `training.sim_backend`、`env.scene`、`training.play_steps`、`env.domain_rand`、`env.noise_config`、`env.commands.vel_limit` | +| **ALLOWLIST** | 自由覆盖,不检查 | `training.sim_backend`、`env.scene`、`training.play_steps`、`env.noise_config`、`env.commands.vel_limit` | ## 当 DENYLIST 字段不一致时 diff --git a/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/5-task_config_translation.md b/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/5-task_config_translation.md index 712205bfa..ce17f5fbd 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/5-task_config_translation.md +++ b/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/5-task_config_translation.md @@ -72,15 +72,15 @@ * - 随机化摩擦 - `EventTerm(...friction)` - `cfg.domain_rand.friction_range` - - owner YAML 中的 `dr.friction.*` + - 使用 `geom_friction` 的 `env.events.` * - 推搡机器人 - `EventTerm(...push)` - `cfg.domain_rand.push_robots` - - `dr.push.*` + - `env.events.push_robot` * - PD 增益 DR - `EventTerm(...stiffness)` - `cfg.domain_rand.randomize_motor_strength` - - `dr.actuator.pd_kp_factor` + - `env.events.pd_gains` ``` ## Curriculum diff --git a/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md b/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md index a03361691..badab3379 100644 --- a/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md +++ b/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md @@ -1,148 +1,63 @@ -# Domain Randomization 契约 +# 域随机化 Contract -Domain randomization 是一个 env-owner 的 provider 契约,加上 backend 能力的 -应用。用户配置示例见 -{doc}`../../2-user_guide/5-domain_randomization/0-index`。 +Manager-Based event term 是 UniLab 唯一的 DR lifecycle。任务 provider 协议已移除, +`NpEnv` 不再携带 DR manager。 -## 生命周期分类 +## 生命周期 -- Init 生命周期:改变模型身份或几何。这些改动在 env/backend 初始化、 - materialization 或 cache 构造期间执行。 -- Reset 生命周期:在同一模型身份内改变状态或参数。Provider 通过 `ResetPlan` - 分发一份 reset 随机化 payload。 -- Interval 生命周期:在两步之间施加扰动,例如 push 或 body force plan。 +- **Construction identity:** `env.fixed_model_variants` 物化最终 read-only + assignment,并把 UniSim `FixedVariantPlan` 附到 `SceneCfg`。backend 在 first + forward 与 CUDA graph capture 前完成 realization。 +- **Reset:** event term 通过 Entity binding 写入 `ResetStateTransaction`; + transaction 只调用一次 `SimBackend.set_state(..., randomization=...)`。 +- **Interval:** event term 通过公开 `SimBackend` contract 使用 backend-owned + interval plan。 -热路径不得解析 XML/资源,也不得用 `getattr` 或 `hasattr` 探测 backend 私有方法。 +## 能力边界 -## Provider 最低要求 +Backend 差异是显式 capability,不是 task-side 分支: -使用 DR 的任务应定义: +- `DomainRandomizationCapabilities.supported_reset_terms` +- `supported_interval_terms` +- fixed-variant layout 与 source format +- per-environment playback 支持 +- curated reset-term contract 与派生量重算义务 -1. 一个由任务拥有的 domain-randomization config dataclass。 -2. 一个 `DomainRandomizationProvider`。 -3. 返回 `ResetPlan` 状态与随机化 payload 的 reset 行为。 -4. 必要时通过 `IntervalRandomizationPlan` 实现的 interval 行为。 -5. 在 env 构造中调用 `self._init_domain_randomization(...)`。 +未声明支持的请求 term 会携带 backend 与 term 名称 fail closed。Manager code 不 +import MuJoCo 或 mjbatch,也不访问 backend model/pool。 -共享类型位于 `unisim.dr.types`(interval term 描述符位于 -`unisim.dr.interval`),两者都由 `src/unilab/dr/__init__.py` 再导出。 -manager 行为位于 `src/unilab/dr/manager.py`。 +## Reset Payload 与默认值 -## Backend 能力边界 +`ResetRandomizationPayload` 是 curated NumPy plan,首维为 selected row count。 +支持项包括 body mass/COM/inertia family、gravity、geometry friction/size/solver +参数、joint damping/armature/friction 与 actuator gains。geometry bounds 等 +派生字段归 backend 所有,caller 不能独立提交。 -Backend 支持是显式的。只有当以下三个部分同时存在时,一个 reset 或 interval -条目才算作统一的 DR 条目: +冷路径 binding 时,`ResetStateTransaction` 向 UniSim 请求 +`SimBackend.get_reset_term_default(term)`。返回表格是权威默认值,只有两种布局: -1. `ResetRandomizationPayload` 中有明确的字段,或 - `IntervalRandomizationPlan.ops` 携带该 term 的 `IntervalTermOp`。 -2. backend 声明并实现了该能力。 -3. 任务 config/provider 对该字段或 op 进行采样并分发。 - -MuJoCo 与 Motrix 的差异保留在 backend 能力声明、backend 实现与 owner YAML 中。 +- canonical model table,例如 `body_mass` 的 `(nbody,)`; +- per-environment fixed-variant table,例如 `(num_envs, nbody)`。 -## Manager Reset 默认值 +对 selected reset subset,event term 使用对应 env rows 作为 baseline。只写部分 +model columns 时,transaction 会用同一 env row 填充未写列,再构造一个 dense +payload。缺失能力、未支持 term、非 floating 表、非法 tail、首维不是 `num_envs` +的 per-env 表均 fail closed。该边界移除了 UniLab 侧为 inertia 默认值重新 compile +MuJoCo XML 的路径。 -Manager-Based model-field term 不编译 engine asset,也不调用旧版按字段拆分的 -getter。冷路径绑定时,`ResetStateTransaction` 通过 UniSim 的 -`SimBackend.get_reset_term_default(term)` 获取默认值,先做校验,再向 -`Entity` 暴露不可变列绑定。 +## Interval Terms -返回表是权威默认值,只有两种布局: +Interval plan 基于 term descriptor:`IntervalRandomizationPlan.ops` 携带来自 +`unisim.dr.interval` 并经 `unilab.dr` re-export 的 `IntervalTermOp`。内置 payload +contract 由 `IntervalTermOp.validate` 强制;未知 backend-owned custom term 传给该 +backend handler table。Ops 与 plans 保持 stdlib/NumPy 数据,可跨 spawn collector +pickle。 -- canonical model table,例如 `body_mass` 的 `(nbody,)`; -- per-environment fixed-variant table,例如 `(num_envs, nbody)`。 +## 仓库证据 -对一次 reset 的 selected rows,event term 使用对应 env row 作为基线。只写模型 -列子集时,transaction 会先用同一 env row 的默认值补齐未写列,然后构造一次 -dense payload。能力缺失、term 不支持、非浮点表、tail 形状错误,以及首维不是 -`num_envs` 的 per-env 表都会 fail closed。 - -该边界移除了此前 UniLab 为获取 `body_inertia` 默认值而重新编译 MuJoCo 场景的 -路径;惯量 identity 与默认值由完成 fixed model variant realization 的 backend -拥有。 - -## Interval Term 描述符 - -Interval plan 基于 term 描述符:`IntervalRandomizationPlan.ops` 携带一个 -`IntervalTermOp` 元组(term 名称、NumPy payload、可选的 `body_ids`),定义在 -`unisim.dr.interval`,并经 `unilab.dr` 再导出。 - -- 内置 term 名称即 `INTERVAL_TERM_*` 常量;其 payload 契约由 - `INTERVAL_TERM_SPECS` 固定(`push`:payload 形状 `(3,)`,不接受 - `body_ids`;四个 body term:payload 形状 `(num_envs, len(body_ids), 3)`, - 且必须携带 `body_ids`)。`IntervalTermOp.validate()` 对内置 term 强制 - 这些契约;未知的自定义 term 原样通过校验。 -- 能力所有权留在 backend: - `DomainRandomizationCapabilities.supported_interval_terms` 是权威声明, - 通过 `supports_interval_term` / `get_unsupported_interval_terms` 查询。 -- `DomainRandomizationManager.apply_interval_randomization_if_due` 是通用的: - 不包含任何 term 名称或按 term 分支,因此 backend 拥有的自定义 term 无需 - 修改 manager。不在能力集合中的 term 会 fail-closed,抛出带有 backend 类型 - 与 term 名称的 `NotImplementedError`;在 backend 一侧, - `SimBackend.apply_interval_randomization` 把每个 op 路由到 handler 表, - 缺少 handler 时 fail-closed,抛出带有 backend 类名与 term 名称的 - `NotImplementedError`。 -- Op 与 plan 必须保持 pickle 安全(protocol 4),以跨 spawn 方式的 collector - 子进程传递:只允许 stdlib + NumPy 的 frozen dataclass。 -- 旧版 plan 字段(`push_perturbation_limit`、`body_ids`、 - `body_linear_velocity_delta`、`body_angular_velocity_delta`、`body_force`、 - `body_torque`)与旧版 `supports_interval_*` 能力布尔位已废弃: - `IntervalRandomizationPlan.iter_ops()` 仍会把已设置的旧字段 1:1 适配为 - op,布尔位仍作为能力回退。新 provider 应填充 `ops`;旧字段将在下一个 - unisim-core major release 中移除。 - -## MuJoCo mjbatch 快照 - -当前 MuJoCo 的 reset 随机化通过 `mjbatch` 的 per-simulation 模型视图写入九个 -受支持字段:backend 先用 `Batch.expand(name)` 展开字段、写入目标 env 行,然后在 -融合 reset 运行 `mj_forward` 之前用 `Batch.set_const(ids)` 刷新派生常量。该接口 -位于 `mjbatch` 包,不在本仓库中;映射到它的 reset-term 常量定义在 -`unisim.dr.types`。 - -支持的 reset 字段及其每 env 整块形状如下。首维始终是 `len(env_ids)`;尾部 -整块大小是该字段在单个 `mjModel` 里的完整 flat 宽度。 - -| 字段 | 每 env 整块形状 | -| --- | --- | -| `body_mass` | `nbody` | -| `body_ipos` | `3 * nbody` | -| `body_iquat` | `4 * nbody` | -| `body_inertia` | `3 * nbody` | -| `dof_armature` | `nv` | -| `gravity` | `3` | -| `geom_friction` | `3 * ngeom` | -| `kp` | `nu` | -| `kd` | `nu` | - -refresh 行为由 backend 固定:`body_mass`、`body_ipos`、`body_iquat`、 -`body_inertia` 与 `dof_armature` 在写入后会触发 `mj_setConst` refresh,而 -`gravity`、`geom_friction`、`kp` 与 `kd` 不触发。 - -两点注意: - -- `geom_size` 不在受支持的 reset 字段里。几何尺寸通过 init-lifecycle 的模型 - materialization 表达(见 `unisim.dr.types` 中的 `GeomSizeOverride` / - `ModelVariantSpec`),不走 reset 随机化。 -- `gravity` 的 reset 随机化需要包含它的 `mjbatch` 构建 - (`expand("gravity")` 覆盖 `mjOption` 向量)。本仓库依赖官方 `mujoco` - 包(`~=3.11.0`,默认版本由 `uv.lock` 钉住)加 `mjbatch`,其可展开字段包含 - `gravity`。 - -## 电机控制扩展 - -对于不将策略输出直接映射到 backend 位置 actuator 的电机-actuator 任务,应将转换 -保留在 env owner 层。通过 `SimBackend.set_pre_step_control(...)` 注册一个 -pre-step 回调;backend 会在物理 substep 之前调用它,并在 stepping 之后刷新 -sensor。 - -Go2W 是当前全电机 actuator 的示例:它的 env owner 将腿部位置目标与轮子力矩组合 -在一起,而 kp/kd 随机化则保留在 env owner 的 cache 中,从而避免将 MuJoCo 位置 -actuator 的机制泄漏到共享 payload 里。 - -## 仓库中的证据 - -- DR 类型:`unisim.dr.types` 与 `unisim.dr.interval`,由 - `src/unilab/dr/__init__.py` 再导出 -- DR manager:`src/unilab/dr/manager.py` -- Backend 接口:`unisim.backend.base` -- Provider 接口:`src/unilab/dr/provider.py` +- Manager lifecycle:`src/unilab/managers/event_manager.py` +- Reset transaction:`src/unilab/base/reset_state.py` +- Entity bindings:`src/unilab/base/entity.py` +- Task-owned fixed variants:`src/unilab/base/variants.py` +- Backend contract/capability types:`unisim.backend.base`、`unisim.dr.types` +- ADR:{doc}`ADR-0010 Fixed Model Variant Ownership Boundary ` diff --git a/src/unilab/base/np_env.py b/src/unilab/base/np_env.py index 0be2b0bf7..c9382769c 100644 --- a/src/unilab/base/np_env.py +++ b/src/unilab/base/np_env.py @@ -20,7 +20,6 @@ from unilab.base.base import ABEnv, EnvCfg, EnvPlayCapabilities from unilab.base.cpu_runtime import apply_env_cpu_runtime from unilab.base.scene import SceneCfg -from unilab.dr import DomainRandomizationManager, DomainRandomizationProvider from unilab.dtype_config import get_global_dtype if TYPE_CHECKING: @@ -129,8 +128,6 @@ def __init__(self, cfg: EnvCfg, backend: SimBackend, num_envs: int): self._truncated_scratch: np.ndarray = np.zeros((self._num_envs,), dtype=bool) self._final_observation_scratch: dict[str, np.ndarray] | None = None self.step_counter = 0 - self._dr_manager: DomainRandomizationManager | None = None - self._init_randomization_applied = False self._nan_guard: NanGuard | None = None self._autoreset = True self._autoreset_reset_active = False @@ -178,6 +175,11 @@ def init_state(self) -> NpEnvState: self._clear_step_final_observation() return self._state + def reset(self, env_indices: np.ndarray) -> Tuple[dict[str, np.ndarray], dict]: + """Reject the removed legacy reset path; concrete owners own reset.""" + + raise NotImplementedError(f"{type(self).__name__} does not define a reset lifecycle") + def _initial_episode_steps(self) -> np.ndarray: """Return initial per-env episode counters. @@ -206,8 +208,6 @@ def step(self, actions: np.ndarray) -> NpEnvState: ctrl = self.apply_action(actions, self._state) apply_action_time = time.perf_counter() - t0 - if self._dr_manager is not None: - self._dr_manager.apply_interval_randomization_if_due(self.step_counter) self._state.truncated.fill(False) self._clear_step_final_observation() @@ -347,13 +347,11 @@ def _clear_reset_done_detail_timing(self, timing: dict[str, Any]) -> None: def _collect_reset_backend_timing_ms(self) -> dict[str, float]: """Backend-sourced reset sub-timings for the last reset call. - The monolithic DR path reports through the DR manager; manager-based - envs override this to surface the reset-state transaction's set_state - timings. Keys outside RESET_DONE_DETAIL_TIMING_KEYS are dropped by the - caller so stale keys never leak into ``info["timing"]``. + Manager-based envs override this to surface the reset-state + transaction's set_state timings. Keys outside + RESET_DONE_DETAIL_TIMING_KEYS are dropped by the caller so stale keys + never leak into ``info["timing"]``. """ - if self._dr_manager is not None: - return self._dr_manager.last_reset_timing_ms return {} def _resolve_nan_guard_model_file(self) -> str: @@ -420,19 +418,6 @@ def _clear_step_final_observation(self) -> None: if isinstance(compat_terminal_mask, np.ndarray): compat_terminal_mask.fill(False) - def _init_domain_randomization(self, provider: "DomainRandomizationProvider") -> None: - from unilab.dr import DomainRandomizationManager - - self._dr_manager = DomainRandomizationManager(self, provider) - if not self._init_randomization_applied: - self._init_randomization_applied = self._dr_manager.apply_init_randomization() - self._backend.materialize() - - def reset(self, env_indices: np.ndarray) -> Tuple[dict[str, np.ndarray], dict]: - if self._dr_manager is None: # pragma: no cover - constructor integration error - raise RuntimeError("Domain-randomization manager has not been initialized") - return self._dr_manager.reset(env_indices) - def _compute_truncated(self, state: NpEnvState) -> np.ndarray: """Compute truncation conditions. diff --git a/src/unilab/dr/__init__.py b/src/unilab/dr/__init__.py index 82cd4befa..7ebf5ae46 100644 --- a/src/unilab/dr/__init__.py +++ b/src/unilab/dr/__init__.py @@ -1,27 +1,28 @@ -"""Domain randomization package. +"""Backend-owned domain-randomization plan types re-exported for tasks. -Invariant: this package must not depend on unilab.base.* +The legacy UniLab provider/manager protocol was removed. Manager-Based tasks +declare reset and interval behavior through Hydra event terms and submit curated +UniSim plans; they do not implement a second reset protocol. """ -from unisim.dr.types import ( +from unisim.dr.interval import ( INTERVAL_TERM_BODY_ANGULAR_VELOCITY_DELTA, INTERVAL_TERM_BODY_FORCE, INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, INTERVAL_TERM_BODY_TORQUE, INTERVAL_TERM_PUSH, + IntervalTermOp, +) +from unisim.dr.types import ( DomainRandomizationCapabilities, GeomSizeOverride, InitRandomizationPlan, IntervalRandomizationPlan, - IntervalTermOp, ModelVariantSpec, ResetPlan, ResetRandomizationPayload, ) -from .manager import DomainRandomizationManager -from .provider import DomainRandomizationProvider - __all__ = [ "INTERVAL_TERM_BODY_ANGULAR_VELOCITY_DELTA", "INTERVAL_TERM_BODY_FORCE", @@ -29,8 +30,6 @@ "INTERVAL_TERM_BODY_TORQUE", "INTERVAL_TERM_PUSH", "DomainRandomizationCapabilities", - "DomainRandomizationManager", - "DomainRandomizationProvider", "GeomSizeOverride", "InitRandomizationPlan", "IntervalRandomizationPlan", diff --git a/src/unilab/dr/dr_utils.py b/src/unilab/dr/dr_utils.py deleted file mode 100644 index 504c60e09..000000000 --- a/src/unilab/dr/dr_utils.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import annotations - -from typing import Any, cast - -import numpy as np -from unisim.dr.types import ( - INTERVAL_TERM_PUSH, - DomainRandomizationCapabilities, - IntervalRandomizationPlan, - IntervalTermOp, - ResetRandomizationPayload, -) - -from unilab.dtype_config import get_global_dtype - - -def _coerce_range(name: str, values: Any) -> tuple[float, float]: - bounds = np.asarray(values, dtype=np.float64) - if bounds.shape != (2,): - raise ValueError(f"domain_rand.{name} must have shape (2,), got {bounds.shape}") - low = float(bounds[0]) - high = float(bounds[1]) - if high < low: - raise ValueError(f"domain_rand.{name} high must be >= low") - return low, high - - -def build_common_reset_randomization( - env: Any, - num_reset: int, - *, - base_kp: np.ndarray | None = None, - base_kd: np.ndarray | None = None, - base_body_mass: np.ndarray | None = None, - base_geom_friction: np.ndarray | None = None, - ground_geom_id: int | None = None, - base_dof_armature: np.ndarray | None = None, -) -> ResetRandomizationPayload | None: - domain_rand = getattr(env.cfg, "domain_rand", None) - if domain_rand is None: - return None - - payload = ResetRandomizationPayload() - if getattr(domain_rand, "randomize_base_mass", False): - low, high = domain_rand.added_mass_range - payload.base_mass_delta = np.random.uniform(low, high, size=(num_reset,)) - - if getattr(domain_rand, "randomize_body_mass", False): - if base_body_mass is None: - raise ValueError("body mass randomization requires a cached base body-mass table") - body_mass_template = np.asarray(base_body_mass, dtype=np.float64) - if body_mass_template.ndim != 1: - raise ValueError( - f"base_body_mass must have shape (nbody,), got {body_mass_template.shape}" - ) - low, high = _coerce_range( - "body_mass_multiplier_range", domain_rand.body_mass_multiplier_range - ) - multipliers = np.random.uniform( - low=low, high=high, size=(num_reset, body_mass_template.size) - ) - body_mass = np.broadcast_to(body_mass_template, multipliers.shape).copy() - randomized = body_mass_template > 0.0 - body_mass[:, randomized] *= multipliers[:, randomized] - payload.body_mass = body_mass - - if getattr(domain_rand, "random_com", False): - base_com_offset = np.zeros((num_reset, 3), dtype=np.float64) - low, high = domain_rand.com_offset_x - base_com_offset[:, 0] = np.random.uniform(low, high, size=(num_reset,)) - com_offset_y = getattr(domain_rand, "com_offset_y", None) - if com_offset_y is not None: - low, high = com_offset_y - base_com_offset[:, 1] = np.random.uniform(low, high, size=(num_reset,)) - com_offset_z = getattr(domain_rand, "com_offset_z", None) - if com_offset_z is not None: - low, high = com_offset_z - base_com_offset[:, 2] = np.random.uniform(low, high, size=(num_reset,)) - payload.base_com_offset = base_com_offset - - if getattr(domain_rand, "randomize_gravity", False): - gravity_range = np.asarray(domain_rand.gravity_range, dtype=np.float64) - if gravity_range.shape != (2, 3): - raise ValueError( - f"domain_rand.gravity_range must have shape (2, 3), got {gravity_range.shape}" - ) - low = np.minimum(gravity_range[0], gravity_range[1]) - high = np.maximum(gravity_range[0], gravity_range[1]) - payload.gravity = np.random.uniform(low=low, high=high, size=(num_reset, 3)) - - if getattr(domain_rand, "randomize_ground_friction", False): - if base_geom_friction is None or ground_geom_id is None: - raise ValueError( - "ground friction randomization requires cached geom friction and ground geom id" - ) - geom_friction_template = np.asarray(base_geom_friction, dtype=np.float64) - if geom_friction_template.ndim != 2 or geom_friction_template.shape[1] != 3: - raise ValueError( - f"base_geom_friction must have shape (ngeom, 3), got {geom_friction_template.shape}" - ) - ground_id = int(ground_geom_id) - if ground_id < 0 or ground_id >= geom_friction_template.shape[0]: - raise ValueError( - f"ground_geom_id must be in [0, {geom_friction_template.shape[0]}), got {ground_id}" - ) - low, high = _coerce_range( - "ground_friction_multiplier_range", - domain_rand.ground_friction_multiplier_range, - ) - geom_friction = np.broadcast_to( - geom_friction_template, (num_reset, *geom_friction_template.shape) - ).copy() - geom_friction[:, ground_id, 0] = geom_friction_template[ground_id, 0] * np.random.uniform( - low=low, high=high, size=(num_reset,) - ) - payload.geom_friction = geom_friction - - if getattr(domain_rand, "randomize_dof_armature", False): - if base_dof_armature is None: - raise ValueError("dof armature randomization requires a cached dof-armature table") - dof_armature_template = np.asarray(base_dof_armature, dtype=np.float64) - if dof_armature_template.ndim != 1: - raise ValueError( - f"base_dof_armature must have shape (nv,), got {dof_armature_template.shape}" - ) - low, high = _coerce_range( - "dof_armature_multiplier_range", domain_rand.dof_armature_multiplier_range - ) - dof_armature = np.broadcast_to( - dof_armature_template, (num_reset, dof_armature_template.size) - ).copy() - randomized = dof_armature_template > 0.0 - dof_armature[:, randomized] *= np.random.uniform( - low=low, high=high, size=(num_reset, int(np.count_nonzero(randomized))) - ) - payload.dof_armature = dof_armature - - num_actuators = getattr(env, "_num_action", None) - need_kp = num_actuators is not None and getattr(domain_rand, "randomize_kp", False) - need_kd = num_actuators is not None and getattr(domain_rand, "randomize_kd", False) - - if need_kp or need_kd: - assert num_actuators is not None - - if need_kp: - kp = ( - base_kp - if base_kp is not None - else np.full(num_actuators, float(env.cfg.control_config.Kp)) - ) - low, high = domain_rand.kp_multiplier_range - payload.kp = (kp * np.random.uniform(low, high, (num_reset, 1))).astype(np.float64) - - if need_kd: - kd = ( - base_kd - if base_kd is not None - else np.full(num_actuators, float(env.cfg.control_config.Kd)) - ) - low, high = domain_rand.kd_multiplier_range - payload.kd = (kd * np.random.uniform(low, high, (num_reset, 1))).astype(np.float64) - - return None if payload.is_empty() else payload - - -def validate_common_reset_randomization( - env: Any, - capabilities: DomainRandomizationCapabilities, - *, - base_kp: np.ndarray | None = None, - base_kd: np.ndarray | None = None, - base_body_mass: np.ndarray | None = None, - base_geom_friction: np.ndarray | None = None, - ground_geom_id: int | None = None, - base_dof_armature: np.ndarray | None = None, -) -> frozenset[str]: - payload = build_common_reset_randomization( - env, - num_reset=1, - base_kp=base_kp, - base_kd=base_kd, - base_body_mass=base_body_mass, - base_geom_friction=base_geom_friction, - ground_geom_id=ground_geom_id, - base_dof_armature=base_dof_armature, - ) - if payload is None: - return frozenset() - return cast(frozenset[str], capabilities.get_unsupported_reset_terms(payload.requested_terms())) diff --git a/src/unilab/dr/manager.py b/src/unilab/dr/manager.py deleted file mode 100644 index 94b41f11a..000000000 --- a/src/unilab/dr/manager.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -import logging -import time -from typing import Any - -import numpy as np -from unisim.dr.types import DomainRandomizationCapabilities - -from .provider import DomainRandomizationProvider - -logger = logging.getLogger(__name__) - - -class DomainRandomizationManager: - def __init__(self, env: Any, provider: DomainRandomizationProvider): - self._env = env - self._provider = provider - self._capabilities: DomainRandomizationCapabilities = env._backend.get_dr_capabilities() - self._warned_reset_terms: frozenset[str] = frozenset() - self._last_reset_timing_ms: dict[str, float] = {} - self._provider.validate(env, self._capabilities) - - @property - def last_reset_timing_ms(self) -> dict[str, float]: - return dict(self._last_reset_timing_ms) - - def apply_init_randomization(self) -> bool: - plan = self._provider.build_init_randomization_plan(self._env) - if plan is None or plan.is_empty(): - return False - self._env._backend.apply_init_randomization(plan) - return True - - def reset(self, env_ids: np.ndarray) -> tuple[dict[str, np.ndarray], dict]: - reset_t0 = time.perf_counter() - self._last_reset_timing_ms = {} - - t0 = time.perf_counter() - plan = self._provider.build_reset_plan(self._env, env_ids) - plan_ms = (time.perf_counter() - t0) * 1000.0 - - t0 = time.perf_counter() - payload = plan.randomization - if payload is not None: - payload, unsupported = self._capabilities.filter_reset_payload(payload) - if unsupported: - self._log_unsupported_reset_terms(unsupported) - payload_filter_ms = (time.perf_counter() - t0) * 1000.0 - - t0 = time.perf_counter() - set_state_result = self._env._backend.set_state( - plan.env_ids, - plan.qpos, - plan.qvel, - randomization=payload, - ) - set_state_ms = (time.perf_counter() - t0) * 1000.0 - backend_set_state_timing: dict[str, float] = {} - if isinstance(set_state_result, dict): - backend_timing = set_state_result.get("timing") - if isinstance(backend_timing, dict): - for key, value in backend_timing.items(): - try: - backend_set_state_timing[str(key)] = float(value) - except (TypeError, ValueError): - continue - - t0 = time.perf_counter() - obs = self._provider.build_reset_observation(self._env, plan.env_ids, plan.info_updates) - build_observation_ms = (time.perf_counter() - t0) * 1000.0 - - total_ms = (time.perf_counter() - reset_t0) * 1000.0 - measured_ms = plan_ms + payload_filter_ms + set_state_ms + build_observation_ms - timing = { - "dr_reset_total_ms": total_ms, - "dr_reset_plan_ms": plan_ms, - "dr_reset_payload_filter_ms": payload_filter_ms, - "dr_reset_set_state_ms": set_state_ms, - "dr_reset_build_observation_ms": build_observation_ms, - "dr_reset_internal_gap_ms": total_ms - measured_ms, - } - if backend_set_state_timing: - timing.update(backend_set_state_timing) - provider_timing = getattr(self._provider, "last_reset_observation_timing_ms", {}) - if isinstance(provider_timing, dict): - timing.update(provider_timing) - self._last_reset_timing_ms = timing - return obs, plan.info_updates - - def apply_interval_randomization_if_due(self, step_counter: int) -> None: - plan = self._provider.build_interval_randomization_plan(self._env, step_counter) - if plan is None or plan.is_empty(): - return - unsupported = self._capabilities.get_unsupported_interval_terms( - op.term for op in plan.iter_ops() - ) - if unsupported: - raise NotImplementedError( - f"{self._env._backend.backend_type} backend does not support " - f"interval terms: {', '.join(sorted(unsupported))}" - ) - self._env._backend.apply_interval_randomization(plan) - - def _log_unsupported_reset_terms(self, unsupported: frozenset[str]) -> None: - new_terms = frozenset(term for term in unsupported if term not in self._warned_reset_terms) - if not new_terms: - return - self._warned_reset_terms |= new_terms - logging.warning( - "%s backend does not support reset randomization terms: %s; skipping them.", - self._env._backend.backend_type, - ", ".join(sorted(new_terms)), - ) diff --git a/src/unilab/dr/provider.py b/src/unilab/dr/provider.py deleted file mode 100644 index ea20cba23..000000000 --- a/src/unilab/dr/provider.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -import abc -from typing import Any - -import numpy as np -from unisim.dr.types import ( - DomainRandomizationCapabilities, - InitRandomizationPlan, - IntervalRandomizationPlan, - ResetPlan, -) - - -class DomainRandomizationProvider(abc.ABC): - @abc.abstractmethod - def validate(self, env: Any, capabilities: DomainRandomizationCapabilities) -> None: - pass - - def build_init_randomization_plan(self, env: Any) -> InitRandomizationPlan | None: - return None - - @abc.abstractmethod - def build_reset_plan(self, env: Any, env_ids: np.ndarray) -> ResetPlan: - pass - - @abc.abstractmethod - def build_reset_observation( - self, env: Any, env_ids: np.ndarray, info_updates: dict[str, Any] - ) -> dict[str, np.ndarray]: - pass - - def build_interval_randomization_plan( - self, env: Any, step_counter: int - ) -> IntervalRandomizationPlan | None: - """Build the interval randomization plan for the upcoming step. - - Populate the plan's ``ops`` tuple with ``IntervalTermOp`` entries. - Returning plans via the legacy fields (``push_perturbation_limit``, - ``body_ids``, ``body_linear_velocity_delta``, - ``body_angular_velocity_delta``, ``body_force``, ``body_torque``) is - deprecated: they are still adapted 1:1 through - ``IntervalRandomizationPlan.iter_ops()``, but they will be removed in - the next unisim-core major release. - """ - return None diff --git a/src/unilab/utils/sim2sim.py b/src/unilab/utils/sim2sim.py index 1489c84d3..536b685d9 100644 --- a/src/unilab/utils/sim2sim.py +++ b/src/unilab/utils/sim2sim.py @@ -19,7 +19,6 @@ class CrossBackendIncompatibleError(RuntimeError): "training.sim_backend", "env.scene", "training.play_steps", - "env.domain_rand", "env.noise_config", "env.commands.vel_limit", ] diff --git a/tests/base/test_dr_legacy_removed.py b/tests/base/test_dr_legacy_removed.py new file mode 100644 index 000000000..2d9d94024 --- /dev/null +++ b/tests/base/test_dr_legacy_removed.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def test_legacy_dr_protocol_is_removed() -> None: + import unilab.base.np_env as np_env + import unilab.dr as dr + + assert not hasattr(dr, "DomainRandomizationManager") + assert not hasattr(dr, "DomainRandomizationProvider") + assert not hasattr(np_env.NpEnv, "_init_domain_randomization") + + source = Path(np_env.__file__).read_text(encoding="utf-8") + assert "_dr_manager" not in source + assert "unilab.dr" not in source + + +def test_fresh_import_graph_does_not_load_legacy_dr_manager() -> None: + code = "\n".join( + [ + "import sys", + "import unilab", + "assert 'unilab.dr.manager' not in sys.modules", + "assert 'unilab.dr.provider' not in sys.modules", + ] + ) + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr diff --git a/tests/base/test_sim_backend.py b/tests/base/test_sim_backend.py index dc9c2472b..df1bbddf5 100644 --- a/tests/base/test_sim_backend.py +++ b/tests/base/test_sim_backend.py @@ -11,13 +11,10 @@ import numpy as np import pytest +from unisim.dr.types import IntervalRandomizationPlan, ResetRandomizationPayload from unilab.assets import ASSETS_ROOT_PATH from unilab.base.scene import SceneCfg -from unilab.dr import ( - IntervalRandomizationPlan, - ResetRandomizationPayload, -) # --------------------------------------------------------------------------- diff --git a/tests/dr/test_manager.py b/tests/dr/test_manager.py deleted file mode 100644 index 018164304..000000000 --- a/tests/dr/test_manager.py +++ /dev/null @@ -1,467 +0,0 @@ -from __future__ import annotations - -import logging -import pickle -from dataclasses import dataclass -from types import SimpleNamespace -from typing import Any - -import numpy as np -import pytest -from unisim.dr.types import ( - RESET_TERM_BASE_MASS, - RESET_TERM_BODY_MASS, - RESET_TERM_DOF_ARMATURE, - RESET_TERM_GEOM_FRICTION, - RESET_TERM_GRAVITY, - RESET_TERM_KP, -) - -from unilab.dr import ( - INTERVAL_TERM_BODY_FORCE, - INTERVAL_TERM_PUSH, - DomainRandomizationCapabilities, - DomainRandomizationManager, - DomainRandomizationProvider, - IntervalRandomizationPlan, - IntervalTermOp, - ResetPlan, - ResetRandomizationPayload, -) -from unilab.dr.dr_utils import build_common_reset_randomization - - -def test_capabilities_filter_reset_payload_drops_unsupported_terms(): - capabilities = DomainRandomizationCapabilities( - supported_reset_terms=frozenset({RESET_TERM_BASE_MASS}) - ) - payload = ResetRandomizationPayload( - base_mass_delta=np.array([0.25]), - gravity=np.array([[0.0, 0.0, -3.71]]), - kp=np.array([[12.0, 12.0]]), - ) - - filtered, unsupported = capabilities.filter_reset_payload(payload) - - assert unsupported == frozenset({RESET_TERM_GRAVITY, RESET_TERM_KP}) - assert filtered is not None - assert filtered.base_mass_delta is not None - np.testing.assert_allclose(filtered.base_mass_delta, np.array([0.25])) - assert filtered.gravity is None - assert filtered.kp is None - - -def test_build_common_reset_randomization_samples_gravity_vector(): - env = SimpleNamespace( - cfg=SimpleNamespace( - domain_rand=SimpleNamespace( - randomize_gravity=True, - gravity_range=[[-1.0, -2.0, -10.5], [1.0, 2.0, -8.5]], - ) - ) - ) - - payload = build_common_reset_randomization(env, num_reset=8) - - assert payload is not None - assert payload.gravity is not None - assert payload.gravity.shape == (8, 3) - assert payload.requested_terms() == frozenset({RESET_TERM_GRAVITY}) - assert np.all(payload.gravity[:, 0] >= -1.0) - assert np.all(payload.gravity[:, 0] <= 1.0) - assert np.all(payload.gravity[:, 1] >= -2.0) - assert np.all(payload.gravity[:, 1] <= 2.0) - assert np.all(payload.gravity[:, 2] >= -10.5) - assert np.all(payload.gravity[:, 2] <= -8.5) - - -def test_build_common_reset_randomization_samples_mass_ground_friction_and_armature(): - env = SimpleNamespace( - cfg=SimpleNamespace( - domain_rand=SimpleNamespace( - randomize_body_mass=True, - body_mass_multiplier_range=[0.5, 0.5], - randomize_ground_friction=True, - ground_friction_multiplier_range=[2.0, 2.0], - randomize_dof_armature=True, - dof_armature_multiplier_range=[3.0, 3.0], - ) - ) - ) - base_body_mass = np.asarray([0.0, 10.0, 2.0, 0.5], dtype=np.float64) - base_geom_friction = np.asarray([[1.0, 0.005, 0.0001], [0.8, 0.004, 0.0002]], dtype=np.float64) - base_dof_armature = np.asarray([0.0, 0.01, 0.02, 0.0], dtype=np.float64) - - payload = build_common_reset_randomization( - env, - num_reset=3, - base_body_mass=base_body_mass, - base_geom_friction=base_geom_friction, - ground_geom_id=1, - base_dof_armature=base_dof_armature, - ) - - assert payload is not None - assert payload.requested_terms() == frozenset( - {RESET_TERM_BODY_MASS, RESET_TERM_GEOM_FRICTION, RESET_TERM_DOF_ARMATURE} - ) - assert payload.body_mass is not None - np.testing.assert_allclose(payload.body_mass[:, 0], 0.0) - np.testing.assert_allclose( - payload.body_mass[:, 1:], np.broadcast_to(base_body_mass[1:] * 0.5, (3, 3)) - ) - assert payload.geom_friction is not None - expected_friction = np.broadcast_to(base_geom_friction, (3, 2, 3)).copy() - expected_friction[:, 1, 0] *= 2.0 - np.testing.assert_allclose(payload.geom_friction, expected_friction) - assert payload.dof_armature is not None - expected_armature = np.broadcast_to(base_dof_armature, (3, 4)).copy() - expected_armature[:, [1, 2]] *= 3.0 - np.testing.assert_allclose(payload.dof_armature, expected_armature) - - -@dataclass -class _FakeBackend: - capabilities: DomainRandomizationCapabilities - backend_type: str = "motrix" - - def __post_init__(self) -> None: - self.last_randomization: ResetRandomizationPayload | None = None - self.interval_plans: list[IntervalRandomizationPlan] = [] - - def get_dr_capabilities(self) -> DomainRandomizationCapabilities: - return self.capabilities - - def set_state( - self, - env_indices: np.ndarray, - qpos: np.ndarray, - qvel: np.ndarray, - randomization: ResetRandomizationPayload | None = None, - ) -> None: - self.last_randomization = randomization - - def apply_interval_randomization(self, plan: IntervalRandomizationPlan) -> None: - self.interval_plans.append(plan) - - -@dataclass -class _FakeTimedBackend: - """Backend that reports set_state sub-timings via the extended contract.""" - - capabilities: DomainRandomizationCapabilities - timing: dict[str, float] - backend_type: str = "motrix" - - def __post_init__(self) -> None: - self.last_randomization: ResetRandomizationPayload | None = None - self.call_count = 0 - - def get_dr_capabilities(self) -> DomainRandomizationCapabilities: - return self.capabilities - - def set_state( - self, - env_indices: np.ndarray, - qpos: np.ndarray, - qvel: np.ndarray, - randomization: ResetRandomizationPayload | None = None, - ) -> dict: - self.last_randomization = randomization - self.call_count += 1 - return {"timing": dict(self.timing)} - - -class _FakeProvider(DomainRandomizationProvider): - def validate(self, env: Any, capabilities: DomainRandomizationCapabilities) -> None: - return None - - def build_reset_plan(self, env: Any, env_ids: np.ndarray) -> ResetPlan: - return ResetPlan( - env_ids=env_ids, - qpos=np.zeros((len(env_ids), 8), dtype=np.float32), - qvel=np.zeros((len(env_ids), 7), dtype=np.float32), - info_updates={"commands": np.zeros((len(env_ids), 3), dtype=np.float32)}, - randomization=ResetRandomizationPayload( - base_mass_delta=np.full((len(env_ids),), 0.1, dtype=np.float32), - kp=np.full((len(env_ids), 2), 5.0, dtype=np.float32), - ), - ) - - def build_reset_observation( - self, env: Any, env_ids: np.ndarray, info_updates: dict[str, Any] - ) -> dict[str, np.ndarray]: - return {"obs": np.zeros((len(env_ids), 1), dtype=np.float32)} - - -def test_manager_skips_unsupported_reset_terms_with_warning(caplog): - backend = _FakeBackend( - capabilities=DomainRandomizationCapabilities( - supported_reset_terms=frozenset({RESET_TERM_BASE_MASS}) - ) - ) - env = SimpleNamespace(_backend=backend) - manager = DomainRandomizationManager(env, _FakeProvider()) - - with caplog.at_level(logging.WARNING): - obs, info = manager.reset(np.array([0, 1], dtype=np.int32)) - - assert obs["obs"].shape == (2, 1) - assert info["commands"].shape == (2, 3) - assert backend.last_randomization is not None - assert backend.last_randomization.base_mass_delta is not None - np.testing.assert_allclose(backend.last_randomization.base_mass_delta, np.array([0.1, 0.1])) - assert backend.last_randomization.kp is None - assert ( - "motrix backend does not support reset randomization terms: kp; skipping them." - in caplog.text - ) - - -def test_manager_keeps_supported_reset_terms_without_warning(caplog): - backend = _FakeBackend( - capabilities=DomainRandomizationCapabilities( - supported_reset_terms=frozenset({RESET_TERM_BASE_MASS, RESET_TERM_KP}) - ) - ) - env = SimpleNamespace(_backend=backend) - manager = DomainRandomizationManager(env, _FakeProvider()) - - with caplog.at_level(logging.WARNING): - obs, info = manager.reset(np.array([0, 1], dtype=np.int32)) - - assert obs["obs"].shape == (2, 1) - assert info["commands"].shape == (2, 3) - assert backend.last_randomization is not None - assert backend.last_randomization.base_mass_delta is not None - assert backend.last_randomization.kp is not None - assert "skipping them" not in caplog.text - - -def test_manager_merges_backend_set_state_sub_timings(): - """Backend-reported ``{"timing": {...}}`` from set_state flows into - ``last_reset_timing_ms`` next to ``dr_reset_set_state_ms``.""" - reported = { - "set_state_mask_ms": 0.12, - "set_state_data_slice_ms": 0.34, - "set_state_forward_kinematic_ms": 2.1, - "set_state_internal_gap_ms": 0.05, - } - backend = _FakeTimedBackend( - capabilities=DomainRandomizationCapabilities( - supported_reset_terms=frozenset({RESET_TERM_BASE_MASS, RESET_TERM_KP}) - ), - timing=reported, - ) - env = SimpleNamespace(_backend=backend) - manager = DomainRandomizationManager(env, _FakeProvider()) - - obs, _ = manager.reset(np.array([0, 1, 2], dtype=np.int32)) - - assert obs["obs"].shape == (3, 1) - assert backend.call_count == 1 - timings = manager.last_reset_timing_ms - # Outer wall-clock measurement still present. - assert "dr_reset_set_state_ms" in timings - # Every reported backend sub-key is merged in. - for key, expected in reported.items(): - assert key in timings - assert timings[key] == pytest.approx(expected) - - -def test_manager_tolerates_missing_or_malformed_backend_timing(): - """Backends may return ``None`` (unchanged behavior) or a dict with - non-numeric values; the manager must not crash and must not add spurious - sub-keys in either case.""" - plain_backend = _FakeBackend( - capabilities=DomainRandomizationCapabilities( - supported_reset_terms=frozenset({RESET_TERM_BASE_MASS, RESET_TERM_KP}) - ) - ) - env = SimpleNamespace(_backend=plain_backend) - manager = DomainRandomizationManager(env, _FakeProvider()) - manager.reset(np.array([0], dtype=np.int32)) - plain_keys = set(manager.last_reset_timing_ms) - assert "dr_reset_set_state_ms" in plain_keys - assert not any(k.startswith("set_state_") for k in plain_keys) - - malformed_backend = _FakeTimedBackend( - capabilities=DomainRandomizationCapabilities( - supported_reset_terms=frozenset({RESET_TERM_BASE_MASS, RESET_TERM_KP}) - ), - timing={"set_state_mask_ms": "not a number", "set_state_data_slice_ms": 0.5}, - ) - env = SimpleNamespace(_backend=malformed_backend) - manager = DomainRandomizationManager(env, _FakeProvider()) - manager.reset(np.array([0], dtype=np.int32)) - timings = manager.last_reset_timing_ms - # Malformed value dropped, well-formed one merged. - assert "set_state_mask_ms" not in timings - assert timings["set_state_data_slice_ms"] == pytest.approx(0.5) - - -class _FakeIntervalProvider(_FakeProvider): - def __init__(self, plan: IntervalRandomizationPlan | None) -> None: - self._plan = plan - - def build_interval_randomization_plan( - self, env: Any, step_counter: int - ) -> IntervalRandomizationPlan | None: - return self._plan - - -def _interval_manager( - plan: IntervalRandomizationPlan | None, - capabilities: DomainRandomizationCapabilities, -) -> tuple[DomainRandomizationManager, _FakeBackend]: - backend = _FakeBackend(capabilities=capabilities) - env = SimpleNamespace(_backend=backend) - manager = DomainRandomizationManager(env, _FakeIntervalProvider(plan)) - return manager, backend - - -def test_manager_dispatches_custom_interval_term_without_manager_change(): - """A backend-owned custom term flows through the generic manager dispatch: - declaring it in ``supported_interval_terms`` is enough, no manager edit.""" - plan = IntervalRandomizationPlan( - ops=(IntervalTermOp("custom_shake", np.zeros((2, 3), dtype=np.float64)),) - ) - capabilities = DomainRandomizationCapabilities( - supported_interval_terms=frozenset({"custom_shake"}) - ) - manager, backend = _interval_manager(plan, capabilities) - - manager.apply_interval_randomization_if_due(step_counter=10) - - assert backend.interval_plans == [plan] - - -def test_manager_rejects_interval_term_missing_from_capabilities(): - plan = IntervalRandomizationPlan( - ops=(IntervalTermOp("custom_shake", np.zeros((2, 3), dtype=np.float64)),) - ) - manager, backend = _interval_manager(plan, DomainRandomizationCapabilities()) - - with pytest.raises(NotImplementedError) as excinfo: - manager.apply_interval_randomization_if_due(step_counter=10) - - assert "custom_shake" in str(excinfo.value) - assert backend.backend_type in str(excinfo.value) - assert backend.interval_plans == [] - - -def test_manager_dispatches_legacy_fields_plan_via_capability_bools(): - """Legacy-field plans are still adapted through ``iter_ops()`` and checked - against the deprecated legacy capability bools.""" - plan = IntervalRandomizationPlan( - push_perturbation_limit=np.asarray([10.0, 10.0, 5.0]), - body_ids=np.asarray([3], dtype=np.int32), - body_force=np.zeros((4, 1, 3), dtype=np.float64), - ) - capabilities = DomainRandomizationCapabilities( - supports_interval_push=True, - supports_interval_body_force=True, - ) - manager, backend = _interval_manager(plan, capabilities) - - manager.apply_interval_randomization_if_due(step_counter=10) - - assert backend.interval_plans == [plan] - - -def test_manager_skips_none_and_empty_interval_plans(): - capabilities = DomainRandomizationCapabilities( - supported_interval_terms=frozenset({INTERVAL_TERM_PUSH}) - ) - none_manager, none_backend = _interval_manager(None, capabilities) - none_manager.apply_interval_randomization_if_due(step_counter=10) - assert none_backend.interval_plans == [] - - empty_manager, empty_backend = _interval_manager(IntervalRandomizationPlan(), capabilities) - empty_manager.apply_interval_randomization_if_due(step_counter=10) - assert empty_backend.interval_plans == [] - - -def test_manager_dispatches_mixed_legacy_and_ops_plan(): - plan = IntervalRandomizationPlan( - push_perturbation_limit=np.asarray([10.0, 10.0, 5.0]), - ops=(IntervalTermOp("custom_shake", np.zeros((2, 3), dtype=np.float64)),), - ) - capabilities = DomainRandomizationCapabilities( - supports_interval_push=True, - supported_interval_terms=frozenset({"custom_shake"}), - ) - manager, backend = _interval_manager(plan, capabilities) - - manager.apply_interval_randomization_if_due(step_counter=10) - - assert backend.interval_plans == [plan] - - -def test_manager_detects_unsupported_terms_from_both_representations(): - capabilities = DomainRandomizationCapabilities( - supports_interval_push=True, - supported_interval_terms=frozenset({"custom_shake"}), - ) - # Legacy-derived term missing from capabilities. - legacy_plan = IntervalRandomizationPlan( - body_ids=np.asarray([0], dtype=np.int32), - body_torque=np.zeros((2, 1, 3), dtype=np.float64), - ) - manager, backend = _interval_manager(legacy_plan, capabilities) - with pytest.raises(NotImplementedError, match="body_torque"): - manager.apply_interval_randomization_if_due(step_counter=10) - assert backend.interval_plans == [] - - # Explicit op term missing from capabilities. - ops_plan = IntervalRandomizationPlan( - push_perturbation_limit=np.asarray([1.0, 1.0, 1.0]), - ops=(IntervalTermOp("custom_twist", np.zeros((2, 3), dtype=np.float64)),), - ) - manager, backend = _interval_manager(ops_plan, capabilities) - with pytest.raises(NotImplementedError, match="custom_twist"): - manager.apply_interval_randomization_if_due(step_counter=10) - assert backend.interval_plans == [] - - -def test_manager_dispatches_multi_op_plan_in_one_backend_call(): - plan = IntervalRandomizationPlan( - ops=( - IntervalTermOp(INTERVAL_TERM_PUSH, np.asarray([10.0, 10.0, 5.0])), - IntervalTermOp( - INTERVAL_TERM_BODY_FORCE, - np.zeros((4, 1, 3), dtype=np.float64), - body_ids=np.asarray([3], dtype=np.int32), - ), - ) - ) - capabilities = DomainRandomizationCapabilities( - supported_interval_terms=frozenset({INTERVAL_TERM_PUSH, INTERVAL_TERM_BODY_FORCE}) - ) - manager, backend = _interval_manager(plan, capabilities) - - manager.apply_interval_randomization_if_due(step_counter=10) - - assert backend.interval_plans == [plan] - - -def test_interval_plan_with_ops_pickle_round_trip(): - plan = IntervalRandomizationPlan( - ops=( - IntervalTermOp(INTERVAL_TERM_PUSH, np.asarray([10.0, 10.0, 5.0])), - IntervalTermOp( - "custom_shake", - np.ones((2, 3), dtype=np.float64), - body_ids=np.asarray([1, 2], dtype=np.int32), - ), - ) - ) - - restored = pickle.loads(pickle.dumps(plan, protocol=4)) - - assert [op.term for op in restored.ops] == [INTERVAL_TERM_PUSH, "custom_shake"] - np.testing.assert_array_equal(restored.ops[0].payload, plan.ops[0].payload) - np.testing.assert_array_equal(restored.ops[1].payload, plan.ops[1].payload) - assert restored.ops[0].body_ids is None - assert restored.ops[1].body_ids is not None - np.testing.assert_array_equal(restored.ops[1].body_ids, plan.ops[1].body_ids) diff --git a/tests/envs/test_manager_based_rl_env.py b/tests/envs/test_manager_based_rl_env.py index 60f050bc4..9a3105ee3 100644 --- a/tests/envs/test_manager_based_rl_env.py +++ b/tests/envs/test_manager_based_rl_env.py @@ -1072,7 +1072,7 @@ def test_np_env_owns_substeps_autoreset_and_final_observation() -> None: assert initial_obs["critic"].shape == (2, 1) assert "log" in initial_info np.testing.assert_array_equal(initial.info["steps"], [0, 0]) - assert env._dr_manager is None + assert not hasattr(env, "_dr_manager") state = env.step(np.array([[0.25], [0.5]], dtype=np.float32)) assert backend.pre_step_control is None From 856b932caaf164fe89a1c6b281b3d99919d88a9b Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 18:28:48 +0800 Subject: [PATCH 04/13] chore: pin roadmap backend integration dependencies --- pyproject.rocm.toml | 8 +++++--- pyproject.toml | 19 +++++++++---------- uv.lock | 28 ++++++---------------------- uv.rocm.lock | 28 ++++++---------------------- 4 files changed, 26 insertions(+), 57 deletions(-) diff --git a/pyproject.rocm.toml b/pyproject.rocm.toml index 90f61112a..249b27190 100644 --- a/pyproject.rocm.toml +++ b/pyproject.rocm.toml @@ -24,8 +24,10 @@ requires-python = ">=3.10,<3.14" dependencies = [ "numpy", # Physics implementations are provided by the independently released - # unisim-core package from the production PyPI index. - "unisim-core>=1.2.0", + # unisim-core package. Roadmap #1563 temporarily pins the pre-release + # branch carrying fixed model variants; restore the published range after + # the corresponding unisim-core release. + "unisim-core @ git+https://github.com/unilabsim/unisim.git@4a23de63bbbe7a936ebe85297842317a802ed3f0", # RL algorithms and async runtimes live in the independently released # uni-rl package (distribution name ``unilab-rl``); see pyproject.toml. "unilab-rl==1.2.0", @@ -79,7 +81,7 @@ mujoco = [ # time, so isolated builds are correct and no compiler preflight is # needed. "mujoco~=3.11.0", - "mjbatch-uni~=0.1.0", + "mjbatch-uni @ git+https://github.com/unilabsim/mjbatch-uni.git@0006734ade970eb040815eca28205e72a20c533d", ] motrix = ["motrixsim-core==0.8.2"] viser = ["viser>=1.0.26", "trimesh>=3.21.7"] diff --git a/pyproject.toml b/pyproject.toml index 16a7ce0d3..7f78f9ef3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,10 @@ requires-python = ">=3.10,<3.14" dependencies = [ "numpy", # Physics implementations are provided by the independently released - # unisim-core package from the production PyPI index. >=1.2.1 is the - # first line whose mujoco adapter executes on the mjbatch-uni engine. - "unisim-core>=1.2.1", + # unisim-core package. Roadmap #1563 temporarily pins the pre-release + # branch carrying fixed model variants; restore the published range after + # the corresponding unisim-core release. + "unisim-core @ git+https://github.com/unilabsim/unisim.git@4a23de63bbbe7a936ebe85297842317a802ed3f0", # RL algorithms and async runtimes (PPO/APPO/SAC/TD3 runners, # collectors, IPC, logging) live in the independently released uni-rl # package (distribution name ``unilab-rl``), consumed via the injected @@ -121,12 +122,10 @@ mujoco = [ # mujoco==3.11.0 — switching MuJoCo versions requires an mjbatch rebuild, # not a UniLab config change. "mujoco~=3.11.0", - # The batch engine is the unilabsim mjbatch fork, published on PyPI as - # mjbatch-uni (#1552). Its build backend pins mujoco==3.11.0 at build - # time, so isolated builds are correct and no compiler preflight is - # needed; prebuilt wheels cover cp310-cp313 on linux x86_64/aarch64 and - # macOS arm64. - "mjbatch-uni~=0.1.0", + # The batch engine is the unilabsim mjbatch fork. Roadmap #1563 pairs the + # integration-only UniSim git pin with the matching pre-release executor + # API; replace both after mjbatch-uni 0.2.x is published. + "mjbatch-uni @ git+https://github.com/unilabsim/mjbatch-uni.git@0006734ade970eb040815eca28205e72a20c533d", ] mjwarp = [ # Keep the Warp backend on the same MuJoCo minor line as the host backend. @@ -176,7 +175,7 @@ viser = ["viser>=1.0.26", "trimesh>=3.21.7"] # required-environments; elsewhere the extra is empty and the CLI reports a # targeted runtime diagnostic. superdex = [ - "unisim-core[superdex] ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", + "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@4a23de63bbbe7a936ebe85297842317a802ed3f0 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 88e9b37b8..5c2c4c6fd 100644 --- a/uv.lock +++ b/uv.lock @@ -2136,28 +2136,13 @@ wheels = [ [[package]] name = "mjbatch-uni" -version = "0.1.0" -source = { registry = "https://pypi.org/simple" } +version = "0.2.0" +source = { git = "https://github.com/unilabsim/mjbatch-uni.git?rev=0006734ade970eb040815eca28205e72a20c533d#0006734ade970eb040815eca28205e72a20c533d" } dependencies = [ { name = "mujoco" }, { 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/bd/5f/284d7e603033b977b825cbb73282f9233d071a4b43da1ef98335fae610db/mjbatch_uni-0.1.0.tar.gz", hash = "sha256:e2856c55cb6179d256360aa56c76c53207cfaf6dee5ba6130bd83fd9b0778a6d", size = 28999, upload-time = "2026-09-13T06:49:56.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/b5/d737ce697d40763b32d4c3ddf7a2188a2f1abd6bc2a8fb2396006048d6f9/mjbatch_uni-0.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40f787b39f3a4c4214bc41d6928fa6d3ea7737d012dd72e64c39c533fdaaf197", size = 140421, upload-time = "2026-09-13T06:49:38.906Z" }, - { url = "https://files.pythonhosted.org/packages/cc/18/1ec66faa8c51e72c84696c7432cd17ab45c32834e9fea580488d10c202a2/mjbatch_uni-0.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07310303cc9a4f67ba64965325a4456783556586aa69a0f18756ae686cdf8430", size = 164953, upload-time = "2026-09-13T06:49:40.537Z" }, - { url = "https://files.pythonhosted.org/packages/12/b8/f04152b71065d6ba14363f8e66bd2f0ade429af058b19dc75ae691dd8c58/mjbatch_uni-0.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf8ab61861d59d4e537ddca47c1e5df1fc5e80c5a81d753f7d7608a528a99dfb", size = 175346, upload-time = "2026-09-13T06:49:42.052Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a5/734641dfb1d442b3c53c9024b050be59652a00d6ac2d830502350fcf6233/mjbatch_uni-0.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4b02e5c3829225e697207719b22061004c255f332f724cdc220fc2c633f233e", size = 140003, upload-time = "2026-09-13T06:49:43.424Z" }, - { url = "https://files.pythonhosted.org/packages/fb/59/11885b99ef0797e1a286a5edd5017fec074a6a145c6dd8d59599cc6bda95/mjbatch_uni-0.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2fd277ea5f9c045d263c79425ea92fbce0bc7b5d79e648dfd4ea18d1501282", size = 164584, upload-time = "2026-09-13T06:49:44.675Z" }, - { url = "https://files.pythonhosted.org/packages/af/12/9ca93e335f9a6f7d0e62e8eff24b8771d73ed4c38273c8950856133d3ddf/mjbatch_uni-0.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0476b1dd333d95118123c9b4c05d6e42dc652cb8c69fc5b0960aed1dbf0ec09e", size = 175091, upload-time = "2026-09-13T06:49:46.473Z" }, - { url = "https://files.pythonhosted.org/packages/76/d1/5f46d25aa730bc2dbf1e90a5cfc9c0744d19a4be766ff79988db9bf0e518/mjbatch_uni-0.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23bb9f101fcb02c04482eea4285fedccea19450550902151c76a7739491cf54c", size = 139197, upload-time = "2026-09-13T06:49:47.923Z" }, - { url = "https://files.pythonhosted.org/packages/32/ee/8308873ed1a1697a44454ebe9f03a3b2c962aa0d1786ed5c79939a5a7d57/mjbatch_uni-0.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3c65e2a0936bb210f4b6c5229512490e07ac8e7be42cc14e464b5d4cef93de2", size = 163419, upload-time = "2026-09-13T06:49:49.311Z" }, - { url = "https://files.pythonhosted.org/packages/9b/7f/9b5d8f6c51abb8a3f02d9a1f4bd2d49e7b74a238921840cf9ab747b8372f/mjbatch_uni-0.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25fbf20cbb9e72bc98e37c1a65ae621d9cea5904d3e3a8e8aad5bbcd59faa1ed", size = 174676, upload-time = "2026-09-13T06:49:50.994Z" }, - { url = "https://files.pythonhosted.org/packages/4b/95/13657cf8311dbb21d78ca219c07675899a45fa27f5eedfb6e56711fb5452/mjbatch_uni-0.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87296ee8012d4435e1652d80e4679d5196ecd196c0ed05bb23601490bb2e1428", size = 139260, upload-time = "2026-09-13T06:49:52.406Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1a/4d456c39d58b581df6b157f3f2df59cd9a5a0a876cda7f0765d152c34bc2/mjbatch_uni-0.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6cb335cd3c745228c33d372b6e95115d9fe68249ebb8dc09fbc78de000e1a27", size = 163355, upload-time = "2026-09-13T06:49:53.777Z" }, - { url = "https://files.pythonhosted.org/packages/67/da/d5a2d73db2a21d884615a62c169733c0ce7ec24a3633515bd8a5ebb95ab9/mjbatch_uni-0.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:838e207355095053f70d571803de2b7958a95cafed9e7df19c6aa2cbf3ef135a", size = 174664, upload-time = "2026-09-13T06:49:55.173Z" }, -] [[package]] name = "ml-dtypes" @@ -5210,7 +5195,7 @@ requires-dist = [ { name = "imgui-bundle", marker = "extra == 'newton'", specifier = ">=1.92.0" }, { name = "lark", specifier = ">=1.3.1" }, { name = "mediapy" }, - { name = "mjbatch-uni", marker = "extra == 'mujoco'", specifier = "~=0.1.0" }, + { name = "mjbatch-uni", marker = "extra == 'mujoco'", git = "https://github.com/unilabsim/mjbatch-uni.git?rev=0006734ade970eb040815eca28205e72a20c533d" }, { name = "motrixsim-core", marker = "extra == 'motrix'", specifier = "==0.8.2" }, { name = "mujoco", marker = "extra == 'drake'", specifier = ">=3.5" }, { name = "mujoco", marker = "extra == 'mujoco'", specifier = "~=3.11.0" }, @@ -5240,8 +5225,8 @@ requires-dist = [ { name = "trimesh", marker = "extra == 'viser'", specifier = ">=3.21.7" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", specifier = ">=1.2.1" }, - { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=4a23de63bbbe7a936ebe85297842317a802ed3f0" }, + { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=4a23de63bbbe7a936ebe85297842317a802ed3f0" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, { name = "warp-lang", marker = "extra == 'mjwarp'", specifier = "==1.16.0" }, @@ -5285,12 +5270,11 @@ wheels = [ [[package]] name = "unisim-core" version = "1.2.1" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/unilabsim/unisim.git?rev=4a23de63bbbe7a936ebe85297842317a802ed3f0#4a23de63bbbe7a936ebe85297842317a802ed3f0" } 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/8c/46/cfc76e05dc6770c7296849ebb2d91aba63ae5c137a964597b5d8c5396015/unisim_core-1.2.1.tar.gz", hash = "sha256:0fe214134e66cf0a06913689ed9061e0301217f51a245571b883541bd5860206", size = 250011, upload-time = "2026-09-13T07:02:51.499Z" } [package.optional-dependencies] superdex = [ diff --git a/uv.rocm.lock b/uv.rocm.lock index 06b35c9cc..c0d5d6145 100644 --- a/uv.rocm.lock +++ b/uv.rocm.lock @@ -1686,28 +1686,13 @@ wheels = [ [[package]] name = "mjbatch-uni" -version = "0.1.0" -source = { registry = "https://pypi.org/simple" } +version = "0.2.0" +source = { git = "https://github.com/unilabsim/mjbatch-uni.git?rev=0006734ade970eb040815eca28205e72a20c533d#0006734ade970eb040815eca28205e72a20c533d" } dependencies = [ { name = "mujoco" }, { 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/bd/5f/284d7e603033b977b825cbb73282f9233d071a4b43da1ef98335fae610db/mjbatch_uni-0.1.0.tar.gz", hash = "sha256:e2856c55cb6179d256360aa56c76c53207cfaf6dee5ba6130bd83fd9b0778a6d", size = 28999, upload-time = "2026-09-13T06:49:56.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/b5/d737ce697d40763b32d4c3ddf7a2188a2f1abd6bc2a8fb2396006048d6f9/mjbatch_uni-0.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40f787b39f3a4c4214bc41d6928fa6d3ea7737d012dd72e64c39c533fdaaf197", size = 140421, upload-time = "2026-09-13T06:49:38.906Z" }, - { url = "https://files.pythonhosted.org/packages/cc/18/1ec66faa8c51e72c84696c7432cd17ab45c32834e9fea580488d10c202a2/mjbatch_uni-0.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07310303cc9a4f67ba64965325a4456783556586aa69a0f18756ae686cdf8430", size = 164953, upload-time = "2026-09-13T06:49:40.537Z" }, - { url = "https://files.pythonhosted.org/packages/12/b8/f04152b71065d6ba14363f8e66bd2f0ade429af058b19dc75ae691dd8c58/mjbatch_uni-0.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf8ab61861d59d4e537ddca47c1e5df1fc5e80c5a81d753f7d7608a528a99dfb", size = 175346, upload-time = "2026-09-13T06:49:42.052Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a5/734641dfb1d442b3c53c9024b050be59652a00d6ac2d830502350fcf6233/mjbatch_uni-0.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4b02e5c3829225e697207719b22061004c255f332f724cdc220fc2c633f233e", size = 140003, upload-time = "2026-09-13T06:49:43.424Z" }, - { url = "https://files.pythonhosted.org/packages/fb/59/11885b99ef0797e1a286a5edd5017fec074a6a145c6dd8d59599cc6bda95/mjbatch_uni-0.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2fd277ea5f9c045d263c79425ea92fbce0bc7b5d79e648dfd4ea18d1501282", size = 164584, upload-time = "2026-09-13T06:49:44.675Z" }, - { url = "https://files.pythonhosted.org/packages/af/12/9ca93e335f9a6f7d0e62e8eff24b8771d73ed4c38273c8950856133d3ddf/mjbatch_uni-0.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0476b1dd333d95118123c9b4c05d6e42dc652cb8c69fc5b0960aed1dbf0ec09e", size = 175091, upload-time = "2026-09-13T06:49:46.473Z" }, - { url = "https://files.pythonhosted.org/packages/76/d1/5f46d25aa730bc2dbf1e90a5cfc9c0744d19a4be766ff79988db9bf0e518/mjbatch_uni-0.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23bb9f101fcb02c04482eea4285fedccea19450550902151c76a7739491cf54c", size = 139197, upload-time = "2026-09-13T06:49:47.923Z" }, - { url = "https://files.pythonhosted.org/packages/32/ee/8308873ed1a1697a44454ebe9f03a3b2c962aa0d1786ed5c79939a5a7d57/mjbatch_uni-0.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3c65e2a0936bb210f4b6c5229512490e07ac8e7be42cc14e464b5d4cef93de2", size = 163419, upload-time = "2026-09-13T06:49:49.311Z" }, - { url = "https://files.pythonhosted.org/packages/9b/7f/9b5d8f6c51abb8a3f02d9a1f4bd2d49e7b74a238921840cf9ab747b8372f/mjbatch_uni-0.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25fbf20cbb9e72bc98e37c1a65ae621d9cea5904d3e3a8e8aad5bbcd59faa1ed", size = 174676, upload-time = "2026-09-13T06:49:50.994Z" }, - { url = "https://files.pythonhosted.org/packages/4b/95/13657cf8311dbb21d78ca219c07675899a45fa27f5eedfb6e56711fb5452/mjbatch_uni-0.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87296ee8012d4435e1652d80e4679d5196ecd196c0ed05bb23601490bb2e1428", size = 139260, upload-time = "2026-09-13T06:49:52.406Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1a/4d456c39d58b581df6b157f3f2df59cd9a5a0a876cda7f0765d152c34bc2/mjbatch_uni-0.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6cb335cd3c745228c33d372b6e95115d9fe68249ebb8dc09fbc78de000e1a27", size = 163355, upload-time = "2026-09-13T06:49:53.777Z" }, - { url = "https://files.pythonhosted.org/packages/67/da/d5a2d73db2a21d884615a62c169733c0ce7ec24a3633515bd8a5ebb95ab9/mjbatch_uni-0.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:838e207355095053f70d571803de2b7958a95cafed9e7df19c6aa2cbf3ef135a", size = 174664, upload-time = "2026-09-13T06:49:55.173Z" }, -] [[package]] name = "ml-dtypes" @@ -3770,7 +3755,7 @@ requires-dist = [ { name = "imageio-ffmpeg", specifier = ">=0.6.0" }, { name = "lark", specifier = ">=1.3.1" }, { name = "mediapy" }, - { name = "mjbatch-uni", marker = "extra == 'mujoco'", specifier = "~=0.1.0" }, + { name = "mjbatch-uni", marker = "extra == 'mujoco'", git = "https://github.com/unilabsim/mjbatch-uni.git?rev=0006734ade970eb040815eca28205e72a20c533d" }, { name = "motrixsim-core", marker = "extra == 'motrix'", specifier = "==0.8.2" }, { name = "mujoco", marker = "extra == 'mujoco'", specifier = "~=3.11.0" }, { name = "ninja", marker = "sys_platform == 'linux'" }, @@ -3790,7 +3775,7 @@ requires-dist = [ { name = "triton-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==3.6.0", index = "https://download.pytorch.org/whl/rocm7.2" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", specifier = ">=1.2.0" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=4a23de63bbbe7a936ebe85297842317a802ed3f0" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, ] @@ -3830,13 +3815,12 @@ wheels = [ [[package]] name = "unisim-core" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } +version = "1.2.1" +source = { git = "https://github.com/unilabsim/unisim.git?rev=4a23de63bbbe7a936ebe85297842317a802ed3f0#4a23de63bbbe7a936ebe85297842317a802ed3f0" } 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/bb/b7/2cf8626884236d9e14256fa4330b9e04a8a41598867ced1ac4030f827989/unisim_core-1.2.0.tar.gz", hash = "sha256:fccd67edbde98eeb16d2e6f9f5f8ce608dd2790266a673788897cbd9a42fa4ce", size = 253069, upload-time = "2026-09-10T05:19:05.388Z" } [[package]] name = "urllib3" From c1078dad61e78962e066a1230c1621bf9282e5f6 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 18:41:51 +0800 Subject: [PATCH 05/13] feat: add representative SimTool fixed variants --- pyproject.rocm.toml | 2 +- pyproject.toml | 4 +- .../env/benchmark_simtool_real_fixed_tools.py | 140 +++++++++++++ .../manipulation/simtool_real/__init__.py | 15 ++ .../simtool_real/representative.py | 198 ++++++++++++++++++ tests/envs/test_simtool_real_fixed_tools.py | 157 ++++++++++++++ uv.lock | 6 +- uv.rocm.lock | 4 +- 8 files changed, 518 insertions(+), 8 deletions(-) create mode 100644 scripts/benchmark/env/benchmark_simtool_real_fixed_tools.py create mode 100644 src/unilab/tasks/manipulation/simtool_real/__init__.py create mode 100644 src/unilab/tasks/manipulation/simtool_real/representative.py create mode 100644 tests/envs/test_simtool_real_fixed_tools.py diff --git a/pyproject.rocm.toml b/pyproject.rocm.toml index 249b27190..d7741f27b 100644 --- a/pyproject.rocm.toml +++ b/pyproject.rocm.toml @@ -27,7 +27,7 @@ dependencies = [ # unisim-core package. Roadmap #1563 temporarily pins the pre-release # branch carrying fixed model variants; restore the published range after # the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@4a23de63bbbe7a936ebe85297842317a802ed3f0", + "unisim-core @ git+https://github.com/unilabsim/unisim.git@199cf7802cf93d8e64be54d4e43693856ed8a43f", # RL algorithms and async runtimes live in the independently released # uni-rl package (distribution name ``unilab-rl``); see pyproject.toml. "unilab-rl==1.2.0", diff --git a/pyproject.toml b/pyproject.toml index 7f78f9ef3..1bb786e4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ # unisim-core package. Roadmap #1563 temporarily pins the pre-release # branch carrying fixed model variants; restore the published range after # the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@4a23de63bbbe7a936ebe85297842317a802ed3f0", + "unisim-core @ git+https://github.com/unilabsim/unisim.git@199cf7802cf93d8e64be54d4e43693856ed8a43f", # RL algorithms and async runtimes (PPO/APPO/SAC/TD3 runners, # collectors, IPC, logging) live in the independently released uni-rl # package (distribution name ``unilab-rl``), consumed via the injected @@ -175,7 +175,7 @@ viser = ["viser>=1.0.26", "trimesh>=3.21.7"] # required-environments; elsewhere the extra is empty and the CLI reports a # targeted runtime diagnostic. superdex = [ - "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@4a23de63bbbe7a936ebe85297842317a802ed3f0 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", + "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@199cf7802cf93d8e64be54d4e43693856ed8a43f ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", ] [dependency-groups] diff --git a/scripts/benchmark/env/benchmark_simtool_real_fixed_tools.py b/scripts/benchmark/env/benchmark_simtool_real_fixed_tools.py new file mode 100644 index 000000000..435cf86ab --- /dev/null +++ b/scripts/benchmark/env/benchmark_simtool_real_fixed_tools.py @@ -0,0 +1,140 @@ +"""Benchmark representative SimToolReal fixed-tool construction and rollouts.""" + +from __future__ import annotations + +import argparse +import json +import resource +import tempfile +import time +from pathlib import Path +from typing import Any + +import numpy as np + +from unilab.envs import make_manager_based_rl_env +from unilab.tasks.manipulation.simtool_real import ( + build_representative_simtool_real_env_cfg, + write_representative_simtool_real_sources, +) + + +def _rss_bytes() -> int: + status = Path("/proc/self/status") + if status.is_file(): + for line in status.read_text(encoding="utf-8").splitlines(): + if line.startswith("VmRSS:"): + return int(line.split()[1]) * 1024 + return int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) * 1024 + + +def run_benchmark( + *, + backend: str, + num_envs: int, + num_variants: int, + steps: int, + warmup_steps: int, + variant_root: Path, +) -> dict[str, Any]: + rss_before_source = _rss_bytes() + source_started = time.perf_counter() + sources = write_representative_simtool_real_sources( + variant_root, + variant_count=num_variants, + ) + source_materialization_seconds = time.perf_counter() - source_started + cfg = build_representative_simtool_real_env_cfg(sources) + + rss_before_env = _rss_bytes() + construction_started = time.perf_counter() + env = make_manager_based_rl_env(cfg, num_envs=num_envs, backend_type=backend) + env_construction_seconds = time.perf_counter() - construction_started + + reset_started = time.perf_counter() + env.init_state() + first_reset_seconds = time.perf_counter() - reset_started + rss_after_construction = _rss_bytes() + + actions = np.zeros((num_envs, 1), dtype=np.float32) + for _ in range(warmup_steps): + env.step(actions) + step_started = time.perf_counter() + for _ in range(steps): + state = env.step(actions) + measured_steps_seconds = time.perf_counter() - step_started + + peak_rss_bytes = _rss_bytes() + plan = cfg.scene.fixed_variant_plan + assert plan is not None + result: dict[str, Any] = { + "backend": backend, + "num_envs": num_envs, + "num_variants": num_variants, + "steps": steps, + "warmup_steps": warmup_steps, + "unique_assigned_variants": len(set(int(value) for value in plan.assignment)), + "finite": bool(np.isfinite(state.obs["obs"]).all() and np.isfinite(state.reward).all()), + "timings": { + "source_materialization_seconds": source_materialization_seconds, + "env_construction_seconds": env_construction_seconds, + "first_reset_seconds": first_reset_seconds, + "measured_steps_seconds": measured_steps_seconds, + "throughput_env_steps_per_s": float(steps * num_envs / measured_steps_seconds), + }, + "memory": { + "rss_before_source_bytes": rss_before_source, + "rss_before_env_bytes": rss_before_env, + "rss_after_construction_bytes": rss_after_construction, + "peak_rss_bytes": peak_rss_bytes, + "construction_delta_bytes": rss_after_construction - rss_before_env, + }, + } + env.close() + result["memory"]["rss_after_close_bytes"] = _rss_bytes() + return result + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backend", choices=("mujoco", "mjwarp"), default="mujoco") + parser.add_argument("--num-envs", type=int, default=256) + parser.add_argument("--num-variants", type=int, default=64) + parser.add_argument("--steps", type=int, default=100) + parser.add_argument("--warmup-steps", type=int, default=10) + parser.add_argument( + "--output", + type=Path, + default=Path("scripts/benchmark/outputs/simtool_real_fixed_tools/result.json"), + ) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + if args.num_envs <= 0: + raise SystemExit("num-envs must be positive") + if min(args.num_variants, args.steps, args.warmup_steps) < 0: + raise SystemExit("num-envs, num-variants, steps, and warmup-steps must be non-negative") + if args.num_variants == 0 or args.steps == 0: + raise SystemExit("num-variants and steps must be positive") + with tempfile.TemporaryDirectory(prefix="simtool-real-fixed-tools-") as temporary: + result = run_benchmark( + backend=args.backend, + num_envs=args.num_envs, + num_variants=args.num_variants, + steps=args.steps, + warmup_steps=args.warmup_steps, + variant_root=Path(temporary), + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(result, indent=2, sort_keys=True)) + print(f"Saved: {args.output.resolve()}") + + +if __name__ == "__main__": + main() diff --git a/src/unilab/tasks/manipulation/simtool_real/__init__.py b/src/unilab/tasks/manipulation/simtool_real/__init__.py new file mode 100644 index 000000000..0734693fc --- /dev/null +++ b/src/unilab/tasks/manipulation/simtool_real/__init__.py @@ -0,0 +1,15 @@ +"""Representative SimToolReal fixed-tool fixture and Manager-Based owner config.""" + +from .representative import ( + RepresentativeSimToolRealSourceSet, + RepresentativeSimToolRealVariant, + build_representative_simtool_real_env_cfg, + write_representative_simtool_real_sources, +) + +__all__ = [ + "RepresentativeSimToolRealSourceSet", + "RepresentativeSimToolRealVariant", + "build_representative_simtool_real_env_cfg", + "write_representative_simtool_real_sources", +] diff --git a/src/unilab/tasks/manipulation/simtool_real/representative.py b/src/unilab/tasks/manipulation/simtool_real/representative.py new file mode 100644 index 000000000..32f8c6c3f --- /dev/null +++ b/src/unilab/tasks/manipulation/simtool_real/representative.py @@ -0,0 +1,198 @@ +"""Deterministic generated fixture for same-layout SimTool tool variants. + +The generated sources are deliberately internal: they stand in for the private +600-tool catalog without turning a synthetic workload into a production task +registration or a public asset promise. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from unilab.base.entity import EntityCfg +from unilab.base.scene import SceneCfg +from unilab.base.variants import ( + FixedModelVariantAssignmentCfg, + FixedModelVariantCatalogCfg, + FixedModelVariantCfg, +) +from unilab.envs import ManagerBasedRlEnvCfg, mdp +from unilab.managers import ( + EventTermCfg, + ObservationGroupCfg, + ObservationTermCfg, + RewardTermCfg, + TerminationTermCfg, +) +from unilab.managers.scene_entity_config import SceneEntityCfg + +_TETRAHEDRON_OBJ = """v 0 0 0 +v 1 0 0 +v 0 1 0 +v 0 0 1 +f 1 2 3 +f 1 2 4 +f 1 3 4 +f 2 3 4 +""" + + +@dataclass(frozen=True) +class RepresentativeSimToolRealVariant: + """Parameters that vary while preserving the representative public layout.""" + + name: str + mass_kg: float + mesh_scale: tuple[float, float, float] + rgba: tuple[float, float, float, float] + + +@dataclass(frozen=True) +class RepresentativeSimToolRealSourceSet: + """Materialized absolute sources ready for UniSim backend consumption.""" + + output_dir: Path + mesh_file: Path + variants: tuple[RepresentativeSimToolRealVariant, ...] + model_files: tuple[Path, ...] + + +def write_representative_simtool_real_sources( + output_dir: str | Path, + *, + variant_count: int = 3, +) -> RepresentativeSimToolRealSourceSet: + """Write deterministic same-layout MJCF variants and their shared mesh.""" + + if isinstance(variant_count, bool) or not isinstance(variant_count, int): + raise TypeError("variant_count must be an integer") + if variant_count < 2: + raise ValueError("variant_count must be at least 2") + + root = Path(output_dir).expanduser().resolve() + root.mkdir(parents=True, exist_ok=True) + mesh_file = root / "simtool_handle.obj" + mesh_file.write_text(_TETRAHEDRON_OBJ, encoding="utf-8") + + variants: list[RepresentativeSimToolRealVariant] = [] + model_files: list[Path] = [] + for index in range(variant_count): + name = f"tool_{index:04d}" + variant = RepresentativeSimToolRealVariant( + name=name, + mass_kg=0.4 + 0.25 * index, + mesh_scale=(1.0 + 0.08 * index, 0.9 + 0.06 * index, 0.8 + 0.05 * index), + rgba=(0.1 + 0.2 * index % 1.0, 0.8 - 0.15 * index, 0.2 + 0.25 * index, 1.0), + ) + model_file = root / f"{name}.xml" + model_file.write_text(_variant_xml(variant, mesh_file), encoding="utf-8") + variants.append(variant) + model_files.append(model_file) + + return RepresentativeSimToolRealSourceSet( + output_dir=root, + mesh_file=mesh_file, + variants=tuple(variants), + model_files=tuple(model_files), + ) + + +def build_representative_simtool_real_env_cfg( + sources: RepresentativeSimToolRealSourceSet, +) -> ManagerBasedRlEnvCfg: + """Build a direct Manager-Based config without registering a synthetic task.""" + + tool_joints = SceneEntityCfg("tool", joint_names=("tool_pitch",)) + tool_body = SceneEntityCfg("tool", body_names=("tool",)) + return ManagerBasedRlEnvCfg( + scene=SceneCfg( + model_file=str(sources.model_files[0]), + entities={ + "tool": EntityCfg( + root_body_name="tool", + joint_names=("tool_pitch",), + body_names=("tool",), + geom_names=("floor", "handle"), + actuator_names=("tool_motor",), + ) + }, + ), + fixed_model_variants=FixedModelVariantCatalogCfg( + variants=tuple( + FixedModelVariantCfg(variant.name, str(path)) + for variant, path in zip(sources.variants, sources.model_files, strict=True) + ), + assignment=FixedModelVariantAssignmentCfg(mode="round_robin"), + ), + sim_dt=0.002, + ctrl_dt=0.01, + max_episode_seconds=1.0, + seed=7, + observations={ + "policy": ObservationGroupCfg( + terms={ + "joint_pos": ObservationTermCfg( + func=mdp.joint_pos_rel, + params={"asset_cfg": tool_joints}, + ), + "joint_vel": ObservationTermCfg( + func=mdp.joint_vel_rel, + params={"asset_cfg": tool_joints}, + ), + } + ) + }, + actions={ + "effort": mdp.JointEffortActionCfg( + entity_name="tool", + actuator_names=("tool_pitch",), + scale=0.5, + ) + }, + events={ + "reset_scene_to_default": EventTermCfg( + func=mdp.reset_scene_to_default, + mode="reset", + ), + "randomize_body_mass_inertia": EventTermCfg( + func=mdp.randomize_body_mass_inertia, + mode="reset", + params={ + "asset_cfg": tool_body, + "scale_range": (0.95, 1.05), + }, + ), + }, + rewards={"alive": RewardTermCfg(func=mdp.is_alive, weight=1.0)}, + terminations={"time_out": TerminationTermCfg(func=mdp.time_out, time_out=True)}, + policy_observation_group="policy", + ) + + +def _variant_xml( + variant: RepresentativeSimToolRealVariant, + mesh_file: Path, +) -> str: + scale = " ".join(f"{value:.6f}" for value in variant.mesh_scale) + rgba = " ".join(f"{value:.6f}" for value in variant.rgba) + mass = f"{variant.mass_kg:.6f}" + return f""" + +""" diff --git a/tests/envs/test_simtool_real_fixed_tools.py b/tests/envs/test_simtool_real_fixed_tools.py new file mode 100644 index 000000000..29039bff5 --- /dev/null +++ b/tests/envs/test_simtool_real_fixed_tools.py @@ -0,0 +1,157 @@ +"""Representative SimToolReal fixed-tool Manager-Based integration.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np +import pytest + +from unilab.envs import make_manager_based_rl_env +from unilab.tasks.manipulation.simtool_real import ( + build_representative_simtool_real_env_cfg, + write_representative_simtool_real_sources, +) + + +def _object_names( + mujoco: Any, + model: Any, + object_type: int, + count: int, +) -> tuple[str, ...]: + return tuple(mujoco.mj_id2name(model, object_type, index) or "" for index in range(count)) + + +def test_generated_sources_preserve_public_layout_and_vary_model_fields( + tmp_path: Path, +) -> None: + mujoco = pytest.importorskip("mujoco", reason="SimToolReal layout audit requires MuJoCo") + sources = write_representative_simtool_real_sources(tmp_path, variant_count=4) + models = [mujoco.MjModel.from_xml_path(str(path)) for path in sources.model_files] + + for model in models: + for name in ("nq", "nv", "nu", "nbody", "njnt", "ngeom", "nsensor"): + assert getattr(model, name) == getattr(models[0], name) + for object_type, count_name in ( + (mujoco.mjtObj.mjOBJ_BODY, "nbody"), + (mujoco.mjtObj.mjOBJ_JOINT, "njnt"), + (mujoco.mjtObj.mjOBJ_GEOM, "ngeom"), + (mujoco.mjtObj.mjOBJ_MESH, "nmesh"), + (mujoco.mjtObj.mjOBJ_ACTUATOR, "nu"), + ): + named = _object_names(mujoco, model, object_type, int(getattr(model, count_name))) + reference = _object_names( + mujoco, models[0], object_type, int(getattr(models[0], count_name)) + ) + assert named == reference + + np.testing.assert_allclose( + [model.body_mass[1] for model in models], + [variant.mass_kg for variant in sources.variants], + rtol=0.0, + atol=0.0, + ) + assert len({model.body_inertia[1].tobytes() for model in models}) == len(models) + assert len({model.geom_size[1].tobytes() for model in models}) == len(models) + + +def test_cpu_manager_rollout_uses_immutable_variant_identity_and_reset_dr( + tmp_path: Path, +) -> None: + pytest.importorskip( + "unisim.backend.mujoco.backend", + reason="SimToolReal CPU rollout requires the MuJoCo adapter", + ) + sources = write_representative_simtool_real_sources(tmp_path) + cfg = build_representative_simtool_real_env_cfg(sources) + env = make_manager_based_rl_env(cfg, num_envs=6, backend_type="mujoco") + try: + plan = cfg.scene.fixed_variant_plan + assert plan is not None + np.testing.assert_array_equal(plan.assignment, np.tile(np.arange(3, dtype=np.int32), 2)) + assert not plan.assignment.flags.writeable + + backend = env._backend + assert backend.get_dr_capabilities().supports_fixed_variant_plan(plan) + default_mass = backend.get_reset_term_default("body_mass") + assert default_mass.shape == (6, backend.model.nbody) + assert not default_mass.flags.writeable + np.testing.assert_allclose( + default_mass[:, 1], + [variant.mass_kg for variant in sources.variants] * 2, + ) + + state = env.init_state() + assert state.obs["obs"].shape == (6, 2) + assert np.isfinite(state.obs["obs"]).all() + state = env.step(np.zeros((6, 1), dtype=np.float32)) + assert np.isfinite(state.obs["obs"]).all() + assert np.isfinite(state.reward).all() + + playback_mass = [float(env.get_playback_model(index).body_mass[1]) for index in range(3)] + np.testing.assert_allclose(playback_mass, [variant.mass_kg for variant in sources.variants]) + + env.reset() + assert cfg.scene.fixed_variant_plan is plan + np.testing.assert_allclose( + backend.get_reset_term_default("body_mass")[:, 1], default_mass[:, 1] + ) + finally: + env.close() + + +def test_cpu_and_mjwarp_representative_rollouts_match_on_cuda( + tmp_path: Path, +) -> None: + pytest.importorskip( + "unisim.backend.mujoco.backend", + reason="SimToolReal cross-backend comparison requires the MuJoCo adapter", + ) + pytest.importorskip( + "unisim.backend.mjwarp.backend", + reason="SimToolReal MJWarp comparison requires mujoco-warp", + ) + warp = pytest.importorskip("warp", reason="SimToolReal MJWarp test requires Warp") + warp.init() + if not bool(warp.get_device().is_cuda): + pytest.skip("SimToolReal MJWarp comparison requires CUDA") + + mjwarp_cfg = build_representative_simtool_real_env_cfg( + write_representative_simtool_real_sources(tmp_path / "mjwarp") + ) + cpu_cfg = build_representative_simtool_real_env_cfg( + write_representative_simtool_real_sources(tmp_path / "mujoco") + ) + mjwarp_env = make_manager_based_rl_env(mjwarp_cfg, num_envs=3, backend_type="mjwarp") + cpu_env = make_manager_based_rl_env(cpu_cfg, num_envs=3, backend_type="mujoco") + try: + plan = mjwarp_cfg.scene.fixed_variant_plan + assert plan is not None + assert mjwarp_env._backend.get_dr_capabilities().supports_fixed_variant_plan(plan) + assert [Path(mjwarp_env.get_playback_model(index)).resolve() for index in range(3)] == [ + Path(variant.model_file).resolve() for variant in plan.variants + ] + + actions = np.zeros((3, 1), dtype=np.float32) + cpu_env.init_state() + mjwarp_state = mjwarp_env.step(actions) + cpu_state = cpu_env.step(actions) + assert np.isfinite(mjwarp_state.obs["obs"]).all() + assert np.isfinite(cpu_state.obs["obs"]).all() + np.testing.assert_allclose( + mjwarp_env.scene["tool"].data.joint_pos, + cpu_env.scene["tool"].data.joint_pos, + rtol=2e-4, + atol=2e-6, + ) + np.testing.assert_allclose( + mjwarp_env.scene["tool"].data.joint_vel, + cpu_env.scene["tool"].data.joint_vel, + rtol=2e-4, + atol=2e-5, + ) + finally: + mjwarp_env.close() + cpu_env.close() diff --git a/uv.lock b/uv.lock index 5c2c4c6fd..dd73da20b 100644 --- a/uv.lock +++ b/uv.lock @@ -5225,8 +5225,8 @@ requires-dist = [ { name = "trimesh", marker = "extra == 'viser'", specifier = ">=3.21.7" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=4a23de63bbbe7a936ebe85297842317a802ed3f0" }, - { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=4a23de63bbbe7a936ebe85297842317a802ed3f0" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=199cf7802cf93d8e64be54d4e43693856ed8a43f" }, + { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=199cf7802cf93d8e64be54d4e43693856ed8a43f" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, { name = "warp-lang", marker = "extra == 'mjwarp'", specifier = "==1.16.0" }, @@ -5270,7 +5270,7 @@ wheels = [ [[package]] name = "unisim-core" version = "1.2.1" -source = { git = "https://github.com/unilabsim/unisim.git?rev=4a23de63bbbe7a936ebe85297842317a802ed3f0#4a23de63bbbe7a936ebe85297842317a802ed3f0" } +source = { git = "https://github.com/unilabsim/unisim.git?rev=199cf7802cf93d8e64be54d4e43693856ed8a43f#199cf7802cf93d8e64be54d4e43693856ed8a43f" } 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'" }, diff --git a/uv.rocm.lock b/uv.rocm.lock index c0d5d6145..3fa0c1ccc 100644 --- a/uv.rocm.lock +++ b/uv.rocm.lock @@ -3775,7 +3775,7 @@ requires-dist = [ { name = "triton-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==3.6.0", index = "https://download.pytorch.org/whl/rocm7.2" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=4a23de63bbbe7a936ebe85297842317a802ed3f0" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=199cf7802cf93d8e64be54d4e43693856ed8a43f" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, ] @@ -3816,7 +3816,7 @@ wheels = [ [[package]] name = "unisim-core" version = "1.2.1" -source = { git = "https://github.com/unilabsim/unisim.git?rev=4a23de63bbbe7a936ebe85297842317a802ed3f0#4a23de63bbbe7a936ebe85297842317a802ed3f0" } +source = { git = "https://github.com/unilabsim/unisim.git?rev=199cf7802cf93d8e64be54d4e43693856ed8a43f#199cf7802cf93d8e64be54d4e43693856ed8a43f" } 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'" }, From 24c8c6bef47b5f82afe3a9e21ac6b3ae54e4d402 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 18:48:58 +0800 Subject: [PATCH 06/13] test: cover representative fixed-tool PPO rollout --- tests/envs/test_simtool_real_fixed_tools.py | 122 ++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/envs/test_simtool_real_fixed_tools.py b/tests/envs/test_simtool_real_fixed_tools.py index 29039bff5..d531146b5 100644 --- a/tests/envs/test_simtool_real_fixed_tools.py +++ b/tests/envs/test_simtool_real_fixed_tools.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from tempfile import TemporaryDirectory from typing import Any import numpy as np @@ -15,6 +16,68 @@ ) +class _PpoVecEnvWrapper: + """Minimal CPU RSL-RL adapter for the representative training smoke.""" + + def __init__(self, env: Any, device: str = "cpu") -> None: + import torch + + from unilab.utils.tensor import to_torch + + self._torch = torch + self._to_torch = to_torch + self.env = env + self.cfg = env.cfg + self.device = device + self.num_envs = env.num_envs + self.observation_space = env.observation_space + self.action_space = env.action_space + self.num_obs = int(env.obs_groups_spec["obs"]) + self.num_privileged_obs = self.num_obs + self.num_actions = int(env.action_space.shape[0]) + self.episode_returns = torch.zeros(self.num_envs, device=device) + self.episode_lengths = torch.zeros(self.num_envs, device=device) + self.episode_length_buf = self.episode_lengths + self.max_episode_length = np.ceil(env.cfg.max_episode_seconds / env.cfg.ctrl_dt) + self.reset() + + def _observations(self, obs: dict[str, np.ndarray]) -> Any: + from tensordict import TensorDict + + actor = self._to_torch(obs["obs"], self.device) + return TensorDict( + {"actor": actor, "policy": actor}, + batch_size=self.num_envs, + device=self.device, + ) + + def step(self, actions: Any) -> tuple[Any, Any, Any, dict[str, Any]]: + actions_np = ( + actions.detach().cpu().numpy() if isinstance(actions, self._torch.Tensor) else actions + ) + state = self.env.step(actions_np) + rewards = self._to_torch(state.reward, self.device) + dones = self._to_torch(state.terminated | state.truncated, self.device).bool() + self.episode_returns += rewards + self.episode_lengths += 1 + return self._observations(state.obs), rewards, dones, {"time_outs": dones} + + def reset(self) -> tuple[Any, dict[str, Any]]: + if self.env.state is None: + self.env.init_state() + obs, _ = self.env.reset(np.arange(self.num_envs, dtype=np.int32)) + self.episode_returns[:] = 0 + self.episode_lengths[:] = 0 + return self._observations(obs), {} + + def get_observations(self) -> Any: + assert self.env.state is not None + return self._observations(self.env.state.obs) + + def get_privileged_observations(self) -> Any: + return self.get_observations() + + def _object_names( mujoco: Any, model: Any, @@ -155,3 +218,62 @@ def test_cpu_and_mjwarp_representative_rollouts_match_on_cuda( finally: mjwarp_env.close() cpu_env.close() + + +@pytest.mark.slow +def test_cpu_representative_fixed_tools_complete_one_ppo_iteration( + tmp_path: Path, +) -> None: + pytest.importorskip( + "unisim.backend.mujoco.backend", + reason="SimToolReal PPO smoke requires the MuJoCo adapter", + ) + pytest.importorskip("rsl_rl", reason="SimToolReal PPO smoke requires rsl_rl") + from rsl_rl.runners import OnPolicyRunner + from uni_rl.algos.rsl_rl import normalize_ppo_train_cfg + + from unilab.structured_configs import PPOConfig + + sources = write_representative_simtool_real_sources(tmp_path) + cfg = build_representative_simtool_real_env_cfg(sources) + env = make_manager_based_rl_env(cfg, num_envs=12, backend_type="mujoco") + wrapped = _PpoVecEnvWrapper(env) + train_cfg = PPOConfig().to_dict() + train_cfg.update( + { + "runner": {"logger": "none"}, + "num_steps_per_env": 2, + "empirical_normalization": False, + "policy": { + "actor_hidden_dims": [16], + "critic_hidden_dims": [16], + "activation": "elu", + "init_noise_std": 1.0, + }, + } + ) + train_cfg["algorithm"]["num_learning_epochs"] = 1 + train_cfg["algorithm"]["num_mini_batches"] = 1 + train_cfg = normalize_ppo_train_cfg(train_cfg) + + try: + with TemporaryDirectory() as log_dir: + runner = OnPolicyRunner(wrapped, train_cfg, log_dir=log_dir, device="cpu") + runner.learn(num_learning_iterations=1, init_at_random_ep_len=True) + parameters = [ + parameter.detach().cpu().numpy() + for parameter in ( + *runner.alg.actor.parameters(), + *runner.alg.critic.parameters(), + ) + ] + optimizer_steps = [ + int(state["step"].item()) + for state in runner.alg.optimizer.state.values() + if "step" in state + ] + finally: + env.close() + assert parameters + assert all(np.isfinite(parameter).all() for parameter in parameters) + assert sum(optimizer_steps) > 0 From 1fd412940448b07badb05c50eac61e5a8d54ef4a Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 19:51:46 +0800 Subject: [PATCH 07/13] refactor: ablate fixed variant owner API --- ...-fixed-model-variant-ownership-boundary.md | 11 +- .../5-domain_randomization/0-index.md | 12 +- .../5-domain_randomization/0-index.md | 8 +- pyproject.rocm.toml | 4 +- pyproject.toml | 6 +- .../env/benchmark_simtool_real_fixed_tools.py | 23 +- src/unilab/base/entity.py | 14 +- src/unilab/base/reset_state.py | 30 +- src/unilab/base/variants.py | 302 ++++-------------- src/unilab/envs/manager_based_rl_env.py | 13 +- .../manipulation/simtool_real/__init__.py | 2 - .../simtool_real/representative.py | 23 +- tests/base/test_fixed_model_variants.py | 128 +++----- tests/envs/test_simtool_real_fixed_tools.py | 80 +---- uv.lock | 10 +- uv.rocm.lock | 8 +- 16 files changed, 189 insertions(+), 485 deletions(-) diff --git a/docs/sphinx/source/adr/ADR-0010-fixed-model-variant-ownership-boundary.md b/docs/sphinx/source/adr/ADR-0010-fixed-model-variant-ownership-boundary.md index 95d4010ad..80cc5e3ff 100644 --- a/docs/sphinx/source/adr/ADR-0010-fixed-model-variant-ownership-boundary.md +++ b/docs/sphinx/source/adr/ADR-0010-fixed-model-variant-ownership-boundary.md @@ -36,8 +36,8 @@ executor 细节或 live engine objects 上移到 task 层。 UniLab 只拥有任务选择语义: -- 声明 named fixed model/tool variant source catalog; -- 在冷路径生成最终 env-to-variant assignment; +- 声明 named fixed model/tool variant source catalog 与 optional explicit names; +- 在冷路径直接生成 UniSim construction-time plan; - 保持 assignment 在 backend construction/materialization 后不可变; - 不打开、解析或编译 variant source,不持有 `MjSpec`、`MjModel`、mjbatch object、 Warp array 或任何 backend-private handle。 @@ -57,9 +57,10 @@ same-layout compiler coherence、mesh dedup、per-world arrays、CUDA graph capt ### Task Configuration And Assignment `EnvCfg.fixed_model_variants` 是 task owner 的声明性 catalog。每个 entry 只有 -name 与 source path descriptor;assignment 支持 deterministic `round_robin` 或 -task 已经展开的 explicit names。materialization 输出 `int32`、形状 `(num_envs,)` -的 final index array,并标记 read-only。backend-local copies 可以存在,但不能改写 +name 与 source path descriptor;空 `explicit_variant_names` 选择 deterministic +round-robin,非空列表表示 task 已经展开的 exact assignment。Manager factory 直接 +生成携带 read-only `int32`、形状 `(num_envs,)` final index array 的 UniSim plan; +不公开第二个 materialization 对象。backend-local copies 可以存在,但不能改写 task final identity。 Assignment 是 construction-time task identity,不在 reset 时重采样。reset event terms diff --git a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md index 629cb1e9b..ec70951a1 100644 --- a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md +++ b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md @@ -100,14 +100,14 @@ env: source_model_file: tools/tool_a.xml - name: tool_b source_model_file: tools/tool_b.xml - assignment: - mode: round_robin + # Omit explicit_variant_names for deterministic round-robin assignment. + explicit_variant_names: [tool_a, tool_b] ``` -`materialize_fixed_model_variants(...)` turns the declaration into a read-only -`int32` assignment with shape `(num_envs,)`. An owner may instead provide every -name with `mode: explicit`. The assignment is task identity: it is fixed after -backend construction and is not resampled by reset events. +The Manager factory turns the declaration into a read-only `int32` assignment +with shape `(num_envs,)`; an empty explicit list selects round-robin. +The assignment is task identity: it is fixed after backend construction and is +not resampled by reset events. UniLab does not open, parse, or compile `source_model_file`, and does not hold `MjSpec`, `MjModel`, mjbatch, or Warp objects. UniSim adapters own source diff --git a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md index 68c98f9fa..f75de65c2 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md @@ -92,12 +92,12 @@ env: source_model_file: tools/tool_a.xml - name: tool_b source_model_file: tools/tool_b.xml - assignment: - mode: round_robin + # 省略 explicit_variant_names 时使用确定性 round-robin assignment。 + explicit_variant_names: [tool_a, tool_b] ``` -`materialize_fixed_model_variants(...)` 会把它物化为形状 `(num_envs,)`、只读的 -`int32` assignment。Owner 也可以用 `mode: explicit` 提供全部名称。Assignment 是 +Manager factory 会把它物化为形状 `(num_envs,)`、只读的 `int32` assignment; +空 explicit list 选择 round-robin。Assignment 是 task identity:backend construction 后固定,reset event 不会重新采样。 UniLab 不打开、解析或编译 `source_model_file`,也不持有 `MjSpec`、`MjModel`、 diff --git a/pyproject.rocm.toml b/pyproject.rocm.toml index d7741f27b..89056e9d2 100644 --- a/pyproject.rocm.toml +++ b/pyproject.rocm.toml @@ -27,7 +27,7 @@ dependencies = [ # unisim-core package. Roadmap #1563 temporarily pins the pre-release # branch carrying fixed model variants; restore the published range after # the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@199cf7802cf93d8e64be54d4e43693856ed8a43f", + "unisim-core @ git+https://github.com/unilabsim/unisim.git@6506a4d30e03f419093fc17aa69a611f2212195d", # RL algorithms and async runtimes live in the independently released # uni-rl package (distribution name ``unilab-rl``); see pyproject.toml. "unilab-rl==1.2.0", @@ -81,7 +81,7 @@ mujoco = [ # time, so isolated builds are correct and no compiler preflight is # needed. "mujoco~=3.11.0", - "mjbatch-uni @ git+https://github.com/unilabsim/mjbatch-uni.git@0006734ade970eb040815eca28205e72a20c533d", + "mjbatch-uni @ git+https://github.com/unilabsim/mjbatch-uni.git@a1ff84a9957d85cbc556b109162012c55760055e", ] motrix = ["motrixsim-core==0.8.2"] viser = ["viser>=1.0.26", "trimesh>=3.21.7"] diff --git a/pyproject.toml b/pyproject.toml index 1bb786e4d..69c0b146e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ # unisim-core package. Roadmap #1563 temporarily pins the pre-release # branch carrying fixed model variants; restore the published range after # the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@199cf7802cf93d8e64be54d4e43693856ed8a43f", + "unisim-core @ git+https://github.com/unilabsim/unisim.git@6506a4d30e03f419093fc17aa69a611f2212195d", # RL algorithms and async runtimes (PPO/APPO/SAC/TD3 runners, # collectors, IPC, logging) live in the independently released uni-rl # package (distribution name ``unilab-rl``), consumed via the injected @@ -125,7 +125,7 @@ mujoco = [ # The batch engine is the unilabsim mjbatch fork. Roadmap #1563 pairs the # integration-only UniSim git pin with the matching pre-release executor # API; replace both after mjbatch-uni 0.2.x is published. - "mjbatch-uni @ git+https://github.com/unilabsim/mjbatch-uni.git@0006734ade970eb040815eca28205e72a20c533d", + "mjbatch-uni @ git+https://github.com/unilabsim/mjbatch-uni.git@a1ff84a9957d85cbc556b109162012c55760055e", ] mjwarp = [ # Keep the Warp backend on the same MuJoCo minor line as the host backend. @@ -175,7 +175,7 @@ viser = ["viser>=1.0.26", "trimesh>=3.21.7"] # required-environments; elsewhere the extra is empty and the CLI reports a # targeted runtime diagnostic. superdex = [ - "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@199cf7802cf93d8e64be54d4e43693856ed8a43f ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", + "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@6506a4d30e03f419093fc17aa69a611f2212195d ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", ] [dependency-groups] diff --git a/scripts/benchmark/env/benchmark_simtool_real_fixed_tools.py b/scripts/benchmark/env/benchmark_simtool_real_fixed_tools.py index 435cf86ab..2f7644d9c 100644 --- a/scripts/benchmark/env/benchmark_simtool_real_fixed_tools.py +++ b/scripts/benchmark/env/benchmark_simtool_real_fixed_tools.py @@ -4,7 +4,7 @@ import argparse import json -import resource +import sys import tempfile import time from pathlib import Path @@ -12,6 +12,11 @@ import numpy as np +ROOT_DIR = Path(__file__).resolve().parents[3] +if str(ROOT_DIR) not in sys.path: + sys.path.append(str(ROOT_DIR)) + +from scripts.benchmark.core.mem_profile import current_memory_bytes, peak_rss_bytes from unilab.envs import make_manager_based_rl_env from unilab.tasks.manipulation.simtool_real import ( build_representative_simtool_real_env_cfg, @@ -20,12 +25,10 @@ def _rss_bytes() -> int: - status = Path("/proc/self/status") - if status.is_file(): - for line in status.read_text(encoding="utf-8").splitlines(): - if line.startswith("VmRSS:"): - return int(line.split()[1]) * 1024 - return int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) * 1024 + value = current_memory_bytes().get("rss_bytes") + if not isinstance(value, int): + raise RuntimeError("current RSS is unavailable") + return value def run_benchmark( @@ -64,7 +67,7 @@ def run_benchmark( state = env.step(actions) measured_steps_seconds = time.perf_counter() - step_started - peak_rss_bytes = _rss_bytes() + peak_rss = peak_rss_bytes() plan = cfg.scene.fixed_variant_plan assert plan is not None result: dict[str, Any] = { @@ -86,7 +89,7 @@ def run_benchmark( "rss_before_source_bytes": rss_before_source, "rss_before_env_bytes": rss_before_env, "rss_after_construction_bytes": rss_after_construction, - "peak_rss_bytes": peak_rss_bytes, + "peak_rss_bytes": peak_rss, "construction_delta_bytes": rss_after_construction - rss_before_env, }, } @@ -115,7 +118,7 @@ def main() -> None: if args.num_envs <= 0: raise SystemExit("num-envs must be positive") if min(args.num_variants, args.steps, args.warmup_steps) < 0: - raise SystemExit("num-envs, num-variants, steps, and warmup-steps must be non-negative") + raise SystemExit("num-variants, steps, and warmup-steps must be non-negative") if args.num_variants == 0 or args.steps == 0: raise SystemExit("num-variants and steps must be positive") with tempfile.TemporaryDirectory(prefix="simtool-real-fixed-tools-") as temporary: diff --git a/src/unilab/base/entity.py b/src/unilab/base/entity.py index 436a58a1f..162ea65c6 100644 --- a/src/unilab/base/entity.py +++ b/src/unilab/base/entity.py @@ -95,6 +95,14 @@ def _as_column_index(ids: np.ndarray) -> slice | np.ndarray: return index +def _readonly_array(values: np.ndarray) -> np.ndarray: + result = np.asarray(values) + if result.flags.writeable: + result = result.copy() + result.setflags(write=False) + return result + + # Matching semantics derived from mujocolab/mjlab v1.6.0 (0fb8a681), # src/mjlab/utils/lab_api/string.py. Copyright 2025, The mjlab Developers; # adapted for the UniLab NumPy facade under Apache-2.0. @@ -2217,11 +2225,7 @@ def _readonly_local_binding( local_ids: np.ndarray, defaults: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: - bound_ids = np.array(local_ids, copy=True) - bound_ids.setflags(write=False) - bound_defaults = np.array(defaults, copy=True) - bound_defaults.setflags(write=False) - return bound_ids, bound_defaults + return _readonly_array(local_ids), _readonly_array(defaults) def _materialize_joint_model_dof_ids(self) -> np.ndarray: """Resolve full model DOF addresses once for reset-time model fields.""" diff --git a/src/unilab/base/reset_state.py b/src/unilab/base/reset_state.py index f883a6ed7..d3c3814f1 100644 --- a/src/unilab/base/reset_state.py +++ b/src/unilab/base/reset_state.py @@ -57,6 +57,14 @@ def _randomization_term_tail(field: str) -> tuple[int, ...]: raise ValueError(f"unknown reset randomization term {field!r}") from exc +def _readonly_array(values: np.ndarray) -> np.ndarray: + result = np.asarray(values) + if result.flags.writeable: + result = result.copy() + result.setflags(write=False) + return result + + class ResetStateTransaction: """Reusable, fail-closed transaction for reset-mode state mutation.""" @@ -644,27 +652,21 @@ def bind_actuator_gain_write( self._materialize_default_actuator_gains(term_name) assert self._default_kp is not None assert self._default_kd is not None - selected_kp = np.array( + selected_kp = _readonly_array( self._select_randomization_default_columns( self._default_kp, columns, field=RESET_TERM_KP, - ), - copy=True, + ) ) - selected_kd = np.array( + selected_kd = _readonly_array( self._select_randomization_default_columns( self._default_kd, columns, field=RESET_TERM_KD, - ), - copy=True, + ) ) - selected_kp.setflags(write=False) - selected_kd.setflags(write=False) - bound_columns = np.array(columns, copy=True) - bound_columns.setflags(write=False) - return bound_columns, selected_kp, selected_kd + return _readonly_array(columns), selected_kp, selected_kd def write_actuator_gains( self, @@ -1222,11 +1224,7 @@ def _readonly_binding( columns: np.ndarray, selected_default: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: - bound_columns = np.array(columns, copy=True) - bound_columns.setflags(write=False) - selected = np.array(selected_default, copy=True) - selected.setflags(write=False) - return bound_columns, selected + return _readonly_array(columns), _readonly_array(selected_default) def _write_selected_randomization( self, diff --git a/src/unilab/base/variants.py b/src/unilab/base/variants.py index e4e12451b..e41b17398 100644 --- a/src/unilab/base/variants.py +++ b/src/unilab/base/variants.py @@ -1,6 +1,6 @@ -"""Task-owned fixed model variants and immutable assignment materialization. +"""Task-owned fixed model variant declarations. -UniLab owns *which* variant each environment uses. It does not compile engine +UniLab owns *which* variant each environment uses. It does not compile engine models or select an executor representation; those responsibilities stay behind the UniSim backend contract. """ @@ -8,7 +8,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Literal import numpy as np from unisim.dr.types import ( @@ -27,38 +26,12 @@ class FixedModelVariantCfg: source_model_file: str -@dataclass(frozen=True) -class FixedModelVariantAssignmentCfg: - """Declare how a task maps environments to named fixed variants.""" - - mode: Literal["round_robin", "explicit"] = "round_robin" - explicit_variant_names: tuple[str, ...] = field(default_factory=tuple) - - def __post_init__(self) -> None: - object.__setattr__( - self, "explicit_variant_names", _string_tuple(self.explicit_variant_names) - ) - if self.mode not in ("round_robin", "explicit"): - raise ValueError( - "FixedModelVariantAssignmentCfg.mode must be 'round_robin' or 'explicit'; " - f"got {self.mode!r}" - ) - for index, name in enumerate(self.explicit_variant_names): - if not name.strip(): - raise ValueError( - "FixedModelVariantAssignmentCfg.explicit_variant_names" - f"[{index}] must be a non-empty string" - ) - - @dataclass(frozen=True) class FixedModelVariantCatalogCfg: - """Task-owned catalog of same-public-layout model/tool sources.""" + """Task-owned variant sources and their optional explicit assignment.""" variants: tuple[FixedModelVariantCfg, ...] = field(default_factory=tuple) - assignment: FixedModelVariantAssignmentCfg = field( - default_factory=FixedModelVariantAssignmentCfg - ) + explicit_variant_names: tuple[str, ...] = field(default_factory=tuple) def __post_init__(self) -> None: if not isinstance(self.variants, (list, tuple)): @@ -67,252 +40,107 @@ def __post_init__(self) -> None: f"FixedModelVariantCfg, got {type(self.variants).__name__}" ) object.__setattr__(self, "variants", tuple(self.variants)) - if not isinstance(self.assignment, FixedModelVariantAssignmentCfg): - raise TypeError( - "FixedModelVariantCatalogCfg.assignment must be " - f"FixedModelVariantAssignmentCfg, got {type(self.assignment).__name__}" - ) - _validate_catalog(self) - - -@dataclass(frozen=True) -class FixedModelVariantMaterialization: - """The final immutable variant selection handed to the owner boundary. - - ``model_assignments`` is an ``int32`` array with shape ``(num_envs,)`` and - is marked read-only. Consumers that need a writable projection must call - :meth:`copy_model_assignments`; they must never mutate the final task - identity in place. - """ - - variants: tuple[FixedModelVariantCfg, ...] - model_assignments: np.ndarray - - def __post_init__(self) -> None: - if not isinstance(self.variants, (list, tuple)): - raise TypeError( - "FixedModelVariantMaterialization.variants must be a sequence of " - f"FixedModelVariantCfg, got {type(self.variants).__name__}" - ) - object.__setattr__(self, "variants", tuple(self.variants)) - if not isinstance(self.model_assignments, np.ndarray): - raise TypeError( - "Fixed model variant model_assignments must be np.ndarray, " - f"got {type(self.model_assignments).__name__}" - ) - if self.model_assignments.dtype.kind not in "iu": - raise ValueError( - "Fixed model variant model_assignments must have an integer dtype; " - f"got {self.model_assignments.dtype}" - ) object.__setattr__( self, - "model_assignments", - np.ascontiguousarray(self.model_assignments, dtype=np.int32), + "explicit_variant_names", + _string_tuple(self.explicit_variant_names, "explicit_variant_names"), ) - self.model_assignments.setflags(write=False) - - @property - def variant_names(self) -> tuple[str, ...]: - return tuple(variant.name for variant in self.variants) - - def copy_model_assignments(self) -> np.ndarray: - """Return a writable backend-local copy of the final assignment.""" - - return np.array(self.model_assignments, dtype=np.int32, copy=True) - - -def _validate_catalog(catalog: FixedModelVariantCatalogCfg) -> None: - if not catalog.variants: - raise ValueError("FixedModelVariantCatalogCfg.variants must not be empty") - _validate_variants(catalog.variants, "FixedModelVariantCatalogCfg.variants") - if catalog.assignment.mode == "round_robin" and catalog.assignment.explicit_variant_names: - raise ValueError( - "FixedModelVariantAssignmentCfg.explicit_variant_names must be empty " - "when assignment mode is 'round_robin'" - ) - - -def _validate_variants(variants: tuple[FixedModelVariantCfg, ...], label_prefix: str) -> None: - if not variants: - raise ValueError(f"{label_prefix} must not be empty") - - names: set[str] = set() - for index, variant in enumerate(variants): - label = f"{label_prefix}[{index}]" - if not isinstance(variant, FixedModelVariantCfg): - raise TypeError(f"{label} must be FixedModelVariantCfg, got {type(variant).__name__}") - if not isinstance(variant.name, str) or not variant.name.strip(): - raise ValueError(f"{label}.name must be a non-empty string") - if not isinstance(variant.source_model_file, str) or not variant.source_model_file.strip(): - raise ValueError( - f"{label}('{variant.name}').source_model_file must be a non-empty string" - ) - if variant.name in names: - raise ValueError( - "Fixed model variant names must be unique; duplicate " - f"{variant.name!r} was declared more than once" - ) - names.add(variant.name) - - -def _string_tuple(values: object) -> tuple[str, ...]: - if isinstance(values, str) or not isinstance(values, (list, tuple)): - raise TypeError(f"Expected a sequence of strings, got {type(values).__name__}") - result = tuple(values) - if any(not isinstance(value, str) for value in result): - kinds = sorted({type(value).__name__ for value in result if not isinstance(value, str)}) - raise TypeError(f"Expected a sequence of strings, got {kinds}") - return result - + _validate_catalog(self) -def materialize_fixed_model_variants( - catalog: FixedModelVariantCatalogCfg, num_envs: int -) -> FixedModelVariantMaterialization: - """Materialize a final assignment without touching an engine object. - This is deliberately a cold-path operation: it resolves only task names and - integer indices. It never opens or compiles ``source_model_file``. - """ +def _build_fixed_variant_plan( + catalog: FixedModelVariantCatalogCfg, + num_envs: int, +) -> FixedVariantPlan: + """Build the immutable UniSim construction-time variant identity.""" - _validate_catalog(catalog) if isinstance(num_envs, bool) or not isinstance(num_envs, (int, np.integer)): raise TypeError(f"num_envs must be a positive integer, got {num_envs!r}") if num_envs <= 0: - raise ValueError(f"num_envs must be positive, got {num_envs}") + raise ValueError(f"num_envs must be positive, got {num_envs!r}") - variant_indices = {variant.name: index for index, variant in enumerate(catalog.variants)} - if catalog.assignment.mode == "round_robin": - if catalog.assignment.explicit_variant_names: - raise ValueError( - "FixedModelVariantAssignmentCfg.explicit_variant_names must be empty " - "when assignment mode is 'round_robin'" - ) - assignments = np.arange(num_envs, dtype=np.int32) % np.int32(len(catalog.variants)) - else: - requested = catalog.assignment.explicit_variant_names + if catalog.explicit_variant_names: + requested = catalog.explicit_variant_names if len(requested) != num_envs: raise ValueError( "Explicit fixed-variant assignment must contain exactly num_envs names; " f"expected {num_envs}, got {len(requested)}" ) - unknown = [name for name in requested if name not in variant_indices] - if unknown: - available = [variant.name for variant in catalog.variants] - raise ValueError( - f"Explicit fixed-variant assignment references unknown variants {unknown}; " - f"available variants are {available}" - ) + variant_indices = {variant.name: index for index, variant in enumerate(catalog.variants)} assignments = np.fromiter( (variant_indices[name] for name in requested), dtype=np.int32, count=num_envs, ) + else: + assignments = np.arange(num_envs, dtype=np.int32) % np.int32(len(catalog.variants)) + assignments.setflags(write=False) - materialization = FixedModelVariantMaterialization(catalog.variants, assignments) - validate_fixed_model_variant_materialization(materialization, num_envs) - return materialization - - -def prepare_fixed_model_variants( - catalog: FixedModelVariantCatalogCfg, - num_envs: int, - capabilities: DomainRandomizationCapabilities, -) -> FixedModelVariantMaterialization: - """Materialize a task assignment after negotiating the single DR contract. - - This owner-layer helper is the integration seam used by env construction. - It intentionally accepts only the already-materialized backend capability - object; it never probes a backend type or optional engine package. - """ - - require_fixed_model_variant_support(capabilities) - return materialize_fixed_model_variants(catalog, num_envs) - - -def build_fixed_variant_plan( - materialization: FixedModelVariantMaterialization, -) -> FixedVariantPlan: - """Translate the final task selection into UniSim's neutral variant plan.""" - - num_envs = int(materialization.model_assignments.size) - validate_fixed_model_variant_materialization(materialization, num_envs) return FixedVariantPlan( - assignment=materialization.model_assignments, + assignment=assignments, variants=tuple( ModelSourceDescriptor(model_file=variant.source_model_file) - for variant in materialization.variants + for variant in catalog.variants ), layout=FixedVariantLayout.SAME_LAYOUT, ) -def validate_fixed_model_variant_materialization( - materialization: FixedModelVariantMaterialization, - num_envs: int, - *, - require_immutable_assignment: bool = True, +def _require_fixed_variant_support( + capabilities: DomainRandomizationCapabilities, + plan: FixedVariantPlan, ) -> None: - """Validate the final assignment shape, range, and immutable state.""" + """Fail closed unless UniSim can realize the complete variant plan.""" - if not isinstance(materialization, FixedModelVariantMaterialization): - raise TypeError( - "Fixed model variant materialization must be " - f"FixedModelVariantMaterialization, got {type(materialization).__name__}" - ) - if isinstance(num_envs, bool) or not isinstance(num_envs, (int, np.integer)) or num_envs <= 0: - raise ValueError(f"num_envs must be positive, got {num_envs!r}") - _validate_variants(materialization.variants, "FixedModelVariantMaterialization.variants") - assignments = materialization.model_assignments - if not isinstance(assignments, np.ndarray): - raise TypeError( - "Fixed model variant model_assignments must be np.ndarray, " - f"got {type(assignments).__name__}" - ) - if assignments.shape != (num_envs,): - raise ValueError( - f"Fixed model variant model_assignments must have shape ({num_envs},); " - f"got {assignments.shape}" - ) - if assignments.dtype.kind not in "iu": - raise ValueError( - "Fixed model variant model_assignments must have an integer dtype; " - f"got {assignments.dtype}" - ) - if np.any(assignments < 0) or np.any(assignments >= len(materialization.variants)): - raise ValueError("Fixed model variant model_assignments contains an out-of-range index") - if require_immutable_assignment and assignments.flags.writeable: - raise ValueError( - "Final fixed model variant model_assignments must be read-only after materialization" + rejections = capabilities.fixed_variant_rejections(plan) + if rejections: + rendered = "; ".join(rejections) + raise NotImplementedError( + f"{type(capabilities).__name__} cannot realize the fixed variant plan: {rendered}" ) -def require_fixed_model_variant_support( - capabilities: DomainRandomizationCapabilities, -) -> None: - """Fail closed unless UniSim explicitly declares fixed-variant support. +def _validate_catalog(catalog: FixedModelVariantCatalogCfg) -> None: + if not catalog.variants: + raise ValueError("FixedModelVariantCatalogCfg.variants must not be empty") - The authoritative declaration remains UniSim's DR capability object. This - helper intentionally does not infer support from a backend type, installed - engine, or optional import. - """ + names: set[str] = set() + for index, variant in enumerate(catalog.variants): + label = f"FixedModelVariantCatalogCfg.variants[{index}]" + if not isinstance(variant, FixedModelVariantCfg): + raise TypeError(f"{label} must be FixedModelVariantCfg, got {type(variant).__name__}") + if not isinstance(variant.name, str) or not variant.name.strip(): + raise ValueError(f"{label}.name must be a non-empty string") + if not isinstance(variant.source_model_file, str) or not variant.source_model_file.strip(): + raise ValueError( + f"{label}('{variant.name}').source_model_file must be a non-empty string" + ) + if variant.name in names: + raise ValueError( + "Fixed model variant names must be unique; duplicate " + f"{variant.name!r} was declared more than once" + ) + names.add(variant.name) - declared = getattr(capabilities, "supports_fixed_variants", False) - if declared is not True: - raise NotImplementedError( - f"{type(capabilities).__name__} does not support fixed model variants " - f"(supports_fixed_variants={declared!r})" + unknown = [name for name in catalog.explicit_variant_names if name not in names] + if unknown: + available = [variant.name for variant in catalog.variants] + raise ValueError( + "Explicit fixed-variant assignment references unknown variants " + f"{unknown}; available variants are {available}" ) +def _string_tuple(values: object, name: str) -> tuple[str, ...]: + if isinstance(values, str) or not isinstance(values, (list, tuple)): + raise TypeError(f"{name} must be a sequence of strings, got {type(values).__name__}") + result = tuple(values) + if any(not isinstance(value, str) or not value.strip() for value in result): + raise ValueError(f"{name} must contain non-empty strings") + return result + + __all__ = [ - "FixedModelVariantAssignmentCfg", "FixedModelVariantCatalogCfg", "FixedModelVariantCfg", - "FixedModelVariantMaterialization", - "build_fixed_variant_plan", - "materialize_fixed_model_variants", - "prepare_fixed_model_variants", - "require_fixed_model_variant_support", - "validate_fixed_model_variant_materialization", ] diff --git a/src/unilab/envs/manager_based_rl_env.py b/src/unilab/envs/manager_based_rl_env.py index c8e6ae935..dad342786 100644 --- a/src/unilab/envs/manager_based_rl_env.py +++ b/src/unilab/envs/manager_based_rl_env.py @@ -28,9 +28,8 @@ from unilab.base.reset_state import ResetStateTransaction from unilab.base.scene import SceneCfg, resolve_scene_default_qpos from unilab.base.variants import ( - build_fixed_variant_plan, - materialize_fixed_model_variants, - require_fixed_model_variant_support, + _build_fixed_variant_plan, + _require_fixed_variant_support, ) from unilab.dtype_config import get_global_dtype from unilab.managers import ( @@ -786,9 +785,7 @@ def make_manager_based_rl_env( cfg.validate() if cfg.fixed_model_variants is not None and cfg.scene is not None: # Validate the complete task identity before allocating backend resources. - cfg.scene.fixed_variant_plan = build_fixed_variant_plan( - materialize_fixed_model_variants(cfg.fixed_model_variants, num_envs) - ) + cfg.scene.fixed_variant_plan = _build_fixed_variant_plan(cfg.fixed_model_variants, num_envs) # Constrain the process before backend materialization so native pools size # themselves from the rank-owned CPU block. apply_env_cpu_runtime(cfg.cpu_ids) @@ -807,7 +804,9 @@ def make_manager_based_rl_env( ) try: if cfg.scene.fixed_variant_plan is not None: - require_fixed_model_variant_support(backend.get_dr_capabilities()) + _require_fixed_variant_support( + backend.get_dr_capabilities(), cfg.scene.fixed_variant_plan + ) return ManagerBasedRlEnv(cfg, backend, num_envs) except Exception: backend.cleanup_scene_assets() diff --git a/src/unilab/tasks/manipulation/simtool_real/__init__.py b/src/unilab/tasks/manipulation/simtool_real/__init__.py index 0734693fc..3ef472189 100644 --- a/src/unilab/tasks/manipulation/simtool_real/__init__.py +++ b/src/unilab/tasks/manipulation/simtool_real/__init__.py @@ -2,14 +2,12 @@ from .representative import ( RepresentativeSimToolRealSourceSet, - RepresentativeSimToolRealVariant, build_representative_simtool_real_env_cfg, write_representative_simtool_real_sources, ) __all__ = [ "RepresentativeSimToolRealSourceSet", - "RepresentativeSimToolRealVariant", "build_representative_simtool_real_env_cfg", "write_representative_simtool_real_sources", ] diff --git a/src/unilab/tasks/manipulation/simtool_real/representative.py b/src/unilab/tasks/manipulation/simtool_real/representative.py index 32f8c6c3f..37916e5b6 100644 --- a/src/unilab/tasks/manipulation/simtool_real/representative.py +++ b/src/unilab/tasks/manipulation/simtool_real/representative.py @@ -13,7 +13,6 @@ from unilab.base.entity import EntityCfg from unilab.base.scene import SceneCfg from unilab.base.variants import ( - FixedModelVariantAssignmentCfg, FixedModelVariantCatalogCfg, FixedModelVariantCfg, ) @@ -39,7 +38,7 @@ @dataclass(frozen=True) -class RepresentativeSimToolRealVariant: +class _VariantSpec: """Parameters that vary while preserving the representative public layout.""" name: str @@ -52,9 +51,6 @@ class RepresentativeSimToolRealVariant: class RepresentativeSimToolRealSourceSet: """Materialized absolute sources ready for UniSim backend consumption.""" - output_dir: Path - mesh_file: Path - variants: tuple[RepresentativeSimToolRealVariant, ...] model_files: tuple[Path, ...] @@ -75,11 +71,11 @@ def write_representative_simtool_real_sources( mesh_file = root / "simtool_handle.obj" mesh_file.write_text(_TETRAHEDRON_OBJ, encoding="utf-8") - variants: list[RepresentativeSimToolRealVariant] = [] + variants: list[_VariantSpec] = [] model_files: list[Path] = [] for index in range(variant_count): name = f"tool_{index:04d}" - variant = RepresentativeSimToolRealVariant( + variant = _VariantSpec( name=name, mass_kg=0.4 + 0.25 * index, mesh_scale=(1.0 + 0.08 * index, 0.9 + 0.06 * index, 0.8 + 0.05 * index), @@ -90,12 +86,7 @@ def write_representative_simtool_real_sources( variants.append(variant) model_files.append(model_file) - return RepresentativeSimToolRealSourceSet( - output_dir=root, - mesh_file=mesh_file, - variants=tuple(variants), - model_files=tuple(model_files), - ) + return RepresentativeSimToolRealSourceSet(model_files=tuple(model_files)) def build_representative_simtool_real_env_cfg( @@ -120,10 +111,8 @@ def build_representative_simtool_real_env_cfg( ), fixed_model_variants=FixedModelVariantCatalogCfg( variants=tuple( - FixedModelVariantCfg(variant.name, str(path)) - for variant, path in zip(sources.variants, sources.model_files, strict=True) + FixedModelVariantCfg(path.stem, str(path)) for path in sources.model_files ), - assignment=FixedModelVariantAssignmentCfg(mode="round_robin"), ), sim_dt=0.002, ctrl_dt=0.01, @@ -171,7 +160,7 @@ def build_representative_simtool_real_env_cfg( def _variant_xml( - variant: RepresentativeSimToolRealVariant, + variant: _VariantSpec, mesh_file: Path, ) -> str: scale = " ".join(f"{value:.6f}" for value in variant.mesh_scale) diff --git a/tests/base/test_fixed_model_variants.py b/tests/base/test_fixed_model_variants.py index 2a1c6ca5d..2c4c1abe0 100644 --- a/tests/base/test_fixed_model_variants.py +++ b/tests/base/test_fixed_model_variants.py @@ -1,47 +1,45 @@ from __future__ import annotations from pathlib import Path -from typing import Literal import numpy as np import pytest from omegaconf import OmegaConf -from unisim.dr.types import DomainRandomizationCapabilities +from unisim.dr.types import DomainRandomizationCapabilities, FixedVariantLayout from unilab.base.base import EnvCfg from unilab.base.config_materialization import apply_cfg_overrides from unilab.base.entity import EntityCfg from unilab.base.scene import SceneCfg from unilab.base.variants import ( - FixedModelVariantAssignmentCfg, FixedModelVariantCatalogCfg, FixedModelVariantCfg, - build_fixed_variant_plan, - materialize_fixed_model_variants, - prepare_fixed_model_variants, - require_fixed_model_variant_support, - validate_fixed_model_variant_materialization, + _build_fixed_variant_plan, + _require_fixed_variant_support, ) from unilab.envs import manager_based_rl_env from unilab.envs.manager_based_rl_env import ManagerBasedRlEnvCfg, make_manager_based_rl_env -def _catalog( - mode: Literal["round_robin", "explicit"] = "round_robin", - names: tuple[str, ...] = (), -) -> FixedModelVariantCatalogCfg: +def _catalog(explicit_variant_names: tuple[str, ...] = ()) -> FixedModelVariantCatalogCfg: return FixedModelVariantCatalogCfg( variants=( FixedModelVariantCfg("tool_a", "tools/a.xml"), FixedModelVariantCfg("tool_b", "tools/b.xml"), ), - assignment=FixedModelVariantAssignmentCfg(mode=mode, explicit_variant_names=names), + explicit_variant_names=explicit_variant_names, ) -def test_hydra_materializes_typed_catalog_and_assignment() -> None: - cfg = EnvCfg() +def _capabilities() -> DomainRandomizationCapabilities: + return DomainRandomizationCapabilities( + supports_fixed_variants=True, + supported_fixed_variant_layouts=frozenset({FixedVariantLayout.SAME_LAYOUT}), + ) + +def test_hydra_materializes_typed_catalog_and_explicit_names() -> None: + cfg = EnvCfg() apply_cfg_overrides( cfg, OmegaConf.create( @@ -51,63 +49,36 @@ def test_hydra_materializes_typed_catalog_and_assignment() -> None: {"name": "tool_a", "source_model_file": "tools/a.xml"}, {"name": "tool_b", "source_model_file": "tools/b.xml"}, ], - "assignment": {"mode": "explicit", "explicit_variant_names": ["tool_b"]}, + "explicit_variant_names": ["tool_b", "tool_a"], } } ), ) cfg.validate() - assert cfg.fixed_model_variants is not None - assert isinstance(cfg.fixed_model_variants, FixedModelVariantCatalogCfg) - assert cfg.fixed_model_variants.variants == ( - FixedModelVariantCfg("tool_a", "tools/a.xml"), - FixedModelVariantCfg("tool_b", "tools/b.xml"), - ) - assert cfg.fixed_model_variants.assignment == FixedModelVariantAssignmentCfg( - mode="explicit", explicit_variant_names=("tool_b",) - ) + assert cfg.fixed_model_variants == _catalog(("tool_b", "tool_a")) -def test_round_robin_materialization_is_final_and_immutable() -> None: - materialization = materialize_fixed_model_variants(_catalog(), num_envs=5) +def test_default_assignment_is_round_robin_and_immutable() -> None: + plan = _build_fixed_variant_plan(_catalog(), num_envs=5) - assert materialization.variant_names == ("tool_a", "tool_b") - np.testing.assert_array_equal( - materialization.model_assignments, np.array([0, 1, 0, 1, 0], dtype=np.int32) - ) - assert not materialization.model_assignments.flags.writeable + np.testing.assert_array_equal(plan.assignment, np.array([0, 1, 0, 1, 0], dtype=np.int32)) + assert not plan.assignment.flags.writeable with pytest.raises(ValueError, match="assignment destination is read-only"): - materialization.model_assignments[0] = 1 - copied = materialization.copy_model_assignments() - assert copied.flags.writeable - np.testing.assert_array_equal(copied, materialization.model_assignments) + plan.assignment[0] = 1 def test_explicit_assignment_uses_names_not_engine_objects() -> None: - materialization = materialize_fixed_model_variants( - _catalog(mode="explicit", names=("tool_b", "tool_a", "tool_b")), num_envs=3 - ) + plan = _build_fixed_variant_plan(_catalog(("tool_b", "tool_a", "tool_b")), num_envs=3) - np.testing.assert_array_equal( - materialization.model_assignments, np.array([1, 0, 1], dtype=np.int32) + np.testing.assert_array_equal(plan.assignment, np.array([1, 0, 1], dtype=np.int32)) + assert tuple(variant.model_file for variant in plan.variants) == ( + "tools/a.xml", + "tools/b.xml", ) - validate_fixed_model_variant_materialization(materialization, num_envs=3) -@pytest.mark.parametrize( - ("names", "num_envs", "match"), - [ - (("tool_a",), 2, "exactly num_envs"), - (("tool_a", "missing", "tool_b"), 3, "unknown variants"), - ], -) -def test_explicit_assignment_fail_closed(names: tuple[str, ...], num_envs: int, match: str) -> None: - with pytest.raises(ValueError, match=match): - materialize_fixed_model_variants(_catalog(mode="explicit", names=names), num_envs=num_envs) - - -def test_catalog_rejects_duplicate_and_empty_sources() -> None: +def test_catalog_and_assignment_fail_closed() -> None: with pytest.raises(ValueError, match="duplicate 'tool_a'"): FixedModelVariantCatalogCfg( variants=( @@ -119,42 +90,23 @@ def test_catalog_rejects_duplicate_and_empty_sources() -> None: FixedModelVariantCatalogCfg() with pytest.raises(ValueError, match="source_model_file must be a non-empty string"): FixedModelVariantCatalogCfg(variants=(FixedModelVariantCfg("tool_a", " "),)) + with pytest.raises(ValueError, match="unknown variants"): + _catalog(("tool_a", "missing")) + with pytest.raises(ValueError, match="exactly num_envs"): + _build_fixed_variant_plan(_catalog(("tool_a",)), num_envs=2) -def test_final_assignment_validation_rejects_a_writable_array() -> None: - materialization = materialize_fixed_model_variants(_catalog(), num_envs=2) - writable = np.arange(2, dtype=np.int32) - object.__setattr__(materialization, "model_assignments", writable) - - with pytest.raises(ValueError, match="must be read-only"): - validate_fixed_model_variant_materialization(materialization, num_envs=2) - - -def test_fixed_variant_support_fails_closed_on_one_capability_contract() -> None: - catalog = _catalog() - - with pytest.raises(NotImplementedError, match="does not support"): - require_fixed_model_variant_support(DomainRandomizationCapabilities()) - - materialization = prepare_fixed_model_variants( - catalog, - 2, - DomainRandomizationCapabilities(supports_fixed_variants=True), - ) - validate_fixed_model_variant_materialization(materialization, num_envs=2) - +def test_support_negotiation_requires_the_complete_plan_contract() -> None: + plan = _build_fixed_variant_plan(_catalog(), num_envs=2) + _require_fixed_variant_support(_capabilities(), plan) -def test_fixed_variant_materialization_builds_unisim_plan() -> None: - materialization = materialize_fixed_model_variants(_catalog(), num_envs=2) - plan = build_fixed_variant_plan(materialization) + unsupported = DomainRandomizationCapabilities() + with pytest.raises(NotImplementedError, match="cannot realize"): + _require_fixed_variant_support(unsupported, plan) - assert plan.layout.value == "same_layout" - np.testing.assert_array_equal(plan.assignment, materialization.model_assignments) - assert tuple(variant.model_file for variant in plan.variants) == ( - "tools/a.xml", - "tools/b.xml", - ) - assert not plan.assignment.flags.writeable + partial = DomainRandomizationCapabilities(supports_fixed_variants=True) + with pytest.raises(NotImplementedError, match="layout 'same_layout' is unsupported"): + _require_fixed_variant_support(partial, plan) def test_variant_owner_module_does_not_reference_engine_internals() -> None: @@ -206,7 +158,7 @@ def _fail_if_env_is_constructed(*_args: object, **_kwargs: object) -> None: monkeypatch.setattr(manager_based_rl_env, "ManagerBasedRlEnv", _fail_if_env_is_constructed) - with pytest.raises(NotImplementedError, match="does not support"): + with pytest.raises(NotImplementedError, match="cannot realize"): make_manager_based_rl_env(cfg, num_envs=2, backend_type="mujoco") assert backend.cleaned is True diff --git a/tests/envs/test_simtool_real_fixed_tools.py b/tests/envs/test_simtool_real_fixed_tools.py index d531146b5..222bfc403 100644 --- a/tests/envs/test_simtool_real_fixed_tools.py +++ b/tests/envs/test_simtool_real_fixed_tools.py @@ -8,6 +8,7 @@ import numpy as np import pytest +from uni_rl.algos.rsl_rl import RslRlVecEnvWrapper from unilab.envs import make_manager_based_rl_env from unilab.tasks.manipulation.simtool_real import ( @@ -16,68 +17,6 @@ ) -class _PpoVecEnvWrapper: - """Minimal CPU RSL-RL adapter for the representative training smoke.""" - - def __init__(self, env: Any, device: str = "cpu") -> None: - import torch - - from unilab.utils.tensor import to_torch - - self._torch = torch - self._to_torch = to_torch - self.env = env - self.cfg = env.cfg - self.device = device - self.num_envs = env.num_envs - self.observation_space = env.observation_space - self.action_space = env.action_space - self.num_obs = int(env.obs_groups_spec["obs"]) - self.num_privileged_obs = self.num_obs - self.num_actions = int(env.action_space.shape[0]) - self.episode_returns = torch.zeros(self.num_envs, device=device) - self.episode_lengths = torch.zeros(self.num_envs, device=device) - self.episode_length_buf = self.episode_lengths - self.max_episode_length = np.ceil(env.cfg.max_episode_seconds / env.cfg.ctrl_dt) - self.reset() - - def _observations(self, obs: dict[str, np.ndarray]) -> Any: - from tensordict import TensorDict - - actor = self._to_torch(obs["obs"], self.device) - return TensorDict( - {"actor": actor, "policy": actor}, - batch_size=self.num_envs, - device=self.device, - ) - - def step(self, actions: Any) -> tuple[Any, Any, Any, dict[str, Any]]: - actions_np = ( - actions.detach().cpu().numpy() if isinstance(actions, self._torch.Tensor) else actions - ) - state = self.env.step(actions_np) - rewards = self._to_torch(state.reward, self.device) - dones = self._to_torch(state.terminated | state.truncated, self.device).bool() - self.episode_returns += rewards - self.episode_lengths += 1 - return self._observations(state.obs), rewards, dones, {"time_outs": dones} - - def reset(self) -> tuple[Any, dict[str, Any]]: - if self.env.state is None: - self.env.init_state() - obs, _ = self.env.reset(np.arange(self.num_envs, dtype=np.int32)) - self.episode_returns[:] = 0 - self.episode_lengths[:] = 0 - return self._observations(obs), {} - - def get_observations(self) -> Any: - assert self.env.state is not None - return self._observations(self.env.state.obs) - - def get_privileged_observations(self) -> Any: - return self.get_observations() - - def _object_names( mujoco: Any, model: Any, @@ -112,7 +51,7 @@ def test_generated_sources_preserve_public_layout_and_vary_model_fields( np.testing.assert_allclose( [model.body_mass[1] for model in models], - [variant.mass_kg for variant in sources.variants], + [0.4 + 0.25 * index for index in range(4)], rtol=0.0, atol=0.0, ) @@ -134,16 +73,13 @@ def test_cpu_manager_rollout_uses_immutable_variant_identity_and_reset_dr( plan = cfg.scene.fixed_variant_plan assert plan is not None np.testing.assert_array_equal(plan.assignment, np.tile(np.arange(3, dtype=np.int32), 2)) - assert not plan.assignment.flags.writeable backend = env._backend - assert backend.get_dr_capabilities().supports_fixed_variant_plan(plan) default_mass = backend.get_reset_term_default("body_mass") assert default_mass.shape == (6, backend.model.nbody) - assert not default_mass.flags.writeable np.testing.assert_allclose( default_mass[:, 1], - [variant.mass_kg for variant in sources.variants] * 2, + [0.4 + 0.25 * index for index in range(3)] * 2, ) state = env.init_state() @@ -154,13 +90,10 @@ def test_cpu_manager_rollout_uses_immutable_variant_identity_and_reset_dr( assert np.isfinite(state.reward).all() playback_mass = [float(env.get_playback_model(index).body_mass[1]) for index in range(3)] - np.testing.assert_allclose(playback_mass, [variant.mass_kg for variant in sources.variants]) + np.testing.assert_allclose(playback_mass, [0.4 + 0.25 * index for index in range(3)]) env.reset() - assert cfg.scene.fixed_variant_plan is plan - np.testing.assert_allclose( - backend.get_reset_term_default("body_mass")[:, 1], default_mass[:, 1] - ) + assert np.isfinite(env.state.obs["obs"]).all() finally: env.close() @@ -192,7 +125,6 @@ def test_cpu_and_mjwarp_representative_rollouts_match_on_cuda( try: plan = mjwarp_cfg.scene.fixed_variant_plan assert plan is not None - assert mjwarp_env._backend.get_dr_capabilities().supports_fixed_variant_plan(plan) assert [Path(mjwarp_env.get_playback_model(index)).resolve() for index in range(3)] == [ Path(variant.model_file).resolve() for variant in plan.variants ] @@ -237,7 +169,7 @@ def test_cpu_representative_fixed_tools_complete_one_ppo_iteration( sources = write_representative_simtool_real_sources(tmp_path) cfg = build_representative_simtool_real_env_cfg(sources) env = make_manager_based_rl_env(cfg, num_envs=12, backend_type="mujoco") - wrapped = _PpoVecEnvWrapper(env) + wrapped = RslRlVecEnvWrapper(env) train_cfg = PPOConfig().to_dict() train_cfg.update( { diff --git a/uv.lock b/uv.lock index dd73da20b..51460930e 100644 --- a/uv.lock +++ b/uv.lock @@ -2137,7 +2137,7 @@ wheels = [ [[package]] name = "mjbatch-uni" version = "0.2.0" -source = { git = "https://github.com/unilabsim/mjbatch-uni.git?rev=0006734ade970eb040815eca28205e72a20c533d#0006734ade970eb040815eca28205e72a20c533d" } +source = { git = "https://github.com/unilabsim/mjbatch-uni.git?rev=a1ff84a9957d85cbc556b109162012c55760055e#a1ff84a9957d85cbc556b109162012c55760055e" } dependencies = [ { name = "mujoco" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -5195,7 +5195,7 @@ requires-dist = [ { name = "imgui-bundle", marker = "extra == 'newton'", specifier = ">=1.92.0" }, { name = "lark", specifier = ">=1.3.1" }, { name = "mediapy" }, - { name = "mjbatch-uni", marker = "extra == 'mujoco'", git = "https://github.com/unilabsim/mjbatch-uni.git?rev=0006734ade970eb040815eca28205e72a20c533d" }, + { name = "mjbatch-uni", marker = "extra == 'mujoco'", git = "https://github.com/unilabsim/mjbatch-uni.git?rev=a1ff84a9957d85cbc556b109162012c55760055e" }, { name = "motrixsim-core", marker = "extra == 'motrix'", specifier = "==0.8.2" }, { name = "mujoco", marker = "extra == 'drake'", specifier = ">=3.5" }, { name = "mujoco", marker = "extra == 'mujoco'", specifier = "~=3.11.0" }, @@ -5225,8 +5225,8 @@ requires-dist = [ { name = "trimesh", marker = "extra == 'viser'", specifier = ">=3.21.7" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=199cf7802cf93d8e64be54d4e43693856ed8a43f" }, - { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=199cf7802cf93d8e64be54d4e43693856ed8a43f" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=6506a4d30e03f419093fc17aa69a611f2212195d" }, + { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=6506a4d30e03f419093fc17aa69a611f2212195d" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, { name = "warp-lang", marker = "extra == 'mjwarp'", specifier = "==1.16.0" }, @@ -5270,7 +5270,7 @@ wheels = [ [[package]] name = "unisim-core" version = "1.2.1" -source = { git = "https://github.com/unilabsim/unisim.git?rev=199cf7802cf93d8e64be54d4e43693856ed8a43f#199cf7802cf93d8e64be54d4e43693856ed8a43f" } +source = { git = "https://github.com/unilabsim/unisim.git?rev=6506a4d30e03f419093fc17aa69a611f2212195d#6506a4d30e03f419093fc17aa69a611f2212195d" } 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'" }, diff --git a/uv.rocm.lock b/uv.rocm.lock index 3fa0c1ccc..9e4e4c2dc 100644 --- a/uv.rocm.lock +++ b/uv.rocm.lock @@ -1687,7 +1687,7 @@ wheels = [ [[package]] name = "mjbatch-uni" version = "0.2.0" -source = { git = "https://github.com/unilabsim/mjbatch-uni.git?rev=0006734ade970eb040815eca28205e72a20c533d#0006734ade970eb040815eca28205e72a20c533d" } +source = { git = "https://github.com/unilabsim/mjbatch-uni.git?rev=a1ff84a9957d85cbc556b109162012c55760055e#a1ff84a9957d85cbc556b109162012c55760055e" } dependencies = [ { name = "mujoco" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -3755,7 +3755,7 @@ requires-dist = [ { name = "imageio-ffmpeg", specifier = ">=0.6.0" }, { name = "lark", specifier = ">=1.3.1" }, { name = "mediapy" }, - { name = "mjbatch-uni", marker = "extra == 'mujoco'", git = "https://github.com/unilabsim/mjbatch-uni.git?rev=0006734ade970eb040815eca28205e72a20c533d" }, + { name = "mjbatch-uni", marker = "extra == 'mujoco'", git = "https://github.com/unilabsim/mjbatch-uni.git?rev=a1ff84a9957d85cbc556b109162012c55760055e" }, { name = "motrixsim-core", marker = "extra == 'motrix'", specifier = "==0.8.2" }, { name = "mujoco", marker = "extra == 'mujoco'", specifier = "~=3.11.0" }, { name = "ninja", marker = "sys_platform == 'linux'" }, @@ -3775,7 +3775,7 @@ requires-dist = [ { name = "triton-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==3.6.0", index = "https://download.pytorch.org/whl/rocm7.2" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=199cf7802cf93d8e64be54d4e43693856ed8a43f" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=6506a4d30e03f419093fc17aa69a611f2212195d" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, ] @@ -3816,7 +3816,7 @@ wheels = [ [[package]] name = "unisim-core" version = "1.2.1" -source = { git = "https://github.com/unilabsim/unisim.git?rev=199cf7802cf93d8e64be54d4e43693856ed8a43f#199cf7802cf93d8e64be54d4e43693856ed8a43f" } +source = { git = "https://github.com/unilabsim/unisim.git?rev=6506a4d30e03f419093fc17aa69a611f2212195d#6506a4d30e03f419093fc17aa69a611f2212195d" } 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'" }, From a21d6953d551198f116b205d8fa76da54a57e4bd Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 19:53:08 +0800 Subject: [PATCH 08/13] chore: pin ablated backend dependencies --- pyproject.rocm.toml | 4 ++-- pyproject.toml | 6 +++--- uv.lock | 10 +++++----- uv.rocm.lock | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pyproject.rocm.toml b/pyproject.rocm.toml index 89056e9d2..b58c5f5db 100644 --- a/pyproject.rocm.toml +++ b/pyproject.rocm.toml @@ -27,7 +27,7 @@ dependencies = [ # unisim-core package. Roadmap #1563 temporarily pins the pre-release # branch carrying fixed model variants; restore the published range after # the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@6506a4d30e03f419093fc17aa69a611f2212195d", + "unisim-core @ git+https://github.com/unilabsim/unisim.git@8eb587c0bcbecdf6a544401d0a376bff9f0db51b", # RL algorithms and async runtimes live in the independently released # uni-rl package (distribution name ``unilab-rl``); see pyproject.toml. "unilab-rl==1.2.0", @@ -81,7 +81,7 @@ mujoco = [ # time, so isolated builds are correct and no compiler preflight is # needed. "mujoco~=3.11.0", - "mjbatch-uni @ git+https://github.com/unilabsim/mjbatch-uni.git@a1ff84a9957d85cbc556b109162012c55760055e", + "mjbatch-uni @ git+https://github.com/unilabsim/mjbatch-uni.git@5fb49b9fde7084557a91c583ebd876e4c0946c12", ] motrix = ["motrixsim-core==0.8.2"] viser = ["viser>=1.0.26", "trimesh>=3.21.7"] diff --git a/pyproject.toml b/pyproject.toml index 69c0b146e..f9a8cd871 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ # unisim-core package. Roadmap #1563 temporarily pins the pre-release # branch carrying fixed model variants; restore the published range after # the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@6506a4d30e03f419093fc17aa69a611f2212195d", + "unisim-core @ git+https://github.com/unilabsim/unisim.git@8eb587c0bcbecdf6a544401d0a376bff9f0db51b", # RL algorithms and async runtimes (PPO/APPO/SAC/TD3 runners, # collectors, IPC, logging) live in the independently released uni-rl # package (distribution name ``unilab-rl``), consumed via the injected @@ -125,7 +125,7 @@ mujoco = [ # The batch engine is the unilabsim mjbatch fork. Roadmap #1563 pairs the # integration-only UniSim git pin with the matching pre-release executor # API; replace both after mjbatch-uni 0.2.x is published. - "mjbatch-uni @ git+https://github.com/unilabsim/mjbatch-uni.git@a1ff84a9957d85cbc556b109162012c55760055e", + "mjbatch-uni @ git+https://github.com/unilabsim/mjbatch-uni.git@5fb49b9fde7084557a91c583ebd876e4c0946c12", ] mjwarp = [ # Keep the Warp backend on the same MuJoCo minor line as the host backend. @@ -175,7 +175,7 @@ viser = ["viser>=1.0.26", "trimesh>=3.21.7"] # required-environments; elsewhere the extra is empty and the CLI reports a # targeted runtime diagnostic. superdex = [ - "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@6506a4d30e03f419093fc17aa69a611f2212195d ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", + "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@8eb587c0bcbecdf6a544401d0a376bff9f0db51b ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 51460930e..45616254b 100644 --- a/uv.lock +++ b/uv.lock @@ -2137,7 +2137,7 @@ wheels = [ [[package]] name = "mjbatch-uni" version = "0.2.0" -source = { git = "https://github.com/unilabsim/mjbatch-uni.git?rev=a1ff84a9957d85cbc556b109162012c55760055e#a1ff84a9957d85cbc556b109162012c55760055e" } +source = { git = "https://github.com/unilabsim/mjbatch-uni.git?rev=5fb49b9fde7084557a91c583ebd876e4c0946c12#5fb49b9fde7084557a91c583ebd876e4c0946c12" } dependencies = [ { name = "mujoco" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -5195,7 +5195,7 @@ requires-dist = [ { name = "imgui-bundle", marker = "extra == 'newton'", specifier = ">=1.92.0" }, { name = "lark", specifier = ">=1.3.1" }, { name = "mediapy" }, - { name = "mjbatch-uni", marker = "extra == 'mujoco'", git = "https://github.com/unilabsim/mjbatch-uni.git?rev=a1ff84a9957d85cbc556b109162012c55760055e" }, + { name = "mjbatch-uni", marker = "extra == 'mujoco'", git = "https://github.com/unilabsim/mjbatch-uni.git?rev=5fb49b9fde7084557a91c583ebd876e4c0946c12" }, { name = "motrixsim-core", marker = "extra == 'motrix'", specifier = "==0.8.2" }, { name = "mujoco", marker = "extra == 'drake'", specifier = ">=3.5" }, { name = "mujoco", marker = "extra == 'mujoco'", specifier = "~=3.11.0" }, @@ -5225,8 +5225,8 @@ requires-dist = [ { name = "trimesh", marker = "extra == 'viser'", specifier = ">=3.21.7" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=6506a4d30e03f419093fc17aa69a611f2212195d" }, - { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=6506a4d30e03f419093fc17aa69a611f2212195d" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=8eb587c0bcbecdf6a544401d0a376bff9f0db51b" }, + { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=8eb587c0bcbecdf6a544401d0a376bff9f0db51b" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, { name = "warp-lang", marker = "extra == 'mjwarp'", specifier = "==1.16.0" }, @@ -5270,7 +5270,7 @@ wheels = [ [[package]] name = "unisim-core" version = "1.2.1" -source = { git = "https://github.com/unilabsim/unisim.git?rev=6506a4d30e03f419093fc17aa69a611f2212195d#6506a4d30e03f419093fc17aa69a611f2212195d" } +source = { git = "https://github.com/unilabsim/unisim.git?rev=8eb587c0bcbecdf6a544401d0a376bff9f0db51b#8eb587c0bcbecdf6a544401d0a376bff9f0db51b" } 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'" }, diff --git a/uv.rocm.lock b/uv.rocm.lock index 9e4e4c2dc..9c96b582d 100644 --- a/uv.rocm.lock +++ b/uv.rocm.lock @@ -1687,7 +1687,7 @@ wheels = [ [[package]] name = "mjbatch-uni" version = "0.2.0" -source = { git = "https://github.com/unilabsim/mjbatch-uni.git?rev=a1ff84a9957d85cbc556b109162012c55760055e#a1ff84a9957d85cbc556b109162012c55760055e" } +source = { git = "https://github.com/unilabsim/mjbatch-uni.git?rev=5fb49b9fde7084557a91c583ebd876e4c0946c12#5fb49b9fde7084557a91c583ebd876e4c0946c12" } dependencies = [ { name = "mujoco" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -3755,7 +3755,7 @@ requires-dist = [ { name = "imageio-ffmpeg", specifier = ">=0.6.0" }, { name = "lark", specifier = ">=1.3.1" }, { name = "mediapy" }, - { name = "mjbatch-uni", marker = "extra == 'mujoco'", git = "https://github.com/unilabsim/mjbatch-uni.git?rev=a1ff84a9957d85cbc556b109162012c55760055e" }, + { name = "mjbatch-uni", marker = "extra == 'mujoco'", git = "https://github.com/unilabsim/mjbatch-uni.git?rev=5fb49b9fde7084557a91c583ebd876e4c0946c12" }, { name = "motrixsim-core", marker = "extra == 'motrix'", specifier = "==0.8.2" }, { name = "mujoco", marker = "extra == 'mujoco'", specifier = "~=3.11.0" }, { name = "ninja", marker = "sys_platform == 'linux'" }, @@ -3775,7 +3775,7 @@ requires-dist = [ { name = "triton-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==3.6.0", index = "https://download.pytorch.org/whl/rocm7.2" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=6506a4d30e03f419093fc17aa69a611f2212195d" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=8eb587c0bcbecdf6a544401d0a376bff9f0db51b" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, ] @@ -3816,7 +3816,7 @@ wheels = [ [[package]] name = "unisim-core" version = "1.2.1" -source = { git = "https://github.com/unilabsim/unisim.git?rev=6506a4d30e03f419093fc17aa69a611f2212195d#6506a4d30e03f419093fc17aa69a611f2212195d" } +source = { git = "https://github.com/unilabsim/unisim.git?rev=8eb587c0bcbecdf6a544401d0a376bff9f0db51b#8eb587c0bcbecdf6a544401d0a376bff9f0db51b" } 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'" }, From 6d1fef60271700527b15b6947f6d612735563961 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 20:40:17 +0800 Subject: [PATCH 09/13] refactor: remove legacy DR provider namespace --- docs/sphinx/source/api_reference/dr/index.md | 14 ------- docs/sphinx/source/api_reference/index.md | 6 --- docs/sphinx/source/changelog.md | 10 ++--- .../5-domain_randomization/0-index.md | 2 +- .../en/2-user_guide/6-terrain/1-procedural.md | 3 +- .../5-task_config_translation.md | 2 +- .../source/en/4-developer_guide/0-index.md | 2 +- .../2-contracts/4-dr_contract.md | 7 ++-- .../5-domain_randomization/0-index.md | 2 +- .../2-user_guide/6-terrain/1-procedural.md | 3 +- .../5-task_config_translation.md | 2 +- .../source/zh_CN/4-developer_guide/0-index.md | 2 +- .../2-contracts/4-dr_contract.md | 7 ++-- pyproject.rocm.toml | 2 +- pyproject.toml | 4 +- .../benchmark_offpolicy_collector_active.py | 2 +- scripts/benchmark/torch_env/walk_flat.py | 6 +-- src/unilab/dr/__init__.py | 40 ------------------- .../tasks/locomotion/common/terrain_spawn.py | 2 +- tests/base/test_dr_legacy_removed.py | 9 +++-- tests/base/test_genesis_backend.py | 12 ------ tests/base/test_motrix_backend_options.py | 36 ----------------- uv.lock | 6 +-- uv.rocm.lock | 4 +- 24 files changed, 40 insertions(+), 145 deletions(-) delete mode 100644 docs/sphinx/source/api_reference/dr/index.md delete mode 100644 src/unilab/dr/__init__.py diff --git a/docs/sphinx/source/api_reference/dr/index.md b/docs/sphinx/source/api_reference/dr/index.md deleted file mode 100644 index 1168c6c7a..000000000 --- a/docs/sphinx/source/api_reference/dr/index.md +++ /dev/null @@ -1,14 +0,0 @@ -# `unilab.dr` — Domain Randomization - -Manager-Based event terms and backend-owned plan types. See the contract document at -{doc}`../../en/4-developer_guide/2-contracts/4-dr_contract` before adding -randomization to a new task. - -```{eval-rst} -.. autosummary:: - :toctree: _autosummary - :template: autosummary/module.rst - :recursive: - - unilab.dr -``` diff --git a/docs/sphinx/source/api_reference/index.md b/docs/sphinx/source/api_reference/index.md index ea625f7a5..5b22144d1 100644 --- a/docs/sphinx/source/api_reference/index.md +++ b/docs/sphinx/source/api_reference/index.md @@ -79,12 +79,6 @@ MuJoCo and Motrix adapters that implement `SimBackend`. ::::{grid} 1 1 3 3 :gutter: 3 -:::{grid-item-card} 🎲 `unilab.dr` -:link: dr/index -:link-type: doc -Declarative domain randomization manager. -::: - :::{grid-item-card} 🏞 `unilab.terrains` :link: terrains/index :link-type: doc diff --git a/docs/sphinx/source/changelog.md b/docs/sphinx/source/changelog.md index f7bc01a02..64eb4f209 100644 --- a/docs/sphinx/source/changelog.md +++ b/docs/sphinx/source/changelog.md @@ -20,16 +20,16 @@ UniLab 遵循[语义化版本](https://semver.org/)。本共享页面以中英 hooks, and the provider-side payload helper are removed. Manager-Based event terms are the sole DR lifecycle: fixed model identity is construction-time, reset terms commit through `ResetStateTransaction`, and interval terms use the - public UniSim plan contract. `unilab.dr` remains only as a thin re-export of - backend-owned plan/capability types. The `env.domain_rand` sim2sim allowlist - entry and provider documentation are removed. + public UniSim plan contract. The `unilab.dr` namespace, `env.domain_rand` + sim2sim allowlist entry, and provider documentation are removed; callers use + the backend-owned `unisim.dr` types directly. 移除 legacy DomainRandomization provider 协议(roadmap #1563、#1567)。 `DomainRandomizationProvider`、`DomainRandomizationManager`、NpEnv hooks 和 provider 侧 payload helper 已删除。Manager-Based event term 成为唯一 DR lifecycle:固定模型 identity 位于 construction-time,reset term 通过 `ResetStateTransaction` 提交,interval term 使用公开 UniSim plan contract。 - `unilab.dr` 仅保留 backend-owned plan/capability 类型的薄 re-export,并移除 - `env.domain_rand` sim2sim allowlist 与 provider 文档。 + `unilab.dr` namespace、`env.domain_rand` sim2sim allowlist 与 provider 文档 + 均已移除;调用方直接使用 backend-owned `unisim.dr` 类型。 - Replace the `mujoco-uni-runtime` dependency (`mujoco_uni` import) with the `mjbatch` native batch engine across the repository (roadmap diff --git a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md index ec70951a1..68776d49c 100644 --- a/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md +++ b/docs/sphinx/source/en/2-user_guide/5-domain_randomization/0-index.md @@ -64,7 +64,7 @@ payload. Task-specific reset sampling remains owned by command/event terms: - Fixed model/tool identity is construction-time and never reset-time DR. A requested backend capability that is not advertised fails closed; there is no -provider-side filtering fallback. +filtering or silent fallback. ## Reset gravity Usage diff --git a/docs/sphinx/source/en/2-user_guide/6-terrain/1-procedural.md b/docs/sphinx/source/en/2-user_guide/6-terrain/1-procedural.md index c5bf00100..151cba5c7 100644 --- a/docs/sphinx/source/en/2-user_guide/6-terrain/1-procedural.md +++ b/docs/sphinx/source/en/2-user_guide/6-terrain/1-procedural.md @@ -27,7 +27,8 @@ During env construction: 5. `go2.xml` is the robot model; `locomotion_task.xml` is the task fragment for rough terrain and contains the contact sensors associated with the terrain `floor` plus the task-level `home` keyframe. 6. The backend instance owns the cold-path scene artifacts until env `close()`; `terrain_origins` is passed back to env via a backend scene attribute, used for spawn / curriculum. -`step()` / `reset()` / DR provider never read XML or access asset files; everything terrain-related happens on the cold path. +`step()`, `reset()`, and Manager-Based event terms never read XML or access +asset files; everything terrain-related happens on the cold path. ## 1. Direct Training diff --git a/docs/sphinx/source/en/3-deployment/3-framework_migration/5-task_config_translation.md b/docs/sphinx/source/en/3-deployment/3-framework_migration/5-task_config_translation.md index 0745c5828..d26dc5664 100644 --- a/docs/sphinx/source/en/3-deployment/3-framework_migration/5-task_config_translation.md +++ b/docs/sphinx/source/en/3-deployment/3-framework_migration/5-task_config_translation.md @@ -32,7 +32,7 @@ A side-by-side map of common config fields across Isaac Lab / Legged Gym * - Observation noise - `noise.obs.*` - `cfg.noise.add_noise` - - DR provider; see DR docs + - `env.observations...noise` ``` ## Reward diff --git a/docs/sphinx/source/en/4-developer_guide/0-index.md b/docs/sphinx/source/en/4-developer_guide/0-index.md index 7e7dbfbe0..14650eda9 100644 --- a/docs/sphinx/source/en/4-developer_guide/0-index.md +++ b/docs/sphinx/source/en/4-developer_guide/0-index.md @@ -39,7 +39,7 @@ Hydra owner YAML identity and backend-selection rules. :::{grid-item-card} Domain randomization contract :link: 2-contracts/4-dr_contract :link-type: doc -Init, reset, interval, and backend capability boundaries for DR providers. +Manager-Based construction, reset, interval, and backend capability boundaries. ::: :::: diff --git a/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md b/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md index 1ca4148d4..73e4a9eb9 100644 --- a/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md +++ b/docs/sphinx/source/en/4-developer_guide/2-contracts/4-dr_contract.md @@ -20,9 +20,8 @@ Backend differences are explicit capabilities, not task-side branches: - `DomainRandomizationCapabilities.supported_reset_terms` - `supported_interval_terms` -- fixed-variant layouts and source formats +- fixed-variant layouts - per-environment playback support -- curated reset-term contracts and derived-quantity obligations An unadvertised requested term fails closed with the backend and term named. Manager code never imports MuJoCo or mjbatch and never accesses a backend model @@ -53,8 +52,8 @@ the former UniLab-side MuJoCo recompilation used to obtain inertia defaults. ## Interval Terms Interval plans are term-descriptor based: `IntervalRandomizationPlan.ops` -carries `IntervalTermOp` entries from `unisim.dr.interval`, re-exported through -`unilab.dr`. Builtin payload contracts are enforced by `IntervalTermOp.validate`; +carries `IntervalTermOp` entries from `unisim.dr.interval`. Builtin payload +contracts are enforced by `IntervalTermOp.validate`; unknown backend-owned custom terms pass through to that backend's handler table. Ops and plans remain pickle-safe stdlib/NumPy data across spawn collectors. diff --git a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md index f75de65c2..1c8433d20 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/5-domain_randomization/0-index.md @@ -59,7 +59,7 @@ command/event term 拥有: - Allegro grasp / object 初始状态采样是 task-specific event logic。 - 固定 model/tool identity 是 construction-time,不是 reset-time DR。 -未显式声明支持的后端能力会 fail closed;不存在 provider 侧过滤回退。 +未显式声明支持的后端能力会 fail closed;不存在过滤或静默回退。 ## Reset gravity 用法 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/6-terrain/1-procedural.md b/docs/sphinx/source/zh_CN/2-user_guide/6-terrain/1-procedural.md index 93086a928..252d93da3 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/6-terrain/1-procedural.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/6-terrain/1-procedural.md @@ -26,7 +26,8 @@ 5. `go2.xml` 是机器人模型;`locomotion_task.xml` 是用于崎岖地形的 task fragment,包含与地形 `floor` 关联的接触传感器以及 task 级别的 `home` keyframe。 6. 后端实例持有冷路径场景产物,直到 env `close()`;`terrain_origins` 通过一个后端场景属性回传给 env,用于 spawn / curriculum。 -`step()` / `reset()` / DR provider 永远不会读取 XML 或访问 asset 文件;所有与地形相关的事情都发生在冷路径上。 +`step()`、`reset()` 和 Manager-Based event term 永远不会读取 XML 或访问 asset +文件;所有与地形相关的事情都发生在冷路径上。 ## 1. 直接训练 diff --git a/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/5-task_config_translation.md b/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/5-task_config_translation.md index ce17f5fbd..790335cec 100644 --- a/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/5-task_config_translation.md +++ b/docs/sphinx/source/zh_CN/3-deployment/3-framework_migration/5-task_config_translation.md @@ -32,7 +32,7 @@ * - 观测噪声 - `noise.obs.*` - `cfg.noise.add_noise` - - DR provider;参见 DR 文档 + - `env.observations...noise` ``` ## Reward diff --git a/docs/sphinx/source/zh_CN/4-developer_guide/0-index.md b/docs/sphinx/source/zh_CN/4-developer_guide/0-index.md index 421d20157..a61aaac97 100644 --- a/docs/sphinx/source/zh_CN/4-developer_guide/0-index.md +++ b/docs/sphinx/source/zh_CN/4-developer_guide/0-index.md @@ -39,7 +39,7 @@ Hydra owner YAML 身份与后端选择规则。 :::{grid-item-card} 域随机化契约 :link: 2-contracts/4-dr_contract :link-type: doc -DR provider 的 init、reset、interval 与后端能力边界。 +Manager-Based construction、reset、interval 与后端能力边界。 ::: :::: diff --git a/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md b/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md index badab3379..29cc36f9e 100644 --- a/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md +++ b/docs/sphinx/source/zh_CN/4-developer_guide/2-contracts/4-dr_contract.md @@ -19,9 +19,8 @@ Backend 差异是显式 capability,不是 task-side 分支: - `DomainRandomizationCapabilities.supported_reset_terms` - `supported_interval_terms` -- fixed-variant layout 与 source format +- fixed-variant layout - per-environment playback 支持 -- curated reset-term contract 与派生量重算义务 未声明支持的请求 term 会携带 backend 与 term 名称 fail closed。Manager code 不 import MuJoCo 或 mjbatch,也不访问 backend model/pool。 @@ -48,8 +47,8 @@ MuJoCo XML 的路径。 ## Interval Terms Interval plan 基于 term descriptor:`IntervalRandomizationPlan.ops` 携带来自 -`unisim.dr.interval` 并经 `unilab.dr` re-export 的 `IntervalTermOp`。内置 payload -contract 由 `IntervalTermOp.validate` 强制;未知 backend-owned custom term 传给该 +`unisim.dr.interval` 的 `IntervalTermOp`。内置 payload contract 由 +`IntervalTermOp.validate` 强制;未知 backend-owned custom term 传给该 backend handler table。Ops 与 plans 保持 stdlib/NumPy 数据,可跨 spawn collector pickle。 diff --git a/pyproject.rocm.toml b/pyproject.rocm.toml index b58c5f5db..abe1b54ff 100644 --- a/pyproject.rocm.toml +++ b/pyproject.rocm.toml @@ -27,7 +27,7 @@ dependencies = [ # unisim-core package. Roadmap #1563 temporarily pins the pre-release # branch carrying fixed model variants; restore the published range after # the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@8eb587c0bcbecdf6a544401d0a376bff9f0db51b", + "unisim-core @ git+https://github.com/unilabsim/unisim.git@c47e93a362b8fcd6b3c256bbf4545d660b2968ce", # RL algorithms and async runtimes live in the independently released # uni-rl package (distribution name ``unilab-rl``); see pyproject.toml. "unilab-rl==1.2.0", diff --git a/pyproject.toml b/pyproject.toml index f9a8cd871..b85a5909b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ # unisim-core package. Roadmap #1563 temporarily pins the pre-release # branch carrying fixed model variants; restore the published range after # the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@8eb587c0bcbecdf6a544401d0a376bff9f0db51b", + "unisim-core @ git+https://github.com/unilabsim/unisim.git@c47e93a362b8fcd6b3c256bbf4545d660b2968ce", # RL algorithms and async runtimes (PPO/APPO/SAC/TD3 runners, # collectors, IPC, logging) live in the independently released uni-rl # package (distribution name ``unilab-rl``), consumed via the injected @@ -175,7 +175,7 @@ viser = ["viser>=1.0.26", "trimesh>=3.21.7"] # required-environments; elsewhere the extra is empty and the CLI reports a # targeted runtime diagnostic. superdex = [ - "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@8eb587c0bcbecdf6a544401d0a376bff9f0db51b ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", + "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@c47e93a362b8fcd6b3c256bbf4545d660b2968ce ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", ] [dependency-groups] diff --git a/scripts/benchmark/rl/benchmark_offpolicy_collector_active.py b/scripts/benchmark/rl/benchmark_offpolicy_collector_active.py index 0c3ba0bb1..f475da457 100644 --- a/scripts/benchmark/rl/benchmark_offpolicy_collector_active.py +++ b/scripts/benchmark/rl/benchmark_offpolicy_collector_active.py @@ -1179,7 +1179,7 @@ def _format_set_state_sub_ms(result: CollectorResult, key: str) -> str: """Format a backend set_state sub-timing as ``ms (%of set_state)``. The percentage is relative to ``dr_reset_set_state_ms`` (the outer - wall-clock measurement in DomainRandomizationManager), not to env_step_ms, + wall-clock measurement around Manager-Based reset state submission), not to env_step_ms, so a reader can see which sub-step dominates set_state. """ stat = result.env_step_timing_ms_per_vector_step.get(key) diff --git a/scripts/benchmark/torch_env/walk_flat.py b/scripts/benchmark/torch_env/walk_flat.py index 6762510aa..d066da98b 100644 --- a/scripts/benchmark/torch_env/walk_flat.py +++ b/scripts/benchmark/torch_env/walk_flat.py @@ -8,9 +8,9 @@ termination, `_compute_reward` (9 active terms under the SAC scales incl. per-term logging every 4 steps), `_compute_obs` (noise + concat, walk profile), and the done-triggered curriculum bookkeeping. -- `G1WalkDomainRandomizationProvider.build_reset_plan` / - `build_reset_observation` (qpos/qvel sampling, commands, gait phase, kp/kd - payload, obs rebuild at batch n_reset). +- the pre-migration reset computations now owned by Manager-Based command and + event terms (qpos/qvel sampling, commands, gait phase, kp/kd payload, obs + rebuild at batch n_reset). - `NpEnv._reset_done_envs` scatter/gather (terminal-obs double copy, obs/info scatter). diff --git a/src/unilab/dr/__init__.py b/src/unilab/dr/__init__.py deleted file mode 100644 index 7ebf5ae46..000000000 --- a/src/unilab/dr/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Backend-owned domain-randomization plan types re-exported for tasks. - -The legacy UniLab provider/manager protocol was removed. Manager-Based tasks -declare reset and interval behavior through Hydra event terms and submit curated -UniSim plans; they do not implement a second reset protocol. -""" - -from unisim.dr.interval import ( - INTERVAL_TERM_BODY_ANGULAR_VELOCITY_DELTA, - INTERVAL_TERM_BODY_FORCE, - INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, - INTERVAL_TERM_BODY_TORQUE, - INTERVAL_TERM_PUSH, - IntervalTermOp, -) -from unisim.dr.types import ( - DomainRandomizationCapabilities, - GeomSizeOverride, - InitRandomizationPlan, - IntervalRandomizationPlan, - ModelVariantSpec, - ResetPlan, - ResetRandomizationPayload, -) - -__all__ = [ - "INTERVAL_TERM_BODY_ANGULAR_VELOCITY_DELTA", - "INTERVAL_TERM_BODY_FORCE", - "INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA", - "INTERVAL_TERM_BODY_TORQUE", - "INTERVAL_TERM_PUSH", - "DomainRandomizationCapabilities", - "GeomSizeOverride", - "InitRandomizationPlan", - "IntervalRandomizationPlan", - "IntervalTermOp", - "ModelVariantSpec", - "ResetPlan", - "ResetRandomizationPayload", -] diff --git a/src/unilab/tasks/locomotion/common/terrain_spawn.py b/src/unilab/tasks/locomotion/common/terrain_spawn.py index 2545fa772..fb5c907d7 100644 --- a/src/unilab/tasks/locomotion/common/terrain_spawn.py +++ b/src/unilab/tasks/locomotion/common/terrain_spawn.py @@ -1,7 +1,7 @@ """Spawn-origin managers for locomotion tasks. ``BaseSpawnManager`` is a no-op default: every env spawns at the world origin -(plus the existing per-env xy jitter from the dr_provider). Used whenever the +(plus per-env xy jitter from the reset event term). Used whenever the env has no procedural terrain — flat scenes don't need spatial separation ``TerrainSpawnManager`` overrides this for terrain scenes: it indexes diff --git a/tests/base/test_dr_legacy_removed.py b/tests/base/test_dr_legacy_removed.py index 2d9d94024..dbc990d02 100644 --- a/tests/base/test_dr_legacy_removed.py +++ b/tests/base/test_dr_legacy_removed.py @@ -1,17 +1,19 @@ from __future__ import annotations +import importlib import subprocess import sys from pathlib import Path +import pytest + def test_legacy_dr_protocol_is_removed() -> None: import unilab.base.np_env as np_env - import unilab.dr as dr - assert not hasattr(dr, "DomainRandomizationManager") - assert not hasattr(dr, "DomainRandomizationProvider") assert not hasattr(np_env.NpEnv, "_init_domain_randomization") + with pytest.raises(ModuleNotFoundError, match="No module named 'unilab.dr'"): + importlib.import_module("unilab.dr") source = Path(np_env.__file__).read_text(encoding="utf-8") assert "_dr_manager" not in source @@ -23,6 +25,7 @@ def test_fresh_import_graph_does_not_load_legacy_dr_manager() -> None: [ "import sys", "import unilab", + "assert 'unilab.dr' not in sys.modules", "assert 'unilab.dr.manager' not in sys.modules", "assert 'unilab.dr.provider' not in sys.modules", ] diff --git a/tests/base/test_genesis_backend.py b/tests/base/test_genesis_backend.py index b4dc9e697..48138881b 100644 --- a/tests/base/test_genesis_backend.py +++ b/tests/base/test_genesis_backend.py @@ -30,10 +30,7 @@ ) from unisim.backend.genesis.materialization import preserve_torch_globals from unisim.dr.types import ( - GeomSizeOverride, - InitRandomizationPlan, IntervalRandomizationPlan, - ModelVariantSpec, ResetRandomizationPayload, ) @@ -485,15 +482,6 @@ def test_interval_randomization_and_body_force(fake_genesis, tiny_model_file: st backend.apply_interval_randomization( IntervalRandomizationPlan(body_force=np.zeros((4, 1, 3), dtype=np.float32)) ) - with pytest.raises(NotImplementedError, match="init-lifecycle randomization"): - backend.apply_init_randomization( - InitRandomizationPlan( - model_assignments=np.zeros(4, dtype=np.int32), - model_variants=( - ModelVariantSpec(geom_size_overrides=(GeomSizeOverride("foot_geom", (0.1,)),)), - ), - ) - ) def test_unsupported_contract_surface_fails_closed(fake_genesis, tiny_model_file: str) -> None: diff --git a/tests/base/test_motrix_backend_options.py b/tests/base/test_motrix_backend_options.py index d0eda0412..f90147256 100644 --- a/tests/base/test_motrix_backend_options.py +++ b/tests/base/test_motrix_backend_options.py @@ -12,10 +12,7 @@ RESET_TERM_GRAVITY, RESET_TERM_KD, RESET_TERM_KP, - GeomSizeOverride, - InitRandomizationPlan, IntervalRandomizationPlan, - ModelVariantSpec, ResetRandomizationPayload, ) @@ -404,39 +401,6 @@ def test_motrix_root_layout_uses_selected_body_floating_base_indices() -> None: backend.get_root_state_layout("missing") -def test_motrix_backend_applies_init_geom_size_overrides(monkeypatch, tmp_path) -> None: - mod, fake_model = _install_fake_motrix(monkeypatch, tmp_path) - backend = mod.MotrixBackend( - SceneCfg(model_file="source.xml"), - num_envs=3, - sim_dt=0.01, - base_name="base", - ) - - backend.apply_init_randomization( - InitRandomizationPlan( - model_assignments=np.asarray([0, 1, 1], dtype=np.int32), - model_variants=( - ModelVariantSpec( - geom_size_overrides=( - GeomSizeOverride(geom_name="floor", size=(0.1, 0.2, 0.0)), - ), - ), - ModelVariantSpec( - geom_size_overrides=( - GeomSizeOverride(geom_name="floor", size=(0.3, 0.4, 0.0)), - ), - ), - ), - ) - ) - - np.testing.assert_allclose( - fake_model.geoms[0].size_override, - np.asarray([[0.1, 0.2], [0.3, 0.4], [0.3, 0.4]], dtype=np.float64), - ) - - def test_motrix_backend_default_override_caches_are_float32(monkeypatch, tmp_path) -> None: mod, _ = _install_fake_motrix(monkeypatch, tmp_path) diff --git a/uv.lock b/uv.lock index 45616254b..3468b4e6d 100644 --- a/uv.lock +++ b/uv.lock @@ -5225,8 +5225,8 @@ requires-dist = [ { name = "trimesh", marker = "extra == 'viser'", specifier = ">=3.21.7" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=8eb587c0bcbecdf6a544401d0a376bff9f0db51b" }, - { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=8eb587c0bcbecdf6a544401d0a376bff9f0db51b" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=c47e93a362b8fcd6b3c256bbf4545d660b2968ce" }, + { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=c47e93a362b8fcd6b3c256bbf4545d660b2968ce" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, { name = "warp-lang", marker = "extra == 'mjwarp'", specifier = "==1.16.0" }, @@ -5270,7 +5270,7 @@ wheels = [ [[package]] name = "unisim-core" version = "1.2.1" -source = { git = "https://github.com/unilabsim/unisim.git?rev=8eb587c0bcbecdf6a544401d0a376bff9f0db51b#8eb587c0bcbecdf6a544401d0a376bff9f0db51b" } +source = { git = "https://github.com/unilabsim/unisim.git?rev=c47e93a362b8fcd6b3c256bbf4545d660b2968ce#c47e93a362b8fcd6b3c256bbf4545d660b2968ce" } 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'" }, diff --git a/uv.rocm.lock b/uv.rocm.lock index 9c96b582d..476b1cec6 100644 --- a/uv.rocm.lock +++ b/uv.rocm.lock @@ -3775,7 +3775,7 @@ requires-dist = [ { name = "triton-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==3.6.0", index = "https://download.pytorch.org/whl/rocm7.2" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=8eb587c0bcbecdf6a544401d0a376bff9f0db51b" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=c47e93a362b8fcd6b3c256bbf4545d660b2968ce" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, ] @@ -3816,7 +3816,7 @@ wheels = [ [[package]] name = "unisim-core" version = "1.2.1" -source = { git = "https://github.com/unilabsim/unisim.git?rev=8eb587c0bcbecdf6a544401d0a376bff9f0db51b#8eb587c0bcbecdf6a544401d0a376bff9f0db51b" } +source = { git = "https://github.com/unilabsim/unisim.git?rev=c47e93a362b8fcd6b3c256bbf4545d660b2968ce#c47e93a362b8fcd6b3c256bbf4545d660b2968ce" } 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'" }, From 1e772530a3969a92765dc06f2759c1ac94a5ae5d Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 20:50:13 +0800 Subject: [PATCH 10/13] chore: pin final UniSim cleanup dependency --- pyproject.rocm.toml | 2 +- pyproject.toml | 4 ++-- uv.lock | 6 +++--- uv.rocm.lock | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.rocm.toml b/pyproject.rocm.toml index abe1b54ff..6fb4418fa 100644 --- a/pyproject.rocm.toml +++ b/pyproject.rocm.toml @@ -27,7 +27,7 @@ dependencies = [ # unisim-core package. Roadmap #1563 temporarily pins the pre-release # branch carrying fixed model variants; restore the published range after # the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@c47e93a362b8fcd6b3c256bbf4545d660b2968ce", + "unisim-core @ git+https://github.com/unilabsim/unisim.git@a020f8bf15581c8b343b523ef3986ccb43f86c68", # RL algorithms and async runtimes live in the independently released # uni-rl package (distribution name ``unilab-rl``); see pyproject.toml. "unilab-rl==1.2.0", diff --git a/pyproject.toml b/pyproject.toml index b85a5909b..a697882f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ # unisim-core package. Roadmap #1563 temporarily pins the pre-release # branch carrying fixed model variants; restore the published range after # the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@c47e93a362b8fcd6b3c256bbf4545d660b2968ce", + "unisim-core @ git+https://github.com/unilabsim/unisim.git@a020f8bf15581c8b343b523ef3986ccb43f86c68", # RL algorithms and async runtimes (PPO/APPO/SAC/TD3 runners, # collectors, IPC, logging) live in the independently released uni-rl # package (distribution name ``unilab-rl``), consumed via the injected @@ -175,7 +175,7 @@ viser = ["viser>=1.0.26", "trimesh>=3.21.7"] # required-environments; elsewhere the extra is empty and the CLI reports a # targeted runtime diagnostic. superdex = [ - "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@c47e93a362b8fcd6b3c256bbf4545d660b2968ce ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", + "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@a020f8bf15581c8b343b523ef3986ccb43f86c68 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 3468b4e6d..993ff1e1c 100644 --- a/uv.lock +++ b/uv.lock @@ -5225,8 +5225,8 @@ requires-dist = [ { name = "trimesh", marker = "extra == 'viser'", specifier = ">=3.21.7" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=c47e93a362b8fcd6b3c256bbf4545d660b2968ce" }, - { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=c47e93a362b8fcd6b3c256bbf4545d660b2968ce" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=a020f8bf15581c8b343b523ef3986ccb43f86c68" }, + { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=a020f8bf15581c8b343b523ef3986ccb43f86c68" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, { name = "warp-lang", marker = "extra == 'mjwarp'", specifier = "==1.16.0" }, @@ -5270,7 +5270,7 @@ wheels = [ [[package]] name = "unisim-core" version = "1.2.1" -source = { git = "https://github.com/unilabsim/unisim.git?rev=c47e93a362b8fcd6b3c256bbf4545d660b2968ce#c47e93a362b8fcd6b3c256bbf4545d660b2968ce" } +source = { git = "https://github.com/unilabsim/unisim.git?rev=a020f8bf15581c8b343b523ef3986ccb43f86c68#a020f8bf15581c8b343b523ef3986ccb43f86c68" } 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'" }, diff --git a/uv.rocm.lock b/uv.rocm.lock index 476b1cec6..b80367ca9 100644 --- a/uv.rocm.lock +++ b/uv.rocm.lock @@ -3775,7 +3775,7 @@ requires-dist = [ { name = "triton-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==3.6.0", index = "https://download.pytorch.org/whl/rocm7.2" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=c47e93a362b8fcd6b3c256bbf4545d660b2968ce" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=a020f8bf15581c8b343b523ef3986ccb43f86c68" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, ] @@ -3816,7 +3816,7 @@ wheels = [ [[package]] name = "unisim-core" version = "1.2.1" -source = { git = "https://github.com/unilabsim/unisim.git?rev=c47e93a362b8fcd6b3c256bbf4545d660b2968ce#c47e93a362b8fcd6b3c256bbf4545d660b2968ce" } +source = { git = "https://github.com/unilabsim/unisim.git?rev=a020f8bf15581c8b343b523ef3986ccb43f86c68#a020f8bf15581c8b343b523ef3986ccb43f86c68" } 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'" }, From 7564d029389f7fcaff4120018f53b958caea7080 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 23:16:15 +0800 Subject: [PATCH 11/13] chore: consume published mjbatch 0.2.0 --- docs/sphinx/source/changelog.md | 25 +++++++++++++++++-------- pyproject.rocm.toml | 12 ++++++------ pyproject.toml | 18 ++++++++---------- uv.lock | 29 ++++++++++++++++++++++------- uv.rocm.lock | 27 +++++++++++++++++++++------ 5 files changed, 74 insertions(+), 37 deletions(-) diff --git a/docs/sphinx/source/changelog.md b/docs/sphinx/source/changelog.md index 64eb4f209..6bbe98e46 100644 --- a/docs/sphinx/source/changelog.md +++ b/docs/sphinx/source/changelog.md @@ -11,7 +11,17 @@ see the [UniLab repository](https://github.com/unilabsim/UniLab). UniLab 遵循[语义化版本](https://semver.org/)。本共享页面以中英文记录重要版本变更; 日常提交记录请参阅 [UniLab 仓库](https://github.com/unilabsim/UniLab)。 -## Unreleased / 未发布 +## 1.3.0 / 2026-09-13 + +- Add task-owned fixed model/tool variants and per-env playback support through + the UniSim construction-time plan contract. A deterministic representative + SimToolReal mesh workload covers CPU and MJWarp rollout parity, reset-time + mass/inertia DR, and one PPO learning iteration. The `mujoco` extra now uses + the published `mjbatch-uni~=0.2.0` executor API. + 新增 task-owned fixed model/tool variants,并通过 UniSim construction-time + plan contract 支持 per-env playback。确定性 SimToolReal mesh 代表性工作负载 + 覆盖 CPU/MJWarp rollout parity、reset-time mass/inertia DR 与一次 PPO learning + iteration。`mujoco` extra 改用已发布的 `mjbatch-uni~=0.2.0` executor API。 - Retire the legacy DomainRandomization provider protocol (roadmap [#1563](https://github.com/Motphys/UniLab/issues/1563), @@ -35,10 +45,9 @@ UniLab 遵循[语义化版本](https://semver.org/)。本共享页面以中英 `mjbatch` native batch engine across the repository (roadmap [#1552](https://github.com/unilabsim/UniLab/issues/1552), [#1553](https://github.com/unilabsim/UniLab/issues/1553)). The `mujoco` - extra now installs `mujoco~=3.11.0` plus `mjbatch` pinned to the - [integration fork](https://github.com/unilabsim/mjbatch); the fork's final - distribution identity (PyPI package vs git pin, and prebuilt wheels) is the - roadmap's open maintainer item. The `sim=mujoco` CLI runtime check now gates + extra now installs `mujoco~=3.11.0` plus the published + [mjbatch-uni](https://github.com/unilabsim/mjbatch-uni) 0.2.x line. The + `sim=mujoco` CLI runtime check now gates on the `mjbatch` module. A post-swap ablation slimmed the pinned fork's API ([#1557](https://github.com/unilabsim/UniLab/issues/1557)): the per-substep callback is `fn(k, state, ctrl)` (no `callback_sensordata` @@ -48,9 +57,9 @@ UniLab 遵循[语义化版本](https://semver.org/)。本共享页面以中英 characterized by the #1554 drift baseline. 全仓库将 `mujoco-uni-runtime` 依赖(`mujoco_uni` 导入)替换为 `mjbatch` 原生 batch 引擎(roadmap #1552、#1553)。`mujoco` extra 现安装 - `mujoco~=3.11.0` 加钉住的 [集成 fork](https://github.com/unilabsim/mjbatch) - `mjbatch`;fork 的最终分发身份(PyPI package 还是 git 钉版、是否提供预编译 - wheel)是 roadmap 上的待定维护事项。`sim=mujoco` 的 CLI 运行时检查改为检查 + `mujoco~=3.11.0` 与已发布的 + [mjbatch-uni](https://github.com/unilabsim/mjbatch-uni) 0.2.x。 + `sim=mujoco` 的 CLI 运行时检查改为检查 `mjbatch` 模块。替换后的消融精简了钉住 fork 的 API(#1557):per-substep 回调为 `fn(k, state, ctrl)`(不再有 `callback_sensordata` 参数), `steps_done` / `stop_on_warning` 已移除,hfield 扫描器只输出高度并自带 diff --git a/pyproject.rocm.toml b/pyproject.rocm.toml index 6fb4418fa..7034af3e0 100644 --- a/pyproject.rocm.toml +++ b/pyproject.rocm.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "unilab" -version = "1.2.0" +version = "1.3.0" description = "Universal Lab for Robot Learning" readme = "README.md" license = "Apache-2.0" @@ -24,10 +24,10 @@ requires-python = ">=3.10,<3.14" dependencies = [ "numpy", # Physics implementations are provided by the independently released - # unisim-core package. Roadmap #1563 temporarily pins the pre-release - # branch carrying fixed model variants; restore the published range after - # the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@a020f8bf15581c8b343b523ef3986ccb43f86c68", + # unisim-core package. Roadmap #1563 temporarily pins the release candidate + # carrying fixed model variants; replace it with the published 1.3.x line + # after the corresponding unisim-core release. + "unisim-core @ git+https://github.com/unilabsim/unisim.git@206056376452003ba488cb3e61fcddb1d32d6038", # RL algorithms and async runtimes live in the independently released # uni-rl package (distribution name ``unilab-rl``); see pyproject.toml. "unilab-rl==1.2.0", @@ -81,7 +81,7 @@ mujoco = [ # time, so isolated builds are correct and no compiler preflight is # needed. "mujoco~=3.11.0", - "mjbatch-uni @ git+https://github.com/unilabsim/mjbatch-uni.git@5fb49b9fde7084557a91c583ebd876e4c0946c12", + "mjbatch-uni~=0.2.0", ] motrix = ["motrixsim-core==0.8.2"] viser = ["viser>=1.0.26", "trimesh>=3.21.7"] diff --git a/pyproject.toml b/pyproject.toml index a697882f8..ab17699ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ source-exclude = [ [project] name = "unilab" -version = "1.2.0" +version = "1.3.0" description = "Configurable, contract-driven robot learning across physics backends" readme = "README.md" license = "Apache-2.0" @@ -41,10 +41,10 @@ requires-python = ">=3.10,<3.14" dependencies = [ "numpy", # Physics implementations are provided by the independently released - # unisim-core package. Roadmap #1563 temporarily pins the pre-release - # branch carrying fixed model variants; restore the published range after - # the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@a020f8bf15581c8b343b523ef3986ccb43f86c68", + # unisim-core package. Roadmap #1563 temporarily pins the release candidate + # carrying fixed model variants; replace it with the published 1.3.x line + # after the corresponding unisim-core release. + "unisim-core @ git+https://github.com/unilabsim/unisim.git@206056376452003ba488cb3e61fcddb1d32d6038", # RL algorithms and async runtimes (PPO/APPO/SAC/TD3 runners, # collectors, IPC, logging) live in the independently released uni-rl # package (distribution name ``unilab-rl``), consumed via the injected @@ -122,10 +122,8 @@ mujoco = [ # mujoco==3.11.0 — switching MuJoCo versions requires an mjbatch rebuild, # not a UniLab config change. "mujoco~=3.11.0", - # The batch engine is the unilabsim mjbatch fork. Roadmap #1563 pairs the - # integration-only UniSim git pin with the matching pre-release executor - # API; replace both after mjbatch-uni 0.2.x is published. - "mjbatch-uni @ git+https://github.com/unilabsim/mjbatch-uni.git@5fb49b9fde7084557a91c583ebd876e4c0946c12", + # The batch engine is the published unilabsim mjbatch fork. + "mjbatch-uni~=0.2.0", ] mjwarp = [ # Keep the Warp backend on the same MuJoCo minor line as the host backend. @@ -175,7 +173,7 @@ viser = ["viser>=1.0.26", "trimesh>=3.21.7"] # required-environments; elsewhere the extra is empty and the CLI reports a # targeted runtime diagnostic. superdex = [ - "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@a020f8bf15581c8b343b523ef3986ccb43f86c68 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", + "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@206056376452003ba488cb3e61fcddb1d32d6038 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 993ff1e1c..225bc1fc0 100644 --- a/uv.lock +++ b/uv.lock @@ -2137,12 +2137,27 @@ wheels = [ [[package]] name = "mjbatch-uni" version = "0.2.0" -source = { git = "https://github.com/unilabsim/mjbatch-uni.git?rev=5fb49b9fde7084557a91c583ebd876e4c0946c12#5fb49b9fde7084557a91c583ebd876e4c0946c12" } +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'" }, { 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/fa/d7/0353b4cff9aa3dbc87ba194514f27b11a81155df684c2259adde8b3f6d7b/mjbatch_uni-0.2.0.tar.gz", hash = "sha256:0e883d307f33636daaa8ed92f729c67c2ca67a5ae54b12114ef40baebf052006", size = 38231, upload-time = "2026-09-13T15:10:57.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/9c/29ded200f34bf03dbca7302c99832bcff15c2557cd11c5208612f146e4aa/mjbatch_uni-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bac2a8fc41cf61f80fbba5dc4c9690bb78ebc51faafe71f6205b60ebd9eda1f", size = 150198, upload-time = "2026-09-13T15:10:41.899Z" }, + { url = "https://files.pythonhosted.org/packages/c5/77/f3adc58f3acb7e5369865344e3fd2e165c29d6a4731dcdbb991c89868e2a/mjbatch_uni-0.2.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b4175221ccd6c50f98456f81e5f52a7709f280275e160c1341d8a64593dbf37", size = 174727, upload-time = "2026-09-13T15:10:43.174Z" }, + { url = "https://files.pythonhosted.org/packages/19/84/be3d64c4f4e40494e83948ea0a08eb00319da722d01d6bbfe6c770e97588/mjbatch_uni-0.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca1da6c406959c7b3c09f5d30e20edf851f091c1b0f139cb57ab1eb69f8e40bc", size = 185121, upload-time = "2026-09-13T15:10:44.506Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/5dba00afd7f6784740144de079cfde14ef1731533a09cbafc5bec7f945cc/mjbatch_uni-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f72e7bccfbd8e0d4f744bfc223265665f32b4e32b3a989f7f03123f811729a18", size = 149779, upload-time = "2026-09-13T15:10:45.931Z" }, + { url = "https://files.pythonhosted.org/packages/f7/55/26240fc42e3fe4f8ffc6ea6605ef72de130c7196bc69bd158527edb1f6de/mjbatch_uni-0.2.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84ca4865dbcfa932cbd4e905d8c15b41fe0a8b8216761a06f9f3668bf0d92256", size = 174360, upload-time = "2026-09-13T15:10:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6b/e066bad7afb095051f1dbd9aa500ca62977069ef880109757e9ff12376d5/mjbatch_uni-0.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:690db3ca8564c154341306c4167123dff1125c4cd334f37b7ed6e5e321b3540f", size = 184863, upload-time = "2026-09-13T15:10:48.513Z" }, + { url = "https://files.pythonhosted.org/packages/36/a4/15f256c6d3a33811bc4bdbce1649ea2d66bdfcb720f0396a4e83b6cd92ea/mjbatch_uni-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ff0c70adf00e19a3878ba4cd7d2549137a60ebe60a6709a92c54ad00fe41cd4", size = 148973, upload-time = "2026-09-13T15:10:49.854Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/09a9cac6b6f91b62c23c943e504ad3d2947d868a7c512bca66d59fdd88d9/mjbatch_uni-0.2.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:090ec1d0aa4e6abdc6cb761fe447e0cd31fba62888ed2a6897b2557ff7f69543", size = 173190, upload-time = "2026-09-13T15:10:50.987Z" }, + { url = "https://files.pythonhosted.org/packages/f6/13/65047e2fd0863133f140ec7f195ec915c88c3502f9ac785ce65ded58951f/mjbatch_uni-0.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9b9a564773698faf37ad89560cb071b50cbef4bc81743cca39602dc2e4aa512", size = 184451, upload-time = "2026-09-13T15:10:52.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cf/b51af8bcd2b50b0c5b3497fac1bb673f3e28bccf7a2808bbb2d55a9ee88f/mjbatch_uni-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5431238c5ca50be35c7efec5b3e342cd8ec450a0855a43d27aec9a277c485be6", size = 149034, upload-time = "2026-09-13T15:10:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f2/8a2871c06b987b349e3e18689ce46ca952538be2ad69d34a2274e74db98b/mjbatch_uni-0.2.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8a4be2acfd29630f133fa65f742c16b29799a3fd3069133fb54434cf29a7ea", size = 173127, upload-time = "2026-09-13T15:10:54.633Z" }, + { url = "https://files.pythonhosted.org/packages/18/48/e8d64f0e00c56e36b5ed9351e7e62129c83bacff19bbe55d53844aefa89e/mjbatch_uni-0.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9f40fcae35da5d8a29448bdfa62fac70c9b69440e10056f3d1e3061b400c9c9", size = 184436, upload-time = "2026-09-13T15:10:55.814Z" }, +] [[package]] name = "ml-dtypes" @@ -5102,7 +5117,7 @@ wheels = [ [[package]] name = "unilab" -version = "1.2.0" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "etils", version = "1.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -5195,7 +5210,7 @@ requires-dist = [ { name = "imgui-bundle", marker = "extra == 'newton'", specifier = ">=1.92.0" }, { name = "lark", specifier = ">=1.3.1" }, { name = "mediapy" }, - { name = "mjbatch-uni", marker = "extra == 'mujoco'", git = "https://github.com/unilabsim/mjbatch-uni.git?rev=5fb49b9fde7084557a91c583ebd876e4c0946c12" }, + { name = "mjbatch-uni", marker = "extra == 'mujoco'", specifier = "~=0.2.0" }, { name = "motrixsim-core", marker = "extra == 'motrix'", specifier = "==0.8.2" }, { name = "mujoco", marker = "extra == 'drake'", specifier = ">=3.5" }, { name = "mujoco", marker = "extra == 'mujoco'", specifier = "~=3.11.0" }, @@ -5225,8 +5240,8 @@ requires-dist = [ { name = "trimesh", marker = "extra == 'viser'", specifier = ">=3.21.7" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=a020f8bf15581c8b343b523ef3986ccb43f86c68" }, - { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=a020f8bf15581c8b343b523ef3986ccb43f86c68" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=206056376452003ba488cb3e61fcddb1d32d6038" }, + { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=206056376452003ba488cb3e61fcddb1d32d6038" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, { name = "warp-lang", marker = "extra == 'mjwarp'", specifier = "==1.16.0" }, @@ -5269,8 +5284,8 @@ wheels = [ [[package]] name = "unisim-core" -version = "1.2.1" -source = { git = "https://github.com/unilabsim/unisim.git?rev=a020f8bf15581c8b343b523ef3986ccb43f86c68#a020f8bf15581c8b343b523ef3986ccb43f86c68" } +version = "1.3.0" +source = { git = "https://github.com/unilabsim/unisim.git?rev=206056376452003ba488cb3e61fcddb1d32d6038#206056376452003ba488cb3e61fcddb1d32d6038" } 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'" }, diff --git a/uv.rocm.lock b/uv.rocm.lock index b80367ca9..d71b7fb9d 100644 --- a/uv.rocm.lock +++ b/uv.rocm.lock @@ -1687,12 +1687,27 @@ wheels = [ [[package]] name = "mjbatch-uni" version = "0.2.0" -source = { git = "https://github.com/unilabsim/mjbatch-uni.git?rev=5fb49b9fde7084557a91c583ebd876e4c0946c12#5fb49b9fde7084557a91c583ebd876e4c0946c12" } +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'" }, { 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/fa/d7/0353b4cff9aa3dbc87ba194514f27b11a81155df684c2259adde8b3f6d7b/mjbatch_uni-0.2.0.tar.gz", hash = "sha256:0e883d307f33636daaa8ed92f729c67c2ca67a5ae54b12114ef40baebf052006", size = 38231, upload-time = "2026-09-13T15:10:57.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/9c/29ded200f34bf03dbca7302c99832bcff15c2557cd11c5208612f146e4aa/mjbatch_uni-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bac2a8fc41cf61f80fbba5dc4c9690bb78ebc51faafe71f6205b60ebd9eda1f", size = 150198, upload-time = "2026-09-13T15:10:41.899Z" }, + { url = "https://files.pythonhosted.org/packages/c5/77/f3adc58f3acb7e5369865344e3fd2e165c29d6a4731dcdbb991c89868e2a/mjbatch_uni-0.2.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b4175221ccd6c50f98456f81e5f52a7709f280275e160c1341d8a64593dbf37", size = 174727, upload-time = "2026-09-13T15:10:43.174Z" }, + { url = "https://files.pythonhosted.org/packages/19/84/be3d64c4f4e40494e83948ea0a08eb00319da722d01d6bbfe6c770e97588/mjbatch_uni-0.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca1da6c406959c7b3c09f5d30e20edf851f091c1b0f139cb57ab1eb69f8e40bc", size = 185121, upload-time = "2026-09-13T15:10:44.506Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/5dba00afd7f6784740144de079cfde14ef1731533a09cbafc5bec7f945cc/mjbatch_uni-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f72e7bccfbd8e0d4f744bfc223265665f32b4e32b3a989f7f03123f811729a18", size = 149779, upload-time = "2026-09-13T15:10:45.931Z" }, + { url = "https://files.pythonhosted.org/packages/f7/55/26240fc42e3fe4f8ffc6ea6605ef72de130c7196bc69bd158527edb1f6de/mjbatch_uni-0.2.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84ca4865dbcfa932cbd4e905d8c15b41fe0a8b8216761a06f9f3668bf0d92256", size = 174360, upload-time = "2026-09-13T15:10:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6b/e066bad7afb095051f1dbd9aa500ca62977069ef880109757e9ff12376d5/mjbatch_uni-0.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:690db3ca8564c154341306c4167123dff1125c4cd334f37b7ed6e5e321b3540f", size = 184863, upload-time = "2026-09-13T15:10:48.513Z" }, + { url = "https://files.pythonhosted.org/packages/36/a4/15f256c6d3a33811bc4bdbce1649ea2d66bdfcb720f0396a4e83b6cd92ea/mjbatch_uni-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ff0c70adf00e19a3878ba4cd7d2549137a60ebe60a6709a92c54ad00fe41cd4", size = 148973, upload-time = "2026-09-13T15:10:49.854Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/09a9cac6b6f91b62c23c943e504ad3d2947d868a7c512bca66d59fdd88d9/mjbatch_uni-0.2.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:090ec1d0aa4e6abdc6cb761fe447e0cd31fba62888ed2a6897b2557ff7f69543", size = 173190, upload-time = "2026-09-13T15:10:50.987Z" }, + { url = "https://files.pythonhosted.org/packages/f6/13/65047e2fd0863133f140ec7f195ec915c88c3502f9ac785ce65ded58951f/mjbatch_uni-0.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9b9a564773698faf37ad89560cb071b50cbef4bc81743cca39602dc2e4aa512", size = 184451, upload-time = "2026-09-13T15:10:52.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cf/b51af8bcd2b50b0c5b3497fac1bb673f3e28bccf7a2808bbb2d55a9ee88f/mjbatch_uni-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5431238c5ca50be35c7efec5b3e342cd8ec450a0855a43d27aec9a277c485be6", size = 149034, upload-time = "2026-09-13T15:10:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f2/8a2871c06b987b349e3e18689ce46ca952538be2ad69d34a2274e74db98b/mjbatch_uni-0.2.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8a4be2acfd29630f133fa65f742c16b29799a3fd3069133fb54434cf29a7ea", size = 173127, upload-time = "2026-09-13T15:10:54.633Z" }, + { url = "https://files.pythonhosted.org/packages/18/48/e8d64f0e00c56e36b5ed9351e7e62129c83bacff19bbe55d53844aefa89e/mjbatch_uni-0.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9f40fcae35da5d8a29448bdfa62fac70c9b69440e10056f3d1e3061b400c9c9", size = 184436, upload-time = "2026-09-13T15:10:55.814Z" }, +] [[package]] name = "ml-dtypes" @@ -3686,7 +3701,7 @@ wheels = [ [[package]] name = "unilab" -version = "1.2.0" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "etils", version = "1.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -3755,7 +3770,7 @@ requires-dist = [ { name = "imageio-ffmpeg", specifier = ">=0.6.0" }, { name = "lark", specifier = ">=1.3.1" }, { name = "mediapy" }, - { name = "mjbatch-uni", marker = "extra == 'mujoco'", git = "https://github.com/unilabsim/mjbatch-uni.git?rev=5fb49b9fde7084557a91c583ebd876e4c0946c12" }, + { name = "mjbatch-uni", marker = "extra == 'mujoco'", specifier = "~=0.2.0" }, { name = "motrixsim-core", marker = "extra == 'motrix'", specifier = "==0.8.2" }, { name = "mujoco", marker = "extra == 'mujoco'", specifier = "~=3.11.0" }, { name = "ninja", marker = "sys_platform == 'linux'" }, @@ -3775,7 +3790,7 @@ requires-dist = [ { name = "triton-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==3.6.0", index = "https://download.pytorch.org/whl/rocm7.2" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=a020f8bf15581c8b343b523ef3986ccb43f86c68" }, + { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=206056376452003ba488cb3e61fcddb1d32d6038" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, ] @@ -3815,8 +3830,8 @@ wheels = [ [[package]] name = "unisim-core" -version = "1.2.1" -source = { git = "https://github.com/unilabsim/unisim.git?rev=a020f8bf15581c8b343b523ef3986ccb43f86c68#a020f8bf15581c8b343b523ef3986ccb43f86c68" } +version = "1.3.0" +source = { git = "https://github.com/unilabsim/unisim.git?rev=206056376452003ba488cb3e61fcddb1d32d6038#206056376452003ba488cb3e61fcddb1d32d6038" } 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'" }, From eb3e664d58fd9424781775e64f171079f434cdc3 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 23:39:49 +0800 Subject: [PATCH 12/13] chore: consume published unisim 1.3.0 --- pyproject.rocm.toml | 7 +++---- pyproject.toml | 9 ++++----- uv.lock | 7 ++++--- uv.rocm.lock | 5 +++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pyproject.rocm.toml b/pyproject.rocm.toml index 7034af3e0..3dcf15798 100644 --- a/pyproject.rocm.toml +++ b/pyproject.rocm.toml @@ -24,10 +24,9 @@ requires-python = ">=3.10,<3.14" dependencies = [ "numpy", # Physics implementations are provided by the independently released - # unisim-core package. Roadmap #1563 temporarily pins the release candidate - # carrying fixed model variants; replace it with the published 1.3.x line - # after the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@206056376452003ba488cb3e61fcddb1d32d6038", + # unisim-core package. The 1.3.0 release carries the fixed model variant + # and per-world reset-default contracts. + "unisim-core>=1.3.0", # RL algorithms and async runtimes live in the independently released # uni-rl package (distribution name ``unilab-rl``); see pyproject.toml. "unilab-rl==1.2.0", diff --git a/pyproject.toml b/pyproject.toml index ab17699ad..5f741aef0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,10 +41,9 @@ requires-python = ">=3.10,<3.14" dependencies = [ "numpy", # Physics implementations are provided by the independently released - # unisim-core package. Roadmap #1563 temporarily pins the release candidate - # carrying fixed model variants; replace it with the published 1.3.x line - # after the corresponding unisim-core release. - "unisim-core @ git+https://github.com/unilabsim/unisim.git@206056376452003ba488cb3e61fcddb1d32d6038", + # unisim-core package. The 1.3.0 release carries the fixed model variant + # and per-world reset-default contracts. + "unisim-core>=1.3.0", # RL algorithms and async runtimes (PPO/APPO/SAC/TD3 runners, # collectors, IPC, logging) live in the independently released uni-rl # package (distribution name ``unilab-rl``), consumed via the injected @@ -173,7 +172,7 @@ viser = ["viser>=1.0.26", "trimesh>=3.21.7"] # required-environments; elsewhere the extra is empty and the CLI reports a # targeted runtime diagnostic. superdex = [ - "unisim-core[superdex] @ git+https://github.com/unilabsim/unisim.git@206056376452003ba488cb3e61fcddb1d32d6038 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", + "unisim-core[superdex]>=1.3.0 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 225bc1fc0..bd2523bfa 100644 --- a/uv.lock +++ b/uv.lock @@ -5240,8 +5240,8 @@ requires-dist = [ { name = "trimesh", marker = "extra == 'viser'", specifier = ">=3.21.7" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=206056376452003ba488cb3e61fcddb1d32d6038" }, - { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", git = "https://github.com/unilabsim/unisim.git?rev=206056376452003ba488cb3e61fcddb1d32d6038" }, + { name = "unisim-core", specifier = ">=1.3.0" }, + { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", specifier = ">=1.3.0" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, { name = "warp-lang", marker = "extra == 'mjwarp'", specifier = "==1.16.0" }, @@ -5285,11 +5285,12 @@ wheels = [ [[package]] name = "unisim-core" version = "1.3.0" -source = { git = "https://github.com/unilabsim/unisim.git?rev=206056376452003ba488cb3e61fcddb1d32d6038#206056376452003ba488cb3e61fcddb1d32d6038" } +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/0f/f2/eab9d0ec83763cda631e978d805cb451187fb6b4ee6addde636d74ad3c48/unisim_core-1.3.0.tar.gz", hash = "sha256:17ddf3c4b0799d920f565ad30aa545b554e91167be79f1ea8fcbb790a9341463", size = 262820, upload-time = "2026-09-13T15:36:43.82Z" } [package.optional-dependencies] superdex = [ diff --git a/uv.rocm.lock b/uv.rocm.lock index d71b7fb9d..7a05aa2ef 100644 --- a/uv.rocm.lock +++ b/uv.rocm.lock @@ -3790,7 +3790,7 @@ requires-dist = [ { name = "triton-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==3.6.0", index = "https://download.pytorch.org/whl/rocm7.2" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", git = "https://github.com/unilabsim/unisim.git?rev=206056376452003ba488cb3e61fcddb1d32d6038" }, + { name = "unisim-core", specifier = ">=1.3.0" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, ] @@ -3831,11 +3831,12 @@ wheels = [ [[package]] name = "unisim-core" version = "1.3.0" -source = { git = "https://github.com/unilabsim/unisim.git?rev=206056376452003ba488cb3e61fcddb1d32d6038#206056376452003ba488cb3e61fcddb1d32d6038" } +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/0f/f2/eab9d0ec83763cda631e978d805cb451187fb6b4ee6addde636d74ad3c48/unisim_core-1.3.0.tar.gz", hash = "sha256:17ddf3c4b0799d920f565ad30aa545b554e91167be79f1ea8fcbb790a9341463", size = 262820, upload-time = "2026-09-13T15:36:43.82Z" } [[package]] name = "urllib3" From c414cbf8d5ce9f3877af76712985a4bdea692c0b Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Sun, 13 Sep 2026 23:50:30 +0800 Subject: [PATCH 13/13] chore: keep UniLab release version unchanged --- docs/sphinx/source/changelog.md | 2 +- pyproject.rocm.toml | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- uv.rocm.lock | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/sphinx/source/changelog.md b/docs/sphinx/source/changelog.md index 6bbe98e46..d58fbb7b8 100644 --- a/docs/sphinx/source/changelog.md +++ b/docs/sphinx/source/changelog.md @@ -11,7 +11,7 @@ see the [UniLab repository](https://github.com/unilabsim/UniLab). UniLab 遵循[语义化版本](https://semver.org/)。本共享页面以中英文记录重要版本变更; 日常提交记录请参阅 [UniLab 仓库](https://github.com/unilabsim/UniLab)。 -## 1.3.0 / 2026-09-13 +## Unreleased / 未发布 - Add task-owned fixed model/tool variants and per-env playback support through the UniSim construction-time plan contract. A deterministic representative diff --git a/pyproject.rocm.toml b/pyproject.rocm.toml index 3dcf15798..53b35bbf9 100644 --- a/pyproject.rocm.toml +++ b/pyproject.rocm.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "unilab" -version = "1.3.0" +version = "1.2.0" description = "Universal Lab for Robot Learning" readme = "README.md" license = "Apache-2.0" diff --git a/pyproject.toml b/pyproject.toml index 5f741aef0..13633e51d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ source-exclude = [ [project] name = "unilab" -version = "1.3.0" +version = "1.2.0" description = "Configurable, contract-driven robot learning across physics backends" readme = "README.md" license = "Apache-2.0" diff --git a/uv.lock b/uv.lock index bd2523bfa..31682b940 100644 --- a/uv.lock +++ b/uv.lock @@ -5117,7 +5117,7 @@ wheels = [ [[package]] name = "unilab" -version = "1.3.0" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "etils", version = "1.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, diff --git a/uv.rocm.lock b/uv.rocm.lock index 7a05aa2ef..6484eefc8 100644 --- a/uv.rocm.lock +++ b/uv.rocm.lock @@ -3701,7 +3701,7 @@ wheels = [ [[package]] name = "unilab" -version = "1.3.0" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "etils", version = "1.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },