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.
- simrig/__init__.py +82 -0
- simrig/_version.py +3 -0
- simrig/browser_render.py +145 -0
- simrig/browser_shell.py +130 -0
- simrig/cli.py +412 -0
- simrig/core.py +144 -0
- simrig/custom_env.py +109 -0
- simrig/huggingface.py +78 -0
- simrig/io.py +49 -0
- simrig/live_view.py +553 -0
- simrig/model_view.py +944 -0
- simrig/mujoco_backend.py +197 -0
- simrig/paths.py +54 -0
- simrig/playground_backend.py +603 -0
- simrig/presets.py +93 -0
- simrig/preview.py +956 -0
- simrig/rendering.py +127 -0
- simrig/scaffold.py +127 -0
- simrig/three_scene.py +107 -0
- simrig/validate_env.py +211 -0
- simrig-0.2.2.dist-info/METADATA +238 -0
- simrig-0.2.2.dist-info/RECORD +26 -0
- simrig-0.2.2.dist-info/WHEEL +5 -0
- simrig-0.2.2.dist-info/entry_points.txt +2 -0
- simrig-0.2.2.dist-info/licenses/LICENSE +21 -0
- simrig-0.2.2.dist-info/top_level.txt +1 -0
simrig/rendering.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""MuJoCo offscreen rendering helpers for browser viewers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class CameraState:
|
|
13
|
+
azimuth: float = 135.0
|
|
14
|
+
elevation: float = -20.0
|
|
15
|
+
distance: float = 2.4
|
|
16
|
+
interactive: bool = True
|
|
17
|
+
|
|
18
|
+
def apply(self, camera: Any) -> Any:
|
|
19
|
+
if not self.interactive or not hasattr(camera, "azimuth"):
|
|
20
|
+
return camera
|
|
21
|
+
camera.azimuth = float(self.azimuth)
|
|
22
|
+
camera.elevation = float(self.elevation)
|
|
23
|
+
camera.distance = float(self.distance)
|
|
24
|
+
return camera
|
|
25
|
+
|
|
26
|
+
def update_from_query(self, query: dict[str, list[str]]) -> None:
|
|
27
|
+
for key in ("azimuth", "elevation", "distance"):
|
|
28
|
+
if key in query:
|
|
29
|
+
setattr(self, key, float(query[key][0]))
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"azimuth": self.azimuth,
|
|
34
|
+
"elevation": self.elevation,
|
|
35
|
+
"distance": self.distance,
|
|
36
|
+
"interactive": self.interactive,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def preferred_gl_backends() -> list[str | None]:
|
|
41
|
+
"""Return MuJoCo GL backends to try, in order."""
|
|
42
|
+
|
|
43
|
+
configured = os.environ.get("MUJOCO_GL")
|
|
44
|
+
if configured:
|
|
45
|
+
return [configured]
|
|
46
|
+
if sys.platform == "darwin":
|
|
47
|
+
return ["glfw", None]
|
|
48
|
+
if os.environ.get("DISPLAY"):
|
|
49
|
+
return ["egl", "glfw", None]
|
|
50
|
+
return ["osmesa", "egl", None]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ensure_offscreen_framebuffer(model: Any, *, height: int, width: int) -> None:
|
|
54
|
+
"""Grow MuJoCo's offscreen buffer so browser frames can exceed 640x480."""
|
|
55
|
+
|
|
56
|
+
vis_global = model.vis.global_
|
|
57
|
+
vis_global.offwidth = max(int(vis_global.offwidth), int(width))
|
|
58
|
+
vis_global.offheight = max(int(vis_global.offheight), int(height))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_mujoco_renderer(
|
|
62
|
+
mujoco: Any,
|
|
63
|
+
model: Any,
|
|
64
|
+
*,
|
|
65
|
+
height: int,
|
|
66
|
+
width: int,
|
|
67
|
+
) -> Any:
|
|
68
|
+
"""Create a MuJoCo offscreen renderer, trying platform-appropriate GL backends."""
|
|
69
|
+
|
|
70
|
+
ensure_offscreen_framebuffer(model, height=height, width=width)
|
|
71
|
+
errors: list[str] = []
|
|
72
|
+
for backend in preferred_gl_backends():
|
|
73
|
+
if backend:
|
|
74
|
+
os.environ["MUJOCO_GL"] = backend
|
|
75
|
+
try:
|
|
76
|
+
return mujoco.Renderer(model, height=height, width=width)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
errors.append(f"{backend or 'default'}: {exc}")
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
"MuJoCo offscreen rendering failed. Tried "
|
|
81
|
+
+ "; ".join(errors)
|
|
82
|
+
+ ". On macOS, run from a desktop session and keep MUJOCO_GL=glfw."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def make_tracking_camera(
|
|
87
|
+
mujoco: Any,
|
|
88
|
+
model: Any,
|
|
89
|
+
data: Any,
|
|
90
|
+
camera: str | int | None,
|
|
91
|
+
) -> tuple[Any, CameraState]:
|
|
92
|
+
"""Build a MuJoCo camera object and mutable browser camera state."""
|
|
93
|
+
|
|
94
|
+
if isinstance(camera, int):
|
|
95
|
+
return camera, CameraState(interactive=False)
|
|
96
|
+
if isinstance(camera, str) and camera.isdigit():
|
|
97
|
+
return int(camera), CameraState(interactive=False)
|
|
98
|
+
if camera not in (None, "track", "tracking"):
|
|
99
|
+
return camera, CameraState(interactive=False)
|
|
100
|
+
|
|
101
|
+
cam = mujoco.MjvCamera()
|
|
102
|
+
cam.type = mujoco.mjtCamera.mjCAMERA_TRACKING
|
|
103
|
+
cam.trackbodyid = tracking_body_id(mujoco, model, data)
|
|
104
|
+
cam.distance = 2.4
|
|
105
|
+
cam.azimuth = 135
|
|
106
|
+
cam.elevation = -20
|
|
107
|
+
state = CameraState(
|
|
108
|
+
azimuth=float(cam.azimuth),
|
|
109
|
+
elevation=float(cam.elevation),
|
|
110
|
+
distance=float(cam.distance),
|
|
111
|
+
interactive=True,
|
|
112
|
+
)
|
|
113
|
+
return cam, state
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def tracking_body_id(mujoco: Any, model: Any, data: Any | None = None) -> int:
|
|
117
|
+
"""Pick a stable body for camera tracking."""
|
|
118
|
+
|
|
119
|
+
del data
|
|
120
|
+
for joint_id in range(model.njnt):
|
|
121
|
+
if model.jnt_type[joint_id] == mujoco.mjtJoint.mjJNT_FREE:
|
|
122
|
+
return int(model.jnt_bodyid[joint_id])
|
|
123
|
+
for name in ("trunk", "base", "torso", "torso_link", "pelvis", "body", "root"):
|
|
124
|
+
body_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, name)
|
|
125
|
+
if body_id >= 0:
|
|
126
|
+
return int(body_id)
|
|
127
|
+
return 1 if model.nbody > 1 else 0
|
simrig/scaffold.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Editable starter files for custom environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from simrig.io import slugify
|
|
8
|
+
|
|
9
|
+
# Markers used by validate-env static checklist (keep in sync with template).
|
|
10
|
+
REQUIRED_SECTION_MARKERS = (
|
|
11
|
+
"SECTION: model loading",
|
|
12
|
+
"SECTION: reset",
|
|
13
|
+
"SECTION: action mapping",
|
|
14
|
+
"SECTION: observations",
|
|
15
|
+
"SECTION: rewards",
|
|
16
|
+
"SECTION: termination",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
REQUIRED_CLASS_METHODS = (
|
|
20
|
+
"__init__",
|
|
21
|
+
"reset",
|
|
22
|
+
"step",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def new_env(name: str, model: str | Path, *, template: str = "mjx", root: Path | str = "envs") -> Path:
|
|
27
|
+
"""Create an editable starter env module."""
|
|
28
|
+
if template != "mjx":
|
|
29
|
+
raise ValueError("SimRig v0 supports only the 'mjx' env template.")
|
|
30
|
+
module_name = slugify(name).replace("-", "_").replace(".", "_")
|
|
31
|
+
path = Path(root) / f"{module_name}.py"
|
|
32
|
+
if path.exists():
|
|
33
|
+
raise FileExistsError(f"Refusing to overwrite existing env template: {path}")
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
path.write_text(_mjx_template(name=name, model=str(model)), encoding="utf-8")
|
|
36
|
+
return path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _mjx_template(*, name: str, model: str) -> str:
|
|
40
|
+
return f'''"""Editable SimRig MJX environment starter for {name}.
|
|
41
|
+
|
|
42
|
+
NOT TRAINABLE YET.
|
|
43
|
+
Fill the SECTION blocks below, remove this banner when reset/step work, then:
|
|
44
|
+
|
|
45
|
+
simrig validate-env PATH --runtime
|
|
46
|
+
simrig smoke PATH --steps 10
|
|
47
|
+
simrig train PATH --preset smoke
|
|
48
|
+
|
|
49
|
+
Prefer subclassing mujoco_playground._src.mjx_env.MjxEnv when Playground is
|
|
50
|
+
installed. Return obs as dict keys `state` and `privileged_state` for SimRig PPO.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
from typing import Any
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
MODEL_PATH = Path({model!r}).expanduser()
|
|
60
|
+
ENV_NAME = {name!r}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def default_config() -> dict[str, Any]:
|
|
64
|
+
return {{
|
|
65
|
+
"episode_length": 1000,
|
|
66
|
+
"action_scale": 1.0,
|
|
67
|
+
"ctrl_dt": 0.02,
|
|
68
|
+
"sim_dt": 0.002,
|
|
69
|
+
"impl": "jax",
|
|
70
|
+
}}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def make_env(config_overrides: dict[str, Any] | None = None) -> "CustomEnv":
|
|
74
|
+
"""Factory used by `simrig smoke/train/eval` for this module."""
|
|
75
|
+
config = default_config()
|
|
76
|
+
if config_overrides:
|
|
77
|
+
config.update(config_overrides)
|
|
78
|
+
return CustomEnv(config=config)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CustomEnv:
|
|
82
|
+
"""Replace this starter with a real mjx_env.MjxEnv implementation.
|
|
83
|
+
|
|
84
|
+
Required for SimRig smoke/train/eval:
|
|
85
|
+
- reset(rng) -> state with .obs, .reward, .done, .data
|
|
86
|
+
- step(state, action) -> state
|
|
87
|
+
- observation_size (dict with `state` and `privileged_state` preferred)
|
|
88
|
+
- action_size
|
|
89
|
+
- mj_model / mjx_model (for demos and previews)
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
|
93
|
+
self.config = config or default_config()
|
|
94
|
+
# SECTION: model loading
|
|
95
|
+
# Load MODEL_PATH into mj_model / mjx_model and cache actuator limits.
|
|
96
|
+
raise NotImplementedError(
|
|
97
|
+
"Define model loading, reset, step, rewards, observations, and termination."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def reset(self, rng: Any) -> Any:
|
|
101
|
+
# SECTION: reset
|
|
102
|
+
# Sample initial qpos/qvel (and any task state). Return a Brax/MJX state.
|
|
103
|
+
raise NotImplementedError("Implement reset randomization.")
|
|
104
|
+
|
|
105
|
+
def step(self, state: Any, action: Any) -> Any:
|
|
106
|
+
# SECTION: action mapping
|
|
107
|
+
# Scale/clip `action` into actuator controls.
|
|
108
|
+
|
|
109
|
+
# SECTION: observations
|
|
110
|
+
# Build policy obs (`state`) and privileged obs (`privileged_state`).
|
|
111
|
+
|
|
112
|
+
# SECTION: rewards
|
|
113
|
+
# Compute dense/sparse reward terms; do not invent them from the model name.
|
|
114
|
+
|
|
115
|
+
# SECTION: termination
|
|
116
|
+
# Set done / truncations from falls, limits, success, or time.
|
|
117
|
+
|
|
118
|
+
raise NotImplementedError("Implement step, including action mapping, obs, reward, termination.")
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def observation_size(self) -> Any:
|
|
122
|
+
raise NotImplementedError("Return dict observation sizes for state/privileged_state.")
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def action_size(self) -> int:
|
|
126
|
+
raise NotImplementedError("Return the actuator/action dimension.")
|
|
127
|
+
'''
|
simrig/three_scene.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Serialize compiled MuJoCo geometry for browser-side Three.js rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def scene_payload(
|
|
11
|
+
mujoco: Any,
|
|
12
|
+
model: Any,
|
|
13
|
+
data: Any,
|
|
14
|
+
*,
|
|
15
|
+
model_name: str,
|
|
16
|
+
) -> dict[str, Any]:
|
|
17
|
+
"""Return visual geometry and current world transforms for a model."""
|
|
18
|
+
|
|
19
|
+
visible_geom_ids = [
|
|
20
|
+
geom_id
|
|
21
|
+
for geom_id in range(model.ngeom)
|
|
22
|
+
if int(model.geom_group[geom_id]) <= 2
|
|
23
|
+
]
|
|
24
|
+
used_mesh_ids = sorted(
|
|
25
|
+
{
|
|
26
|
+
int(model.geom_dataid[geom_id])
|
|
27
|
+
for geom_id in visible_geom_ids
|
|
28
|
+
if int(model.geom_type[geom_id]) == int(mujoco.mjtGeom.mjGEOM_MESH)
|
|
29
|
+
and int(model.geom_dataid[geom_id]) >= 0
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
return {
|
|
33
|
+
"model_name": model_name,
|
|
34
|
+
"coordinate_system": "z-up",
|
|
35
|
+
"meshes": [_mesh_payload(mujoco, model, mesh_id) for mesh_id in used_mesh_ids],
|
|
36
|
+
"geoms": [_geom_payload(mujoco, model, geom_id) for geom_id in visible_geom_ids],
|
|
37
|
+
"transforms": geom_transforms(model, data),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def geom_transforms(model: Any, data: Any) -> list[dict[str, Any]]:
|
|
42
|
+
"""Return each geom's current MuJoCo world position and rotation matrix."""
|
|
43
|
+
|
|
44
|
+
return [
|
|
45
|
+
{
|
|
46
|
+
"id": geom_id,
|
|
47
|
+
"position": np.asarray(data.geom_xpos[geom_id], dtype=float).tolist(),
|
|
48
|
+
"matrix": np.asarray(data.geom_xmat[geom_id], dtype=float).reshape(-1).tolist(),
|
|
49
|
+
}
|
|
50
|
+
for geom_id in range(model.ngeom)
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _mesh_payload(mujoco: Any, model: Any, mesh_id: int) -> dict[str, Any]:
|
|
55
|
+
vert_adr = int(model.mesh_vertadr[mesh_id])
|
|
56
|
+
vert_num = int(model.mesh_vertnum[mesh_id])
|
|
57
|
+
face_adr = int(model.mesh_faceadr[mesh_id])
|
|
58
|
+
face_num = int(model.mesh_facenum[mesh_id])
|
|
59
|
+
name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_MESH, mesh_id)
|
|
60
|
+
return {
|
|
61
|
+
"id": mesh_id,
|
|
62
|
+
"name": name or f"mesh_{mesh_id}",
|
|
63
|
+
"vertices": np.asarray(
|
|
64
|
+
model.mesh_vert[vert_adr : vert_adr + vert_num],
|
|
65
|
+
dtype=np.float32,
|
|
66
|
+
)
|
|
67
|
+
.reshape(-1)
|
|
68
|
+
.tolist(),
|
|
69
|
+
"indices": np.asarray(
|
|
70
|
+
model.mesh_face[face_adr : face_adr + face_num],
|
|
71
|
+
dtype=np.uint32,
|
|
72
|
+
)
|
|
73
|
+
.reshape(-1)
|
|
74
|
+
.tolist(),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _geom_payload(mujoco: Any, model: Any, geom_id: int) -> dict[str, Any]:
|
|
79
|
+
material_id = int(model.geom_matid[geom_id])
|
|
80
|
+
if material_id >= 0:
|
|
81
|
+
rgba = np.asarray(model.mat_rgba[material_id], dtype=float)
|
|
82
|
+
specular = float(model.mat_specular[material_id])
|
|
83
|
+
shininess = float(model.mat_shininess[material_id])
|
|
84
|
+
reflectance = float(model.mat_reflectance[material_id])
|
|
85
|
+
emission = float(model.mat_emission[material_id])
|
|
86
|
+
else:
|
|
87
|
+
rgba = np.asarray(model.geom_rgba[geom_id], dtype=float)
|
|
88
|
+
specular = 0.25
|
|
89
|
+
shininess = 0.25
|
|
90
|
+
reflectance = 0.0
|
|
91
|
+
emission = 0.0
|
|
92
|
+
name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, geom_id)
|
|
93
|
+
return {
|
|
94
|
+
"id": geom_id,
|
|
95
|
+
"name": name or f"geom_{geom_id}",
|
|
96
|
+
"type": int(model.geom_type[geom_id]),
|
|
97
|
+
"mesh_id": int(model.geom_dataid[geom_id]),
|
|
98
|
+
"group": int(model.geom_group[geom_id]),
|
|
99
|
+
"size": np.asarray(model.geom_size[geom_id], dtype=float).tolist(),
|
|
100
|
+
"rgba": rgba.tolist(),
|
|
101
|
+
"material": {
|
|
102
|
+
"specular": specular,
|
|
103
|
+
"shininess": shininess,
|
|
104
|
+
"reflectance": reflectance,
|
|
105
|
+
"emission": emission,
|
|
106
|
+
},
|
|
107
|
+
}
|
simrig/validate_env.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Validation for custom env starter modules (static + optional runtime)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from simrig.scaffold import REQUIRED_CLASS_METHODS, REQUIRED_SECTION_MARKERS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class EnvValidationResult:
|
|
15
|
+
"""Result of custom-env validation.
|
|
16
|
+
|
|
17
|
+
``trainable`` is True only when ``--runtime`` reset/step checks succeed.
|
|
18
|
+
Static-only passes never claim trainability.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
path: str
|
|
22
|
+
passed: bool
|
|
23
|
+
trainable: bool = False
|
|
24
|
+
missing: list[str] = field(default_factory=list)
|
|
25
|
+
warnings: list[str] = field(default_factory=list)
|
|
26
|
+
notes: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_env(path: Path | str, *, runtime: bool = False) -> EnvValidationResult:
|
|
30
|
+
"""Validate a custom env module.
|
|
31
|
+
|
|
32
|
+
Static mode checks scaffold structure. Runtime mode also imports the module,
|
|
33
|
+
constructs the env, and runs a short reset/step smoke when JAX is available.
|
|
34
|
+
"""
|
|
35
|
+
env_path = Path(path).expanduser()
|
|
36
|
+
missing: list[str] = []
|
|
37
|
+
warnings: list[str] = []
|
|
38
|
+
notes = [
|
|
39
|
+
"Static checklist checks structure only.",
|
|
40
|
+
"Use --runtime before proposing simrig smoke/train on a custom module.",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
if not env_path.is_file():
|
|
44
|
+
return EnvValidationResult(
|
|
45
|
+
path=str(env_path),
|
|
46
|
+
passed=False,
|
|
47
|
+
missing=[f"file not found: {env_path}"],
|
|
48
|
+
notes=notes,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
source = env_path.read_text(encoding="utf-8")
|
|
52
|
+
for marker in REQUIRED_SECTION_MARKERS:
|
|
53
|
+
if marker not in source:
|
|
54
|
+
missing.append(f"section marker missing: {marker}")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
tree = ast.parse(source, filename=str(env_path))
|
|
58
|
+
except SyntaxError as exc:
|
|
59
|
+
return EnvValidationResult(
|
|
60
|
+
path=str(env_path),
|
|
61
|
+
passed=False,
|
|
62
|
+
missing=[f"syntax error: {exc}"],
|
|
63
|
+
notes=notes,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if "MODEL_PATH" not in source:
|
|
67
|
+
missing.append("symbol missing: MODEL_PATH")
|
|
68
|
+
if "ENV_NAME" not in source:
|
|
69
|
+
missing.append("symbol missing: ENV_NAME")
|
|
70
|
+
if "def default_config" not in source and "def make_env" not in source:
|
|
71
|
+
missing.append("function missing: default_config or make_env")
|
|
72
|
+
|
|
73
|
+
custom_env = _find_class(tree, "CustomEnv")
|
|
74
|
+
has_make_env = any(
|
|
75
|
+
isinstance(node, ast.FunctionDef) and node.name == "make_env" for node in tree.body
|
|
76
|
+
)
|
|
77
|
+
if custom_env is None and not has_make_env:
|
|
78
|
+
missing.append("class missing: CustomEnv (or define make_env)")
|
|
79
|
+
elif custom_env is not None:
|
|
80
|
+
method_names = {
|
|
81
|
+
node.name
|
|
82
|
+
for node in custom_env.body
|
|
83
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
84
|
+
}
|
|
85
|
+
for name in REQUIRED_CLASS_METHODS:
|
|
86
|
+
if name not in method_names:
|
|
87
|
+
missing.append(f"CustomEnv method missing: {name}")
|
|
88
|
+
for prop in ("observation_size", "action_size"):
|
|
89
|
+
if prop not in method_names:
|
|
90
|
+
missing.append(f"CustomEnv property missing: {prop}")
|
|
91
|
+
|
|
92
|
+
if "NotImplementedError" in source:
|
|
93
|
+
warnings.append("NotImplementedError still present; implementation is incomplete.")
|
|
94
|
+
if "NOT TRAINABLE YET" in source:
|
|
95
|
+
warnings.append("File still marked NOT TRAINABLE YET.")
|
|
96
|
+
|
|
97
|
+
passed = not missing
|
|
98
|
+
trainable = False
|
|
99
|
+
|
|
100
|
+
if runtime and passed:
|
|
101
|
+
runtime_missing, runtime_warnings, runtime_notes, trainable = _runtime_checks(env_path)
|
|
102
|
+
missing.extend(runtime_missing)
|
|
103
|
+
warnings.extend(runtime_warnings)
|
|
104
|
+
notes.extend(runtime_notes)
|
|
105
|
+
passed = not missing
|
|
106
|
+
elif passed:
|
|
107
|
+
notes.append(
|
|
108
|
+
"Checklist structure looks complete. Fill SECTION bodies, then "
|
|
109
|
+
"`simrig validate-env PATH --runtime` and `simrig smoke PATH`."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return EnvValidationResult(
|
|
113
|
+
path=str(env_path),
|
|
114
|
+
passed=passed,
|
|
115
|
+
trainable=trainable,
|
|
116
|
+
missing=missing,
|
|
117
|
+
warnings=warnings,
|
|
118
|
+
notes=notes,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _runtime_checks(env_path: Path) -> tuple[list[str], list[str], list[str], bool]:
|
|
123
|
+
missing: list[str] = []
|
|
124
|
+
warnings: list[str] = []
|
|
125
|
+
notes: list[str] = []
|
|
126
|
+
trainable = False
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
from simrig.custom_env import load_custom_env
|
|
130
|
+
|
|
131
|
+
env = load_custom_env(env_path)
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
missing.append(f"runtime load failed: {exc}")
|
|
134
|
+
return missing, warnings, notes, False
|
|
135
|
+
|
|
136
|
+
notes.append("Custom env module constructed successfully.")
|
|
137
|
+
action_size = getattr(env, "action_size", None)
|
|
138
|
+
observation_size = getattr(env, "observation_size", None)
|
|
139
|
+
|
|
140
|
+
if action_size is None:
|
|
141
|
+
missing.append("runtime: action_size missing")
|
|
142
|
+
obs_warnings = _obs_size_warnings(observation_size)
|
|
143
|
+
warnings.extend(obs_warnings)
|
|
144
|
+
|
|
145
|
+
for attr in ("mj_model", "mjx_model"):
|
|
146
|
+
if getattr(env, attr, None) is None:
|
|
147
|
+
warnings.append(f"runtime: {attr} missing (needed for Playground/Brax demos).")
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
import jax # type: ignore
|
|
151
|
+
import jax.numpy as jp # type: ignore
|
|
152
|
+
except ImportError:
|
|
153
|
+
warnings.append("runtime: JAX not installed; skipped reset/step smoke.")
|
|
154
|
+
notes.append("Install the playground extra to run reset/step validation.")
|
|
155
|
+
return missing, warnings, notes, False
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
state = env.reset(jax.random.PRNGKey(0))
|
|
159
|
+
obs = getattr(state, "obs", None)
|
|
160
|
+
if isinstance(obs, dict):
|
|
161
|
+
if "state" not in obs:
|
|
162
|
+
missing.append("runtime: state.obs missing key `state`")
|
|
163
|
+
if "privileged_state" not in obs:
|
|
164
|
+
warnings.append("runtime: state.obs missing key `privileged_state`")
|
|
165
|
+
elif obs is None:
|
|
166
|
+
missing.append("runtime: state.obs missing")
|
|
167
|
+
else:
|
|
168
|
+
warnings.append(
|
|
169
|
+
"runtime: state.obs is flat; SimRig PPO defaults expect dict keys "
|
|
170
|
+
"`state` and `privileged_state`."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if action_size is None:
|
|
174
|
+
return missing, warnings, notes, False
|
|
175
|
+
state = env.step(state, jp.zeros(int(action_size)))
|
|
176
|
+
_ = float(state.reward), bool(state.done)
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
missing.append(f"runtime reset/step failed: {exc}")
|
|
179
|
+
return missing, warnings, notes, False
|
|
180
|
+
|
|
181
|
+
if missing:
|
|
182
|
+
return missing, warnings, notes, False
|
|
183
|
+
|
|
184
|
+
trainable = True
|
|
185
|
+
notes.append("Runtime reset/step succeeded. Safe to try `simrig smoke` then `simrig train`.")
|
|
186
|
+
return missing, warnings, notes, trainable
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _obs_size_warnings(observation_size: Any) -> list[str]:
|
|
190
|
+
warnings: list[str] = []
|
|
191
|
+
if observation_size is None:
|
|
192
|
+
warnings.append("runtime: observation_size missing")
|
|
193
|
+
return warnings
|
|
194
|
+
if isinstance(observation_size, dict):
|
|
195
|
+
if "state" not in observation_size:
|
|
196
|
+
warnings.append("runtime: observation_size missing key `state`")
|
|
197
|
+
if "privileged_state" not in observation_size:
|
|
198
|
+
warnings.append("runtime: observation_size missing key `privileged_state`")
|
|
199
|
+
else:
|
|
200
|
+
warnings.append(
|
|
201
|
+
"runtime: observation_size is flat; SimRig PPO defaults expect "
|
|
202
|
+
"`state` / `privileged_state`."
|
|
203
|
+
)
|
|
204
|
+
return warnings
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _find_class(tree: ast.AST, name: str) -> ast.ClassDef | None:
|
|
208
|
+
for node in ast.walk(tree):
|
|
209
|
+
if isinstance(node, ast.ClassDef) and node.name == name:
|
|
210
|
+
return node
|
|
211
|
+
return None
|