simrig 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ """MuJoCo model discovery and inspection backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from simrig.core import BackendInfo, ModelInspectionReport, TrainabilityStatus
9
+ from simrig.paths import find_menagerie
10
+
11
+
12
+ def _import_mujoco():
13
+ try:
14
+ import mujoco # type: ignore
15
+ except ImportError as exc:
16
+ raise RuntimeError(
17
+ "MuJoCo is not installed. Install SimRig with the mujoco extra or "
18
+ "install the `mujoco` package."
19
+ ) from exc
20
+ return mujoco
21
+
22
+
23
+ def backend_info() -> BackendInfo:
24
+ try:
25
+ mujoco = _import_mujoco()
26
+ except RuntimeError as exc:
27
+ return BackendInfo(name="mujoco", available=False, detail=str(exc))
28
+ version = getattr(mujoco, "__version__", None)
29
+ return BackendInfo(name="mujoco", available=True, version=version)
30
+
31
+
32
+ def list_models(menagerie: Path | str | None = None) -> list[dict[str, Any]]:
33
+ """List model directories in a MuJoCo Menagerie checkout."""
34
+ root = find_menagerie(menagerie)
35
+ entries: list[dict[str, Any]] = []
36
+ skip = {".git", ".github", "assets", "test", "opensource"}
37
+ for directory in sorted(path for path in root.iterdir() if path.is_dir()):
38
+ if directory.name.startswith(".") or directory.name in skip:
39
+ continue
40
+ xmls = sorted(directory.glob("*.xml"))
41
+ if not xmls:
42
+ continue
43
+ scenes = sorted(directory.glob("scene*.xml"))
44
+ mjx_xmls = sorted(directory.glob("*mjx*.xml"))
45
+ entries.append(
46
+ {
47
+ "name": directory.name,
48
+ "path": str(directory),
49
+ "scene_xmls": [str(path.relative_to(root)) for path in scenes],
50
+ "xmls": [str(path.relative_to(root)) for path in xmls],
51
+ "mjx_xmls": [str(path.relative_to(root)) for path in mjx_xmls],
52
+ "has_readme": (directory / "README.md").is_file(),
53
+ "has_license": (directory / "LICENSE").is_file(),
54
+ }
55
+ )
56
+ return entries
57
+
58
+
59
+ def resolve_model_path(model_or_xml: str | Path, menagerie: Path | str | None = None) -> Path:
60
+ """Resolve a model directory name, relative path, or XML path."""
61
+ raw = Path(model_or_xml).expanduser()
62
+ if raw.is_file():
63
+ return raw.resolve()
64
+ if raw.is_dir():
65
+ scene = _preferred_scene(raw)
66
+ if scene is None:
67
+ raise FileNotFoundError(f"No scene*.xml or *.xml found in {raw}")
68
+ return scene.resolve()
69
+
70
+ root = find_menagerie(menagerie)
71
+ candidate = root / str(model_or_xml)
72
+ if candidate.is_file():
73
+ return candidate.resolve()
74
+ if candidate.is_dir():
75
+ scene = _preferred_scene(candidate)
76
+ if scene is not None:
77
+ return scene.resolve()
78
+
79
+ # Accept relative XML paths inside Menagerie, e.g. unitree_g1/scene.xml.
80
+ relative_candidate = root / raw
81
+ if relative_candidate.is_file():
82
+ return relative_candidate.resolve()
83
+
84
+ raise FileNotFoundError(f"Could not resolve model or XML: {model_or_xml}")
85
+
86
+
87
+ def inspect_model(
88
+ model_or_xml: str | Path,
89
+ *,
90
+ menagerie: Path | str | None = None,
91
+ steps: int = 25,
92
+ noise_scale: float = 0.25,
93
+ ) -> ModelInspectionReport:
94
+ """Compile and briefly step a MuJoCo model."""
95
+ path = resolve_model_path(model_or_xml, menagerie)
96
+ mujoco = _import_mujoco()
97
+ warnings: list[str] = []
98
+ errors: list[str] = []
99
+ notes: list[str] = []
100
+ compiled = False
101
+ stepped = False
102
+ model = None
103
+
104
+ try:
105
+ model = mujoco.MjModel.from_xml_path(str(path))
106
+ compiled = True
107
+ except Exception as exc: # MuJoCo raises several native exception types.
108
+ errors.append(str(exc))
109
+ return ModelInspectionReport(
110
+ name=path.stem,
111
+ path=str(path),
112
+ backend="mujoco",
113
+ status=TrainabilityStatus.FAILED,
114
+ compiled=False,
115
+ stepped=False,
116
+ errors=errors,
117
+ )
118
+
119
+ data = mujoco.MjData(model)
120
+ try:
121
+ for index in range(max(0, steps)):
122
+ _bounded_ctrl_noise(mujoco, model, data, index, noise_scale)
123
+ mujoco.mj_step(model, data)
124
+ stepped = True
125
+ except Exception as exc:
126
+ errors.append(str(exc))
127
+
128
+ for warning_index, count in enumerate(data.warning.number):
129
+ if count:
130
+ try:
131
+ name = mujoco.mjtWarning(warning_index).name
132
+ except Exception:
133
+ name = f"warning_{warning_index}"
134
+ warnings.append(f"{name}: count={int(count)}")
135
+
136
+ has_mjx_hint = "mjx" in path.name.lower() or any(
137
+ "mjx" in sibling.name.lower() for sibling in path.parent.glob("*.xml")
138
+ )
139
+ has_freejoint = _has_freejoint(mujoco, model)
140
+ if model.nu <= 0:
141
+ notes.append("Model has no actuators; it can be inspected but is not directly controllable.")
142
+ if not has_mjx_hint:
143
+ notes.append("No MJX-named XML variant found near this model.")
144
+ notes.append(
145
+ "Raw MuJoCo model inspection does not prove trainability; a task env still defines observations, rewards, resets, and termination."
146
+ )
147
+
148
+ status = TrainabilityStatus.SIMULATABLE if compiled and stepped and not errors else TrainabilityStatus.INSPECTABLE
149
+ return ModelInspectionReport(
150
+ name=path.parent.name if path.parent.name else path.stem,
151
+ path=str(path),
152
+ backend="mujoco",
153
+ status=status,
154
+ compiled=compiled,
155
+ stepped=stepped,
156
+ bodies=int(model.nbody),
157
+ joints=int(model.njnt),
158
+ dofs=int(model.nv),
159
+ actuators=int(model.nu),
160
+ sensors=int(model.nsensor),
161
+ keyframes=int(model.nkey),
162
+ has_freejoint=has_freejoint,
163
+ has_mjx_hint=has_mjx_hint,
164
+ warnings=warnings,
165
+ errors=errors,
166
+ notes=notes,
167
+ )
168
+
169
+
170
+ def _preferred_scene(directory: Path) -> Path | None:
171
+ for pattern in ("scene_mjx.xml", "scene.xml", "scene*.xml", "*.xml"):
172
+ matches = sorted(directory.glob(pattern))
173
+ if matches:
174
+ return matches[0]
175
+ return None
176
+
177
+
178
+ def _bounded_ctrl_noise(mujoco: Any, model: Any, data: Any, index: int, noise: float) -> None:
179
+ for actuator_index in range(model.nu):
180
+ ctrlrange = model.actuator_ctrlrange[actuator_index]
181
+ if model.actuator_ctrllimited[actuator_index]:
182
+ center = 0.5 * (ctrlrange[1] + ctrlrange[0])
183
+ radius = 0.5 * (ctrlrange[1] - ctrlrange[0])
184
+ else:
185
+ center = 0.0
186
+ radius = 1.0
187
+ data.ctrl[actuator_index] = center + radius * noise * (
188
+ 2 * mujoco.mju_Halton(index + 1, actuator_index + 2) - 1
189
+ )
190
+
191
+
192
+ def _has_freejoint(mujoco: Any, model: Any) -> bool:
193
+ for joint_index in range(model.njnt):
194
+ if model.jnt_type[joint_index] == mujoco.mjtJoint.mjJNT_FREE:
195
+ return True
196
+ return False
197
+
simrig/paths.py ADDED
@@ -0,0 +1,54 @@
1
+ """Path helpers for SimRig projects and external model checkouts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ PROJECT_DIRS = ("runs", "reports", "artifacts", "envs", "configs")
10
+
11
+
12
+ def ensure_project_dirs(root: Path | str = ".") -> list[Path]:
13
+ base = Path(root)
14
+ paths = [base / name for name in PROJECT_DIRS]
15
+ for path in paths:
16
+ path.mkdir(parents=True, exist_ok=True)
17
+ return paths
18
+
19
+
20
+ def menagerie_candidates(extra: Path | str | None = None) -> list[Path]:
21
+ candidates: list[Path] = []
22
+ if extra is not None:
23
+ candidates.append(Path(extra).expanduser())
24
+ env_path = os.environ.get("MUJOCO_MENAGERIE_PATH")
25
+ if env_path:
26
+ candidates.append(Path(env_path).expanduser())
27
+ cwd = Path.cwd()
28
+ candidates.extend(
29
+ [
30
+ cwd / "mujoco_menagerie",
31
+ cwd.parent / "mujoco_menagerie",
32
+ Path.home() / "Desktop" / "mujoco_menagerie",
33
+ ]
34
+ )
35
+ deduped: list[Path] = []
36
+ seen: set[Path] = set()
37
+ for candidate in candidates:
38
+ resolved = candidate.resolve()
39
+ if resolved not in seen:
40
+ deduped.append(resolved)
41
+ seen.add(resolved)
42
+ return deduped
43
+
44
+
45
+ def find_menagerie(extra: Path | str | None = None) -> Path:
46
+ for candidate in menagerie_candidates(extra):
47
+ if (candidate / "README.md").is_file() and any(candidate.glob("*/scene*.xml")):
48
+ return candidate
49
+ searched = "\n".join(str(path) for path in menagerie_candidates(extra))
50
+ raise FileNotFoundError(
51
+ "Could not find a MuJoCo Menagerie checkout. Set MUJOCO_MENAGERIE_PATH "
52
+ f"or pass --menagerie. Searched:\n{searched}"
53
+ )
54
+