react-manip 0.1.0__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.
- assets/materials/realistic/common/ceramic.png +0 -0
- assets/materials/realistic/common/cream-plaster.png +0 -0
- assets/materials/realistic/common/gray-felt.png +0 -0
- assets/materials/realistic/common/gray-plaster.png +0 -0
- assets/materials/realistic/common/gray-woodgrain.png +0 -0
- assets/materials/realistic/common/light-gray-floor-tile.png +0 -0
- assets/materials/realistic/common/light-gray-plaster.png +0 -0
- assets/materials/realistic/common/metal.png +0 -0
- assets/materials/realistic/common/oak-wood.png +0 -0
- assets/materials/realistic/common/steel-brushed.png +0 -0
- assets/materials/realistic/common/steel-scratched.png +0 -0
- assets/materials/realistic/common/varnished-wood-panels.png +0 -0
- assets/materials/realistic/common/walnut-wood.png +0 -0
- assets/materials/realistic/common/white-bricks.png +0 -0
- assets/materials/realistic/common/wood-tiles.png +0 -0
- assets/materials/realistic/tiles074/Tiles074_2K_Color.png +0 -0
- assets/materials/realistic/woodfloor049/WoodFloor049_2K_Color.png +0 -0
- configs/box_insertion/profile_default.json +204 -0
- react_manip/__init__.py +12 -0
- react_manip/asset_cli.py +18 -0
- react_manip/builtin.py +168 -0
- react_manip/cameras.py +301 -0
- react_manip/category_objects.py +248 -0
- react_manip/core/__init__.py +60 -0
- react_manip/core/aggregate.py +59 -0
- react_manip/core/compatibility.py +64 -0
- react_manip/core/contracts.py +42 -0
- react_manip/core/geometry.py +269 -0
- react_manip/core/language.py +116 -0
- react_manip/core/metrics.py +196 -0
- react_manip/core/observation.py +59 -0
- react_manip/core/protocol.py +91 -0
- react_manip/core/registry.py +94 -0
- react_manip/core/runner.py +498 -0
- react_manip/core/scenarios.py +110 -0
- react_manip/core/specs.py +203 -0
- react_manip/core/timeouts.py +44 -0
- react_manip/core/visual_randomization.py +261 -0
- react_manip/evaluation/__init__.py +4 -0
- react_manip/evaluation/metrics.py +17 -0
- react_manip/evaluation/protocols.py +326 -0
- react_manip/evaluation/viewer.py +240 -0
- react_manip/instances/__init__.py +6 -0
- react_manip/instances/candidate_bank.py +109 -0
- react_manip/instances/public.py +95 -0
- react_manip/instances/records.py +345 -0
- react_manip/instances/runtime.py +306 -0
- react_manip/instances/suite.py +231 -0
- react_manip/instances/trajectory.py +59 -0
- react_manip/instances/variation.py +118 -0
- react_manip/legacy.py +318 -0
- react_manip/policies/__init__.py +5 -0
- react_manip/policies/base.py +79 -0
- react_manip/rendering.py +58 -0
- react_manip/robot_registry.py +31 -0
- react_manip/robots/__init__.py +33 -0
- react_manip/robots/adapters.py +135 -0
- react_manip/robots/agilex_adapter.py +35 -0
- react_manip/robots/agilex_description.py +74 -0
- react_manip/robots/agilex_robosuite.py +246 -0
- react_manip/robots/arm_actions.py +17 -0
- react_manip/robots/base.py +119 -0
- react_manip/robots/catalog.py +18 -0
- react_manip/robots/legacy_agilex_robosuite.py +169 -0
- react_manip/robots/mobile_base.py +33 -0
- react_manip/robots/mounts.py +80 -0
- react_manip/robots/resolution.py +103 -0
- react_manip/robots/sawyer_robosuite.py +147 -0
- react_manip/robots/sensors.py +137 -0
- react_manip/utils/__init__.py +1 -0
- react_manip/visual_task.py +50 -0
- react_manip-0.1.0.dist-info/METADATA +446 -0
- react_manip-0.1.0.dist-info/RECORD +99 -0
- react_manip-0.1.0.dist-info/WHEEL +5 -0
- react_manip-0.1.0.dist-info/entry_points.txt +2 -0
- react_manip-0.1.0.dist-info/licenses/LICENSE +131 -0
- react_manip-0.1.0.dist-info/top_level.txt +5 -0
- scripts/add_phone_lean_to_bank.py +242 -0
- scripts/agilex_wheeled_builder.py +133 -0
- scripts/audit_task_solvability.py +150 -0
- scripts/build_agilex_assets.py +317 -0
- scripts/build_legacy_agilex_assets.py +244 -0
- scripts/evaluate_all_tasks.py +309 -0
- scripts/evaluate_policy.py +281 -0
- scripts/piperx_assets.lock.json +181 -0
- scripts/pygame_demo.py +360 -0
- scripts/pygame_task_debug.py +242 -0
- scripts/regenerate_instance_bank.py +192 -0
- scripts/remove_task_from_bank.py +103 -0
- scripts/select_task_instances.py +64 -0
- scripts/verify_curling_reachability.py +206 -0
- scripts/verify_piperx_tasks.py +81 -0
- tasks/box_insertion/__init__.py +3 -0
- tasks/box_insertion/assets/scene.xml +50 -0
- tasks/box_insertion/env.py +642 -0
- tasks/box_insertion/evaluation.json +16 -0
- tasks/box_insertion/instance_contract.json +321 -0
- tasks/box_insertion/scoring.py +23 -0
- tasks/box_insertion/task_spec.json +333 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Fixed-horizon policy evaluation runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import inspect
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Callable, Mapping
|
|
11
|
+
|
|
12
|
+
from .metrics import SustainedSuccessTracker
|
|
13
|
+
from .compatibility import validate_task_robot, resolve_task_robot
|
|
14
|
+
from .protocol import normalize_reset, normalize_step
|
|
15
|
+
from .observation import policy_info, policy_observation
|
|
16
|
+
from .registry import TaskDefinition
|
|
17
|
+
from .specs import TaskSpec
|
|
18
|
+
from .timeouts import DEFAULT_EPISODE_WALL_TIMEOUT_SEC, validate_timeout
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _call_with_supported_kwargs(fn: Callable[..., Any], kwargs: dict[str, Any]) -> Any:
|
|
22
|
+
"""Call a factory without hiding errors raised inside its implementation."""
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
signature = inspect.signature(fn)
|
|
26
|
+
except (TypeError, ValueError):
|
|
27
|
+
return fn(**kwargs)
|
|
28
|
+
if any(param.kind == inspect.Parameter.VAR_KEYWORD for param in signature.parameters.values()):
|
|
29
|
+
return fn(**kwargs)
|
|
30
|
+
# Accommodate common adapter spellings without forcing every external
|
|
31
|
+
# policy/factory to use our parameter names. Build this mapping before
|
|
32
|
+
# the direct-name fast path so ``act(obs, info)`` receives both values
|
|
33
|
+
# (rather than only the directly named ``info`` argument).
|
|
34
|
+
aliases = {
|
|
35
|
+
"observation": ("obs", "observation", "state"),
|
|
36
|
+
"info": ("info", "metadata"),
|
|
37
|
+
"robot_id": ("robot_id", "robot", "embodiment"),
|
|
38
|
+
"task_spec": ("task_spec", "spec", "task"),
|
|
39
|
+
"seed": ("seed", "rng_seed"),
|
|
40
|
+
}
|
|
41
|
+
mapped: dict[str, Any] = {}
|
|
42
|
+
for source, value in kwargs.items():
|
|
43
|
+
for name in aliases.get(source, (source,)):
|
|
44
|
+
if name in signature.parameters:
|
|
45
|
+
mapped[name] = value
|
|
46
|
+
break
|
|
47
|
+
if mapped:
|
|
48
|
+
return fn(**mapped)
|
|
49
|
+
# Last resort for simple positional callables (e.g. lambda obs: action).
|
|
50
|
+
positional = [
|
|
51
|
+
param
|
|
52
|
+
for param in signature.parameters.values()
|
|
53
|
+
if param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
54
|
+
]
|
|
55
|
+
values = [kwargs[key] for key in ("observation", "info", "seed", "robot_id", "task_spec") if key in kwargs]
|
|
56
|
+
if positional and values:
|
|
57
|
+
return fn(*values[: len(positional)])
|
|
58
|
+
return fn()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class EvaluationResult:
|
|
63
|
+
task_id: str
|
|
64
|
+
task_version: str
|
|
65
|
+
robot_id: str | None
|
|
66
|
+
policy_id: str
|
|
67
|
+
seed: int | None
|
|
68
|
+
metrics: dict[str, Any]
|
|
69
|
+
wall_time_sec: float
|
|
70
|
+
ended_early: bool
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> dict[str, Any]:
|
|
73
|
+
return {
|
|
74
|
+
"benchmark_api_version": "2.0",
|
|
75
|
+
"metrics_schema_version": "2.0",
|
|
76
|
+
"task_id": self.task_id,
|
|
77
|
+
"task_version": self.task_version,
|
|
78
|
+
"robot_id": self.robot_id,
|
|
79
|
+
"policy_id": self.policy_id,
|
|
80
|
+
"seed": self.seed,
|
|
81
|
+
"wall_time_sec": round(self.wall_time_sec, 6),
|
|
82
|
+
"ended_early": self.ended_early,
|
|
83
|
+
**self.metrics,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class BenchmarkRunner:
|
|
88
|
+
"""Run a policy for a fixed simulation horizon and wall-clock budget.
|
|
89
|
+
|
|
90
|
+
The runner accepts either a :class:`TaskDefinition`, a ``TaskSpec`` plus a
|
|
91
|
+
factory, or a raw factory. This makes it possible to migrate existing
|
|
92
|
+
robosuite environments gradually while new tasks use the registry.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, *, default_horizon_sec: float | None = None, default_control_hz: float | None = None,
|
|
96
|
+
episode_wall_timeout_sec: float | None = DEFAULT_EPISODE_WALL_TIMEOUT_SEC):
|
|
97
|
+
self.default_horizon_sec = default_horizon_sec
|
|
98
|
+
self.default_control_hz = default_control_hz
|
|
99
|
+
self.episode_wall_timeout_sec = validate_timeout(episode_wall_timeout_sec, "episode_wall_timeout_sec")
|
|
100
|
+
|
|
101
|
+
def evaluate(
|
|
102
|
+
self,
|
|
103
|
+
task: TaskDefinition | TaskSpec,
|
|
104
|
+
factory: Callable[..., Any] | None = None,
|
|
105
|
+
policy: Any | None = None,
|
|
106
|
+
*,
|
|
107
|
+
robot_id: str | None = None,
|
|
108
|
+
seed: int | None = 0,
|
|
109
|
+
horizon_sec: float | None = None,
|
|
110
|
+
control_hz: float | None = None,
|
|
111
|
+
policy_id: str | None = None,
|
|
112
|
+
env_kwargs: Mapping[str, Any] | None = None,
|
|
113
|
+
render: bool = False,
|
|
114
|
+
render_realtime: bool = False,
|
|
115
|
+
render_hold_sec: float = 3.0,
|
|
116
|
+
step_callback: Callable[..., Any] | None = None,
|
|
117
|
+
observation_callback: Callable[..., Any] | None = None,
|
|
118
|
+
env: Any | None = None,
|
|
119
|
+
stop_on_done: bool = False,
|
|
120
|
+
score_key: str | None = None,
|
|
121
|
+
camera_observations: bool = True,
|
|
122
|
+
episode_wall_timeout_sec: float | None = None,
|
|
123
|
+
) -> EvaluationResult:
|
|
124
|
+
# A supplied environment is borrowed: reset/step it, but leave closing
|
|
125
|
+
# to its owner. stop_on_done prevents a multi-episode protocol from
|
|
126
|
+
# consuming uncounted retries through a task's reset-on-done behavior.
|
|
127
|
+
if isinstance(task, TaskDefinition):
|
|
128
|
+
spec, task_factory = task.spec, task.factory
|
|
129
|
+
elif isinstance(task, TaskSpec):
|
|
130
|
+
if factory is None:
|
|
131
|
+
raise TypeError("factory is required when task is a TaskSpec")
|
|
132
|
+
spec, task_factory = task, factory
|
|
133
|
+
else: # pragma: no cover - defensive for callers using duck typing
|
|
134
|
+
raise TypeError("task must be TaskDefinition or TaskSpec")
|
|
135
|
+
|
|
136
|
+
hz = float(control_hz or self.default_control_hz or spec.control_hz)
|
|
137
|
+
horizon = float(horizon_sec or self.default_horizon_sec or spec.horizon_sec)
|
|
138
|
+
if hz <= 0 or horizon <= 0:
|
|
139
|
+
raise ValueError("horizon_sec and control_hz must be positive")
|
|
140
|
+
steps_limit = max(1, int(round(horizon * hz)))
|
|
141
|
+
if render_hold_sec < 0:
|
|
142
|
+
raise ValueError("render_hold_sec must be non-negative")
|
|
143
|
+
episode_timeout = validate_timeout(
|
|
144
|
+
self.episode_wall_timeout_sec if episode_wall_timeout_sec is None else episode_wall_timeout_sec,
|
|
145
|
+
"episode_wall_timeout_sec",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
robot_id = resolve_task_robot(spec, robot_id)
|
|
149
|
+
robot_adapter = None
|
|
150
|
+
if robot_id is not None:
|
|
151
|
+
try:
|
|
152
|
+
from .registry import get_robot_registry
|
|
153
|
+
|
|
154
|
+
robot_adapter = get_robot_registry().get(robot_id.lower())
|
|
155
|
+
except KeyError:
|
|
156
|
+
# A custom application may provide a robot factory outside
|
|
157
|
+
# the registry; task-level support checks still apply below.
|
|
158
|
+
robot_adapter = None
|
|
159
|
+
validate_task_robot(spec, robot_id, robot_adapter)
|
|
160
|
+
if robot_adapter is not None:
|
|
161
|
+
robot_spec = getattr(robot_adapter, "spec", None)
|
|
162
|
+
capabilities = getattr(robot_spec, "capabilities", ())
|
|
163
|
+
if "custom_mjcf" in capabilities and hasattr(robot_adapter, "validate_assets"):
|
|
164
|
+
robot_adapter.validate_assets()
|
|
165
|
+
|
|
166
|
+
factory_kwargs: dict[str, Any] = {"seed": seed, "robot_id": robot_id}
|
|
167
|
+
if env_kwargs:
|
|
168
|
+
factory_kwargs.update(dict(env_kwargs))
|
|
169
|
+
if render:
|
|
170
|
+
# robosuite environments use these constructor flags to create a
|
|
171
|
+
# native viewer. ``_call_with_supported_kwargs`` drops them for
|
|
172
|
+
# custom factories that do not expose rendering options.
|
|
173
|
+
factory_kwargs.setdefault("has_renderer", True)
|
|
174
|
+
# Native mjviewer does not need an offscreen context. On macOS an
|
|
175
|
+
# offscreen GLFW / CGL context can create an NSWindow from the
|
|
176
|
+
# mjpython worker thread and abort the process.
|
|
177
|
+
factory_kwargs.setdefault("has_offscreen_renderer", False)
|
|
178
|
+
factory_kwargs.setdefault("use_camera_obs", False)
|
|
179
|
+
owns_env = env is None
|
|
180
|
+
if owns_env:
|
|
181
|
+
env = _call_with_supported_kwargs(task_factory, factory_kwargs)
|
|
182
|
+
tracker = SustainedSuccessTracker(control_hz=hz)
|
|
183
|
+
started = time.perf_counter()
|
|
184
|
+
ended_early = False
|
|
185
|
+
terminal_success = False
|
|
186
|
+
error_message: str | None = None
|
|
187
|
+
reset_count = 0
|
|
188
|
+
wall_clock_timeout = False
|
|
189
|
+
try:
|
|
190
|
+
from ..robots.sensors import policy_camera_ids, CAMERA_CONTRACT_VERSION
|
|
191
|
+
from ..robots.catalog import ALL_ROBOT_IDS
|
|
192
|
+
from integrations.rpent_embodiments.catalog import EMBODIMENTS
|
|
193
|
+
if robot_id in ALL_ROBOT_IDS or robot_id in EMBODIMENTS:
|
|
194
|
+
env.set_camera_observations(policy_camera_ids(robot_id) if camera_observations else ())
|
|
195
|
+
reset_kwargs = {"seed": seed} if seed is not None else {}
|
|
196
|
+
observation, reset_info = _reset_env(env, reset_kwargs)
|
|
197
|
+
observation = policy_observation(observation, spec, env)
|
|
198
|
+
# Every episode exposes the language instruction through reset
|
|
199
|
+
# info, including legacy robosuite environments whose reset only
|
|
200
|
+
# returns an observation.
|
|
201
|
+
prompt_fn = getattr(env, "get_language_prompt", None)
|
|
202
|
+
if callable(prompt_fn):
|
|
203
|
+
prompt = prompt_fn()
|
|
204
|
+
reset_info = dict(reset_info)
|
|
205
|
+
if isinstance(prompt, Mapping):
|
|
206
|
+
reset_info.setdefault("language_prompt", prompt.get("text", ""))
|
|
207
|
+
reset_info.setdefault("language_prompt_structured", prompt)
|
|
208
|
+
if render:
|
|
209
|
+
_render_env(env)
|
|
210
|
+
if policy is None:
|
|
211
|
+
from react_manip.policies.base import RandomPolicy
|
|
212
|
+
|
|
213
|
+
policy = RandomPolicy()
|
|
214
|
+
if hasattr(policy, "set_action_dim"):
|
|
215
|
+
policy.set_action_dim(_action_dim(env))
|
|
216
|
+
if hasattr(policy, "reset"):
|
|
217
|
+
_call_with_supported_kwargs(
|
|
218
|
+
policy.reset,
|
|
219
|
+
{"seed": seed, "task_spec": spec, "episode_index": reset_count},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
last_info: Mapping[str, Any] = reset_info
|
|
223
|
+
last_reward, terminated, truncated = 0.0, False, False
|
|
224
|
+
try:
|
|
225
|
+
for _ in range(steps_limit):
|
|
226
|
+
if episode_timeout is not None and time.perf_counter() - started >= episode_timeout:
|
|
227
|
+
wall_clock_timeout = True
|
|
228
|
+
ended_early = True
|
|
229
|
+
error_message = "wall_clock_timeout"
|
|
230
|
+
tracker.record_failure(at_sec=tracker.steps / hz)
|
|
231
|
+
break
|
|
232
|
+
# Evaluator display/recording is outside policy latency.
|
|
233
|
+
if observation_callback is not None:
|
|
234
|
+
observation_callback(env, observation, last_info)
|
|
235
|
+
tick_started = time.perf_counter()
|
|
236
|
+
policy_started = time.perf_counter()
|
|
237
|
+
action = _call_with_supported_kwargs(
|
|
238
|
+
policy.act,
|
|
239
|
+
{
|
|
240
|
+
"observation": observation,
|
|
241
|
+
"info": policy_info(last_info, policy, env),
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
inference_latency_ms = (time.perf_counter() - policy_started) * 1000.0
|
|
245
|
+
if episode_timeout is not None and time.perf_counter() - started >= episode_timeout:
|
|
246
|
+
wall_clock_timeout = True
|
|
247
|
+
ended_early = True
|
|
248
|
+
error_message = "wall_clock_timeout"
|
|
249
|
+
tracker.record_failure(at_sec=tracker.steps / hz)
|
|
250
|
+
break
|
|
251
|
+
result = normalize_step(env.step(action))
|
|
252
|
+
observation = policy_observation(result.observation, spec, env)
|
|
253
|
+
last_info = result.info
|
|
254
|
+
last_reward, terminated, truncated = result.reward, result.terminated, result.truncated
|
|
255
|
+
if step_callback is not None:
|
|
256
|
+
step_callback(env, _ + 1, result)
|
|
257
|
+
if render:
|
|
258
|
+
_render_env(env)
|
|
259
|
+
if render_realtime:
|
|
260
|
+
time.sleep(max(0.0, (1.0 / hz) - (time.perf_counter() - tick_started)))
|
|
261
|
+
# Preserve task-reported latency when present, otherwise
|
|
262
|
+
# make model inference cost measurable for every policy.
|
|
263
|
+
if "inference_latency_ms" not in last_info:
|
|
264
|
+
last_info = dict(last_info)
|
|
265
|
+
last_info["inference_latency_ms"] = inference_latency_ms
|
|
266
|
+
success_state = _success_state(last_info, result.reward)
|
|
267
|
+
events = _events(last_info)
|
|
268
|
+
failure = last_info.get("failure")
|
|
269
|
+
# A shaped reward may represent partial task progress
|
|
270
|
+
# (for example, a scored prerequisite). When a task
|
|
271
|
+
# explicitly reports terminal success, that ground-truth
|
|
272
|
+
# value must take precedence over the legacy reward
|
|
273
|
+
# fallback.
|
|
274
|
+
if "terminal_success" in last_info:
|
|
275
|
+
terminal_success = bool(last_info["terminal_success"])
|
|
276
|
+
elif "success" in last_info:
|
|
277
|
+
terminal_success = bool(last_info["success"])
|
|
278
|
+
else:
|
|
279
|
+
terminal_success = result.reward > 0.0
|
|
280
|
+
behavior = spec.done_behavior
|
|
281
|
+
if result.done and not terminal_success and failure is None:
|
|
282
|
+
failure = {
|
|
283
|
+
"recoverable": behavior in {"reset", "fail_and_reset"},
|
|
284
|
+
}
|
|
285
|
+
tracker.update(
|
|
286
|
+
success_state,
|
|
287
|
+
events=events,
|
|
288
|
+
failure=failure,
|
|
289
|
+
dt=1.0 / hz,
|
|
290
|
+
info=last_info,
|
|
291
|
+
)
|
|
292
|
+
# A policy/environment call can itself consume the budget.
|
|
293
|
+
# Check after recording the completed tick so the result is
|
|
294
|
+
# still traceable without running another control step.
|
|
295
|
+
if (not result.done and episode_timeout is not None and
|
|
296
|
+
time.perf_counter() - started >= episode_timeout):
|
|
297
|
+
wall_clock_timeout = True
|
|
298
|
+
ended_early = True
|
|
299
|
+
error_message = "wall_clock_timeout"
|
|
300
|
+
tracker.record_failure(at_sec=tracker.steps / hz)
|
|
301
|
+
break
|
|
302
|
+
if result.done:
|
|
303
|
+
if not stop_on_done and behavior in {"reset", "fail_and_reset"} and _can_reset_for_horizon(steps_limit, _):
|
|
304
|
+
reset_count += 1
|
|
305
|
+
observation, last_info = _reset_env(
|
|
306
|
+
env,
|
|
307
|
+
{"seed": None if seed is None else seed + reset_count},
|
|
308
|
+
)
|
|
309
|
+
observation = policy_observation(observation, spec, env)
|
|
310
|
+
if hasattr(policy, "reset"):
|
|
311
|
+
_call_with_supported_kwargs(
|
|
312
|
+
policy.reset,
|
|
313
|
+
{
|
|
314
|
+
"seed": None if seed is None else seed + reset_count,
|
|
315
|
+
"task_spec": spec,
|
|
316
|
+
"episode_index": reset_count,
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
continue
|
|
320
|
+
ended_early = _ + 1 < steps_limit
|
|
321
|
+
break
|
|
322
|
+
except Exception as exc:
|
|
323
|
+
# The interactive evaluator uses a dedicated exception for
|
|
324
|
+
# "skip current episode". Let the manifest runner handle it
|
|
325
|
+
# between episodes instead of turning it into a normal failed
|
|
326
|
+
# EvaluationResult.
|
|
327
|
+
try:
|
|
328
|
+
from react_manip.evaluation.viewer import SkipEpisode
|
|
329
|
+
except ImportError: # pragma: no cover - optional UI module
|
|
330
|
+
SkipEpisode = ()
|
|
331
|
+
if isinstance(exc, SkipEpisode):
|
|
332
|
+
raise
|
|
333
|
+
error_message = f"{type(exc).__name__}: {str(exc)[:240]}"
|
|
334
|
+
ended_early = True
|
|
335
|
+
# The exception occurs between control ticks. Record it as a
|
|
336
|
+
# failure without inventing an extra simulated tick.
|
|
337
|
+
tracker.record_failure(at_sec=tracker.steps / hz)
|
|
338
|
+
finally:
|
|
339
|
+
if render and render_hold_sec > 0:
|
|
340
|
+
time.sleep(render_hold_sec)
|
|
341
|
+
if owns_env and hasattr(env, "close"):
|
|
342
|
+
env.close()
|
|
343
|
+
|
|
344
|
+
metrics = tracker.summarize(spec.success, terminal_success=terminal_success)
|
|
345
|
+
if score_key is not None and error_message is None:
|
|
346
|
+
value = last_info.get(score_key)
|
|
347
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
348
|
+
error_message = f"missing or non-finite final task score: info.{score_key}"
|
|
349
|
+
else:
|
|
350
|
+
metrics["episode_score"] = float(value)
|
|
351
|
+
metrics["score_source"] = f"info.{score_key}"
|
|
352
|
+
sim_time = float(metrics.get("sim_time_sec", 0.0))
|
|
353
|
+
metrics.update({
|
|
354
|
+
"horizon_sec": horizon,
|
|
355
|
+
"control_hz": hz,
|
|
356
|
+
"horizon_steps": steps_limit,
|
|
357
|
+
"real_time_factor": round(sim_time / max(time.perf_counter() - started, 1e-12), 6),
|
|
358
|
+
"episode_wall_timeout_sec": episode_timeout,
|
|
359
|
+
"wall_clock_timeout": wall_clock_timeout,
|
|
360
|
+
"timeout_scope": "episode" if wall_clock_timeout else None,
|
|
361
|
+
})
|
|
362
|
+
from integrations.rpent_embodiments.catalog import EMBODIMENTS
|
|
363
|
+
if robot_id in ALL_ROBOT_IDS or robot_id in EMBODIMENTS:
|
|
364
|
+
metrics['camera_contract_version'] = CAMERA_CONTRACT_VERSION
|
|
365
|
+
metrics['camera_ids'] = list(policy_camera_ids(robot_id)) if camera_observations else []
|
|
366
|
+
metrics['observation_mode'] = 'rgb' if camera_observations else 'state_only_smoke'
|
|
367
|
+
scenario = getattr(env, "scenario", None)
|
|
368
|
+
if scenario is not None:
|
|
369
|
+
metrics.update({"scenario_id": scenario.scenario_id, "scenario_digest": scenario.digest})
|
|
370
|
+
if error_message is not None:
|
|
371
|
+
metrics["error"] = error_message
|
|
372
|
+
metrics["passed"] = False
|
|
373
|
+
if hasattr(policy, "end_episode"):
|
|
374
|
+
_call_with_supported_kwargs(policy.end_episode, {
|
|
375
|
+
"observation": observation, "info": policy_info(last_info, policy, env),
|
|
376
|
+
"reward": last_reward, "terminated": terminated,
|
|
377
|
+
"truncated": truncated or wall_clock_timeout or (not terminated and tracker.steps >= steps_limit),
|
|
378
|
+
"passed": metrics["passed"], "episode_score": metrics.get("episode_score"),
|
|
379
|
+
})
|
|
380
|
+
return EvaluationResult(
|
|
381
|
+
task_id=spec.task_id,
|
|
382
|
+
task_version=spec.task_version,
|
|
383
|
+
robot_id=robot_id,
|
|
384
|
+
policy_id=policy_id or getattr(policy, "policy_id", policy.__class__.__name__),
|
|
385
|
+
seed=seed,
|
|
386
|
+
metrics=metrics,
|
|
387
|
+
wall_time_sec=time.perf_counter() - started,
|
|
388
|
+
ended_early=ended_early,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _reset_env(env: Any, kwargs: dict[str, Any]) -> tuple[Any, Mapping[str, Any]]:
|
|
393
|
+
"""Reset old robosuite and Gymnasium environments without masking errors."""
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
return normalize_reset(env.reset(**kwargs))
|
|
397
|
+
except TypeError as exc:
|
|
398
|
+
# Legacy robosuite reset() has no seed parameter. Only retry for a
|
|
399
|
+
# signature mismatch; errors raised inside reset must propagate.
|
|
400
|
+
try:
|
|
401
|
+
signature = inspect.signature(env.reset)
|
|
402
|
+
except (TypeError, ValueError):
|
|
403
|
+
raise
|
|
404
|
+
if kwargs and "seed" not in signature.parameters:
|
|
405
|
+
return normalize_reset(env.reset())
|
|
406
|
+
raise exc
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _can_reset_for_horizon(steps_limit: int, step_index: int) -> bool:
|
|
410
|
+
# A reset at the final tick would not contribute any simulation time and
|
|
411
|
+
# can make task traces look like an extra episode. Keep the horizon exact.
|
|
412
|
+
return step_index + 1 < steps_limit
|
|
413
|
+
|
|
414
|
+
def evaluate_many(
|
|
415
|
+
self,
|
|
416
|
+
task: TaskDefinition | TaskSpec,
|
|
417
|
+
policy_factory: Callable[[], Any],
|
|
418
|
+
*,
|
|
419
|
+
factory: Callable[..., Any] | None = None,
|
|
420
|
+
episodes: int = 3,
|
|
421
|
+
**kwargs: Any,
|
|
422
|
+
) -> list[EvaluationResult]:
|
|
423
|
+
if episodes < 1:
|
|
424
|
+
raise ValueError("episodes must be >= 1")
|
|
425
|
+
base_seed = kwargs.pop("seed", 0)
|
|
426
|
+
return [
|
|
427
|
+
self.evaluate(
|
|
428
|
+
task,
|
|
429
|
+
factory=factory,
|
|
430
|
+
policy=policy_factory(),
|
|
431
|
+
seed=None if base_seed is None else base_seed + index,
|
|
432
|
+
**kwargs,
|
|
433
|
+
)
|
|
434
|
+
for index in range(episodes)
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _success_state(info: Mapping[str, Any], reward: float) -> bool | None:
|
|
439
|
+
if "success_state" in info:
|
|
440
|
+
return bool(info["success_state"])
|
|
441
|
+
if "success" in info:
|
|
442
|
+
return bool(info["success"])
|
|
443
|
+
# Legacy robosuite tasks generally expose success through positive reward.
|
|
444
|
+
return bool(reward > 0.0)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _events(info: Mapping[str, Any]) -> list[Any]:
|
|
448
|
+
events = info.get("events", info.get("event", ()))
|
|
449
|
+
if events is None:
|
|
450
|
+
return []
|
|
451
|
+
if isinstance(events, (str, bytes)):
|
|
452
|
+
return [events]
|
|
453
|
+
if isinstance(events, int):
|
|
454
|
+
return [None] * max(0, events)
|
|
455
|
+
try:
|
|
456
|
+
return list(events)
|
|
457
|
+
except TypeError:
|
|
458
|
+
return [events]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _action_dim(env: Any) -> int:
|
|
462
|
+
"""Read action dimension from robosuite or Gymnasium-style environments."""
|
|
463
|
+
|
|
464
|
+
value = getattr(env, "action_dim", None)
|
|
465
|
+
if value is not None:
|
|
466
|
+
return int(value)
|
|
467
|
+
space = getattr(env, "action_space", None)
|
|
468
|
+
shape = getattr(space, "shape", None)
|
|
469
|
+
if shape:
|
|
470
|
+
size = 1
|
|
471
|
+
for dimension in shape:
|
|
472
|
+
size *= int(dimension)
|
|
473
|
+
return size
|
|
474
|
+
if hasattr(space, "n"):
|
|
475
|
+
return int(space.n)
|
|
476
|
+
raise AttributeError("environment must expose action_dim or action_space.shape/n")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _render_env(env: Any) -> None:
|
|
480
|
+
"""Refresh a native viewer when interactive rendering is requested."""
|
|
481
|
+
|
|
482
|
+
# robosuite's ``mjviewer`` renderer implements ``render()`` as a no-op;
|
|
483
|
+
# the actual window creation and frame presentation happen in
|
|
484
|
+
# ``MjviewerRenderer.update()``. Prefer that method when available.
|
|
485
|
+
viewer = getattr(env, "viewer", None)
|
|
486
|
+
update_fn = getattr(viewer, "update", None)
|
|
487
|
+
if callable(update_fn):
|
|
488
|
+
update_fn()
|
|
489
|
+
return
|
|
490
|
+
render_fn = getattr(env, "render", None)
|
|
491
|
+
if not callable(render_fn):
|
|
492
|
+
return
|
|
493
|
+
try:
|
|
494
|
+
render_fn()
|
|
495
|
+
except TypeError:
|
|
496
|
+
# Gymnasium environments may require an explicit mode when their
|
|
497
|
+
# render method was not configured at construction time.
|
|
498
|
+
render_fn(mode="human")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Deterministic environment profiles and resolved Scenario instances."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Mapping
|
|
4
|
+
import hashlib, json
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
def _set(dst, path, value):
|
|
8
|
+
cur=dst; parts=path.split('.')
|
|
9
|
+
for part in parts[:-1]: cur=cur.setdefault(part,{})
|
|
10
|
+
cur[parts[-1]]=value
|
|
11
|
+
|
|
12
|
+
def _get(src, path):
|
|
13
|
+
cur = src
|
|
14
|
+
for part in path.split('.'):
|
|
15
|
+
if not isinstance(cur, Mapping) or part not in cur:
|
|
16
|
+
raise ValueError(f"scenario constraint references missing path: {path}")
|
|
17
|
+
cur = cur[part]
|
|
18
|
+
return cur
|
|
19
|
+
|
|
20
|
+
def _profile_rule(profile, path):
|
|
21
|
+
"""Find a sampling rule by its resolved (group-relative) path."""
|
|
22
|
+
for group in ("fixed", "geometry", "sampled", "physics", "appearance", "runtime"):
|
|
23
|
+
cur = profile.get(group, {}) or {}
|
|
24
|
+
if path in cur:
|
|
25
|
+
return cur[path]
|
|
26
|
+
try:
|
|
27
|
+
for part in path.split('.'):
|
|
28
|
+
cur = cur[part]
|
|
29
|
+
except (KeyError, TypeError):
|
|
30
|
+
continue
|
|
31
|
+
return cur
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
def _flatten(value, prefix=''):
|
|
35
|
+
for key,item in value.items():
|
|
36
|
+
path=f'{prefix}.{key}' if prefix else str(key)
|
|
37
|
+
if isinstance(item,Mapping) and 'distribution' not in item: yield from _flatten(item,path)
|
|
38
|
+
else: yield path,item
|
|
39
|
+
|
|
40
|
+
def _sample(raw,rng):
|
|
41
|
+
if not isinstance(raw,Mapping) or raw.get('distribution','fixed')=='fixed': return raw.get('value') if isinstance(raw,Mapping) and 'value' in raw else raw
|
|
42
|
+
if raw['distribution']=='uniform': return np.asarray(rng.uniform(raw['low'],raw['high'])).item()
|
|
43
|
+
if raw['distribution']=='exponential':
|
|
44
|
+
low=float(raw.get('low',0.0)); scale=float(raw.get('scale',1.0)); high=raw.get('high')
|
|
45
|
+
if not np.isfinite(low) or not np.isfinite(scale) or scale<=0: raise ValueError('exponential distribution requires finite low and scale > 0')
|
|
46
|
+
if high is None: return float(low+rng.exponential(scale))
|
|
47
|
+
high=float(high)
|
|
48
|
+
if not np.isfinite(high) or high<=low: raise ValueError('bounded exponential distribution requires finite high > low')
|
|
49
|
+
# Inverse CDF of an exponential conditioned on [low, high]. Unlike
|
|
50
|
+
# clipping an unbounded sample, this creates no probability spike at
|
|
51
|
+
# the upper limit and remains exactly reproducible from the seed.
|
|
52
|
+
span=high-low; normalizer=-np.expm1(-span/scale); u=float(rng.random())
|
|
53
|
+
return float(low-scale*np.log1p(-u*normalizer))
|
|
54
|
+
if raw['distribution']=='choice':
|
|
55
|
+
values = list(raw['values'])
|
|
56
|
+
if not values: raise ValueError('choice distribution requires at least one value')
|
|
57
|
+
return values[int(rng.integers(len(values)))]
|
|
58
|
+
raise ValueError(f"unsupported distribution: {raw['distribution']}")
|
|
59
|
+
|
|
60
|
+
def _apply_constraints(profile, parameters, seed):
|
|
61
|
+
"""Apply declarative cross-field constraints after independent sampling."""
|
|
62
|
+
constraints = profile.get("constraints", ()) or ()
|
|
63
|
+
rng = np.random.default_rng(int(seed) + 4000012)
|
|
64
|
+
for rule in constraints:
|
|
65
|
+
if not isinstance(rule, Mapping) or rule.get("type") != "not_equal":
|
|
66
|
+
raise ValueError(f"unsupported scenario constraint: {rule!r}")
|
|
67
|
+
paths = list(rule.get("paths", ()))
|
|
68
|
+
if len(paths) != 2:
|
|
69
|
+
raise ValueError("not_equal constraint requires exactly two paths")
|
|
70
|
+
left_path, right_path = map(str, paths)
|
|
71
|
+
left = _get(parameters, left_path)
|
|
72
|
+
if _get(parameters, right_path) != left:
|
|
73
|
+
continue
|
|
74
|
+
raw = _profile_rule(profile, right_path)
|
|
75
|
+
values = list(raw.get("values", ())) if (
|
|
76
|
+
isinstance(raw, Mapping) and raw.get("distribution") == "choice"
|
|
77
|
+
) else []
|
|
78
|
+
candidates = [value for value in values if value != left]
|
|
79
|
+
if not candidates:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"cannot satisfy not_equal constraint for {left_path} and {right_path}"
|
|
82
|
+
)
|
|
83
|
+
_set(parameters, right_path, candidates[int(rng.integers(len(candidates)))])
|
|
84
|
+
|
|
85
|
+
class Scenario:
|
|
86
|
+
def __init__(self,schema_version,task_id,scenario_id,seed,parameters,assets=None,metadata=None): self.schema_version,self.task_id,self.scenario_id,self.seed=schema_version,task_id,scenario_id,int(seed); self.parameters,self.assets,self.metadata=parameters,assets or {},metadata or {}
|
|
87
|
+
def to_dict(self, digest=True):
|
|
88
|
+
out={'schema_version':self.schema_version,'task_id':self.task_id,'scenario_id':self.scenario_id,'seed':self.seed,'parameters':self.parameters,'assets':self.assets,'metadata':self.metadata}
|
|
89
|
+
if digest: out['digest']=self.digest
|
|
90
|
+
return out
|
|
91
|
+
@property
|
|
92
|
+
def digest(self): return hashlib.sha256(json.dumps(self.to_dict(False),sort_keys=True,separators=(',',':')).encode()).hexdigest()[:16]
|
|
93
|
+
def save(self,path):
|
|
94
|
+
path = Path(path)
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
path.write_text(json.dumps(self.to_dict(),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
|
97
|
+
@classmethod
|
|
98
|
+
def load(cls,path):
|
|
99
|
+
r=json.loads(Path(path).read_text(encoding='utf-8')); s=cls(r['schema_version'],r['task_id'],r['scenario_id'],r['seed'],r['parameters'],r.get('assets'),r.get('metadata'))
|
|
100
|
+
if r.get('digest') and r['digest']!=s.digest: raise ValueError('scenario digest mismatch')
|
|
101
|
+
return s
|
|
102
|
+
|
|
103
|
+
def sample_scenario(profile:Mapping[str,Any], *, seed=0, scenario_id=None):
|
|
104
|
+
out={}
|
|
105
|
+
for path,value in _flatten(profile.get('fixed',{})): _set(out,path,value)
|
|
106
|
+
for i,(group,values) in enumerate((('geometry',profile.get('geometry',profile.get('sampled',{}))),('physics',profile.get('physics',{})),('appearance',profile.get('appearance',{})),('runtime',profile.get('runtime',{})))):
|
|
107
|
+
rng=np.random.default_rng(int(seed)+i*1000003)
|
|
108
|
+
for path,raw in _flatten(values): _set(out,path,_sample(raw,rng))
|
|
109
|
+
_apply_constraints(profile, out, seed)
|
|
110
|
+
return Scenario(str(profile.get('schema_version','1.0')),str(profile['task_id']),scenario_id or f"{profile['task_id']}_{int(seed):06d}",seed,out,profile.get('assets',{}),{'profile':profile.get('profile_id','default')})
|