From 33b1245346eb4d34f2a593e7d773175c5ddbd2ef Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Wed, 21 Oct 2026 23:19:40 +0800 Subject: [PATCH 1/2] chore(deps): bump unisim-core pin to 2.1.0 (#2474) --- 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 9ad16f76a..796c5bf10 100644 --- a/pyproject.rocm.toml +++ b/pyproject.rocm.toml @@ -23,7 +23,7 @@ classifiers = [ requires-python = ">=3.10,<3.14" dependencies = [ "numpy", - "unisim-core==2.0.1", + "unisim-core==2.1.0", "prettytable>=3.10", "torch==2.14.0", "triton-rocm==3.8.0 ; sys_platform == 'linux' and platform_machine == 'x86_64'", @@ -72,7 +72,7 @@ mujoco = [ "mujoco~=3.11.0", "mjbatch-uni~=0.2.5", ] -motrix = ["unisim-core[motrix]==2.0.1"] +motrix = ["unisim-core[motrix]==2.1.0"] [dependency-groups] # The RL stack (APPO/off-policy/multi-GPU) is merged into unilab itself diff --git a/pyproject.toml b/pyproject.toml index b17d14327..24c0ddc3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ requires-python = ">=3.10,<3.14" dependencies = [ "numpy", - "unisim-core==2.0.1", + "unisim-core==2.1.0", "prettytable>=3.10", # A range (not an exact pin) lets ROCm users substitute a ROCm torch # build; uv.lock pins the tested CUDA builds via tool.uv.sources. @@ -106,12 +106,12 @@ newton = [ "pyglet>=2.1.6,<3", "imgui-bundle>=1.92.0", ] -motrix = ["unisim-core[motrix]==2.0.1"] +motrix = ["unisim-core[motrix]==2.1.0"] genesis = ["genesis-world==1.3.3"] # Wheels are CPython 3.12/3.13 Linux x86_64 only; the marker keeps uv lock # resolvable for the other required-environments. superdex = [ - "unisim-core[superdex]==2.0.1 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", + "unisim-core[superdex]==2.1.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 a8c37c99d..a3a8d0cb4 100644 --- a/uv.lock +++ b/uv.lock @@ -5268,9 +5268,9 @@ requires-dist = [ { name = "tqdm" }, { name = "trimesh", specifier = ">=3.21.7" }, { name = "typing-extensions" }, - { name = "unisim-core", specifier = "==2.0.1" }, - { name = "unisim-core", extras = ["motrix"], marker = "extra == 'motrix'", specifier = "==2.0.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 = "==2.0.1" }, + { name = "unisim-core", specifier = "==2.1.0" }, + { name = "unisim-core", extras = ["motrix"], marker = "extra == 'motrix'", specifier = "==2.1.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 = "==2.1.0" }, { name = "viser", specifier = ">=1.0.26" }, { name = "wandb", specifier = ">=0.22.3" }, { name = "warp-lang", marker = "extra == 'mjwarp'", specifier = "==1.16.0" }, @@ -5289,13 +5289,13 @@ dev = [ [[package]] name = "unisim-core" -version = "2.0.1" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/1b/6b1768f507a5c0556b130af129b81a4d9ef0eec388f3931992569498e731/unisim_core-2.0.1.tar.gz", hash = "sha256:78708458337feb9fab23b34dd6f6e31f7e64d94b6f79a5c4be70389d72cebd2c", size = 698579, upload-time = "2026-10-21T06:07:17.654Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/54/8bbfa4f671b4c26002983afeb88f24e548816de3ccb29112ac4b8ca9ae66/unisim_core-2.1.0.tar.gz", hash = "sha256:4353d0e01214954181a30b6e00442cf3f5e3bb45fba551cee2e6f35440db1d2d", size = 699562, upload-time = "2026-10-21T14:47:45.451Z" } [package.optional-dependencies] motrix = [ diff --git a/uv.rocm.lock b/uv.rocm.lock index a77216725..1f13cae8e 100644 --- a/uv.rocm.lock +++ b/uv.rocm.lock @@ -3797,8 +3797,8 @@ requires-dist = [ { name = "trimesh", specifier = ">=3.21.7" }, { name = "triton-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==3.8.0", index = "https://download.pytorch.org/whl/rocm7.2" }, { name = "typing-extensions" }, - { name = "unisim-core", specifier = "==2.0.1" }, - { name = "unisim-core", extras = ["motrix"], marker = "extra == 'motrix'", specifier = "==2.0.1" }, + { name = "unisim-core", specifier = "==2.1.0" }, + { name = "unisim-core", extras = ["motrix"], marker = "extra == 'motrix'", specifier = "==2.1.0" }, { name = "viser", specifier = ">=1.0.26" }, { name = "wandb", specifier = ">=0.22.3" }, ] @@ -3815,13 +3815,13 @@ dev = [ [[package]] name = "unisim-core" -version = "2.0.1" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/1b/6b1768f507a5c0556b130af129b81a4d9ef0eec388f3931992569498e731/unisim_core-2.0.1.tar.gz", hash = "sha256:78708458337feb9fab23b34dd6f6e31f7e64d94b6f79a5c4be70389d72cebd2c", size = 698579, upload-time = "2026-10-21T06:07:17.654Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/54/8bbfa4f671b4c26002983afeb88f24e548816de3ccb29112ac4b8ca9ae66/unisim_core-2.1.0.tar.gz", hash = "sha256:4353d0e01214954181a30b6e00442cf3f5e3bb45fba551cee2e6f35440db1d2d", size = 699562, upload-time = "2026-10-21T14:47:45.451Z" } [package.optional-dependencies] motrix = [ From 8f4c67efca785bfd898bb33767c44f215c74ba71 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Wed, 21 Oct 2026 23:19:40 +0800 Subject: [PATCH 2/2] fix(base): build interval op payloads as torch tensors for unisim 2.1.0 (#2474) --- tests/base/test_genesis_backend.py | 16 ++++--- tests/base/test_mjwarp_capabilities.py | 7 +-- tests/base/test_mjwarp_dr.py | 24 ++++++---- tests/base/test_motrix_backend_options.py | 5 +- tests/base/test_sim_backend_smoke.py | 58 ++++++++++++++--------- 5 files changed, 67 insertions(+), 43 deletions(-) diff --git a/tests/base/test_genesis_backend.py b/tests/base/test_genesis_backend.py index 92ee100f4..1d0563221 100644 --- a/tests/base/test_genesis_backend.py +++ b/tests/base/test_genesis_backend.py @@ -566,12 +566,14 @@ def test_dr_capabilities_and_tensor_reset_randomization_boundary( def test_interval_randomization_and_body_force(fake_genesis, tiny_model_file: str) -> None: backend = _backend(tiny_model_file) solver = backend._scene.sim.rigid_solver - force = np.ones((4, 2, 3), dtype=np.float32) + force = torch.ones((4, 2, 3), dtype=torch.float32) backend.apply_interval_randomization( IntervalRandomizationPlan( ops=( IntervalTermOp( - INTERVAL_TERM_BODY_FORCE, force, body_ids=np.array([1, 3], dtype=np.int32) + INTERVAL_TERM_BODY_FORCE, + force, + body_ids=torch.as_tensor([1, 3], dtype=torch.int32), ), ) ) @@ -583,7 +585,7 @@ def test_interval_randomization_and_body_force(fake_genesis, tiny_model_file: st with pytest.raises(NotImplementedError, match="does not support interval term 'push'"): backend.apply_interval_randomization( - IntervalRandomizationPlan(ops=(IntervalTermOp(INTERVAL_TERM_PUSH, np.ones((1, 3))),)) + IntervalRandomizationPlan(ops=(IntervalTermOp(INTERVAL_TERM_PUSH, torch.ones((1, 3))),)) ) with pytest.raises( NotImplementedError, match="does not support interval term 'body_linear_velocity_delta'" @@ -593,8 +595,8 @@ def test_interval_randomization_and_body_force(fake_genesis, tiny_model_file: st ops=( IntervalTermOp( INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, - np.zeros((4, 1, 3), dtype=np.float32), - body_ids=np.array([1], dtype=np.int32), + torch.zeros((4, 1, 3), dtype=torch.float32), + body_ids=torch.as_tensor([1], dtype=torch.int32), ), ) ) @@ -603,7 +605,9 @@ def test_interval_randomization_and_body_force(fake_genesis, tiny_model_file: st backend.apply_interval_randomization( IntervalRandomizationPlan( ops=( - IntervalTermOp(INTERVAL_TERM_BODY_FORCE, np.zeros((4, 1, 3), dtype=np.float32)), + IntervalTermOp( + INTERVAL_TERM_BODY_FORCE, torch.zeros((4, 1, 3), dtype=torch.float32) + ), ) ) ) diff --git a/tests/base/test_mjwarp_capabilities.py b/tests/base/test_mjwarp_capabilities.py index 8d7823e69..411fd8d67 100644 --- a/tests/base/test_mjwarp_capabilities.py +++ b/tests/base/test_mjwarp_capabilities.py @@ -8,6 +8,7 @@ import numpy as np import pytest +import torch from unisim.backend.mjwarp.dependencies import load_mjwarp_dependencies from unisim.dr.types import ( INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, @@ -73,7 +74,7 @@ def test_interval_push_and_velocity_require_named_bodies() -> None: with pytest.raises(NotImplementedError, match="push target body"): backend.apply_interval_randomization( IntervalRandomizationPlan( - ops=(IntervalTermOp(INTERVAL_TERM_PUSH, np.ones((3,), dtype=np.float32)),) + ops=(IntervalTermOp(INTERVAL_TERM_PUSH, torch.ones((3,), dtype=torch.float32)),) ) ) with pytest.raises(NotImplementedError, match="exactly one free joint"): @@ -82,8 +83,8 @@ def test_interval_push_and_velocity_require_named_bodies() -> None: ops=( IntervalTermOp( INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, - np.zeros((1, 1, 3), dtype=np.float32), - body_ids=np.asarray([1], dtype=np.int32), + torch.zeros((1, 1, 3), dtype=torch.float32), + body_ids=torch.as_tensor([1], dtype=torch.int32), ), ) ) diff --git a/tests/base/test_mjwarp_dr.py b/tests/base/test_mjwarp_dr.py index 5a06649cf..e26ee944e 100644 --- a/tests/base/test_mjwarp_dr.py +++ b/tests/base/test_mjwarp_dr.py @@ -316,7 +316,7 @@ def test_heavier_world_accelerates_less_under_identical_body_force(backend: Any) randomization=_tensor_payload(body_mass=body_mass), ) - force = np.zeros((backend.num_envs, 1, 3), dtype=np.float32) + force = torch.zeros((backend.num_envs, 1, 3), dtype=torch.float32) force[:, 0, 0] = 300.0 backend.apply_interval_randomization( IntervalRandomizationPlan( @@ -324,7 +324,7 @@ def test_heavier_world_accelerates_less_under_identical_body_force(backend: Any) IntervalTermOp( INTERVAL_TERM_BODY_FORCE, force, - body_ids=np.asarray([backend._base_body_id]), + body_ids=torch.as_tensor([backend._base_body_id]), ), ) ) @@ -348,7 +348,11 @@ def test_push_robots_stages_one_step_force(backend: Any) -> None: _reset_all(backend) backend.apply_interval_randomization( IntervalRandomizationPlan( - ops=(IntervalTermOp(INTERVAL_TERM_PUSH, np.asarray([80.0, 0.0, 0.0])),) + ops=( + IntervalTermOp( + INTERVAL_TERM_PUSH, torch.tensor([80.0, 0.0, 0.0], dtype=torch.float64) + ), + ) ) ) assert backend._xfrc_pending @@ -363,21 +367,23 @@ def test_push_robots_stages_one_step_force(backend: Any) -> None: def test_interval_velocity_delta_kicks_selected_rows(backend: Any) -> None: _reset_all(backend) before = backend.get_base_lin_vel().copy() - delta = np.zeros((backend.num_envs, 1, 3), dtype=np.float32) - delta[[0, 3], 0, :] = np.asarray([[0.4, -0.2, 0.0], [-0.3, 0.1, 0.0]], dtype=np.float32) + delta = torch.zeros((backend.num_envs, 1, 3), dtype=torch.float32) + delta[[0, 3], 0, :] = torch.tensor([[0.4, -0.2, 0.0], [-0.3, 0.1, 0.0]], dtype=torch.float32) backend.apply_interval_randomization( IntervalRandomizationPlan( ops=( IntervalTermOp( INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, delta, - body_ids=np.asarray([backend._base_body_id], dtype=np.int32), + body_ids=torch.as_tensor([backend._base_body_id], dtype=torch.int32), ), ) ) ) after = backend.get_base_lin_vel() - np.testing.assert_allclose(after[[0, 3]], before[[0, 3]] + delta[[0, 3], 0, :], atol=1e-6) + np.testing.assert_allclose( + after[[0, 3]], before[[0, 3]] + delta[[0, 3], 0, :].numpy(), atol=1e-6 + ) np.testing.assert_allclose(after[[1, 2]], before[[1, 2]], atol=1e-6) # The kick commits through the forward barrier, keeping sensors coherent. assert np.isfinite(backend.get_sensor_data("torso_upvector")).all() @@ -419,7 +425,7 @@ def test_low_friction_world_changes_kick_response(backend: Any) -> None: randomization=_tensor_payload(geom_friction=geom_friction), ) start = backend.get_base_pos()[:, :2].copy() - delta = np.zeros((backend.num_envs, 1, 3), dtype=np.float32) + delta = torch.zeros((backend.num_envs, 1, 3), dtype=torch.float32) delta[:, 0, 0] = 1.5 backend.apply_interval_randomization( IntervalRandomizationPlan( @@ -427,7 +433,7 @@ def test_low_friction_world_changes_kick_response(backend: Any) -> None: IntervalTermOp( INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, delta, - body_ids=np.asarray([backend._base_body_id], dtype=np.int32), + body_ids=torch.as_tensor([backend._base_body_id], dtype=torch.int32), ), ) ) diff --git a/tests/base/test_motrix_backend_options.py b/tests/base/test_motrix_backend_options.py index 3c7ad327f..0c592cb46 100644 --- a/tests/base/test_motrix_backend_options.py +++ b/tests/base/test_motrix_backend_options.py @@ -5,6 +5,7 @@ import numpy as np import pytest +import torch from unisim.dr.types import ( INTERVAL_TERM_BODY_FORCE, RESET_TERM_BODY_IPOS, @@ -517,8 +518,8 @@ def test_motrix_backend_interval_body_force_uses_link_external_force_delta() -> ops=( IntervalTermOp( INTERVAL_TERM_BODY_FORCE, - np.asarray([[[1.0, 2.0, 3.0]], [[4.0, 5.0, 6.0]]], dtype=np.float64), - body_ids=np.asarray([0], dtype=np.int32), + torch.as_tensor([[[1.0, 2.0, 3.0]], [[4.0, 5.0, 6.0]]], dtype=torch.float64), + body_ids=torch.as_tensor([0], dtype=torch.int32), ), ) ) diff --git a/tests/base/test_sim_backend_smoke.py b/tests/base/test_sim_backend_smoke.py index 393c6d7b3..c3cdd2d95 100644 --- a/tests/base/test_sim_backend_smoke.py +++ b/tests/base/test_sim_backend_smoke.py @@ -176,13 +176,17 @@ def test_mujoco_interval_root_velocity_kick_is_row_selective_and_refreshes_senso body_ids = bkd.get_body_ids(("base",)) state_before = bkd.get_physics_state().copy() body_velocity_before = _sensor(bkd, "track_linvel_w_base").copy() - delta = np.zeros((NUM_ENVS, 1, 3), dtype=np.float64) - delta[1, 0] = (0.25, -0.4, 0.15) + delta = torch.zeros((NUM_ENVS, 1, 3), dtype=torch.float64) + delta[1, 0] = torch.tensor([0.25, -0.4, 0.15], dtype=torch.float64) bkd.apply_interval_randomization( IntervalRandomizationPlan( ops=( - IntervalTermOp(INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, delta, body_ids=body_ids), + IntervalTermOp( + INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, + delta, + body_ids=torch.as_tensor(body_ids), + ), ) ) ) @@ -203,12 +207,12 @@ def test_mujoco_interval_root_velocity_kick_is_row_selective_and_refreshes_senso ) np.testing.assert_allclose( state_after[1, idx_qvel : idx_qvel + 3], - state_before[1, idx_qvel : idx_qvel + 3] + delta[1, 0], + state_before[1, idx_qvel : idx_qvel + 3] + delta[1, 0].numpy(), atol=1e-7, ) np.testing.assert_allclose( body_velocity_after[1], - body_velocity_before[1] + delta[1, 0], + body_velocity_before[1] + delta[1, 0].numpy(), atol=1e-6, ) @@ -223,14 +227,16 @@ def test_mujoco_interval_root_velocity_kick_rejects_invalid_contracts(): base_name="base", ) body_ids = bkd.get_body_ids(("base",)) - delta = np.zeros((NUM_ENVS, 1, 3), dtype=np.float64) + delta = torch.zeros((NUM_ENVS, 1, 3), dtype=torch.float64) with pytest.raises(RuntimeError, match="requires a materialized backend"): bkd.apply_interval_randomization( IntervalRandomizationPlan( ops=( IntervalTermOp( - INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, delta, body_ids=body_ids + INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, + delta, + body_ids=torch.as_tensor(body_ids), ), ) ) @@ -244,7 +250,7 @@ def test_mujoco_interval_root_velocity_kick_rejects_invalid_contracts(): IntervalTermOp( INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, delta, - body_ids=body_ids.astype(np.float64), + body_ids=torch.as_tensor(body_ids.astype(np.float64)), ), ) ) @@ -256,7 +262,7 @@ def test_mujoco_interval_root_velocity_kick_rejects_invalid_contracts(): IntervalTermOp( INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, delta, - body_ids=np.asarray([body_ids[0] + 1], dtype=np.int32), + body_ids=torch.as_tensor([int(body_ids[0]) + 1], dtype=torch.int32), ), ) ) @@ -267,8 +273,8 @@ def test_mujoco_interval_root_velocity_kick_rejects_invalid_contracts(): ops=( IntervalTermOp( INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, - np.zeros((NUM_ENVS + 1, 1, 3), dtype=np.float64), - body_ids=body_ids, + torch.zeros((NUM_ENVS + 1, 1, 3), dtype=torch.float64), + body_ids=torch.as_tensor(body_ids), ), ) ) @@ -279,14 +285,14 @@ def test_mujoco_interval_root_velocity_kick_rejects_invalid_contracts(): ops=( IntervalTermOp( INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, - np.zeros((NUM_ENVS, 3), dtype=np.float64), - body_ids=body_ids, + torch.zeros((NUM_ENVS, 3), dtype=torch.float64), + body_ids=torch.as_tensor(body_ids), ), ) ) ) - invalid = delta.copy() - invalid[0, 0, 0] = np.nan + invalid = delta.clone() + invalid[0, 0, 0] = torch.nan with pytest.raises(ValueError, match="contains NaN or Inf"): bkd.apply_interval_randomization( IntervalRandomizationPlan( @@ -294,7 +300,7 @@ def test_mujoco_interval_root_velocity_kick_rejects_invalid_contracts(): IntervalTermOp( INTERVAL_TERM_BODY_LINEAR_VELOCITY_DELTA, invalid, - body_ids=body_ids, + body_ids=torch.as_tensor(body_ids), ), ) ) @@ -322,13 +328,17 @@ def test_mujoco_interval_root_angular_velocity_kick_converts_to_body_frame(): _set_state(bkd, np.arange(NUM_ENVS, dtype=np.int32), qpos, qvel) body_ids = bkd.get_body_ids(("base",)) - delta = np.zeros((NUM_ENVS, 1, 3), dtype=np.float64) - delta[1, 0] = (0.5, 0.0, 0.0) # world-frame +x angular rate + delta = torch.zeros((NUM_ENVS, 1, 3), dtype=torch.float64) + delta[1, 0] = torch.tensor([0.5, 0.0, 0.0], dtype=torch.float64) # world-frame +x angular rate bkd.apply_interval_randomization( IntervalRandomizationPlan( ops=( - IntervalTermOp(INTERVAL_TERM_BODY_ANGULAR_VELOCITY_DELTA, delta, body_ids=body_ids), + IntervalTermOp( + INTERVAL_TERM_BODY_ANGULAR_VELOCITY_DELTA, + delta, + body_ids=torch.as_tensor(body_ids), + ), ) ) ) @@ -396,17 +406,19 @@ def test_mujoco_interval_body_force_and_torque_have_observable_effect(tmp_path): idx_qvel = 1 + bkd.nq baseline = bkd.get_physics_state()[:, idx_qvel : idx_qvel + 6].copy() - force = np.zeros((NUM_ENVS, 1, 3), dtype=np.float64) + force = torch.zeros((NUM_ENVS, 1, 3), dtype=torch.float64) force[:, 0, 2] = 10.0 # N, world +z - torque = np.zeros((NUM_ENVS, 1, 3), dtype=np.float64) + torque = torch.zeros((NUM_ENVS, 1, 3), dtype=torch.float64) torque[:, 0, 2] = 1.0 # Nm, world z _set_state(bkd, ids, qpos, qvel0) bkd.apply_interval_randomization( IntervalRandomizationPlan( ops=( - IntervalTermOp(INTERVAL_TERM_BODY_FORCE, force, body_ids=body_ids), - IntervalTermOp(INTERVAL_TERM_BODY_TORQUE, torque, body_ids=body_ids), + IntervalTermOp(INTERVAL_TERM_BODY_FORCE, force, body_ids=torch.as_tensor(body_ids)), + IntervalTermOp( + INTERVAL_TERM_BODY_TORQUE, torque, body_ids=torch.as_tensor(body_ids) + ), ) ) )