diff --git a/docs/sphinx/source/changelog.md b/docs/sphinx/source/changelog.md
index 02961bb08..aa1506530 100644
--- a/docs/sphinx/source/changelog.md
+++ b/docs/sphinx/source/changelog.md
@@ -13,6 +13,40 @@ UniLab 遵循[语义化版本](https://semver.org/)。本共享页面以中英
PyPI 版本变更与未发布变更;发布日期采用 PyPI 上传日期。完整提交历史请参阅
[UniLab 仓库](https://github.com/unilabsim/UniLab)。
+## Unreleased / 未发布
+
+### Added / 新增
+
+- Added IsaacGym fixed-variant protocol and real-runtime coverage. The
+ deterministic worker mock validates and echoes the construction-time variant
+ assignment, while the external Preview-4 slow lane realizes per-env actor
+ asset selection and per-variant keyframes; public layout drift fails closed.
+ IsaacGym fixed variants and the still-pending 600-variant support decision
+ are documented on the backend page.
+ 新增 IsaacGym fixed-variant 协议层与真实 runtime 覆盖。确定性 worker mock
+ 校验并回显 construction-time variant assignment;外部 Preview 4 slow lane
+ 验证逐环境 actor 资产选择与逐变体 keyframe;公共布局漂移 fail closed。
+ IsaacGym fixed variants 及仍待决策的 600 变体支持边界已写入后端文档。
+- Added the IsaacGym fixed-variant scale benchmark. Each variant count runs in
+ a fresh child process and records source generation, construction time,
+ live worker RSS, and control/physics/env-step rates; results are written as
+ a versioned JSON artifact for the #1579 support decision.
+ 新增 IsaacGym fixed-variant 规模 benchmark。每个 variant 数在独立子进程中
+ 运行,记录源生成、构造时间、worker 实时 RSS 与 control/physics/env-step
+ 速率,并输出版本化 JSON artifact 供 #1579 support 决策使用。
+
+### Changed / 变更
+
+- Raised the base UniSim requirement to `unisim-core>=1.4.1` to consume the
+ published IsaacGym fixed-variant adapter. The optional `superdex` extra's
+ own `>=1.4.0` constraint is unchanged: the base requirement already forces
+ every installed profile to 1.4.1 or newer, and SuperDex has no 1.4.1-specific
+ dependency change. The UniLab package version is unchanged.
+ 将基础 UniSim 依赖提升到 `unisim-core>=1.4.1`,以消费已发布的 IsaacGym
+ fixed-variant adapter。可选 `superdex` extra 自身的 `>=1.4.0` 约束保持不变:
+ 基础依赖已经强制所有安装 profile 使用 1.4.1 或更新版本,且 SuperDex 在
+ 1.4.1 中没有专属依赖变化。UniLab 包版本保持不变。
+
## 1.3.0 (2026-09-14)
### Breaking changes / 破坏性变更
diff --git a/docs/sphinx/source/en/2-user_guide/3-backends/3-isaacgym.md b/docs/sphinx/source/en/2-user_guide/3-backends/3-isaacgym.md
index 99846f88f..69af94326 100644
--- a/docs/sphinx/source/en/2-user_guide/3-backends/3-isaacgym.md
+++ b/docs/sphinx/source/en/2-user_guide/3-backends/3-isaacgym.md
@@ -44,6 +44,14 @@ parameter is parsed from the XML rather than read from the importer.
`get_joint_range()` still reports the XML values. Joint `armature` and
`frictionloss` (resolved through MJCF default classes) are applied to the
PhysX dofs.
+- **Fixed model variants**: `env.fixed_model_variants` is realized by
+ actor-level asset selection. Each complete MJCF source is loaded once and
+ every environment's actor is created from its immutable assignment row;
+ dof/body counts and name order must match the canonical variant, while
+ internal PhysX shape counts may differ. Playback resolves the assigned
+ source and native rendering already shows that environment's actor. No
+ production task ships an IsaacGym fixed-variant owner yet, and 600-variant
+ scale remains gated on benchmark #1579.
## Prerequisites
diff --git a/docs/sphinx/source/zh_CN/2-user_guide/3-backends/3-isaacgym.md b/docs/sphinx/source/zh_CN/2-user_guide/3-backends/3-isaacgym.md
index 21263dab1..9f37da2e8 100644
--- a/docs/sphinx/source/zh_CN/2-user_guide/3-backends/3-isaacgym.md
+++ b/docs/sphinx/source/zh_CN/2-user_guide/3-backends/3-isaacgym.md
@@ -35,6 +35,12 @@ benchmark 脚本
- **关节限位**:importer 会丢弃 joint range,因此 PhysX 侧没有关节
限位;`get_joint_range()` 仍返回 XML 值。关节 `armature` 与
`frictionloss`(经 MJCF default class 解析)会应用到 PhysX dof。
+- **固定模型变体**:`env.fixed_model_variants` 通过 actor 级资产选择实现。
+ 每个完整 MJCF 源只装载一次,每个环境的 actor 按不可变 assignment 行创建;
+ dof/body 数量与名称顺序必须与规范变体一致,内部 PhysX shape 数量可以不同。
+ playback 解析对应 source,原生渲染展示的就是该环境的 actor。当前还没有
+ 生产任务提供 IsaacGym fixed-variant owner,600 变体规模仍以 benchmark
+ #1579 为 gate。
## 前置条件
diff --git a/pyproject.rocm.toml b/pyproject.rocm.toml
index c64dde7ab..10536bdd5 100644
--- a/pyproject.rocm.toml
+++ b/pyproject.rocm.toml
@@ -24,10 +24,10 @@ requires-python = ">=3.10,<3.14"
dependencies = [
"numpy",
# Physics implementations are provided by the independently released
- # unisim-core package. The 1.4.0 release carries the fixed model variant,
- # per-world reset-default, per-env gravity, and substep body-wrench
- # contracts.
- "unisim-core>=1.4.0",
+ # unisim-core package. The 1.4.1 release carries the fixed model variant,
+ # per-world reset-default, per-env gravity, substep body-wrench, and
+ # IsaacGym fixed-variant contracts.
+ "unisim-core>=1.4.1",
# 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 009663790..0b1b0568c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,10 +41,10 @@ requires-python = ">=3.10,<3.14"
dependencies = [
"numpy",
# Physics implementations are provided by the independently released
- # unisim-core package. The 1.4.0 release carries the fixed model variant,
- # per-world reset-default, per-env gravity, and substep body-wrench
- # contracts.
- "unisim-core>=1.4.0",
+ # unisim-core package. The 1.4.1 release carries the fixed model variant,
+ # per-world reset-default, per-env gravity, substep body-wrench, and
+ # IsaacGym fixed-variant contracts.
+ "unisim-core>=1.4.1",
# 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
diff --git a/scripts/benchmark/physics/benchmark_isaacgym_fixed_variants.py b/scripts/benchmark/physics/benchmark_isaacgym_fixed_variants.py
new file mode 100644
index 000000000..02e73aa25
--- /dev/null
+++ b/scripts/benchmark/physics/benchmark_isaacgym_fixed_variants.py
@@ -0,0 +1,277 @@
+#!/usr/bin/env python3
+"""Benchmark IsaacGym fixed-variant construction, memory, and stepping.
+
+Each variant count runs in a fresh child process so ``ru_maxrss`` for the
+IsaacGym worker is not contaminated by an earlier K point. The child prints one
+JSON line; the parent collects the lines into the final artifact.
+
+Example:
+ uv run --no-sync python \\
+ scripts/benchmark/physics/benchmark_isaacgym_fixed_variants.py \\
+ --variant-counts 1 4 64 600 --num-envs 4096 \\
+ --output /tmp/isaacgym-fixed-variants.json
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import platform
+import subprocess
+import sys
+import tempfile
+import time
+from collections import defaultdict
+from datetime import datetime, timezone
+from pathlib import Path
+
+import numpy as np
+from unisim.backend.isaacgym.dependencies import isaacgym_runtime_available
+from unisim.dr.types import FixedVariantPlan, ModelSourceDescriptor
+from unisim.factory import create_backend
+from unisim.scene import SceneCfg
+
+_RESULT_MARKER = "__ISAACGYM_FIXED_VARIANT_RESULT__"
+
+
+def _descendant_rss_kib() -> int:
+ """Return the live RSS sum of this process's descendants (the worker)."""
+ processes: dict[int, tuple[int, int]] = {}
+ for entry in Path("/proc").iterdir():
+ if not entry.name.isdigit():
+ continue
+ try:
+ ppid: int | None = None
+ rss_kib = 0
+ for line in (entry / "status").read_text(encoding="utf-8").splitlines():
+ if line.startswith("PPid:"):
+ ppid = int(line.split()[1])
+ elif line.startswith("VmRSS:"):
+ rss_kib = int(line.split()[1])
+ break
+ if ppid is not None:
+ processes[int(entry.name)] = (ppid, rss_kib)
+ except (FileNotFoundError, ProcessLookupError, ValueError, PermissionError):
+ continue
+
+ children: dict[int, list[int]] = defaultdict(list)
+ for pid, (ppid, _rss) in processes.items():
+ children[ppid].append(pid)
+ reachable: list[int] = list(children.get(os.getpid(), ()))
+ total = 0
+ while reachable:
+ pid = reachable.pop()
+ total += processes[pid][1]
+ reachable.extend(children.get(pid, ()))
+ return total
+
+
+def _variant_xml(index: int) -> str:
+ mass = 1.0 + 0.0025 * index
+ size = 0.08 + 0.0001 * (index % 100)
+ key = (index % 20) * 0.01
+ kp = 20.0 + (index % 30)
+ return f"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+def _write_sources(root: Path, count: int) -> tuple[Path, ...]:
+ files: list[Path] = []
+ for index in range(count):
+ path = root / f"variant_{index:04d}.xml"
+ path.write_text(_variant_xml(index), encoding="utf-8")
+ files.append(path)
+ return tuple(files)
+
+
+def _gpu_info() -> dict[str, str]:
+ try:
+ raw = subprocess.check_output(
+ ["nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader"],
+ text=True,
+ ).strip()
+ name, driver = (part.strip() for part in raw.splitlines()[0].split(",", 1))
+ return {"gpu": name, "driver": driver}
+ except Exception:
+ return {"gpu": "unknown", "driver": "unknown"}
+
+
+def _run_one(args: argparse.Namespace) -> None:
+ if not isaacgym_runtime_available():
+ raise RuntimeError("IsaacGym runtime is unavailable; set UNISIM_ISAACGYM_HOME")
+
+ with tempfile.TemporaryDirectory(prefix="isaacgym-fixed-variants-") as tmp:
+ source_root = Path(tmp)
+ source_start = time.perf_counter()
+ sources = _write_sources(source_root, args.variant_count)
+ source_seconds = time.perf_counter() - source_start
+
+ assignment = np.arange(args.num_envs, dtype=np.int32) % np.int32(args.variant_count)
+ plan = FixedVariantPlan(
+ assignment=assignment,
+ variants=tuple(ModelSourceDescriptor(str(path)) for path in sources),
+ )
+ construction_start = time.perf_counter()
+ backend = create_backend(
+ "isaacgym",
+ SceneCfg(model_file=str(sources[0]), fixed_variant_plan=plan),
+ args.num_envs,
+ args.sim_dt,
+ base_name="base",
+ device_id=args.device_id,
+ worker_timeout_s=args.worker_timeout_s,
+ )
+ try:
+ if not backend.get_dr_capabilities().supports_fixed_variants:
+ raise RuntimeError("installed unisim-core lacks IsaacGym fixed variants")
+ backend.materialize()
+ construction_seconds = time.perf_counter() - construction_start
+ worker_rss_kib = _descendant_rss_kib()
+
+ ctrl = np.zeros((args.num_envs, 3), dtype=np.float32)
+ for _ in range(args.warmup_steps):
+ backend.step(ctrl, nsteps=args.nsteps)
+ step_start = time.perf_counter()
+ for _ in range(args.measure_steps):
+ backend.step(ctrl, nsteps=args.nsteps)
+ step_seconds = time.perf_counter() - step_start
+ if not np.isfinite(backend.get_dof_pos()).all():
+ raise RuntimeError("benchmark rollout produced non-finite dof state")
+ finally:
+ backend.close()
+
+ physics_steps = args.measure_steps * args.nsteps
+ result = {
+ "variant_count": args.variant_count,
+ "num_envs": args.num_envs,
+ "device_id": args.device_id,
+ "nsteps": args.nsteps,
+ "source_generation_s": source_seconds,
+ "construction_s": construction_seconds,
+ "worker_rss_kib": worker_rss_kib,
+ "measure_control_steps": args.measure_steps,
+ "step_s": step_seconds,
+ "control_steps_per_s": args.measure_steps / step_seconds,
+ "physics_steps_per_s": physics_steps / step_seconds,
+ "env_steps_per_s": (physics_steps * args.num_envs) / step_seconds,
+ }
+ print(_RESULT_MARKER + json.dumps(result), flush=True)
+
+
+def _spawn_measurement(args: argparse.Namespace, variant_count: int) -> dict[str, float | int]:
+ command = [
+ sys.executable,
+ str(Path(__file__).resolve()),
+ "--run-one",
+ "--variant-count",
+ str(variant_count),
+ "--num-envs",
+ str(args.num_envs),
+ "--device-id",
+ str(args.device_id),
+ "--nsteps",
+ str(args.nsteps),
+ "--warmup-steps",
+ str(args.warmup_steps),
+ "--measure-steps",
+ str(args.measure_steps),
+ "--sim-dt",
+ str(args.sim_dt),
+ "--worker-timeout-s",
+ str(args.worker_timeout_s),
+ ]
+ completed = subprocess.run(command, check=True, text=True, capture_output=True)
+ lines = [line for line in completed.stdout.splitlines() if line.startswith(_RESULT_MARKER)]
+ if len(lines) != 1:
+ raise RuntimeError(
+ f"measurement child for K={variant_count} did not emit one result; "
+ f"stdout={completed.stdout!r}, stderr={completed.stderr!r}"
+ )
+ result: dict[str, float | int] = json.loads(lines[0][len(_RESULT_MARKER) :])
+ print(
+ f"K={variant_count:>3}: construct={result['construction_s']:.3f}s, "
+ f"worker_rss={result['worker_rss_kib'] / 1024:.1f}MiB, "
+ f"env_steps/s={result['env_steps_per_s']:.0f}",
+ flush=True,
+ )
+ return result
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--variant-counts", type=int, nargs="+", default=[1, 4, 64, 600])
+ parser.add_argument("--num-envs", type=int, default=4096)
+ parser.add_argument("--device-id", type=int, default=0)
+ parser.add_argument("--nsteps", type=int, default=2)
+ parser.add_argument("--warmup-steps", type=int, default=10)
+ parser.add_argument("--measure-steps", type=int, default=100)
+ parser.add_argument("--sim-dt", type=float, default=0.005)
+ parser.add_argument("--worker-timeout-s", type=float, default=600.0)
+ parser.add_argument("--output", type=Path, default=None)
+ parser.add_argument("--run-one", action="store_true")
+ parser.add_argument("--variant-count", type=int, default=1)
+ args = parser.parse_args(argv)
+
+ if args.run_one:
+ _run_one(args)
+ return 0
+
+ artifact = {
+ "schema": "unilab.isaacgym_fixed_variants.v1",
+ "created_at": datetime.now(timezone.utc).isoformat(),
+ "platform": platform.platform(),
+ "python": platform.python_version(),
+ **_gpu_info(),
+ "parameters": {
+ "num_envs": args.num_envs,
+ "device_id": args.device_id,
+ "nsteps": args.nsteps,
+ "warmup_steps": args.warmup_steps,
+ "measure_steps": args.measure_steps,
+ "sim_dt": args.sim_dt,
+ },
+ "results": [
+ _spawn_measurement(args, variant_count) for variant_count in args.variant_counts
+ ],
+ }
+ rendered = json.dumps(artifact, indent=2)
+ if args.output is not None:
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(rendered + "\n", encoding="utf-8")
+ print(f"wrote {args.output}", flush=True)
+ else:
+ print(rendered, flush=True)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/base/isaacgym_mock_worker.py b/tests/base/isaacgym_mock_worker.py
index b61892a84..a1818d255 100644
--- a/tests/base/isaacgym_mock_worker.py
+++ b/tests/base/isaacgym_mock_worker.py
@@ -27,6 +27,10 @@
``capture_wrong_shape`` return malformed camera frames.
Model dims come from ``UNILAB_ISAACGYM_MOCK_DOF_NAMES`` /
``UNILAB_ISAACGYM_MOCK_BODY_NAMES`` (comma-separated).
+Fixed-variant INIT payloads are validated and echoed: the mock checks one
+dof-field table and one optional keyframe per variant, applies each env's
+assigned keyframe, and returns ``fixed_variant_count`` /
+``fixed_variant_assignment`` in the handshake.
"""
from __future__ import annotations
@@ -63,6 +67,8 @@ def __init__(
startup_render_mode: str | None = None,
startup_width: int = 1280,
startup_height: int = 720,
+ variant_assignment: List[int] | None = None,
+ variant_count: int | None = None,
):
self.graphics_enabled = graphics_enabled
self.startup_render_mode = startup_render_mode
@@ -84,6 +90,8 @@ def __init__(
self.capture_ready = False
self.capture_width = 0
self.capture_height = 0
+ self.variant_assignment = variant_assignment
+ self.variant_count = variant_count
def attach(self, slots: Dict[str, Dict[str, Any]]) -> None:
from multiprocessing import resource_tracker, shared_memory
@@ -124,9 +132,15 @@ def step(self, nsteps: int) -> None:
self.root[:, 0:3] += self.root[:, 7:10] * dt
self.write_state_slots()
- def apply_keyframe(self, qpos_values: Any, joint_names: List[str]) -> None:
+ def apply_keyframe(
+ self,
+ qpos_values: Any,
+ joint_names: List[str],
+ env_ids: List[int] | None = None,
+ ) -> None:
"""Apply the INIT keyframe pose with the same name-mapping rules as the
real worker (root columns pass through in the mock's wxyz convention)."""
+ selected = list(range(self.num_envs)) if env_ids is None else list(env_ids)
qpos = np.asarray(qpos_values, dtype=np.float32).reshape(-1)
expected = 7 + self.num_dof
if qpos.size != expected:
@@ -142,9 +156,33 @@ def apply_keyframe(self, qpos_values: Any, joint_names: List[str]) -> None:
raise RuntimeError(
"isaacgym asset dof %r is missing from mjcf_joint_names" % dof_name
)
- self.dof[:, dof_index, 0] = qpos[7 + index_by_name[dof_name]]
- self.root[:, 0:3] = qpos[0:3]
- self.root[:, 3:7] = qpos[3:7]
+ self.dof[selected, dof_index, 0] = qpos[7 + index_by_name[dof_name]]
+ self.root[selected, 0:3] = qpos[0:3]
+ self.root[selected, 3:7] = qpos[3:7]
+
+ def apply_variant_keyframes(
+ self,
+ qpos_by_variant: List[Any],
+ joint_names: List[str],
+ ) -> None:
+ """Apply the assigned variant's INIT keyframe to each environment."""
+ if self.variant_assignment is None:
+ raise RuntimeError("variant keyframes require a variant assignment")
+ if self.variant_count is None:
+ raise RuntimeError("variant assignment requires a variant count")
+ if len(qpos_by_variant) != self.variant_count:
+ raise RuntimeError(
+ "variant keyframe table has %d rows for %d variants"
+ % (len(qpos_by_variant), self.variant_count)
+ )
+ if any(value is None for value in qpos_by_variant):
+ raise RuntimeError("every assigned variant must provide a keyframe")
+ for env_index, variant_index in enumerate(self.variant_assignment):
+ self.apply_keyframe(
+ qpos_by_variant[variant_index],
+ joint_names,
+ env_ids=[env_index],
+ )
def set_state(self, count: int) -> None:
env_ids = self.slots["reset_env_ids"][:count].astype(np.int64)
@@ -176,6 +214,11 @@ def meta(self, behavior: str = "ok") -> Dict[str, Any]:
"env_origins": [[float(i) * 2.0, 0.0, 0.0] for i in range(self.num_envs)],
"collision_filtering_applied": True,
}
+ if self.variant_assignment is not None:
+ if self.variant_count is None:
+ raise RuntimeError("variant assignment requires a variant count")
+ result["fixed_variant_count"] = self.variant_count
+ result["fixed_variant_assignment"] = list(self.variant_assignment)
if self.startup_render_mode is not None:
result.update(
{
@@ -287,6 +330,27 @@ def dispatch(cmd: str, payload: Any) -> Tuple[str, Any]:
if cmd == protocol.CMD_INIT:
if behavior == "fail_init":
raise RuntimeError("mock init failure")
+ variant_files = payload.get("variant_model_files")
+ variant_assignment: List[int] | None = None
+ variant_count: int | None = None
+ if variant_files is not None:
+ variant_count = len(variant_files)
+ variant_assignment = [
+ int(value) for value in payload.get("variant_assignment") or []
+ ]
+ if len(variant_assignment) != int(payload["num_envs"]):
+ raise RuntimeError(
+ "variant assignment has %d entries for %d envs"
+ % (len(variant_assignment), int(payload["num_envs"]))
+ )
+ if any(value < 0 or value >= variant_count for value in variant_assignment):
+ raise RuntimeError("variant assignment contains an invalid index")
+ variant_fields = payload.get("variant_dof_fields") or []
+ if len(variant_fields) != variant_count:
+ raise RuntimeError(
+ "variant dof-field table has %d rows for %d variants"
+ % (len(variant_fields), variant_count)
+ )
sim = _MockSim(
num_envs=int(payload["num_envs"]),
sim_dt=float(payload["sim_dt"]),
@@ -303,9 +367,19 @@ def dispatch(cmd: str, payload: Any) -> Tuple[str, Any]:
),
startup_width=int(payload.get("render_width", 1280)),
startup_height=int(payload.get("render_height", 720)),
+ variant_assignment=variant_assignment,
+ variant_count=variant_count,
)
+ variant_keyframes = payload.get("variant_keyframe_qpos")
keyframe_qpos = payload.get("keyframe_qpos")
- if keyframe_qpos is not None:
+ if variant_keyframes is not None:
+ if variant_count is None or len(variant_keyframes) != variant_count:
+ raise RuntimeError("variant keyframe row count mismatch")
+ if any(value is not None for value in variant_keyframes):
+ sim.apply_variant_keyframes(
+ variant_keyframes, payload.get("mjcf_joint_names") or []
+ )
+ elif keyframe_qpos is not None:
sim.apply_keyframe(keyframe_qpos, payload.get("mjcf_joint_names") or [])
return protocol.CMD_META, sim.meta(behavior)
assert sim is not None
diff --git a/tests/base/test_isaacgym_fixed_variants.py b/tests/base/test_isaacgym_fixed_variants.py
new file mode 100644
index 000000000..6f78d907c
--- /dev/null
+++ b/tests/base/test_isaacgym_fixed_variants.py
@@ -0,0 +1,206 @@
+"""IsaacGym fixed-variant integration through the deterministic protocol mock."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import numpy as np
+import pytest
+from unisim.backend.isaacgym.backend import IsaacGymBackend
+from unisim.dr.types import FixedVariantPlan, ModelSourceDescriptor
+
+from unilab.base.backend_factory import create_backend
+from unilab.base.scene import SceneCfg
+
+_MOCK_WORKER = Path(__file__).resolve().parent / "isaacgym_mock_worker.py"
+_SIM_DT = 0.005
+_DOF_NAMES = ("j0", "j1", "j2")
+_BODY_NAMES = ("base", "link0", "link1", "link2")
+
+
+def _variant_xml(
+ *,
+ mass: float,
+ size: float,
+ key_dof: tuple[float, float, float],
+ kp: tuple[float, float, float],
+) -> str:
+ return f"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+def _write_variants(root: Path, *, count: int = 3) -> tuple[Path, ...]:
+ files: list[Path] = []
+ for index in range(count):
+ path = root / f"variant_{index}.xml"
+ path.write_text(
+ _variant_xml(
+ mass=1.0 + 0.25 * index,
+ size=0.08 + 0.01 * index,
+ key_dof=(0.1 * index, 0.2, -0.1),
+ kp=(20.0 + 10.0 * index, 30.0, 40.0),
+ ),
+ encoding="utf-8",
+ )
+ files.append(path)
+ return tuple(files)
+
+
+def _make_backend(
+ sources: tuple[Path, ...],
+ assignment: tuple[int, ...],
+ monkeypatch: pytest.MonkeyPatch,
+) -> IsaacGymBackend:
+ monkeypatch.setenv("UNILAB_ISAACGYM_MOCK_DOF_NAMES", ",".join(_DOF_NAMES))
+ monkeypatch.setenv("UNILAB_ISAACGYM_MOCK_BODY_NAMES", ",".join(_BODY_NAMES))
+ plan = FixedVariantPlan(
+ assignment=np.asarray(assignment, dtype=np.int32),
+ variants=tuple(ModelSourceDescriptor(str(path)) for path in sources),
+ )
+ backend = create_backend(
+ "isaacgym",
+ SceneCfg(model_file=str(sources[0]), fixed_variant_plan=plan),
+ len(assignment),
+ _SIM_DT,
+ base_name="base",
+ worker_command=[sys.executable, str(_MOCK_WORKER)],
+ worker_timeout_s=30.0,
+ )
+ assert isinstance(backend, IsaacGymBackend)
+ return backend
+
+
+def test_mock_protocol_realizes_immutable_variants(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ sources = _write_variants(tmp_path)
+ backend = _make_backend(sources, (1, 0, 2, 1), monkeypatch)
+ try:
+ capabilities = backend.get_dr_capabilities()
+ assert capabilities.supports_fixed_variants
+ assert capabilities.supports_per_env_playback
+
+ backend.materialize()
+ assert backend.model.dof_names == _DOF_NAMES
+ assert backend.model.body_names == _BODY_NAMES
+ assert [backend.get_playback_model(index) for index in range(4)] == [
+ str(sources[1]),
+ str(sources[0]),
+ str(sources[2]),
+ str(sources[1]),
+ ]
+ # Assignment row 0 selects variant 1, so the canonical default is that
+ # variant's task-initial keyframe rather than scene.model_file's row.
+ np.testing.assert_allclose(
+ backend.get_default_dof_pos(), np.asarray([0.1, 0.2, -0.1]), atol=1e-7
+ )
+ np.testing.assert_allclose(
+ backend.get_dof_pos(),
+ np.asarray(
+ [
+ [0.1, 0.2, -0.1],
+ [0.0, 0.2, -0.1],
+ [0.2, 0.2, -0.1],
+ [0.1, 0.2, -0.1],
+ ],
+ dtype=np.float32,
+ ),
+ atol=1e-7,
+ )
+ backend.step(np.zeros((4, 3), dtype=np.float32), nsteps=1)
+ assert np.isfinite(backend.get_dof_pos()).all()
+ finally:
+ backend.close()
+
+
+def test_public_layout_drift_fails_closed(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ sources = list(_write_variants(tmp_path, count=2))
+ drifting = tmp_path / "drifting.xml"
+ drifting.write_text(
+ _variant_xml(
+ mass=2.0,
+ size=0.2,
+ key_dof=(0.3, 0.2, -0.1),
+ kp=(50.0, 30.0, 40.0),
+ ).replace(
+ '',
+ ''
+ '',
+ ),
+ encoding="utf-8",
+ )
+ sources.append(drifting)
+ backend = _make_backend(tuple(sources), (0, 1, 0), monkeypatch)
+ try:
+ with pytest.raises(ValueError, match="changes public joint_names"):
+ backend.materialize()
+ finally:
+ backend.close()
+
+
+@pytest.mark.slow
+def test_real_isaacgym_runtime_fixed_variants(tmp_path: Path) -> None:
+ """Real Preview-4 lane; skips when the dedicated runtime is unavailable."""
+ from unisim.backend.isaacgym.dependencies import isaacgym_runtime_available
+
+ if not isaacgym_runtime_available():
+ pytest.skip("IsaacGym Preview 4 runtime is not installed")
+
+ sources = _write_variants(tmp_path)
+ plan = FixedVariantPlan(
+ assignment=np.asarray([0, 1, 2], dtype=np.int32),
+ variants=tuple(ModelSourceDescriptor(str(path)) for path in sources),
+ )
+ backend = create_backend(
+ "isaacgym",
+ SceneCfg(model_file=str(sources[0]), fixed_variant_plan=plan),
+ 3,
+ _SIM_DT,
+ base_name="base",
+ device_id=-1,
+ worker_timeout_s=120.0,
+ )
+ assert isinstance(backend, IsaacGymBackend)
+ try:
+ backend.materialize()
+ assert [backend.get_playback_model(index) for index in range(3)] == [
+ str(path) for path in sources
+ ]
+ backend.step(np.zeros((3, 3), dtype=np.float32), nsteps=1)
+ assert np.isfinite(backend.get_dof_pos()).all()
+ assert np.isfinite(backend.get_base_pos()).all()
+ finally:
+ backend.close()
diff --git a/uv.lock b/uv.lock
index fe8df3c44..211b3f755 100644
--- a/uv.lock
+++ b/uv.lock
@@ -5240,7 +5240,7 @@ 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.4.0" },
+ { name = "unisim-core", specifier = ">=1.4.1" },
{ 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.4.0" },
{ name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" },
{ name = "wandb" },
@@ -5284,13 +5284,13 @@ wheels = [
[[package]]
name = "unisim-core"
-version = "1.4.0"
+version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fa/cf/639036c780e264389d54f30e58c0a862d52b724be6f17cc62cba5bb9780a/unisim_core-1.4.0.tar.gz", hash = "sha256:cc7db53d779af7b1612615aa3a0216575a3236f508520b63d358f86af326adee", size = 266406, upload-time = "2026-09-14T13:08:44.977Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/57/4da896cef1f76e9902cfe4deb0f5dcf678a2217507dd39fe286a7edb6984/unisim_core-1.4.1.tar.gz", hash = "sha256:aa71cc1f90615929be2e75851d636271675666caa140629c042e6a46fb45d657", size = 269776, upload-time = "2026-09-15T08:21:16.014Z" }
[package.optional-dependencies]
superdex = [
diff --git a/uv.rocm.lock b/uv.rocm.lock
index f9f29d78f..8a620d3ce 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", specifier = ">=1.4.0" },
+ { name = "unisim-core", specifier = ">=1.4.1" },
{ name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" },
{ name = "wandb" },
]
@@ -3830,13 +3830,13 @@ wheels = [
[[package]]
name = "unisim-core"
-version = "1.4.0"
+version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fa/cf/639036c780e264389d54f30e58c0a862d52b724be6f17cc62cba5bb9780a/unisim_core-1.4.0.tar.gz", hash = "sha256:cc7db53d779af7b1612615aa3a0216575a3236f508520b63d358f86af326adee", size = 266406, upload-time = "2026-09-14T13:08:44.977Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/57/4da896cef1f76e9902cfe4deb0f5dcf678a2217507dd39fe286a7edb6984/unisim_core-1.4.1.tar.gz", hash = "sha256:aa71cc1f90615929be2e75851d636271675666caa140629c042e6a46fb45d657", size = 269776, upload-time = "2026-09-15T08:21:16.014Z" }
[[package]]
name = "urllib3"