diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 09e355ee513f..27f13d198e29 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -85,6 +85,8 @@ jobs: needs: [doc-build-type] # run on deploy branches to create multi-version docs if: needs.doc-build-type.outputs.trigger-deploy == 'true' + env: + DOCS_DEFAULT_REF: v3.0.0-EA steps: - name: Checkout code @@ -116,18 +118,9 @@ jobs: git for-each-ref --format="%(refname:short)" refs/heads/ | xargs -r git branch -D make multi-docs - - name: Set default docs version + - name: Verify default docs version working-directory: ./docs - env: - DOCS_DEFAULT_REF: v3.0.0-beta2 - run: | - test -n "$DOCS_DEFAULT_REF" - test -f "_build/$DOCS_DEFAULT_REF/index.html" || { - echo "::error::Default docs ref '$DOCS_DEFAULT_REF' was not built" - exit 1 - } - sed "s|url=\./[^\"]*/index.html|url=./$DOCS_DEFAULT_REF/index.html|" \ - _redirect/index.html > _build/index.html + run: grep -Fq "url=./$DOCS_DEFAULT_REF/index.html" _build/index.html - name: Upload GitHub Pages artifact uses: actions/upload-pages-artifact@v4 diff --git a/docs/Makefile b/docs/Makefile index 82f4c708b7f0..96daebd61d5b 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -7,11 +7,14 @@ SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build +DOCS_DEFAULT_REF ?= v3.0.0-EA .PHONY: multi-docs multi-docs: @sphinx-multiversion "$(SOURCEDIR)" "$(BUILDDIR)" --jobs=auto $(SPHINXOPTS) - @cp _redirect/index.html $(BUILDDIR)/index.html + @test -f "$(BUILDDIR)/$(DOCS_DEFAULT_REF)/index.html" || \ + (echo "Default docs ref '$(DOCS_DEFAULT_REF)' was not built" && exit 1) + @sed 's|url=\./[^"]*/index.html|url=./$(DOCS_DEFAULT_REF)/index.html|' _redirect/index.html > $(BUILDDIR)/index.html .PHONY: current-docs current-docs: diff --git a/docs/source/how-to/cloning.rst b/docs/source/how-to/cloning.rst index c1b462701b48..d31f8b548941 100644 --- a/docs/source/how-to/cloning.rst +++ b/docs/source/how-to/cloning.rst @@ -327,6 +327,11 @@ roots as a global-only plan. This keeps their existing native physics initializa USD runs before native physics contexts so the destination topology exists when they consume it. No fallback context is constructed during dispatch. +Setting ``replicate_physics=False`` skips the active physics replication context. +Other declared contexts still run, including USD replication when configured, so +they can create the per-environment prims without replicating the physics model. +The physics backend can then parse those prims independently. + Collision Filtering ------------------- diff --git a/source/isaaclab/changelog.d/delay-buffer-invalid-state.rst b/source/isaaclab/changelog.d/delay-buffer-invalid-state.rst new file mode 100644 index 000000000000..787145138c99 --- /dev/null +++ b/source/isaaclab/changelog.d/delay-buffer-invalid-state.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed ``DelayBuffer.set_time_lag`` leaving invalid per-batch delays in the buffer by validating requested + values before assignment, without cloning the live lag tensor. diff --git a/source/isaaclab/changelog.d/jichuanh-simcontext-device-guard.major.rst b/source/isaaclab/changelog.d/jichuanh-simcontext-device-guard.major.rst new file mode 100644 index 000000000000..604a385edf66 --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-simcontext-device-guard.major.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* **Breaking:** Changed :class:`~isaaclab.sim.SimulationContext` to reject construction while a + context already exists, instead of returning it and discarding the requested configuration. + This includes no-argument construction and reusing the same configuration. Use + :meth:`~isaaclab.sim.SimulationContext.instance` to retrieve the live context, or call + :meth:`~isaaclab.sim.SimulationContext.clear_instance` before constructing a replacement. diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 12219a681512..1f294f61b65a 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -95,19 +95,14 @@ class SimulationContext: * Simulation state (play, pause, step, stop) * Rendering and visualization - The singleton instance can be accessed using the ``instance()`` class method. + Use :meth:`instance` to retrieve the live context. Construction always creates a new context + and raises if one already exists; call :meth:`clear_instance` before constructing a replacement. """ # SINGLETON PATTERN _instance: SimulationContext | None = None - def __new__(cls, cfg: SimulationCfg | None = None): - """Enforce singleton pattern.""" - if cls._instance is not None: - return cls._instance - return super().__new__(cls) - @classmethod def instance(cls) -> SimulationContext | None: """Get the singleton instance, or None if not created.""" @@ -118,9 +113,15 @@ def __init__(self, cfg: SimulationCfg | None = None): Args: cfg: Simulation configuration. Defaults to None (uses default config). + + Raises: + RuntimeError: If a simulation context already exists. """ if type(self)._instance is not None: - return # Already initialized + raise RuntimeError( + "A SimulationContext already exists. Use SimulationContext.instance() to retrieve it," + " or call SimulationContext.clear_instance() before constructing a replacement." + ) from pxr import UsdUtils # noqa: PLC0415 diff --git a/source/isaaclab/isaaclab/utils/buffers/delay_buffer.py b/source/isaaclab/isaaclab/utils/buffers/delay_buffer.py index 56086922613d..c74bab9fb079 100644 --- a/source/isaaclab/isaaclab/utils/buffers/delay_buffer.py +++ b/source/isaaclab/isaaclab/utils/buffers/delay_buffer.py @@ -125,29 +125,26 @@ def set_time_lag(self, time_lag: int | torch.Tensor, batch_ids: Sequence[int] | if batch_ids is None: batch_ids = slice(None) - # parse requested time_lag + # Validate the requested values before changing the live configuration. if isinstance(time_lag, int): - # set the time lags across provided batch indices - self._time_lags[batch_ids] = time_lag + min_time_lag = max_time_lag = time_lag elif isinstance(time_lag, torch.Tensor): # check valid dtype for time_lag: must be int or long if time_lag.dtype not in [torch.int, torch.long]: raise TypeError(f"Invalid dtype for time_lag: {time_lag.dtype}. Expected torch.int or torch.long.") - # set the time lags - self._time_lags[batch_ids] = time_lag.to(device=self.device) + min_time_lag = int(time_lag.min().item()) if time_lag.numel() else 0 + max_time_lag = int(time_lag.max().item()) if time_lag.numel() else 0 else: raise TypeError(f"Invalid type for time_lag: {type(time_lag)}. Expected int or integer tensor.") - # compute the min and max time lag - self._min_time_lag = int(torch.min(self._time_lags).item()) - self._max_time_lag = int(torch.max(self._time_lags).item()) - # check that time_lag is feasible - if self._min_time_lag < 0: - raise ValueError(f"The minimum time lag cannot be negative. Received: {self._min_time_lag}") - if self._max_time_lag > self._history_length: - raise ValueError( - f"The maximum time lag cannot be larger than the history length. Received: {self._max_time_lag}" - ) + if min_time_lag < 0: + raise ValueError(f"The minimum time lag cannot be negative. Received: {min_time_lag}") + if max_time_lag > self._history_length: + raise ValueError(f"The maximum time lag cannot be larger than the history length. Received: {max_time_lag}") + + self._time_lags[batch_ids] = time_lag.to(device=self.device) if isinstance(time_lag, torch.Tensor) else time_lag + self._min_time_lag = int(self._time_lags.min().item()) + self._max_time_lag = int(self._time_lags.max().item()) def reset(self, batch_ids: Sequence[int] | None = None): """Reset the data in the delay buffer at the specified batch indices. diff --git a/source/isaaclab/test/managers/test_event_manager.py b/source/isaaclab/test/managers/test_event_manager.py index cd3cde703a55..762d2bcb4e42 100644 --- a/source/isaaclab/test/managers/test_event_manager.py +++ b/source/isaaclab/test/managers/test_event_manager.py @@ -94,7 +94,8 @@ def env(): # create sim sim = SimulationContext() # create dummy environment - return DummyEnv(num_envs, 0.01, device, sim, dummy1, dummy2) + yield DummyEnv(num_envs, 0.01, device, sim, dummy1, dummy2) + SimulationContext.clear_instance() def test_str(env): diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index c839f7b7b473..4cd0a14ea27c 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -118,7 +118,9 @@ def setup_env(): # let the simulation play (we need this for observation manager to compute obs dims) env.sim._app_control_on_stop_handle = None env.sim.reset() - return env + yield env + sim.stop() + sim_utils.SimulationContext.clear_instance() def test_str(setup_env): diff --git a/source/isaaclab/test/managers/test_reward_manager.py b/source/isaaclab/test/managers/test_reward_manager.py index 57b9fd57f02f..d3cc9fd74187 100644 --- a/source/isaaclab/test/managers/test_reward_manager.py +++ b/source/isaaclab/test/managers/test_reward_manager.py @@ -43,7 +43,8 @@ def grilled_chicken_with_yoghurt(env, hot: bool, bland: float): @pytest.fixture def env(): sim = SimulationContext() - return namedtuple("ManagerBasedRLEnv", ["num_envs", "dt", "device", "sim"])(20, 0.1, "cpu", sim) + yield namedtuple("ManagerBasedRLEnv", ["num_envs", "dt", "device", "sim"])(20, 0.1, "cpu", sim) + SimulationContext.clear_instance() def test_str(env): diff --git a/source/isaaclab/test/managers/test_termination_manager.py b/source/isaaclab/test/managers/test_termination_manager.py index 095a95d69db2..43e066e1b211 100644 --- a/source/isaaclab/test/managers/test_termination_manager.py +++ b/source/isaaclab/test/managers/test_termination_manager.py @@ -52,7 +52,8 @@ def fail_every_3_steps(env) -> torch.Tensor: @pytest.fixture def env(): sim = SimulationContext() - return DummyEnv(num_envs=20, device="cpu", sim=sim) + yield DummyEnv(num_envs=20, device="cpu", sim=sim) + SimulationContext.clear_instance() def test_initial_state_and_shapes(env): diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 57cfa0f86dbe..3b1f68cdffdc 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -22,6 +22,13 @@ from isaaclab.sim import SimulationCfg, SimulationContext +@pytest.fixture(autouse=True) +def cleanup_simulation_context(): + """Release the simulation context after each test.""" + yield + SimulationContext.clear_instance() + + def _make_xform(stage, path="/World/Art"): UsdGeom.Xform.Define(stage, path) return stage.GetPrimAtPath(path) diff --git a/source/isaaclab/test/sim/test_collision_fragments.py b/source/isaaclab/test/sim/test_collision_fragments.py index f5da64c8b255..b4ae604a71c6 100644 --- a/source/isaaclab/test/sim/test_collision_fragments.py +++ b/source/isaaclab/test/sim/test_collision_fragments.py @@ -22,6 +22,13 @@ pytestmark = pytest.mark.integration +@pytest.fixture(autouse=True) +def cleanup_simulation_context(): + """Release the simulation context after each test.""" + yield + SimulationContext.clear_instance() + + def _make_xform(stage, path="/World/Body"): UsdGeom.Xform.Define(stage, path) return stage.GetPrimAtPath(path) diff --git a/source/isaaclab/test/sim/test_joint_drive_fragments.py b/source/isaaclab/test/sim/test_joint_drive_fragments.py index be4c19de6424..2c4069a5b589 100644 --- a/source/isaaclab/test/sim/test_joint_drive_fragments.py +++ b/source/isaaclab/test/sim/test_joint_drive_fragments.py @@ -24,6 +24,13 @@ pytestmark = pytest.mark.integration +@pytest.fixture(autouse=True) +def cleanup_simulation_context(): + """Release the simulation context after each test.""" + yield + SimulationContext.clear_instance() + + def _make_revolute_joint(stage, path="/World/Articulation/joint_0"): UsdGeom.Xform.Define(stage, "/World/Articulation") UsdGeom.Cube.Define(stage, "/World/Articulation/body0") diff --git a/source/isaaclab/test/sim/test_mass_fragments.py b/source/isaaclab/test/sim/test_mass_fragments.py index b57808d501ae..e5effb5f38e4 100644 --- a/source/isaaclab/test/sim/test_mass_fragments.py +++ b/source/isaaclab/test/sim/test_mass_fragments.py @@ -22,6 +22,13 @@ pytestmark = pytest.mark.integration +@pytest.fixture(autouse=True) +def cleanup_simulation_context(): + """Release the simulation context after each test.""" + yield + SimulationContext.clear_instance() + + def _make_xform(stage, path="/World/Body"): UsdGeom.Xform.Define(stage, path) return stage.GetPrimAtPath(path) diff --git a/source/isaaclab/test/sim/test_material_fragments.py b/source/isaaclab/test/sim/test_material_fragments.py index fbea81b5bc91..d08c0d2cf362 100644 --- a/source/isaaclab/test/sim/test_material_fragments.py +++ b/source/isaaclab/test/sim/test_material_fragments.py @@ -21,6 +21,14 @@ pytestmark = pytest.mark.integration + +@pytest.fixture(autouse=True) +def cleanup_simulation_context(): + """Release the simulation context after each test.""" + yield + SimulationContext.clear_instance() + + # ------------------------------------------------------------------------------------- # RigidBodyMaterialFragment marker + metadata # ------------------------------------------------------------------------------------- diff --git a/source/isaaclab/test/sim/test_mesh_collision_fragments.py b/source/isaaclab/test/sim/test_mesh_collision_fragments.py index 0d8af815d02c..4ecbcf8fcdb8 100644 --- a/source/isaaclab/test/sim/test_mesh_collision_fragments.py +++ b/source/isaaclab/test/sim/test_mesh_collision_fragments.py @@ -22,6 +22,13 @@ pytestmark = pytest.mark.integration +@pytest.fixture(autouse=True) +def cleanup_simulation_context(): + """Release the simulation context after each test.""" + yield + SimulationContext.clear_instance() + + def _make_xform(stage, path="/World/Mesh"): UsdGeom.Xform.Define(stage, path) return stage.GetPrimAtPath(path) diff --git a/source/isaaclab/test/sim/test_schema_fragments.py b/source/isaaclab/test/sim/test_schema_fragments.py index 7055976f8c5d..9f2615e3f3e0 100644 --- a/source/isaaclab/test/sim/test_schema_fragments.py +++ b/source/isaaclab/test/sim/test_schema_fragments.py @@ -22,6 +22,13 @@ pytestmark = pytest.mark.integration +@pytest.fixture(autouse=True) +def cleanup_simulation_context(): + """Release the simulation context after each test.""" + yield + SimulationContext.clear_instance() + + def _make_xform(stage, path="/World/Body"): UsdGeom.Xform.Define(stage, path) return stage.GetPrimAtPath(path) diff --git a/source/isaaclab/test/sim/test_schema_writer_nested_targets.py b/source/isaaclab/test/sim/test_schema_writer_nested_targets.py index ca084c45bfed..fc3cb37513b7 100644 --- a/source/isaaclab/test/sim/test_schema_writer_nested_targets.py +++ b/source/isaaclab/test/sim/test_schema_writer_nested_targets.py @@ -30,6 +30,13 @@ """Link prim paths relative to the robot root; ``link2/link3`` is a nested child link.""" +@pytest.fixture(autouse=True) +def cleanup_simulation_context(): + """Release the simulation context after each test.""" + yield + SimulationContext.clear_instance() + + def _author_robot_usd(path: str) -> None: """Author a minimal articulated-robot USD layout. diff --git a/source/isaaclab/test/sim/test_simulation_context.py b/source/isaaclab/test/sim/test_simulation_context.py index 95b663f25578..2617f1078cd8 100644 --- a/source/isaaclab/test/sim/test_simulation_context.py +++ b/source/isaaclab/test/sim/test_simulation_context.py @@ -169,20 +169,30 @@ def test_instance_before_creation(): @pytest.mark.isaacsim_ci def test_singleton(): - """Tests that the singleton is working.""" - sim1 = SimulationContext() - sim2 = SimulationContext() - assert sim1 is sim2 - - # try to delete the singleton - sim2.clear_instance() - assert sim1.instance() is None - # create new instance - sim3 = SimulationContext() - assert sim1 is not sim3 - assert sim1.instance() is sim3.instance() - # clear instance - sim3.clear_instance() + """Construction creates a context; only instance() retrieves the live context.""" + sim = SimulationContext(SimulationCfg(dt=0.01)) + live_device, live_dt = sim.cfg.device, sim.cfg.dt + other_device = "cpu" if live_device.startswith("cuda") else "cuda:0" + for args in ( + (), + (None,), + (sim.cfg,), + (sim.cfg.copy(),), + (sim.cfg.replace(dt=2.0 * live_dt),), + (sim.cfg.replace(device=other_device),), + ): + with pytest.raises(RuntimeError, match=r"SimulationContext\.instance\(\)"): + SimulationContext(*args) + assert SimulationContext.instance() is sim + assert sim.cfg.dt == live_dt + assert sim.cfg.device == sim.device == live_device + + SimulationContext.clear_instance() + assert SimulationContext.instance() is None + replacement = SimulationContext(SimulationCfg(dt=2.0 * live_dt)) + assert replacement is not sim + assert SimulationContext.instance() is replacement + assert replacement.cfg.dt == 2.0 * live_dt """ diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index 0afd370b4c4a..390d186e6f79 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -40,6 +40,13 @@ pytestmark = pytest.mark.integration +@pytest.fixture(autouse=True) +def cleanup_simulation_context(): + """Release the simulation context after each test.""" + yield + SimulationContext.clear_instance() + + def _new_sim(): sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) diff --git a/source/isaaclab/test/utils/test_delay_buffer.py b/source/isaaclab/test/utils/test_delay_buffer.py index 354281c07a24..203bf4fb15a8 100644 --- a/source/isaaclab/test/utils/test_delay_buffer.py +++ b/source/isaaclab/test/utils/test_delay_buffer.py @@ -93,6 +93,31 @@ def test_random_time_lags(delay_buffer): assert torch.all(error == 0) +@pytest.mark.parametrize( + ("time_lag", "batch_ids"), + [ + (5, [2]), + (-1, [2]), + (torch.tensor([5, 1], dtype=torch.int), [2, 3]), + ], +) +def test_invalid_time_lag_does_not_mutate_state(delay_buffer, time_lag, batch_ids, monkeypatch): + """Reject invalid inputs before copying or changing the live lag configuration.""" + initial_lags = torch.arange(delay_buffer.batch_size, dtype=torch.int) % 5 + delay_buffer.set_time_lag(initial_lags) + expected_lags = delay_buffer.time_lags + expected_values = expected_lags.clone() + monkeypatch.setattr(expected_lags, "clone", lambda: pytest.fail("Validate the requested lag before copying state.")) + + with pytest.raises(ValueError): + delay_buffer.set_time_lag(time_lag, batch_ids) + + assert delay_buffer.time_lags is expected_lags + assert torch.equal(delay_buffer.time_lags, expected_values) + assert delay_buffer.min_time_lag == 0 + assert delay_buffer.max_time_lag == 4 + + def test_compute_result_independent_of_internal_buffer(delay_buffer): """``compute()``'s returned tensor must not alias the internal circular buffer storage.