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
8 changes: 7 additions & 1 deletion examples/so101_curobo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@ recipe: `create() → add_frame()* → save_episode() → finalize()`.
- **Isaac:** `--backend isaac` calls `create_simulation("isaac", render_mode="rtx_realtime")`.
Needs the Isaac Sim runtime (~30 GB) and backend registration (#67 **T1**), plus a
faithful SO-101 USD via `add_robot(usd_path=...)` (**T2**). Falls back to MuJoCo otherwise.
- **cuRobo:** `--planner curobo` (+ `--curobo-urdf` / `SO101_URDF`).
- **cuRobo:** `--planner curobo` (+ optional `--curobo-urdf` / `SO101_URDF`).
**SO-101 URDF resolution:** the URDF is resolved in this order — explicit
`--curobo-urdf` → `SO101_URDF` env → the **auto-downloaded `strands-robots`
SO-101 cache URDF** (`~/.strands_robots/assets/robotstudio_so101/`, the same
asset the default MuJoCo demo fetches). So once you've run the MuJoCo demo
(or on any box with internet), `--planner curobo` finds a URDF + meshes
with **no flag needed**; pass `--curobo-urdf` only to override with your own.
**Driver:** NVIDIA's docs recommend driver ≥ 580.65.06 for cuRobo's latest
release, but this example is **validated on driver 550 / CUDA 12.4 / L4** —
the 580 floor is conservative, since CUDA 12.x kernels run on a 12.4 driver.
Expand Down
14 changes: 12 additions & 2 deletions examples/so101_curobo/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,18 @@ def main(argv: "list[str] | None" = None) -> None:
p = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
p.add_argument("--backend", default="mujoco", choices=["mujoco", "isaac"])
p.add_argument("--planner", default="auto", choices=["auto", "scripted", "curobo", "precomputed"])
p.add_argument("--curobo-urdf", default=None, help="SO-101 URDF for cuRobo (or env SO101_URDF).")
p.add_argument("--curobo-asset", default="", help="Mesh root for the SO-101 URDF (or env SO101_ASSET).")
p.add_argument(
"--curobo-urdf",
default=None,
help="SO-101 URDF for cuRobo. Defaults to env SO101_URDF, else the "
"auto-downloaded strands-robots SO-101 cache URDF (no flag needed once "
"the MuJoCo demo has populated the asset cache).",
)
p.add_argument(
"--curobo-asset",
default="",
help="Mesh root for the SO-101 URDF. Defaults to env SO101_ASSET, else the cache mesh dir.",
)
p.add_argument(
"--curobo-traj",
default=None,
Expand Down
11 changes: 8 additions & 3 deletions examples/so101_curobo/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,16 +142,21 @@ def build(self) -> "SO101CuroboDemo":
# conventions + EE frame), and the Isaac backend has no data_config
# path at all -- it requires a URDF. In both cases load the arm from
# that URDF so plans execute correctly / the backend can build it.
import os

needs_urdf = getattr(self.planner.primary, "name", "") == "curobo" or self.backend in (
"isaac",
"isaacsim",
"isaac_sim",
)
robot_urdf = None
if needs_urdf:
robot_urdf = self.planner_kwargs.get("urdf_path") or os.environ.get("SO101_URDF")
# Same precedence as the cuRobo planner (explicit kwarg ->
# SO101_URDF -> the auto-downloaded strands-robots cache URDF) so
# the sim arm loads the EXACT URDF cuRobo plans with, and the
# cuRobo/URDF path works out-of-the-box once the MuJoCo demo has
# populated the SO-101 asset cache.
from .planner import resolve_so101_urdf

robot_urdf = resolve_so101_urdf(self.planner_kwargs.get("urdf_path"))
self.scene = build_pick_place_scene(
self.sim, camera_size=self.camera_size, backend=backend, robot_urdf=robot_urdf
)
Expand Down
13 changes: 11 additions & 2 deletions examples/so101_curobo/plan_curobo_offline.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,17 @@

def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--urdf", default=os.environ.get("SO101_URDF"))
ap.add_argument("--asset", default=os.environ.get("SO101_ASSET"))
ap.add_argument(
"--urdf",
default=os.environ.get("SO101_URDF"),
help="SO-101 URDF. Defaults to $SO101_URDF, else the auto-downloaded "
"strands-robots SO-101 cache URDF (the one the MuJoCo demo fetches).",
)
ap.add_argument(
"--asset",
default=os.environ.get("SO101_ASSET"),
help="Mesh dir for the URDF. Defaults to $SO101_ASSET, else the cache mesh dir.",
)
ap.add_argument("--cube-xy", nargs=2, type=float, default=[0.34, 0.0])
ap.add_argument("--place-xy", nargs=2, type=float, default=[0.0, 0.25])
ap.add_argument(
Expand Down
107 changes: 98 additions & 9 deletions examples/so101_curobo/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,90 @@ def curobo_available() -> bool:
return False


def _so101_cache_urdf() -> "tuple[Optional[str], str]":
"""Best-effort: resolve the SO-101 URDF + mesh dir from the strands-robots cache.

The default MuJoCo demo already auto-downloads the SO-101 model into the
external ``~/.strands_robots`` cache (via ``strands_robots.assets``), and
that download ships a URDF (``so101_new_calib.urdf``) and a ``assets/``
mesh dir alongside the MJCF. The cuRobo / URDF-sim paths previously
*ignored* that and forced the user to supply ``--curobo-urdf`` /
``SO101_URDF`` by hand even though a perfectly good URDF was already on
disk. This resolves that cached URDF so the cuRobo path works
out-of-the-box on the same machine the MuJoCo demo already ran on.

Returns ``(urdf_path, asset_path)``; ``urdf_path`` is ``None`` when the
cache / package isn't available (so callers keep their existing
"raise an actionable error" behaviour). ``resolve_model_dir`` triggers the
same auto-download the MuJoCo path uses, so a clean box with internet
populates the cache here too.
"""
try:
import glob

from strands_robots.assets import resolve_model_dir # type: ignore[import-not-found]
except Exception: # noqa: BLE001
return None, ""
try:
model_dir = resolve_model_dir("so101")
except Exception: # noqa: BLE001
return None, ""
if not model_dir:
return None, ""
model_dir = str(model_dir)
# Prefer the new-calibration URDF; fall back to any *.urdf in the dir.
preferred = os.path.join(model_dir, "so101_new_calib.urdf")
if os.path.exists(preferred):
urdf = preferred
else:
matches = sorted(glob.glob(os.path.join(model_dir, "*.urdf")))
urdf = matches[0] if matches else None
if not urdf:
return None, ""
# Meshes live in the cache dir's ``assets/`` subdir when present; cuRobo's
# RobotBuilder resolves ``package://`` / relative mesh refs against it.
asset_dir = os.path.join(model_dir, "assets")
asset_path = asset_dir if os.path.isdir(asset_dir) else model_dir
return urdf, asset_path


def resolve_so101_urdf(urdf_path: "Optional[str]" = None) -> "Optional[str]":
"""Resolve the SO-101 URDF with the documented precedence.

1. explicit ``urdf_path`` argument,
2. the ``SO101_URDF`` env var,
3. the auto-downloaded ``strands_robots`` SO-101 cache URDF
(see :func:`_so101_cache_urdf`).

Returns ``None`` only when none of the three resolve, so callers keep
their existing fail-with-hint behaviour for a box that has neither an
explicit URDF nor the cache.
"""
if urdf_path:
return urdf_path
env = os.environ.get("SO101_URDF")
if env:
return env
cached, _ = _so101_cache_urdf()
return cached


def resolve_so101_asset(asset_path: str = "") -> str:
"""Resolve the SO-101 mesh dir (for cuRobo) with the same precedence.

explicit ``asset_path`` -> ``SO101_ASSET`` env -> the cache mesh dir.
Returns ``""`` when none resolve (cuRobo treats that as "no extra mesh
search path", unchanged from before).
"""
if asset_path:
return asset_path
env = os.environ.get("SO101_ASSET")
if env:
return env
_, cached_assets = _so101_cache_urdf()
return cached_assets


CUROBO_AVAILABLE = curobo_available()

# The SO-101 gripper's fingers extend along the gripper_frame_link +Z axis. From
Expand Down Expand Up @@ -352,10 +436,8 @@ def __init__(
fingertip_offset: Optional[Sequence[float]] = None,
**_ignored,
):
import os

self.urdf_path = urdf_path or os.environ.get("SO101_URDF")
self.asset_path = asset_path or os.environ.get("SO101_ASSET", "")
self.urdf_path = resolve_so101_urdf(urdf_path)
self.asset_path = resolve_so101_asset(asset_path)
self.tool_frame = tool_frame
self.self_collision = self_collision
self.device = device
Expand Down Expand Up @@ -425,8 +507,10 @@ def _ensure(self):

if not self.urdf_path or not os.path.exists(self.urdf_path):
raise RuntimeError(
"CuroboMotionPlanner needs an SO-101 URDF. Pass urdf_path=... or set "
"SO101_URDF (+ SO101_ASSET for meshes). See README (#67 T2/T4)."
"CuroboMotionPlanner needs an SO-101 URDF. It auto-resolves the "
"strands-robots SO-101 cache URDF (the one the MuJoCo demo "
"downloads), but that wasn't found here -- pass urdf_path=... or "
"set SO101_URDF (+ SO101_ASSET for meshes). See README (#67 T2/T4)."
)
from curobo.motion_planner import MotionPlanner, MotionPlannerCfg
from curobo.robot_builder import RobotBuilder
Expand Down Expand Up @@ -772,12 +856,17 @@ def emit(arm_q, gripval, phase):


def _curobo_usable(kwargs: dict) -> bool:
"""cuRobo is usable only if installed AND an SO-101 URDF is resolvable."""
import os
"""cuRobo is usable only if installed AND an SO-101 URDF is resolvable.

URDF resolution follows :func:`resolve_so101_urdf` (explicit kwarg ->
``SO101_URDF`` -> the auto-downloaded strands-robots cache URDF), so the
``auto`` path now selects cuRobo out-of-the-box on a box that already ran
the MuJoCo demo, instead of silently falling back to scripted just because
no URDF flag was passed.
"""
if not CUROBO_AVAILABLE:
return False
return bool(kwargs.get("urdf_path") or os.environ.get("SO101_URDF"))
return bool(resolve_so101_urdf(kwargs.get("urdf_path")))


def make_planner(prefer: str = "auto", robot_cfg: str = "so101", **kwargs):
Expand Down
51 changes: 51 additions & 0 deletions examples/so101_curobo/smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,57 @@ def test_rerun_default_root_is_idempotent():
assert out["second"]["total_frames"] > 0


def test_resolve_so101_urdf_precedence(monkeypatch, tmp_path):
"""``resolve_so101_urdf`` honours explicit arg -> SO101_URDF -> cache.

Pure-Python (no cuRobo/Isaac); the cache fallback is mocked so the test
runs anywhere. Guards the #67-followup that defaults the cuRobo/URDF path
to the auto-downloaded strands-robots SO-101 URDF.
"""
from examples.so101_curobo import planner as P

explicit = tmp_path / "explicit.urdf"
explicit.write_text("<robot/>")
env_urdf = tmp_path / "env.urdf"
env_urdf.write_text("<robot/>")
cache_urdf = tmp_path / "cache" / "so101_new_calib.urdf"
cache_urdf.parent.mkdir()
cache_urdf.write_text("<robot/>")
(cache_urdf.parent / "assets").mkdir()

# Mock the cache resolver so we don't depend on a real download.
monkeypatch.setattr(
P,
"_so101_cache_urdf",
lambda: (str(cache_urdf), str(cache_urdf.parent / "assets")),
)

# 1. explicit wins over everything.
monkeypatch.setenv("SO101_URDF", str(env_urdf))
assert P.resolve_so101_urdf(str(explicit)) == str(explicit)

# 2. env wins when no explicit arg.
assert P.resolve_so101_urdf(None) == str(env_urdf)

# 3. cache fallback when neither explicit nor env is set.
monkeypatch.delenv("SO101_URDF", raising=False)
assert P.resolve_so101_urdf(None) == str(cache_urdf)
# ...and the mesh dir resolves to the cache assets/ subdir.
monkeypatch.delenv("SO101_ASSET", raising=False)
assert P.resolve_so101_asset("") == str(cache_urdf.parent / "assets")


def test_resolve_so101_urdf_none_when_unavailable(monkeypatch):
"""With no explicit arg, no env, and no cache, the resolver returns None so
callers keep their existing fail-with-hint behaviour.
"""
from examples.so101_curobo import planner as P

monkeypatch.delenv("SO101_URDF", raising=False)
monkeypatch.setattr(P, "_so101_cache_urdf", lambda: (None, ""))
assert P.resolve_so101_urdf(None) is None


def main() -> int:
ok, why = _deps_ok()
if not ok:
Expand Down
Loading