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
react_manip/cameras.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Shared camera contract. World cameras are fixed; robot cameras are body mounted.
|
|
2
|
+
|
|
3
|
+
No rendering is performed unless explicitly requested. Legacy MJCF names stay
|
|
4
|
+
available, while canonical names have the same meaning in every task.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
FIXED_CAMERAS = ('workspace_oblique', 'workspace_top', 'room_overview')
|
|
11
|
+
LABELS = {'workspace_oblique': 'Workspace oblique', 'workspace_top': 'Workspace top',
|
|
12
|
+
'room_overview': 'Room overview', 'basket_top': 'Basket top'}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Workspace:
|
|
17
|
+
center: tuple = (0., 0., .75)
|
|
18
|
+
half_size: tuple = (.6, .8)
|
|
19
|
+
forward: tuple = (1., 0.)
|
|
20
|
+
room_half_size: tuple = (3., 3.)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Task-level declarations, not robot geometry or hidden object state. The
|
|
24
|
+
# table envelope is refined from the compiled static support where available.
|
|
25
|
+
WORKSPACES = {
|
|
26
|
+
'find_object_in_drawer': Workspace((.10, 0., .80), (.65, .65)),
|
|
27
|
+
'find_object_in_drawer1': Workspace((.10, 0., .80), (.65, .65)),
|
|
28
|
+
'find_object_in_drawer2': Workspace((-.10, 0., .48), (.80, .75)),
|
|
29
|
+
'rope_hang': Workspace((.55, 0., .85), (.55, .55)),
|
|
30
|
+
'pan_flip': Workspace((.6, 0., 1.0), (.45, .4)),
|
|
31
|
+
'rail_ball_to_cup': Workspace((.7, 0., .65), (.65, .5)),
|
|
32
|
+
'conveyor_grasp': Workspace((2., 0., .9), (.9, .3), (0., -1.), (3.3, 2.5)),
|
|
33
|
+
'laptop_into_drawer': Workspace((.1, 0., .8), (.65, .65)),
|
|
34
|
+
'box_insertion': Workspace((.25, 0., .78), (.45, .8)),
|
|
35
|
+
'button_indicator_matching': Workspace((.25, 0., .78), (.45, .8)),
|
|
36
|
+
'pen_strike': Workspace((0., 0., .75), (.5, .5)),
|
|
37
|
+
'pitch_pot': Workspace((-.25, 0., .18), (.65, .65)),
|
|
38
|
+
'paddle_juggle': Workspace((.15, 0., .8), (.7, .7)),
|
|
39
|
+
'phone_power_on': Workspace((0., 0., .15), (.5, .5)),
|
|
40
|
+
'turntable_gamble': Workspace((-.05, 0., .54), (.65, .65)),
|
|
41
|
+
'ball_to_target_hole': Workspace((0., 0., .75), (.7, .7)),
|
|
42
|
+
'cup_lid_matching': Workspace(), 'leveling_board': Workspace(),
|
|
43
|
+
'ring_toss': Workspace(), 'phone_cup': Workspace(),
|
|
44
|
+
'car_drive': Workspace((1.25, 0., .07), (1.25, 1.), (1., 0.), (5., 5.)),
|
|
45
|
+
# Local target view; the room view covers the entire curling lane.
|
|
46
|
+
'curling_sweep': Workspace((6., 0., .68), (1., .8), (1., 0.), (7., 3.)),
|
|
47
|
+
'curling_push': Workspace((2.85, 0., .65), (2.65, .55), (1., 0.), (4., 2.)),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _vec(x):
|
|
52
|
+
return ' '.join(f'{float(v):.9g}' for v in x)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def look_at(position, target, up=(0., 0., 1.)):
|
|
56
|
+
"""MuJoCo basis: -Z optical forward, +Y image-up, +X image-right."""
|
|
57
|
+
z = np.asarray(position, float) - target
|
|
58
|
+
z /= np.linalg.norm(z)
|
|
59
|
+
x = np.cross(up, z)
|
|
60
|
+
if np.linalg.norm(x) < 1e-8:
|
|
61
|
+
raise ValueError('camera up is parallel to viewing direction')
|
|
62
|
+
x /= np.linalg.norm(x)
|
|
63
|
+
return np.column_stack((x, np.cross(z, x), z))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fixed_poses(workspace):
|
|
67
|
+
c = np.array(workspace.center, float)
|
|
68
|
+
forward = np.r_[workspace.forward, 0.]
|
|
69
|
+
forward /= np.linalg.norm(forward)
|
|
70
|
+
side = np.cross(forward, [0, 0, 1])
|
|
71
|
+
# Prefer 1.5 m above the surface; enlarge for broad native workspaces
|
|
72
|
+
# instead of silently cropping them or using extreme fisheye-like FOVs.
|
|
73
|
+
height = max(1.5, max(workspace.half_size) / np.tan(np.deg2rad(37.5)) * 1.12)
|
|
74
|
+
top = c + [0, 0, height]
|
|
75
|
+
distance = max(1.6, max(workspace.half_size) * 1.7)
|
|
76
|
+
oblique = c + forward * distance + side * distance * .25 + [0, 0, distance]
|
|
77
|
+
room_center = c.copy()
|
|
78
|
+
if workspace.center[0] == 2. and workspace.forward == (0., -1.):
|
|
79
|
+
room_center[1] = 1.0
|
|
80
|
+
rx, ry = workspace.room_half_size
|
|
81
|
+
room = room_center + np.array([rx * .8, ry * .8, max(2.4, .9 * max(rx, ry))])
|
|
82
|
+
return {'workspace_top': (top, look_at(top, c, forward), 75.),
|
|
83
|
+
'workspace_oblique': (oblique, look_at(oblique, c), 65.),
|
|
84
|
+
'room_overview': (room, look_at(room, room_center), 85.)}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ensure_scene_cameras(arena, task_id=None):
|
|
88
|
+
"""Insert canonical fixed cameras before compilation; retain old names."""
|
|
89
|
+
world = arena.worldbody
|
|
90
|
+
task_id = task_id or arena.root.get('model', '')
|
|
91
|
+
workspace = WORKSPACES.get(task_id, Workspace())
|
|
92
|
+
poses = fixed_poses(workspace)
|
|
93
|
+
# Always reserve the optional regional view for this multi-zone task.
|
|
94
|
+
if 'conveyor' in task_id:
|
|
95
|
+
poses['basket_top'] = (np.array([2., 1.3, 2.25]), np.eye(3), 65.)
|
|
96
|
+
existing = {c.get('name') for c in world.iter('camera')}
|
|
97
|
+
for name, (position, rotation, fovy) in poses.items():
|
|
98
|
+
if name not in existing:
|
|
99
|
+
ET.SubElement(world, 'camera', name=name, pos=_vec(position),
|
|
100
|
+
xyaxes=_vec(np.r_[rotation[:, 0], rotation[:, 1]]), fovy=str(fovy))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def ensure_robot_cameras(robot):
|
|
104
|
+
"""Normalize imported names using embodiment-owned sensor parameters."""
|
|
105
|
+
from copy import deepcopy
|
|
106
|
+
from .robots.sensors import sensor_profile, install_declared_cameras
|
|
107
|
+
install_declared_cameras(robot)
|
|
108
|
+
profile = sensor_profile(robot)
|
|
109
|
+
prefix = robot.naming_prefix
|
|
110
|
+
entries = [(b, c) for b in robot.worldbody.iter('body') for c in b.findall('camera')]
|
|
111
|
+
existing = {c.get('name') for _, c in entries}
|
|
112
|
+
for body, camera in entries:
|
|
113
|
+
name = camera.get('name', '')
|
|
114
|
+
canonical = None
|
|
115
|
+
if name.endswith('_head_camera'):
|
|
116
|
+
canonical = prefix + 'head'
|
|
117
|
+
elif name.endswith('eye_in_hand'):
|
|
118
|
+
arm = 'left' if 'left' in name else 'right'
|
|
119
|
+
canonical = prefix + 'wrist_' + arm
|
|
120
|
+
elif name.endswith('overhead_camera'):
|
|
121
|
+
canonical = prefix + 'overhead_debug'
|
|
122
|
+
if canonical and canonical not in existing:
|
|
123
|
+
clone = deepcopy(camera)
|
|
124
|
+
clone.set('name', canonical)
|
|
125
|
+
if canonical.endswith('_head') and profile.head_xyaxes is not None:
|
|
126
|
+
# Fixed mount pitch, not target tracking. The legacy head
|
|
127
|
+
# name retains its original horizontal optical axis.
|
|
128
|
+
for attr in ('quat', 'euler', 'axisangle', 'zaxis'):
|
|
129
|
+
clone.attrib.pop(attr, None)
|
|
130
|
+
clone.set('xyaxes', profile.head_xyaxes)
|
|
131
|
+
# The imported mast visual encloses the old x=.16 sensor.
|
|
132
|
+
# A small forward bracket puts the optical centre outside it.
|
|
133
|
+
position = np.fromstring(clone.get('pos', '0 0 0'), sep=' ')
|
|
134
|
+
if profile.head_min_x is not None:
|
|
135
|
+
position[0] = max(profile.head_min_x, position[0])
|
|
136
|
+
clone.set('pos', _vec(position))
|
|
137
|
+
camera.set('pos', _vec(position))
|
|
138
|
+
elif '_wrist_' in canonical and profile.wrist_target is not None:
|
|
139
|
+
# Mount the sensor on the eef rear/top face. The imported
|
|
140
|
+
# asset used a +Y side offset, which made the view look
|
|
141
|
+
# rolled by roughly 90 degrees. Use an explicit local
|
|
142
|
+
# position when the embodiment declares one, then aim at a
|
|
143
|
+
# point 6 cm beyond TCP with a tool-frame up vector.
|
|
144
|
+
position = (np.asarray(profile.wrist_position, dtype=float)
|
|
145
|
+
if profile.wrist_position is not None else
|
|
146
|
+
np.fromstring(clone.get('pos', '0 0 0'), sep=' '))
|
|
147
|
+
rotation = look_at(position, np.array(profile.wrist_target), profile.wrist_up)
|
|
148
|
+
for attr in ('quat', 'euler', 'axisangle', 'zaxis'):
|
|
149
|
+
clone.attrib.pop(attr, None)
|
|
150
|
+
camera.attrib.pop(attr, None)
|
|
151
|
+
camera.set('pos', _vec(position))
|
|
152
|
+
clone.set('pos', _vec(position))
|
|
153
|
+
clone.set('xyaxes', _vec(np.r_[rotation[:, 0], rotation[:, 1]]))
|
|
154
|
+
camera.set('xyaxes', clone.get('xyaxes'))
|
|
155
|
+
body.append(clone)
|
|
156
|
+
existing.add(canonical)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def raw_sim(env):
|
|
160
|
+
sim = getattr(env, 'sim', env)
|
|
161
|
+
model = getattr(sim.model, '_model', sim.model)
|
|
162
|
+
data = getattr(sim.data, '_data', sim.data)
|
|
163
|
+
return sim, model, data
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def configure_fixed_cameras(env):
|
|
167
|
+
"""Called after reset, never during step: no tracking or hidden-state input."""
|
|
168
|
+
import mujoco
|
|
169
|
+
_, model, data = raw_sim(env)
|
|
170
|
+
module = type(env).__module__.split('.')
|
|
171
|
+
task_id = getattr(env, 'task_id', module[1] if len(module) > 1 else '')
|
|
172
|
+
w = getattr(env, 'camera_workspace', WORKSPACES.get(task_id, Workspace()))
|
|
173
|
+
if isinstance(w, dict):
|
|
174
|
+
w = Workspace(**w)
|
|
175
|
+
center, half = np.array(w.center), np.array(w.half_size)
|
|
176
|
+
# Read only static table supports, not the randomly placed target objects.
|
|
177
|
+
if task_id != 'conveyor_grasp':
|
|
178
|
+
for name in ('table', 'table_top', 'table_top_col', 'table_collision'):
|
|
179
|
+
gid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, name)
|
|
180
|
+
if gid >= 0 and model.geom_type[gid] == mujoco.mjtGeom.mjGEOM_BOX:
|
|
181
|
+
extent = np.abs(data.geom_xmat[gid].reshape(3, 3)) @ model.geom_size[gid]
|
|
182
|
+
center = data.geom_xpos[gid].copy() + [0, 0, extent[2]]
|
|
183
|
+
half = extent[:2] * 1.08
|
|
184
|
+
break
|
|
185
|
+
if task_id == 'pitch_pot':
|
|
186
|
+
bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, 'pot')
|
|
187
|
+
if bid >= 0:
|
|
188
|
+
center = data.xpos[bid].copy() + [0, 0, .18]
|
|
189
|
+
half = np.array([.65, .65])
|
|
190
|
+
forward = np.array(w.forward)
|
|
191
|
+
robots = getattr(env, 'robots', ())
|
|
192
|
+
if robots:
|
|
193
|
+
root = robots[0].robot_model.root_body
|
|
194
|
+
bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, root)
|
|
195
|
+
if bid >= 0:
|
|
196
|
+
direction = center[:2] - data.xpos[bid, :2]
|
|
197
|
+
if np.linalg.norm(direction) > .1:
|
|
198
|
+
forward = direction / np.linalg.norm(direction)
|
|
199
|
+
poses = fixed_poses(Workspace(tuple(center), tuple(half), tuple(forward), w.room_half_size))
|
|
200
|
+
if robots and bid >= 0:
|
|
201
|
+
anchors = [center[:2] - half, center[:2] + half, data.xpos[bid, :2]]
|
|
202
|
+
if hasattr(env, 'bin_centers'):
|
|
203
|
+
anchors.extend(np.asarray(env.bin_centers)[:, :2])
|
|
204
|
+
anchors = np.asarray(anchors)
|
|
205
|
+
room_center = np.r_[(anchors.min(axis=0) + anchors.max(axis=0)) / 2, center[2]]
|
|
206
|
+
rx, ry = w.room_half_size
|
|
207
|
+
room_pos = room_center + [rx * .8, ry * .8, max(2.4, .9 * max(rx, ry))]
|
|
208
|
+
poses['room_overview'] = (room_pos, look_at(room_pos, room_center), 85.)
|
|
209
|
+
if task_id == 'car_drive':
|
|
210
|
+
ids = [i for i in range(model.ngeom) if (mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, i) or '').startswith('road_')]
|
|
211
|
+
if ids:
|
|
212
|
+
route_center = data.geom_xpos[ids].mean(axis=0)
|
|
213
|
+
room_pos = route_center + [4, -5, 5]
|
|
214
|
+
poses['room_overview'] = (room_pos, look_at(room_pos, route_center), 85.)
|
|
215
|
+
if hasattr(env, 'bin_centers'):
|
|
216
|
+
bins = np.asarray(env.bin_centers)
|
|
217
|
+
c = bins.mean(axis=0)
|
|
218
|
+
p = c + [0, 0, 1.5]
|
|
219
|
+
poses['basket_top'] = (p, look_at(p, c, np.r_[forward, 0]), 65.)
|
|
220
|
+
# Optional task-owned static framing, reconstructed from scene dimensions.
|
|
221
|
+
# Preserve it through both factory setup and hard resets.
|
|
222
|
+
poses.update(getattr(env, 'fixed_camera_poses', {}))
|
|
223
|
+
for name, (position, rotation, fovy) in poses.items():
|
|
224
|
+
cid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_CAMERA, name)
|
|
225
|
+
if cid < 0:
|
|
226
|
+
raise ValueError(f'{task_id}: missing canonical camera {name}')
|
|
227
|
+
model.cam_pos[cid] = position
|
|
228
|
+
mujoco.mju_mat2Quat(model.cam_quat[cid], rotation.flatten())
|
|
229
|
+
model.cam_fovy[cid] = fovy
|
|
230
|
+
mujoco.mj_forward(model, data)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def camera_specs(env):
|
|
234
|
+
import mujoco
|
|
235
|
+
_, model, _ = raw_sim(env)
|
|
236
|
+
result = []
|
|
237
|
+
for i in range(model.ncam):
|
|
238
|
+
name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_CAMERA, i)
|
|
239
|
+
canonical = name in FIXED_CAMERAS or name == 'basket_top' or name.endswith(('_head', '_wrist_right', '_wrist_left', '_overhead_debug'))
|
|
240
|
+
role = 'fixed' if model.cam_bodyid[i] == 0 else 'mounted'
|
|
241
|
+
default_policy = name in ('workspace_top', 'workspace_oblique') or name.endswith(('_head', '_wrist_right', '_wrist_left'))
|
|
242
|
+
result.append(dict(id=name, label=LABELS.get(name, name.replace('_head', ' head').replace('_wrist_right', ' right wrist').replace('_wrist_left', ' left wrist')),
|
|
243
|
+
canonical=canonical, display_default=canonical and not name.endswith('_overhead_debug'),
|
|
244
|
+
mounting=role, parent_body=mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, int(model.cam_bodyid[i])),
|
|
245
|
+
width=640, height=480, fovy=float(model.cam_fovy[i]), policy_default=default_policy,
|
|
246
|
+
calibrated=False, image_format='RGB uint8 HWC, top-left origin'))
|
|
247
|
+
def order(s):
|
|
248
|
+
n = s['id']
|
|
249
|
+
if n in FIXED_CAMERAS:
|
|
250
|
+
return (FIXED_CAMERAS.index(n), n)
|
|
251
|
+
return (3 if n.endswith('_head') else 4 if n.endswith('_wrist_right') else 5 if n.endswith('_wrist_left') else 6, n)
|
|
252
|
+
return sorted(result, key=order)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def display_camera_names(env, *, include_legacy=False):
|
|
256
|
+
"""Standard operator views; retain legacy/debug cameras only by opt-in.
|
|
257
|
+
|
|
258
|
+
An unmigrated environment with no standard views keeps its original views.
|
|
259
|
+
This selection does not change model cameras or policy observations.
|
|
260
|
+
"""
|
|
261
|
+
specs = camera_specs(env)
|
|
262
|
+
selected = [s['id'] for s in specs if s['display_default']]
|
|
263
|
+
if include_legacy or not selected:
|
|
264
|
+
return [s['id'] for s in specs]
|
|
265
|
+
return selected
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Fixed mounts, expressed in the robot mount frame (+X forward, +Z up).
|
|
269
|
+
# Task profiles describe static workspace framing, never target tracking.
|
|
270
|
+
POLICY_CAMERA = 'robot0_head_camera'
|
|
271
|
+
EGO_PROFILES = {
|
|
272
|
+
'car_drive2': (50., 100.),
|
|
273
|
+
'curling_push': (40., 80.),
|
|
274
|
+
'conveyor_grasp': (70., 100.),
|
|
275
|
+
'tilte_maze': (65., 60.),
|
|
276
|
+
# These two tasks use a tall vertical workspace. The generic 60-degree
|
|
277
|
+
# downward pitch puts the pan's toss arc and the ring-toss far row against
|
|
278
|
+
# (or beyond) the top edge of the 4:3 policy image. Lower pitch means the
|
|
279
|
+
# optical axis is tilted upward while preserving the robot-mounted pose.
|
|
280
|
+
'pan_flip': (40., 85.),
|
|
281
|
+
'ring_toss1': (50., 80.),
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def configure_policy_camera(env):
|
|
286
|
+
"""Pitch the robot main sensor; preserve its body attachment."""
|
|
287
|
+
import mujoco
|
|
288
|
+
_, model, data = raw_sim(env)
|
|
289
|
+
module = type(env).__module__.split('.')
|
|
290
|
+
task_id = getattr(env, 'task_id', module[1] if len(module) > 1 else '')
|
|
291
|
+
pitch, fovy = EGO_PROFILES.get(task_id, (60., 80.))
|
|
292
|
+
angle = np.deg2rad(pitch)
|
|
293
|
+
rotation = look_at(np.zeros(3), np.array([np.cos(angle), 0., -np.sin(angle)]))
|
|
294
|
+
for name in (POLICY_CAMERA, 'robot0_head'):
|
|
295
|
+
cid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_CAMERA, name)
|
|
296
|
+
if cid < 0:
|
|
297
|
+
continue
|
|
298
|
+
if model.cam_bodyid[cid] == 0:
|
|
299
|
+
raise ValueError(f'{name} must be mounted on the robot')
|
|
300
|
+
mujoco.mju_mat2Quat(model.cam_quat[cid], rotation.flatten())
|
|
301
|
+
model.cam_fovy[cid] = fovy
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Shared category-object assets and collision proxies, independent of tasks."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
import json
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
OBJECT_LINEAR_SCALE = 1.55
|
|
9
|
+
OBJECT_CATEGORY_NAMES = (
|
|
10
|
+
"footwear",
|
|
11
|
+
"drinkware",
|
|
12
|
+
"electronics",
|
|
13
|
+
"toys",
|
|
14
|
+
"kitchen_tools",
|
|
15
|
+
"office_supplies",
|
|
16
|
+
"personal_care",
|
|
17
|
+
"planters",
|
|
18
|
+
)
|
|
19
|
+
CATEGORY_VARIANTS = {
|
|
20
|
+
category: tuple(range(10)) for category in OBJECT_CATEGORY_NAMES
|
|
21
|
+
}
|
|
22
|
+
CATEGORY_COLORS = (
|
|
23
|
+
(0.78, 0.16, 0.10, 1.0),
|
|
24
|
+
(0.10, 0.58, 0.22, 1.0),
|
|
25
|
+
(0.12, 0.30, 0.82, 1.0),
|
|
26
|
+
(0.66, 0.18, 0.72, 1.0),
|
|
27
|
+
(0.84, 0.48, 0.08, 1.0),
|
|
28
|
+
(0.10, 0.62, 0.66, 1.0),
|
|
29
|
+
(0.76, 0.28, 0.48, 1.0),
|
|
30
|
+
(0.38, 0.56, 0.18, 1.0),
|
|
31
|
+
)
|
|
32
|
+
# Keep the existing shared download cache and attribution paths intact.
|
|
33
|
+
ASSET_ROOT = Path(__file__).resolve().parents[1] / "assets/category_drawer_sorting"
|
|
34
|
+
ASSET_MANIFEST = ASSET_ROOT / "manifest.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load_asset_manifest() -> dict[str, Any]:
|
|
38
|
+
if not ASSET_MANIFEST.is_file():
|
|
39
|
+
return {"categories": {}}
|
|
40
|
+
return json.loads(ASSET_MANIFEST.read_text(encoding="utf-8"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_EXTERNAL_ASSET_MANIFEST = _load_asset_manifest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _external_asset(category: int, variant: int) -> dict[str, Any] | None:
|
|
47
|
+
category_name = OBJECT_CATEGORY_NAMES[category]
|
|
48
|
+
entries = _EXTERNAL_ASSET_MANIFEST.get("categories", {}).get(category_name, ())
|
|
49
|
+
if variant >= len(entries):
|
|
50
|
+
return None
|
|
51
|
+
asset_id = str(entries[variant])
|
|
52
|
+
directory = ASSET_ROOT / "gso" / asset_id
|
|
53
|
+
metadata_path = directory / "cl_manip_asset.json"
|
|
54
|
+
if not metadata_path.is_file():
|
|
55
|
+
return None
|
|
56
|
+
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
57
|
+
mesh = directory / str(metadata.get("visual_mesh", "meshes/model.obj"))
|
|
58
|
+
texture = directory / str(metadata.get("texture", "materials/textures/texture.png"))
|
|
59
|
+
if not mesh.is_file() or not texture.is_file():
|
|
60
|
+
return None
|
|
61
|
+
return {**metadata, "mesh_path": mesh, "texture_path": texture}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _vec(values) -> str:
|
|
65
|
+
return " ".join(f"{float(value):.6g}" for value in values)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _variant_spec(category: int, variant: int) -> dict[str, Any]:
|
|
69
|
+
"""Return one of ten deterministic, graspable variants per category."""
|
|
70
|
+
|
|
71
|
+
if not 0 <= category < len(OBJECT_CATEGORY_NAMES):
|
|
72
|
+
raise ValueError(f"invalid category index: {category}")
|
|
73
|
+
if not 0 <= variant < 10:
|
|
74
|
+
raise ValueError(f"variant must be in [0, 9], got {variant}")
|
|
75
|
+
scale = 0.86 + 0.028 * variant
|
|
76
|
+
aspect = 0.88 + 0.06 * (variant % 3)
|
|
77
|
+
height = 0.024 + 0.0015 * (variant % 5)
|
|
78
|
+
rgba = np.asarray(CATEGORY_COLORS[category], dtype=float).copy()
|
|
79
|
+
rgba[:3] *= 0.86 + 0.025 * (variant % 6)
|
|
80
|
+
if category == 0: # footwear fallback: elongated sole plus rounded toe
|
|
81
|
+
long = 0.040 * scale
|
|
82
|
+
width = 0.022 * aspect
|
|
83
|
+
half_height = 0.018 + 0.001 * (variant % 4)
|
|
84
|
+
toe = 0.019 + 0.0008 * (variant % 5)
|
|
85
|
+
geoms = [
|
|
86
|
+
("box", (long, width, half_height), (-0.006, 0.0, 0.0), None),
|
|
87
|
+
("sphere", (toe,), (long - 0.004, 0.0, 0.003), None),
|
|
88
|
+
]
|
|
89
|
+
extent = np.array([long + toe, max(width, toe), max(half_height, toe + 0.003)])
|
|
90
|
+
elif category == 1: # drinkware fallback: upright canister
|
|
91
|
+
radius = 0.030 * scale
|
|
92
|
+
half_height = 0.030 + 0.0015 * (variant % 4)
|
|
93
|
+
geoms = [("cylinder", (radius, half_height), (0.0, 0.0, 0.0), None)]
|
|
94
|
+
extent = np.array([radius, radius, half_height])
|
|
95
|
+
elif category == 2: # electronics fallback: flat device with raised control area
|
|
96
|
+
long = 0.043 * scale
|
|
97
|
+
width = 0.030 * aspect
|
|
98
|
+
half_height = 0.014 + 0.001 * (variant % 4)
|
|
99
|
+
geoms = [
|
|
100
|
+
("box", (long, width, half_height), (0.0, 0.0, 0.0), None),
|
|
101
|
+
("box", (long * 0.45, width * 0.55, 0.004), (0.006, 0.0, half_height + 0.004), None),
|
|
102
|
+
]
|
|
103
|
+
extent = np.array([long, width, half_height + 0.008])
|
|
104
|
+
elif category == 3: # toys fallback: stable dumbbell with rounded end caps
|
|
105
|
+
long = 0.040 * scale
|
|
106
|
+
radius = 0.018 + 0.001 * (variant % 4)
|
|
107
|
+
half_height = 0.023 + 0.001 * (variant % 3)
|
|
108
|
+
geoms = [
|
|
109
|
+
("box", (long, 0.013, half_height), (0.0, 0.0, 0.0), None),
|
|
110
|
+
("sphere", (radius,), (-long, 0.0, 0.006), None),
|
|
111
|
+
("sphere", (radius,), (long, 0.0, 0.006), None),
|
|
112
|
+
]
|
|
113
|
+
extent = np.array([long + radius, radius, max(half_height, radius + 0.006)])
|
|
114
|
+
elif category == 4: # kitchen tools: elongated handle with a broad head
|
|
115
|
+
long = 0.042 * scale
|
|
116
|
+
width = 0.013 + 0.001 * (variant % 3)
|
|
117
|
+
half_height = 0.012 + 0.001 * (variant % 4)
|
|
118
|
+
geoms = [
|
|
119
|
+
("box", (long, width, half_height), (0.0, 0.0, 0.0), None),
|
|
120
|
+
("sphere", (width * 1.45,), (long, 0.0, 0.002), None),
|
|
121
|
+
]
|
|
122
|
+
extent = np.array([long + width * 1.45, width * 1.45, max(half_height, width * 1.45)])
|
|
123
|
+
elif category == 5: # office supplies: compact trays, holders and cartridges
|
|
124
|
+
long = 0.038 * scale
|
|
125
|
+
width = 0.029 * aspect
|
|
126
|
+
half_height = 0.020 + 0.0015 * (variant % 4)
|
|
127
|
+
geoms = [
|
|
128
|
+
("box", (long, width, half_height), (0.0, 0.0, 0.0), None),
|
|
129
|
+
("box", (long * 0.72, width * 0.65, 0.004), (0.0, 0.0, half_height + 0.004), None),
|
|
130
|
+
]
|
|
131
|
+
extent = np.array([long, width, half_height + 0.008])
|
|
132
|
+
elif category == 6: # personal care: bottle-like containers
|
|
133
|
+
radius = 0.022 + 0.001 * (variant % 4)
|
|
134
|
+
half_height = 0.034 * scale
|
|
135
|
+
geoms = [
|
|
136
|
+
("cylinder", (radius, half_height), (0.0, 0.0, 0.0), None),
|
|
137
|
+
("cylinder", (radius * 0.52, 0.006), (0.0, 0.0, half_height + 0.006), None),
|
|
138
|
+
]
|
|
139
|
+
extent = np.array([radius, radius, half_height + 0.012])
|
|
140
|
+
else: # planters: squat, stable containers
|
|
141
|
+
radius = 0.031 * scale
|
|
142
|
+
half_height = 0.027 + 0.0015 * (variant % 4)
|
|
143
|
+
geoms = [
|
|
144
|
+
("cylinder", (radius, half_height), (0.0, 0.0, 0.0), None),
|
|
145
|
+
("cylinder", (radius * 1.08, 0.004), (0.0, 0.0, half_height + 0.004), None),
|
|
146
|
+
]
|
|
147
|
+
extent = np.array([radius * 1.08, radius * 1.08, half_height + 0.008])
|
|
148
|
+
# Normalize the horizontal grasp width, leaving clearance inside an
|
|
149
|
+
# 8 cm parallel gripper. Uniform scaling preserves primitive geometry.
|
|
150
|
+
scaled_extent = np.asarray(extent, dtype=float) * OBJECT_LINEAR_SCALE
|
|
151
|
+
grasp_width = 2.0 * float(np.min(scaled_extent[:2]))
|
|
152
|
+
grasp_scale = float(np.clip(grasp_width, 0.045, 0.060) / grasp_width)
|
|
153
|
+
object_scale = OBJECT_LINEAR_SCALE * grasp_scale
|
|
154
|
+
geoms = [
|
|
155
|
+
(
|
|
156
|
+
geom_type,
|
|
157
|
+
tuple(np.asarray(size, dtype=float) * object_scale),
|
|
158
|
+
tuple(np.asarray(offset, dtype=float) * object_scale),
|
|
159
|
+
quat,
|
|
160
|
+
)
|
|
161
|
+
for geom_type, size, offset, quat in geoms
|
|
162
|
+
]
|
|
163
|
+
return {
|
|
164
|
+
"geoms": geoms,
|
|
165
|
+
"extent": np.asarray(extent, dtype=float) * object_scale,
|
|
166
|
+
"rgba": rgba,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _add_geom(parent, *, name: str, geom_type: str, size, pos, rgba, collision: bool):
|
|
171
|
+
attrs = {
|
|
172
|
+
"name": name,
|
|
173
|
+
"type": geom_type,
|
|
174
|
+
"size": _vec(size),
|
|
175
|
+
"pos": _vec(pos),
|
|
176
|
+
"rgba": _vec(rgba),
|
|
177
|
+
"friction": "0.8 0.01 0.001",
|
|
178
|
+
# 1 m drops reach ~4.4 m/s: the default 20 ms soft response can
|
|
179
|
+
# penetrate a 5 cm tabletop. 5 ms remains above 2 physics steps.
|
|
180
|
+
"solref": "0.005 1",
|
|
181
|
+
# Include torsional and rolling resistance, not just sliding friction.
|
|
182
|
+
"condim": "6" if collision else "3",
|
|
183
|
+
"group": "0" if collision else "1",
|
|
184
|
+
"contype": "1" if collision else "0",
|
|
185
|
+
"conaffinity": "1" if collision else "0",
|
|
186
|
+
"density": "320" if collision else "0",
|
|
187
|
+
}
|
|
188
|
+
return ET.SubElement(parent, "geom", **attrs)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _add_object(asset, world, *, name: str, category: int, variant: int, pos) -> np.ndarray:
|
|
192
|
+
spec = _variant_spec(category, variant)
|
|
193
|
+
body = ET.SubElement(world, "body", name=name, pos=_vec(pos))
|
|
194
|
+
ET.SubElement(body, "freejoint", name=f"{name}_free")
|
|
195
|
+
external = _external_asset(category, variant)
|
|
196
|
+
for index, (geom_type, size, offset, _quat) in enumerate(spec["geoms"]):
|
|
197
|
+
_add_geom(
|
|
198
|
+
body, name=f"{name}_collision_{index}", geom_type=geom_type,
|
|
199
|
+
size=size, pos=offset, rgba=spec["rgba"], collision=True,
|
|
200
|
+
)
|
|
201
|
+
if external is None:
|
|
202
|
+
_add_geom(
|
|
203
|
+
body, name=f"{name}_visual_{index}", geom_type=geom_type,
|
|
204
|
+
size=size, pos=offset, rgba=spec["rgba"], collision=False,
|
|
205
|
+
)
|
|
206
|
+
if spec["geoms"][0][0] == "cylinder":
|
|
207
|
+
# A flat, inset foot supports upright containers with stable box/box
|
|
208
|
+
# contacts. The analytic rim otherwise rocks on changing CCD points.
|
|
209
|
+
# Extend only 1 mm below the rim; preserve the visible cylinder body.
|
|
210
|
+
radius, half_height = spec["geoms"][0][1]
|
|
211
|
+
foot_half_width = radius * 0.65
|
|
212
|
+
_add_geom(
|
|
213
|
+
body, name=f"{name}_collision_foot", geom_type="box",
|
|
214
|
+
size=(foot_half_width, foot_half_width, 0.001),
|
|
215
|
+
pos=(0, 0, -half_height), rgba=spec["rgba"], collision=True,
|
|
216
|
+
)
|
|
217
|
+
if external is not None:
|
|
218
|
+
minimum = np.asarray(external["bounds_min"], dtype=float)
|
|
219
|
+
maximum = np.asarray(external["bounds_max"], dtype=float)
|
|
220
|
+
source_size = maximum - minimum
|
|
221
|
+
target_size = 2.0 * np.asarray(spec["extent"], dtype=float) * 0.96
|
|
222
|
+
# Fit each axis to the physical proxy. An inscribed uniform fit can
|
|
223
|
+
# make a thin scan nearly invisible inside a much larger collider.
|
|
224
|
+
mesh_scale = target_size / source_size
|
|
225
|
+
source_center = (minimum + maximum) / 2.0
|
|
226
|
+
mesh_name = f"{name}_scan_mesh"
|
|
227
|
+
texture_name = f"{name}_scan_texture"
|
|
228
|
+
material_name = f"{name}_scan_material"
|
|
229
|
+
ET.SubElement(
|
|
230
|
+
asset, "mesh", name=mesh_name,
|
|
231
|
+
file=str(external["mesh_path"].resolve()),
|
|
232
|
+
scale=_vec(mesh_scale),
|
|
233
|
+
)
|
|
234
|
+
ET.SubElement(
|
|
235
|
+
asset, "texture", name=texture_name, type="2d",
|
|
236
|
+
file=str(external["texture_path"].resolve()),
|
|
237
|
+
)
|
|
238
|
+
ET.SubElement(
|
|
239
|
+
asset, "material", name=material_name, texture=texture_name,
|
|
240
|
+
texuniform="false", specular="0.05", reflectance="0",
|
|
241
|
+
)
|
|
242
|
+
ET.SubElement(
|
|
243
|
+
body, "geom", name=f"{name}_scan_visual", type="mesh",
|
|
244
|
+
mesh=mesh_name, material=material_name,
|
|
245
|
+
pos=_vec(-source_center * mesh_scale),
|
|
246
|
+
group="1", contype="0", conaffinity="0", density="0",
|
|
247
|
+
)
|
|
248
|
+
return np.asarray(spec["extent"], dtype=float)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Public API for the extensible, fixed-horizon react_manip.
|
|
2
|
+
|
|
3
|
+
The modules in :mod:`react_manip.core` deliberately do not import MuJoCo or
|
|
4
|
+
robosuite. This keeps the protocol, metrics, and registry usable by tooling
|
|
5
|
+
and CI on machines where the simulator is not installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .metrics import SustainedSuccessTracker
|
|
9
|
+
from .aggregate import aggregate_results
|
|
10
|
+
from .compatibility import validate_task_robot
|
|
11
|
+
from .protocol import BenchmarkTask, Policy, StepResult, normalize_reset, normalize_step
|
|
12
|
+
from .registry import (
|
|
13
|
+
RobotRegistry,
|
|
14
|
+
TaskDefinition,
|
|
15
|
+
TaskRegistry,
|
|
16
|
+
get_robot_registry,
|
|
17
|
+
get_task_registry,
|
|
18
|
+
register_robot,
|
|
19
|
+
register_task,
|
|
20
|
+
)
|
|
21
|
+
from .runner import BenchmarkRunner, EvaluationResult
|
|
22
|
+
from .specs import SuccessRule, TaskSpec
|
|
23
|
+
from .scenarios import Scenario, sample_scenario
|
|
24
|
+
from .contracts import ObservationSpec, ActionSpec, FeedbackSpec
|
|
25
|
+
from .geometry import GeometryFeatureAdapter
|
|
26
|
+
from .observation import policy_observation
|
|
27
|
+
from .language import EpisodeLanguagePrompt, build_episode_prompt
|
|
28
|
+
from .visual_randomization import ensure_workspace_camera
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"BenchmarkRunner",
|
|
32
|
+
"BenchmarkTask",
|
|
33
|
+
"EvaluationResult",
|
|
34
|
+
"Policy",
|
|
35
|
+
"RobotRegistry",
|
|
36
|
+
"StepResult",
|
|
37
|
+
"SuccessRule",
|
|
38
|
+
"SustainedSuccessTracker",
|
|
39
|
+
"TaskDefinition",
|
|
40
|
+
"TaskRegistry",
|
|
41
|
+
"TaskSpec",
|
|
42
|
+
"get_robot_registry",
|
|
43
|
+
"get_task_registry",
|
|
44
|
+
"register_robot",
|
|
45
|
+
"register_task",
|
|
46
|
+
"normalize_reset",
|
|
47
|
+
"normalize_step",
|
|
48
|
+
"validate_task_robot",
|
|
49
|
+
"Scenario",
|
|
50
|
+
"sample_scenario",
|
|
51
|
+
"ObservationSpec",
|
|
52
|
+
"ActionSpec",
|
|
53
|
+
"FeedbackSpec",
|
|
54
|
+
"GeometryFeatureAdapter",
|
|
55
|
+
"policy_observation",
|
|
56
|
+
"aggregate_results",
|
|
57
|
+
"EpisodeLanguagePrompt",
|
|
58
|
+
"build_episode_prompt",
|
|
59
|
+
"ensure_workspace_camera",
|
|
60
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Aggregate fixed-horizon episode results into benchmark-level statistics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import sqrt
|
|
6
|
+
from typing import Any, Iterable, Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
NUMERIC_FIELDS = (
|
|
10
|
+
"success_occupancy",
|
|
11
|
+
"longest_success_streak_sec",
|
|
12
|
+
"completed_events",
|
|
13
|
+
"event_rate_hz",
|
|
14
|
+
"failure_count",
|
|
15
|
+
"recoverable_failure_count",
|
|
16
|
+
"recovery_count",
|
|
17
|
+
"peak_force_N",
|
|
18
|
+
"mean_inference_latency_ms",
|
|
19
|
+
"mean_action_age_ms",
|
|
20
|
+
"real_time_factor",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def aggregate_results(results: Iterable[Any]) -> dict[str, Any]:
|
|
25
|
+
"""Return deterministic summary statistics for a set of episodes."""
|
|
26
|
+
|
|
27
|
+
rows = [_as_mapping(item) for item in results]
|
|
28
|
+
if not rows:
|
|
29
|
+
raise ValueError("cannot aggregate an empty result set")
|
|
30
|
+
passed = [bool(row.get("passed", row.get("metrics", {}).get("passed", False))) for row in rows]
|
|
31
|
+
summary: dict[str, Any] = {
|
|
32
|
+
"episodes": len(rows),
|
|
33
|
+
"pass_count": sum(passed),
|
|
34
|
+
"pass_rate": round(sum(passed) / len(passed), 6),
|
|
35
|
+
}
|
|
36
|
+
for field in NUMERIC_FIELDS:
|
|
37
|
+
values = []
|
|
38
|
+
for row in rows:
|
|
39
|
+
value = row.get(field)
|
|
40
|
+
if value is None and isinstance(row.get("metrics"), Mapping):
|
|
41
|
+
value = row["metrics"].get(field)
|
|
42
|
+
if isinstance(value, (int, float)):
|
|
43
|
+
values.append(float(value))
|
|
44
|
+
if values:
|
|
45
|
+
mean = sum(values) / len(values)
|
|
46
|
+
variance = sum((value - mean) ** 2 for value in values) / len(values)
|
|
47
|
+
summary[f"mean_{field}"] = round(mean, 6)
|
|
48
|
+
summary[f"std_{field}"] = round(sqrt(variance), 6)
|
|
49
|
+
return summary
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _as_mapping(value: Any) -> Mapping[str, Any]:
|
|
53
|
+
if isinstance(value, Mapping):
|
|
54
|
+
return value
|
|
55
|
+
if hasattr(value, "to_dict"):
|
|
56
|
+
result = value.to_dict()
|
|
57
|
+
if isinstance(result, Mapping):
|
|
58
|
+
return result
|
|
59
|
+
raise TypeError(f"result must be a mapping or expose to_dict(), got {type(value).__name__}")
|