Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 4 additions & 11 deletions .github/workflows/docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions docs/source/how-to/cloning.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------------

Expand Down
5 changes: 5 additions & 0 deletions source/isaaclab/changelog.d/delay-buffer-invalid-state.rst
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 9 additions & 8 deletions source/isaaclab/isaaclab/sim/simulation_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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

Expand Down
27 changes: 12 additions & 15 deletions source/isaaclab/isaaclab/utils/buffers/delay_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion source/isaaclab/test/managers/test_event_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion source/isaaclab/test/managers/test_observation_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion source/isaaclab/test/managers/test_reward_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion source/isaaclab/test/managers/test_termination_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 7 additions & 0 deletions source/isaaclab/test/sim/test_articulation_fragments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions source/isaaclab/test/sim/test_collision_fragments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions source/isaaclab/test/sim/test_joint_drive_fragments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
7 changes: 7 additions & 0 deletions source/isaaclab/test/sim/test_mass_fragments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions source/isaaclab/test/sim/test_material_fragments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# -------------------------------------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions source/isaaclab/test/sim/test_mesh_collision_fragments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions source/isaaclab/test/sim/test_schema_fragments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
38 changes: 24 additions & 14 deletions source/isaaclab/test/sim/test_simulation_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


"""
Expand Down
7 changes: 7 additions & 0 deletions source/isaaclab/test/sim/test_tendon_fragments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
25 changes: 25 additions & 0 deletions source/isaaclab/test/utils/test_delay_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading