jolly-cli 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.
- jolly/__init__.py +4 -0
- jolly/__main__.py +4 -0
- jolly/assets/__init__.py +1 -0
- jolly/assets/jolly6.urdf +21 -0
- jolly/assets/so101.urdf +17 -0
- jolly/benchmark.py +73 -0
- jolly/challenges.py +109 -0
- jolly/cli.py +317 -0
- jolly/core/__init__.py +1 -0
- jolly/core/errors.py +10 -0
- jolly/core/models.py +87 -0
- jolly/core/physics.py +516 -0
- jolly/core/scenes.py +56 -0
- jolly/core/store.py +65 -0
- jolly/web/__init__.py +1 -0
- jolly/web/app.py +128 -0
- jolly/web/index.html +58 -0
- jolly_cli-0.1.0.dist-info/METADATA +188 -0
- jolly_cli-0.1.0.dist-info/RECORD +23 -0
- jolly_cli-0.1.0.dist-info/WHEEL +4 -0
- jolly_cli-0.1.0.dist-info/entry_points.txt +2 -0
- jolly_cli-0.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- jolly_cli-0.1.0.dist-info/licenses/LICENSE-MIT +21 -0
jolly/core/models.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from jolly.core.errors import ConfigurationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class RobotModel:
|
|
12
|
+
id: str
|
|
13
|
+
name: str
|
|
14
|
+
urdf: str
|
|
15
|
+
arm_joint_names: tuple[str, ...]
|
|
16
|
+
gripper_joint_names: tuple[str, ...]
|
|
17
|
+
end_effector_link: str
|
|
18
|
+
home_degrees: tuple[float, ...]
|
|
19
|
+
description: str
|
|
20
|
+
source: str
|
|
21
|
+
license: str
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def dof(self) -> int:
|
|
25
|
+
return len(self.arm_joint_names)
|
|
26
|
+
|
|
27
|
+
def export(self) -> dict[str, object]:
|
|
28
|
+
data = asdict(self)
|
|
29
|
+
data["dof"] = self.dof
|
|
30
|
+
return data
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
MODELS: dict[str, RobotModel] = {
|
|
34
|
+
"jolly6": RobotModel(
|
|
35
|
+
id="jolly6",
|
|
36
|
+
name="Jolly-6",
|
|
37
|
+
urdf="jolly6.urdf",
|
|
38
|
+
arm_joint_names=(
|
|
39
|
+
"base_yaw",
|
|
40
|
+
"shoulder_pitch",
|
|
41
|
+
"elbow_pitch",
|
|
42
|
+
"wrist_pitch",
|
|
43
|
+
"wrist_yaw",
|
|
44
|
+
"wrist_roll",
|
|
45
|
+
),
|
|
46
|
+
gripper_joint_names=("left_finger", "right_finger"),
|
|
47
|
+
end_effector_link="tool_link",
|
|
48
|
+
home_degrees=(0.0, -25.0, 70.0, -45.0, 0.0, 0.0),
|
|
49
|
+
description="Original six-axis educational arm with a parallel gripper.",
|
|
50
|
+
source="Bundled with Jolly",
|
|
51
|
+
license="MIT OR Apache-2.0",
|
|
52
|
+
),
|
|
53
|
+
"so101": RobotModel(
|
|
54
|
+
id="so101",
|
|
55
|
+
name="SO-101 simplified simulation profile",
|
|
56
|
+
urdf="so101.urdf",
|
|
57
|
+
arm_joint_names=(
|
|
58
|
+
"shoulder_pan",
|
|
59
|
+
"shoulder_lift",
|
|
60
|
+
"elbow_flex",
|
|
61
|
+
"wrist_flex",
|
|
62
|
+
"wrist_roll",
|
|
63
|
+
),
|
|
64
|
+
gripper_joint_names=("gripper",),
|
|
65
|
+
end_effector_link="tool_link",
|
|
66
|
+
home_degrees=(0.0, 0.0, 0.0, 0.0, 0.0),
|
|
67
|
+
description="Lightweight collision model that follows the open SO-101 five-axis kinematic layout.",
|
|
68
|
+
source="Derived from TheRobotStudio/SO-ARM100 SO-101 URDF metadata",
|
|
69
|
+
license="Apache-2.0",
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_model(model_id: str) -> RobotModel:
|
|
75
|
+
try:
|
|
76
|
+
return MODELS[model_id]
|
|
77
|
+
except KeyError as exc:
|
|
78
|
+
available = ", ".join(sorted(MODELS))
|
|
79
|
+
raise ConfigurationError(f"Unknown robot model '{model_id}'. Available: {available}") from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def model_path(model: RobotModel) -> Path:
|
|
83
|
+
return Path(str(files("jolly.assets").joinpath(model.urdf)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def list_models() -> list[dict[str, object]]:
|
|
87
|
+
return [MODELS[key].export() for key in sorted(MODELS)]
|
jolly/core/physics.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Iterable, Sequence
|
|
9
|
+
|
|
10
|
+
def _import_pybullet():
|
|
11
|
+
"""Import PyBullet without its unconditional native build-time banner."""
|
|
12
|
+
try:
|
|
13
|
+
output_fds = [sys.stdout.fileno(), sys.stderr.fileno()]
|
|
14
|
+
except (AttributeError, OSError):
|
|
15
|
+
output_fds = []
|
|
16
|
+
if not output_fds or os.name == "nt":
|
|
17
|
+
import pybullet
|
|
18
|
+
|
|
19
|
+
return pybullet
|
|
20
|
+
saved_fds = [os.dup(fd) for fd in output_fds]
|
|
21
|
+
try:
|
|
22
|
+
with open(os.devnull, "w", encoding="utf-8") as null:
|
|
23
|
+
for fd in output_fds:
|
|
24
|
+
os.dup2(null.fileno(), fd)
|
|
25
|
+
import pybullet
|
|
26
|
+
finally:
|
|
27
|
+
for fd, saved_fd in zip(output_fds, saved_fds, strict=True):
|
|
28
|
+
os.dup2(saved_fd, fd)
|
|
29
|
+
os.close(saved_fd)
|
|
30
|
+
return pybullet
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
p = _import_pybullet()
|
|
34
|
+
|
|
35
|
+
from jolly.core.errors import MotionError
|
|
36
|
+
from jolly.core.models import RobotModel, get_model, model_path
|
|
37
|
+
from jolly.core.scenes import get_scene
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class JointInfo:
|
|
42
|
+
index: int
|
|
43
|
+
q_index: int
|
|
44
|
+
name: str
|
|
45
|
+
link_name: str
|
|
46
|
+
lower: float
|
|
47
|
+
upper: float
|
|
48
|
+
max_force: float
|
|
49
|
+
max_velocity: float
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PhysicsEngine:
|
|
53
|
+
"""Own a PyBullet world and expose deterministic robot-arm operations."""
|
|
54
|
+
|
|
55
|
+
timestep = 1.0 / 240.0
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
model: str = "jolly6",
|
|
60
|
+
scene: str = "empty",
|
|
61
|
+
*,
|
|
62
|
+
gui: bool = False,
|
|
63
|
+
realtime: bool = False,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.model: RobotModel = get_model(model)
|
|
66
|
+
self.scene_id = scene
|
|
67
|
+
self.gui = gui
|
|
68
|
+
self.realtime = realtime
|
|
69
|
+
self.client = p.connect(p.GUI if gui else p.DIRECT)
|
|
70
|
+
if self.client < 0:
|
|
71
|
+
raise RuntimeError("PyBullet could not create a physics client.")
|
|
72
|
+
self.robot_id = -1
|
|
73
|
+
self.plane_id = -1
|
|
74
|
+
self.joints: dict[str, JointInfo] = {}
|
|
75
|
+
self.arm_joints: list[JointInfo] = []
|
|
76
|
+
self.gripper_joints: list[JointInfo] = []
|
|
77
|
+
self.ik_solution_indexes: dict[int, int] = {}
|
|
78
|
+
self.end_effector_index = -1
|
|
79
|
+
self.object_ids: dict[str, int] = {}
|
|
80
|
+
self.object_specs: dict[str, dict[str, object]] = {}
|
|
81
|
+
self.held_object: str | None = None
|
|
82
|
+
self.gripper = 0.0
|
|
83
|
+
try:
|
|
84
|
+
self._load_world()
|
|
85
|
+
except Exception:
|
|
86
|
+
p.disconnect(self.client)
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
def __enter__(self) -> "PhysicsEngine":
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def __exit__(self, *_: object) -> None:
|
|
93
|
+
self.close()
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
if p.isConnected(self.client):
|
|
97
|
+
p.disconnect(self.client)
|
|
98
|
+
|
|
99
|
+
def _load_world(self) -> None:
|
|
100
|
+
p.resetSimulation(physicsClientId=self.client)
|
|
101
|
+
p.setGravity(0, 0, -9.81, physicsClientId=self.client)
|
|
102
|
+
p.setTimeStep(self.timestep, physicsClientId=self.client)
|
|
103
|
+
p.setPhysicsEngineParameter(
|
|
104
|
+
fixedTimeStep=self.timestep,
|
|
105
|
+
numSolverIterations=100,
|
|
106
|
+
deterministicOverlappingPairs=1,
|
|
107
|
+
physicsClientId=self.client,
|
|
108
|
+
)
|
|
109
|
+
plane_shape = p.createCollisionShape(p.GEOM_PLANE, physicsClientId=self.client)
|
|
110
|
+
self.plane_id = p.createMultiBody(0, plane_shape, physicsClientId=self.client)
|
|
111
|
+
p.changeVisualShape(self.plane_id, -1, rgbaColor=[0.13, 0.15, 0.18, 1], physicsClientId=self.client)
|
|
112
|
+
self.robot_id = p.loadURDF(
|
|
113
|
+
str(model_path(self.model)),
|
|
114
|
+
useFixedBase=True,
|
|
115
|
+
flags=p.URDF_USE_SELF_COLLISION | p.URDF_USE_INERTIA_FROM_FILE,
|
|
116
|
+
physicsClientId=self.client,
|
|
117
|
+
)
|
|
118
|
+
self._index_robot()
|
|
119
|
+
self._load_scene()
|
|
120
|
+
self.reset()
|
|
121
|
+
|
|
122
|
+
def _index_robot(self) -> None:
|
|
123
|
+
for index in range(p.getNumJoints(self.robot_id, physicsClientId=self.client)):
|
|
124
|
+
raw = p.getJointInfo(self.robot_id, index, physicsClientId=self.client)
|
|
125
|
+
info = JointInfo(
|
|
126
|
+
index=index,
|
|
127
|
+
q_index=int(raw[3]),
|
|
128
|
+
name=raw[1].decode(),
|
|
129
|
+
link_name=raw[12].decode(),
|
|
130
|
+
lower=float(raw[8]),
|
|
131
|
+
upper=float(raw[9]),
|
|
132
|
+
max_force=float(raw[10]) or 25.0,
|
|
133
|
+
max_velocity=float(raw[11]) or 2.0,
|
|
134
|
+
)
|
|
135
|
+
self.joints[info.name] = info
|
|
136
|
+
if info.link_name == self.model.end_effector_link:
|
|
137
|
+
self.end_effector_index = index
|
|
138
|
+
try:
|
|
139
|
+
self.arm_joints = [self.joints[name] for name in self.model.arm_joint_names]
|
|
140
|
+
self.gripper_joints = [self.joints[name] for name in self.model.gripper_joint_names]
|
|
141
|
+
except KeyError as exc:
|
|
142
|
+
raise RuntimeError(f"Robot model is missing expected joint {exc.args[0]!r}.") from exc
|
|
143
|
+
if self.end_effector_index < 0:
|
|
144
|
+
raise RuntimeError(f"Robot model is missing end-effector link '{self.model.end_effector_link}'.")
|
|
145
|
+
movable = sorted((info for info in self.joints.values() if info.q_index >= 0), key=lambda info: info.q_index)
|
|
146
|
+
self.ik_solution_indexes = {info.index: offset for offset, info in enumerate(movable)}
|
|
147
|
+
|
|
148
|
+
def _load_scene(self) -> None:
|
|
149
|
+
scene = get_scene(self.scene_id)
|
|
150
|
+
self.object_ids.clear()
|
|
151
|
+
self.object_specs.clear()
|
|
152
|
+
for raw_spec in scene["objects"]:
|
|
153
|
+
spec = dict(raw_spec)
|
|
154
|
+
half_extents = [float(value) / 2.0 for value in spec["size"]]
|
|
155
|
+
collision = p.createCollisionShape(p.GEOM_BOX, halfExtents=half_extents, physicsClientId=self.client)
|
|
156
|
+
visual = p.createVisualShape(
|
|
157
|
+
p.GEOM_BOX,
|
|
158
|
+
halfExtents=half_extents,
|
|
159
|
+
rgbaColor=spec["color"],
|
|
160
|
+
physicsClientId=self.client,
|
|
161
|
+
)
|
|
162
|
+
body = p.createMultiBody(
|
|
163
|
+
baseMass=float(spec["mass"]),
|
|
164
|
+
baseCollisionShapeIndex=collision,
|
|
165
|
+
baseVisualShapeIndex=visual,
|
|
166
|
+
basePosition=spec["position"],
|
|
167
|
+
physicsClientId=self.client,
|
|
168
|
+
)
|
|
169
|
+
name = str(spec["name"])
|
|
170
|
+
self.object_ids[name] = body
|
|
171
|
+
self.object_specs[name] = spec
|
|
172
|
+
p.changeDynamics(body, -1, lateralFriction=0.8, restitution=0.05, physicsClientId=self.client)
|
|
173
|
+
|
|
174
|
+
def reset(self) -> dict[str, object]:
|
|
175
|
+
self.held_object = None
|
|
176
|
+
self.gripper = 0.0
|
|
177
|
+
for info, degrees in zip(self.arm_joints, self.model.home_degrees, strict=True):
|
|
178
|
+
p.resetJointState(self.robot_id, info.index, math.radians(degrees), physicsClientId=self.client)
|
|
179
|
+
self._set_gripper_state(0.0)
|
|
180
|
+
self._hold_current_pose()
|
|
181
|
+
self._step(60)
|
|
182
|
+
return self.state()
|
|
183
|
+
|
|
184
|
+
def restore(self, state: dict[str, object]) -> None:
|
|
185
|
+
joints = state.get("joints", [])
|
|
186
|
+
if isinstance(joints, list) and len(joints) == self.model.dof:
|
|
187
|
+
for info, joint in zip(self.arm_joints, joints, strict=True):
|
|
188
|
+
value = float(joint["position_degrees"] if isinstance(joint, dict) else joint)
|
|
189
|
+
p.resetJointState(self.robot_id, info.index, math.radians(value), physicsClientId=self.client)
|
|
190
|
+
self.gripper = float(state.get("gripper", 0.0))
|
|
191
|
+
self._set_gripper_state(self.gripper)
|
|
192
|
+
saved_objects = state.get("objects", [])
|
|
193
|
+
if isinstance(saved_objects, list):
|
|
194
|
+
for item in saved_objects:
|
|
195
|
+
if not isinstance(item, dict) or item.get("name") not in self.object_ids:
|
|
196
|
+
continue
|
|
197
|
+
p.resetBasePositionAndOrientation(
|
|
198
|
+
self.object_ids[str(item["name"])],
|
|
199
|
+
item.get("position", [0, 0, 0]),
|
|
200
|
+
item.get("orientation", [0, 0, 0, 1]),
|
|
201
|
+
physicsClientId=self.client,
|
|
202
|
+
)
|
|
203
|
+
held = state.get("held_object")
|
|
204
|
+
self.held_object = str(held) if held in self.object_ids else None
|
|
205
|
+
self._sync_held_object()
|
|
206
|
+
self._hold_current_pose()
|
|
207
|
+
p.performCollisionDetection(physicsClientId=self.client)
|
|
208
|
+
|
|
209
|
+
def move_joints(
|
|
210
|
+
self,
|
|
211
|
+
degrees: Sequence[float],
|
|
212
|
+
*,
|
|
213
|
+
gripper: float | None = None,
|
|
214
|
+
steps: int = 120,
|
|
215
|
+
allow_collision: bool = False,
|
|
216
|
+
) -> dict[str, object]:
|
|
217
|
+
if len(degrees) != self.model.dof:
|
|
218
|
+
raise MotionError(f"Model '{self.model.id}' needs {self.model.dof} joint angles, received {len(degrees)}.")
|
|
219
|
+
if not all(math.isfinite(float(value)) for value in degrees):
|
|
220
|
+
raise MotionError("Joint angles must be finite numbers.")
|
|
221
|
+
targets = [math.radians(float(value)) for value in degrees]
|
|
222
|
+
for target, info in zip(targets, self.arm_joints, strict=True):
|
|
223
|
+
if not info.lower <= target <= info.upper:
|
|
224
|
+
raise MotionError(
|
|
225
|
+
f"Joint '{info.name}' target {math.degrees(target):.2f}° is outside "
|
|
226
|
+
f"[{math.degrees(info.lower):.2f}°, {math.degrees(info.upper):.2f}°]."
|
|
227
|
+
)
|
|
228
|
+
start = [p.getJointState(self.robot_id, info.index, physicsClientId=self.client)[0] for info in self.arm_joints]
|
|
229
|
+
old_gripper = self.gripper
|
|
230
|
+
old_held_object = self.held_object
|
|
231
|
+
old_objects = self._object_poses()
|
|
232
|
+
steps = max(1, min(int(steps), 2400))
|
|
233
|
+
collision_detected = False
|
|
234
|
+
for frame in range(1, steps + 1):
|
|
235
|
+
blend = frame / steps
|
|
236
|
+
values = [a + (b - a) * blend for a, b in zip(start, targets, strict=True)]
|
|
237
|
+
for info, value in zip(self.arm_joints, values, strict=True):
|
|
238
|
+
p.resetJointState(self.robot_id, info.index, value, physicsClientId=self.client)
|
|
239
|
+
if gripper is not None:
|
|
240
|
+
self._set_gripper_state(old_gripper + (float(gripper) - old_gripper) * blend)
|
|
241
|
+
self._sync_held_object()
|
|
242
|
+
p.performCollisionDetection(physicsClientId=self.client)
|
|
243
|
+
if self.collisions()["collision"]:
|
|
244
|
+
collision_detected = True
|
|
245
|
+
if not allow_collision:
|
|
246
|
+
break
|
|
247
|
+
if self.realtime:
|
|
248
|
+
time.sleep(self.timestep)
|
|
249
|
+
if collision_detected and not allow_collision:
|
|
250
|
+
for info, value in zip(self.arm_joints, start, strict=True):
|
|
251
|
+
p.resetJointState(self.robot_id, info.index, value, physicsClientId=self.client)
|
|
252
|
+
self._set_gripper_state(old_gripper)
|
|
253
|
+
self._restore_object_poses(old_objects)
|
|
254
|
+
raise MotionError("Motion stopped because the robot collided. Use --allow-collision only for controlled tests.")
|
|
255
|
+
if gripper is not None:
|
|
256
|
+
self._set_gripper_state(float(gripper))
|
|
257
|
+
self._update_grasp()
|
|
258
|
+
self._hold_current_pose()
|
|
259
|
+
self._step(8)
|
|
260
|
+
self._clamp_arm_to_limits()
|
|
261
|
+
final_collisions = self.collisions()
|
|
262
|
+
if final_collisions["collision"] and not allow_collision:
|
|
263
|
+
for info, value in zip(self.arm_joints, start, strict=True):
|
|
264
|
+
p.resetJointState(self.robot_id, info.index, value, physicsClientId=self.client)
|
|
265
|
+
self._set_gripper_state(old_gripper)
|
|
266
|
+
self.held_object = old_held_object
|
|
267
|
+
self._restore_object_poses(old_objects)
|
|
268
|
+
self._hold_current_pose()
|
|
269
|
+
raise MotionError("Motion rolled back because settling created a collision.")
|
|
270
|
+
return self.state()
|
|
271
|
+
|
|
272
|
+
def _clamp_arm_to_limits(self) -> None:
|
|
273
|
+
for info in self.arm_joints:
|
|
274
|
+
value = p.getJointState(self.robot_id, info.index, physicsClientId=self.client)[0]
|
|
275
|
+
clamped = min(max(value, info.lower), info.upper)
|
|
276
|
+
if clamped != value:
|
|
277
|
+
p.resetJointState(self.robot_id, info.index, clamped, physicsClientId=self.client)
|
|
278
|
+
self._hold_current_pose()
|
|
279
|
+
|
|
280
|
+
def reach(
|
|
281
|
+
self,
|
|
282
|
+
x: float,
|
|
283
|
+
y: float,
|
|
284
|
+
z: float,
|
|
285
|
+
*,
|
|
286
|
+
gripper: float | None = None,
|
|
287
|
+
steps: int = 160,
|
|
288
|
+
allow_collision: bool = False,
|
|
289
|
+
tolerance: float = 0.025,
|
|
290
|
+
) -> dict[str, object]:
|
|
291
|
+
target = [float(x), float(y), float(z)]
|
|
292
|
+
if not all(math.isfinite(value) for value in target):
|
|
293
|
+
raise MotionError("Target coordinates must be finite numbers.")
|
|
294
|
+
if z < 0.005 or math.sqrt(x * x + y * y + z * z) > 0.75:
|
|
295
|
+
raise MotionError("Target is outside the simulator safety workspace.")
|
|
296
|
+
solution = p.calculateInverseKinematics(
|
|
297
|
+
self.robot_id,
|
|
298
|
+
self.end_effector_index,
|
|
299
|
+
target,
|
|
300
|
+
maxNumIterations=300,
|
|
301
|
+
residualThreshold=1e-5,
|
|
302
|
+
physicsClientId=self.client,
|
|
303
|
+
)
|
|
304
|
+
degrees: list[float] = []
|
|
305
|
+
for info in self.arm_joints:
|
|
306
|
+
solution_index = self.ik_solution_indexes.get(info.index, -1)
|
|
307
|
+
if solution_index < 0 or solution_index >= len(solution):
|
|
308
|
+
raise MotionError(f"IK did not return a value for joint '{info.name}'.")
|
|
309
|
+
radians = min(max(float(solution[solution_index]), info.lower), info.upper)
|
|
310
|
+
degrees.append(math.degrees(radians))
|
|
311
|
+
result = self.move_joints(degrees, gripper=gripper, steps=steps, allow_collision=allow_collision)
|
|
312
|
+
actual = result["end_effector"]["position"]
|
|
313
|
+
error = math.dist(actual, target)
|
|
314
|
+
result["ik"] = {"target": target, "error_meters": round(error, 6), "within_tolerance": error <= tolerance}
|
|
315
|
+
if error > tolerance:
|
|
316
|
+
raise MotionError(f"IK target was not reachable within {tolerance:.3f} m; residual error is {error:.3f} m.")
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
def _set_gripper_state(self, value: float) -> None:
|
|
320
|
+
if not 0.0 <= value <= 1.0:
|
|
321
|
+
raise MotionError("Gripper must be between 0.0 (open) and 1.0 (closed).")
|
|
322
|
+
self.gripper = float(value)
|
|
323
|
+
for info in self.gripper_joints:
|
|
324
|
+
target = info.upper * (1.0 - self.gripper)
|
|
325
|
+
p.resetJointState(self.robot_id, info.index, target, physicsClientId=self.client)
|
|
326
|
+
|
|
327
|
+
def _hold_current_pose(self) -> None:
|
|
328
|
+
for info in [*self.arm_joints, *self.gripper_joints]:
|
|
329
|
+
target = p.getJointState(self.robot_id, info.index, physicsClientId=self.client)[0]
|
|
330
|
+
p.setJointMotorControl2(
|
|
331
|
+
self.robot_id,
|
|
332
|
+
info.index,
|
|
333
|
+
p.POSITION_CONTROL,
|
|
334
|
+
targetPosition=target,
|
|
335
|
+
force=max(info.max_force, 10.0),
|
|
336
|
+
maxVelocity=max(info.max_velocity, 0.2),
|
|
337
|
+
physicsClientId=self.client,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def _update_grasp(self) -> None:
|
|
341
|
+
if self.gripper <= 0.25:
|
|
342
|
+
self.held_object = None
|
|
343
|
+
return
|
|
344
|
+
if self.gripper < 0.75 or self.held_object is not None:
|
|
345
|
+
return
|
|
346
|
+
ee = self.end_effector_position()
|
|
347
|
+
candidates: list[tuple[float, str]] = []
|
|
348
|
+
for name, body in self.object_ids.items():
|
|
349
|
+
if not self.object_specs[name].get("graspable"):
|
|
350
|
+
continue
|
|
351
|
+
position, _ = p.getBasePositionAndOrientation(body, physicsClientId=self.client)
|
|
352
|
+
candidates.append((math.dist(ee, position), name))
|
|
353
|
+
if candidates:
|
|
354
|
+
distance, name = min(candidates)
|
|
355
|
+
if distance <= 0.10:
|
|
356
|
+
self.held_object = name
|
|
357
|
+
self._sync_held_object()
|
|
358
|
+
|
|
359
|
+
def _sync_held_object(self) -> None:
|
|
360
|
+
if self.held_object is None or self.held_object not in self.object_ids:
|
|
361
|
+
return
|
|
362
|
+
position = self.end_effector_position()
|
|
363
|
+
held_position = [position[0], position[1], max(0.02, position[2] - 0.045)]
|
|
364
|
+
p.resetBasePositionAndOrientation(
|
|
365
|
+
self.object_ids[self.held_object], held_position, [0, 0, 0, 1], physicsClientId=self.client
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def _step(self, count: int) -> None:
|
|
369
|
+
for _ in range(count):
|
|
370
|
+
self._sync_held_object()
|
|
371
|
+
p.stepSimulation(physicsClientId=self.client)
|
|
372
|
+
if self.realtime:
|
|
373
|
+
time.sleep(self.timestep)
|
|
374
|
+
|
|
375
|
+
def end_effector_position(self) -> list[float]:
|
|
376
|
+
state = p.getLinkState(
|
|
377
|
+
self.robot_id,
|
|
378
|
+
self.end_effector_index,
|
|
379
|
+
computeForwardKinematics=True,
|
|
380
|
+
physicsClientId=self.client,
|
|
381
|
+
)
|
|
382
|
+
return [float(value) for value in state[4]]
|
|
383
|
+
|
|
384
|
+
def forward_kinematics(self, degrees: Sequence[float] | None = None) -> dict[str, object]:
|
|
385
|
+
if degrees is not None:
|
|
386
|
+
if len(degrees) != self.model.dof:
|
|
387
|
+
raise MotionError(f"Forward kinematics needs {self.model.dof} joint angles.")
|
|
388
|
+
previous = [p.getJointState(self.robot_id, j.index, physicsClientId=self.client)[0] for j in self.arm_joints]
|
|
389
|
+
for info, value in zip(self.arm_joints, degrees, strict=True):
|
|
390
|
+
p.resetJointState(self.robot_id, info.index, math.radians(float(value)), physicsClientId=self.client)
|
|
391
|
+
else:
|
|
392
|
+
previous = []
|
|
393
|
+
link = p.getLinkState(
|
|
394
|
+
self.robot_id,
|
|
395
|
+
self.end_effector_index,
|
|
396
|
+
computeForwardKinematics=True,
|
|
397
|
+
physicsClientId=self.client,
|
|
398
|
+
)
|
|
399
|
+
if previous:
|
|
400
|
+
for info, value in zip(self.arm_joints, previous, strict=True):
|
|
401
|
+
p.resetJointState(self.robot_id, info.index, value, physicsClientId=self.client)
|
|
402
|
+
quaternion = [float(value) for value in link[5]]
|
|
403
|
+
return {
|
|
404
|
+
"position": [round(float(value), 6) for value in link[4]],
|
|
405
|
+
"orientation_quaternion": [round(value, 6) for value in quaternion],
|
|
406
|
+
"orientation_euler_degrees": [round(math.degrees(value), 4) for value in p.getEulerFromQuaternion(quaternion)],
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
def collisions(self) -> dict[str, object]:
|
|
410
|
+
p.performCollisionDetection(physicsClientId=self.client)
|
|
411
|
+
details: list[dict[str, object]] = []
|
|
412
|
+
names_by_body = {body: name for name, body in self.object_ids.items()}
|
|
413
|
+
contacts = p.getContactPoints(bodyA=self.robot_id, physicsClientId=self.client)
|
|
414
|
+
for contact in contacts:
|
|
415
|
+
body_b = int(contact[2])
|
|
416
|
+
link_a = int(contact[3])
|
|
417
|
+
link_b = int(contact[4])
|
|
418
|
+
if self.held_object and body_b == self.object_ids.get(self.held_object):
|
|
419
|
+
continue
|
|
420
|
+
if body_b == self.plane_id and link_a == -1:
|
|
421
|
+
continue
|
|
422
|
+
if body_b == self.robot_id and abs(link_a - link_b) <= 1:
|
|
423
|
+
continue
|
|
424
|
+
details.append(
|
|
425
|
+
{
|
|
426
|
+
"type": "self" if body_b == self.robot_id else "environment",
|
|
427
|
+
"robot_link": self._link_name(link_a),
|
|
428
|
+
"other": self._link_name(link_b) if body_b == self.robot_id else names_by_body.get(body_b, "floor"),
|
|
429
|
+
"distance": round(float(contact[8]), 6),
|
|
430
|
+
"normal_force": round(float(contact[9]), 6),
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
if self.held_object and self.held_object in self.object_ids:
|
|
434
|
+
held_body = self.object_ids[self.held_object]
|
|
435
|
+
for contact in p.getContactPoints(bodyA=held_body, physicsClientId=self.client):
|
|
436
|
+
body_b = int(contact[2])
|
|
437
|
+
if body_b in (self.robot_id, held_body):
|
|
438
|
+
continue
|
|
439
|
+
details.append(
|
|
440
|
+
{
|
|
441
|
+
"type": "held_object_environment",
|
|
442
|
+
"robot_link": self.model.end_effector_link,
|
|
443
|
+
"other": names_by_body.get(body_b, "floor"),
|
|
444
|
+
"distance": round(float(contact[8]), 6),
|
|
445
|
+
"normal_force": round(float(contact[9]), 6),
|
|
446
|
+
}
|
|
447
|
+
)
|
|
448
|
+
return {"collision": bool(details), "count": len(details), "contacts": details}
|
|
449
|
+
|
|
450
|
+
def _link_name(self, index: int) -> str:
|
|
451
|
+
if index == -1:
|
|
452
|
+
return "base_link"
|
|
453
|
+
raw = p.getJointInfo(self.robot_id, index, physicsClientId=self.client)
|
|
454
|
+
return raw[12].decode()
|
|
455
|
+
|
|
456
|
+
def _object_poses(self) -> dict[str, tuple[Sequence[float], Sequence[float]]]:
|
|
457
|
+
return {
|
|
458
|
+
name: p.getBasePositionAndOrientation(body, physicsClientId=self.client)
|
|
459
|
+
for name, body in self.object_ids.items()
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
def _restore_object_poses(self, poses: dict[str, tuple[Sequence[float], Sequence[float]]]) -> None:
|
|
463
|
+
for name, (position, orientation) in poses.items():
|
|
464
|
+
p.resetBasePositionAndOrientation(
|
|
465
|
+
self.object_ids[name], position, orientation, physicsClientId=self.client
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def state(self) -> dict[str, object]:
|
|
469
|
+
joint_states = []
|
|
470
|
+
for info in self.arm_joints:
|
|
471
|
+
raw = p.getJointState(self.robot_id, info.index, physicsClientId=self.client)
|
|
472
|
+
joint_states.append(
|
|
473
|
+
{
|
|
474
|
+
"name": info.name,
|
|
475
|
+
"position_degrees": round(math.degrees(float(raw[0])), 5),
|
|
476
|
+
"velocity_degrees_per_second": round(math.degrees(float(raw[1])), 5),
|
|
477
|
+
"limits_degrees": [round(math.degrees(info.lower), 4), round(math.degrees(info.upper), 4)],
|
|
478
|
+
}
|
|
479
|
+
)
|
|
480
|
+
objects = []
|
|
481
|
+
for name, body in self.object_ids.items():
|
|
482
|
+
position, orientation = p.getBasePositionAndOrientation(body, physicsClientId=self.client)
|
|
483
|
+
objects.append(
|
|
484
|
+
{
|
|
485
|
+
"name": name,
|
|
486
|
+
"position": [round(float(value), 6) for value in position],
|
|
487
|
+
"orientation": [round(float(value), 6) for value in orientation],
|
|
488
|
+
"graspable": bool(self.object_specs[name].get("graspable")),
|
|
489
|
+
}
|
|
490
|
+
)
|
|
491
|
+
return {
|
|
492
|
+
"ok": True,
|
|
493
|
+
"model": {"id": self.model.id, "name": self.model.name, "dof": self.model.dof},
|
|
494
|
+
"scene": self.scene_id,
|
|
495
|
+
"joints": joint_states,
|
|
496
|
+
"gripper": round(self.gripper, 4),
|
|
497
|
+
"end_effector": self.forward_kinematics(),
|
|
498
|
+
"collisions": self.collisions(),
|
|
499
|
+
"held_object": self.held_object,
|
|
500
|
+
"objects": objects,
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
def joint_positions(self) -> list[list[float]]:
|
|
504
|
+
points = [[0.0, 0.0, 0.0]]
|
|
505
|
+
for info in self.arm_joints:
|
|
506
|
+
state = p.getLinkState(self.robot_id, info.index, computeForwardKinematics=True, physicsClientId=self.client)
|
|
507
|
+
points.append([round(float(value), 6) for value in state[4]])
|
|
508
|
+
points.append([round(value, 6) for value in self.end_effector_position()])
|
|
509
|
+
return points
|
|
510
|
+
|
|
511
|
+
def run_viewer(self) -> None:
|
|
512
|
+
if not self.gui:
|
|
513
|
+
raise RuntimeError("Viewer requires gui=True.")
|
|
514
|
+
p.resetDebugVisualizerCamera(0.9, 40, -28, [0.18, 0, 0.14], physicsClientId=self.client)
|
|
515
|
+
while p.isConnected(self.client):
|
|
516
|
+
self._step(1)
|
jolly/core/scenes.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
|
|
5
|
+
from jolly.core.errors import ConfigurationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SCENES: dict[str, dict[str, object]] = {
|
|
9
|
+
"empty": {
|
|
10
|
+
"description": "A clear floor for kinematics and motion checks.",
|
|
11
|
+
"objects": [],
|
|
12
|
+
},
|
|
13
|
+
"blocks": {
|
|
14
|
+
"description": "Three graspable blocks and two marked target pads.",
|
|
15
|
+
"objects": [
|
|
16
|
+
{"name": "red_block", "kind": "box", "position": [0.30, -0.16, 0.025], "size": [0.05, 0.05, 0.05], "color": [0.92, 0.18, 0.16, 1.0], "mass": 0.08, "graspable": True},
|
|
17
|
+
{"name": "blue_block", "kind": "box", "position": [0.32, 0.00, 0.025], "size": [0.05, 0.05, 0.05], "color": [0.12, 0.45, 0.95, 1.0], "mass": 0.08, "graspable": True},
|
|
18
|
+
{"name": "green_block", "kind": "box", "position": [0.30, 0.16, 0.025], "size": [0.05, 0.05, 0.05], "color": [0.16, 0.72, 0.28, 1.0], "mass": 0.08, "graspable": True},
|
|
19
|
+
{"name": "left_target", "kind": "box", "position": [0.16, -0.27, 0.006], "size": [0.12, 0.12, 0.012], "color": [0.96, 0.68, 0.12, 0.45], "mass": 0.0, "graspable": False},
|
|
20
|
+
{"name": "right_target", "kind": "box", "position": [0.16, 0.27, 0.006], "size": [0.12, 0.12, 0.012], "color": [0.68, 0.22, 0.92, 0.45], "mass": 0.0, "graspable": False},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
"shelf": {
|
|
24
|
+
"description": "A block must move from the floor onto a low shelf.",
|
|
25
|
+
"objects": [
|
|
26
|
+
{"name": "cargo", "kind": "box", "position": [0.28, -0.16, 0.03], "size": [0.06, 0.06, 0.06], "color": [0.15, 0.68, 0.92, 1.0], "mass": 0.10, "graspable": True},
|
|
27
|
+
{"name": "shelf", "kind": "box", "position": [0.28, 0.18, 0.12], "size": [0.22, 0.16, 0.025], "color": [0.52, 0.34, 0.18, 1.0], "mass": 0.0, "graspable": False},
|
|
28
|
+
{"name": "shelf_back", "kind": "box", "position": [0.39, 0.18, 0.21], "size": [0.025, 0.16, 0.20], "color": [0.42, 0.27, 0.14, 1.0], "mass": 0.0, "graspable": False},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
"obstacles": {
|
|
32
|
+
"description": "Two barriers create a collision-avoidance reaching task.",
|
|
33
|
+
"objects": [
|
|
34
|
+
{"name": "goal", "kind": "box", "position": [0.38, 0.00, 0.025], "size": [0.05, 0.05, 0.05], "color": [0.18, 0.82, 0.34, 1.0], "mass": 0.05, "graspable": True},
|
|
35
|
+
{"name": "barrier_left", "kind": "box", "position": [0.22, -0.09, 0.11], "size": [0.05, 0.09, 0.22], "color": [0.85, 0.22, 0.18, 1.0], "mass": 0.0, "graspable": False},
|
|
36
|
+
{"name": "barrier_right", "kind": "box", "position": [0.22, 0.09, 0.11], "size": [0.05, 0.09, 0.22], "color": [0.85, 0.22, 0.18, 1.0], "mass": 0.0, "graspable": False},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_scene(scene_id: str) -> dict[str, object]:
|
|
43
|
+
try:
|
|
44
|
+
scene = deepcopy(SCENES[scene_id])
|
|
45
|
+
except KeyError as exc:
|
|
46
|
+
available = ", ".join(sorted(SCENES))
|
|
47
|
+
raise ConfigurationError(f"Unknown scene '{scene_id}'. Available: {available}") from exc
|
|
48
|
+
scene["id"] = scene_id
|
|
49
|
+
return scene
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def list_scenes() -> list[dict[str, object]]:
|
|
53
|
+
return [
|
|
54
|
+
{"id": key, "description": str(SCENES[key]["description"]), "object_count": len(SCENES[key]["objects"])}
|
|
55
|
+
for key in sorted(SCENES)
|
|
56
|
+
]
|