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/__init__.py
ADDED
jolly/__main__.py
ADDED
jolly/assets/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bundled robot models."""
|
jolly/assets/jolly6.urdf
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<?xml version="1.0"?><robot name="jolly6">
|
|
2
|
+
<link name="base_link"><inertial><mass value="1.5"/><inertia ixx="0.02" ixy="0" ixz="0" iyy="0.02" iyz="0" izz="0.02"/></inertial><visual><origin xyz="0 0 0.04"/><geometry><cylinder radius="0.075" length="0.08"/></geometry><material name="base"><color rgba="0.12 0.14 0.18 1"/></material></visual><collision><origin xyz="0 0 0.04"/><geometry><cylinder radius="0.075" length="0.08"/></geometry></collision></link>
|
|
3
|
+
<link name="shoulder_link"><inertial><origin xyz="0 0 0"/><mass value="0.12"/><inertia ixx="0.0004" ixy="0" ixz="0" iyy="0.0004" iyz="0" izz="0.0004"/></inertial><visual><origin xyz="0 0 0.035" rpy="0 0 0"/><geometry><cylinder radius="0.032" length="0.07"/></geometry><material name="m_shoulder_link"><color rgba="0.95 0.55 0.10 1"/></material></visual><collision><origin xyz="0 0 0.035" rpy="0 0 0"/><geometry><cylinder radius="0.032" length="0.07"/></geometry></collision></link>
|
|
4
|
+
<link name="upper_arm_link"><inertial><origin xyz="0 0 0"/><mass value="0.12"/><inertia ixx="0.0004" ixy="0" ixz="0" iyy="0.0004" iyz="0" izz="0.0004"/></inertial><visual><origin xyz="0.1 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.025" length="0.2"/></geometry><material name="m_upper_arm_link"><color rgba="0.15 0.55 0.95 1"/></material></visual><collision><origin xyz="0.1 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.025" length="0.2"/></geometry></collision></link>
|
|
5
|
+
<link name="forearm_link"><inertial><origin xyz="0 0 0"/><mass value="0.12"/><inertia ixx="0.0004" ixy="0" ixz="0" iyy="0.0004" iyz="0" izz="0.0004"/></inertial><visual><origin xyz="0.09 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.022" length="0.18"/></geometry><material name="m_forearm_link"><color rgba="0.12 0.72 0.82 1"/></material></visual><collision><origin xyz="0.09 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.022" length="0.18"/></geometry></collision></link>
|
|
6
|
+
<link name="wrist_pitch_link"><inertial><origin xyz="0 0 0"/><mass value="0.12"/><inertia ixx="0.0004" ixy="0" ixz="0" iyy="0.0004" iyz="0" izz="0.0004"/></inertial><visual><origin xyz="0.045 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.018" length="0.09"/></geometry><material name="m_wrist_pitch_link"><color rgba="0.15 0.55 0.95 1"/></material></visual><collision><origin xyz="0.045 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.018" length="0.09"/></geometry></collision></link>
|
|
7
|
+
<link name="wrist_yaw_link"><inertial><origin xyz="0 0 0"/><mass value="0.12"/><inertia ixx="0.0004" ixy="0" ixz="0" iyy="0.0004" iyz="0" izz="0.0004"/></inertial><visual><origin xyz="0.03 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.017" length="0.06"/></geometry><material name="m_wrist_yaw_link"><color rgba="0.95 0.55 0.10 1"/></material></visual><collision><origin xyz="0.03 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.017" length="0.06"/></geometry></collision></link>
|
|
8
|
+
<link name="wrist_roll_link"><inertial><origin xyz="0 0 0"/><mass value="0.12"/><inertia ixx="0.0004" ixy="0" ixz="0" iyy="0.0004" iyz="0" izz="0.0004"/></inertial><visual><origin xyz="0.0275 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.016" length="0.055"/></geometry><material name="m_wrist_roll_link"><color rgba="0.15 0.55 0.95 1"/></material></visual><collision><origin xyz="0.0275 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.016" length="0.055"/></geometry></collision></link>
|
|
9
|
+
<link name="tool_link"><inertial><mass value="0.08"/><inertia ixx="0.0001" ixy="0" ixz="0" iyy="0.0001" iyz="0" izz="0.0001"/></inertial><visual><origin xyz="0.025 0 0"/><geometry><box size="0.05 0.07 0.025"/></geometry><material name="tool"><color rgba="0.18 0.2 0.24 1"/></material></visual><collision><origin xyz="0.025 0 0"/><geometry><box size="0.05 0.07 0.025"/></geometry></collision></link>
|
|
10
|
+
<link name="left_finger_link"><inertial><mass value="0.025"/><inertia ixx="0.00002" ixy="0" ixz="0" iyy="0.00002" iyz="0" izz="0.00002"/></inertial><visual><origin xyz="0.035 0 0"/><geometry><box size="0.07 0.012 0.018"/></geometry><material name="finger"><color rgba="0.92 0.92 0.95 1"/></material></visual><collision><origin xyz="0.035 0 0"/><geometry><box size="0.07 0.012 0.018"/></geometry></collision></link>
|
|
11
|
+
<link name="right_finger_link"><inertial><mass value="0.025"/><inertia ixx="0.00002" ixy="0" ixz="0" iyy="0.00002" iyz="0" izz="0.00002"/></inertial><visual><origin xyz="0.035 0 0"/><geometry><box size="0.07 0.012 0.018"/></geometry><material name="finger2"><color rgba="0.92 0.92 0.95 1"/></material></visual><collision><origin xyz="0.035 0 0"/><geometry><box size="0.07 0.012 0.018"/></geometry></collision></link>
|
|
12
|
+
<joint name="base_yaw" type="revolute"><parent link="base_link"/><child link="shoulder_link"/><origin xyz="0 0 0.08"/><axis xyz="0 0 1"/><limit lower="-3.14159" upper="3.14159" effort="45" velocity="2.5"/><dynamics damping="0.08" friction="0.02"/></joint>
|
|
13
|
+
<joint name="shoulder_pitch" type="revolute"><parent link="shoulder_link"/><child link="upper_arm_link"/><origin xyz="0 0 0.07"/><axis xyz="0 1 0"/><limit lower="-2.35" upper="2.35" effort="45" velocity="2.5"/><dynamics damping="0.08" friction="0.02"/></joint>
|
|
14
|
+
<joint name="elbow_pitch" type="revolute"><parent link="upper_arm_link"/><child link="forearm_link"/><origin xyz=".20 0 0"/><axis xyz="0 1 0"/><limit lower="-2.6" upper="2.6" effort="45" velocity="2.5"/><dynamics damping="0.08" friction="0.02"/></joint>
|
|
15
|
+
<joint name="wrist_pitch" type="revolute"><parent link="forearm_link"/><child link="wrist_pitch_link"/><origin xyz=".18 0 0"/><axis xyz="0 1 0"/><limit lower="-2.6" upper="2.6" effort="45" velocity="2.5"/><dynamics damping="0.08" friction="0.02"/></joint>
|
|
16
|
+
<joint name="wrist_yaw" type="revolute"><parent link="wrist_pitch_link"/><child link="wrist_yaw_link"/><origin xyz=".09 0 0"/><axis xyz="0 0 1"/><limit lower="-3.14159" upper="3.14159" effort="45" velocity="2.5"/><dynamics damping="0.08" friction="0.02"/></joint>
|
|
17
|
+
<joint name="wrist_roll" type="revolute"><parent link="wrist_yaw_link"/><child link="wrist_roll_link"/><origin xyz=".06 0 0"/><axis xyz="1 0 0"/><limit lower="-3.14159" upper="3.14159" effort="45" velocity="2.5"/><dynamics damping="0.08" friction="0.02"/></joint>
|
|
18
|
+
<joint name="tool_fixed" type="fixed"><parent link="wrist_roll_link"/><child link="tool_link"/><origin xyz=".055 0 0"/></joint>
|
|
19
|
+
<joint name="left_finger" type="prismatic"><parent link="tool_link"/><child link="left_finger_link"/><origin xyz=".025 .012 0"/><axis xyz="0 1 0"/><limit lower="0" upper=".03" effort="20" velocity=".2"/></joint>
|
|
20
|
+
<joint name="right_finger" type="prismatic"><parent link="tool_link"/><child link="right_finger_link"/><origin xyz=".025 -.012 0"/><axis xyz="0 -1 0"/><limit lower="0" upper=".03" effort="20" velocity=".2"/></joint>
|
|
21
|
+
</robot>
|
jolly/assets/so101.urdf
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<?xml version="1.0"?><robot name="so101_sim">
|
|
2
|
+
<link name="base_link"><inertial><mass value="0.3"/><inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/></inertial><visual><origin xyz="0 0 .03"/><geometry><box size=".10 .10 .06"/></geometry><material name="yellow"><color rgba="1 .72 .08 1"/></material></visual><collision><origin xyz="0 0 .03"/><geometry><box size=".10 .10 .06"/></geometry></collision></link>
|
|
3
|
+
<link name="shoulder_link"><inertial><origin xyz="0 0 0"/><mass value="0.12"/><inertia ixx="0.0004" ixy="0" ixz="0" iyy="0.0004" iyz="0" izz="0.0004"/></inertial><visual><origin xyz="0 0 0.035" rpy="0 0 0"/><geometry><cylinder radius="0.022" length="0.07"/></geometry><material name="m_shoulder_link"><color rgba="1 .72 .08 1"/></material></visual><collision><origin xyz="0 0 0.035" rpy="0 0 0"/><geometry><cylinder radius="0.022" length="0.07"/></geometry></collision></link>
|
|
4
|
+
<link name="upper_arm_link"><inertial><origin xyz="0 0 0"/><mass value="0.12"/><inertia ixx="0.0004" ixy="0" ixz="0" iyy="0.0004" iyz="0" izz="0.0004"/></inertial><visual><origin xyz="0.0575 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.018" length="0.115"/></geometry><material name="m_upper_arm_link"><color rgba="1 .72 .08 1"/></material></visual><collision><origin xyz="0.0575 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.018" length="0.115"/></geometry></collision></link>
|
|
5
|
+
<link name="lower_arm_link"><inertial><origin xyz="0 0 0"/><mass value="0.12"/><inertia ixx="0.0004" ixy="0" ixz="0" iyy="0.0004" iyz="0" izz="0.0004"/></inertial><visual><origin xyz="0.0675 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.017" length="0.135"/></geometry><material name="m_lower_arm_link"><color rgba="1 .72 .08 1"/></material></visual><collision><origin xyz="0.0675 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.017" length="0.135"/></geometry></collision></link>
|
|
6
|
+
<link name="wrist_link"><inertial><origin xyz="0 0 0"/><mass value="0.12"/><inertia ixx="0.0004" ixy="0" ixz="0" iyy="0.0004" iyz="0" izz="0.0004"/></inertial><visual><origin xyz="0.0325 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.015" length="0.065"/></geometry><material name="m_wrist_link"><color rgba="0.12 .12 .14 1"/></material></visual><collision><origin xyz="0.0325 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.015" length="0.065"/></geometry></collision></link>
|
|
7
|
+
<link name="gripper_link"><inertial><origin xyz="0 0 0"/><mass value="0.12"/><inertia ixx="0.0004" ixy="0" ixz="0" iyy="0.0004" iyz="0" izz="0.0004"/></inertial><visual><origin xyz="0.04 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.014" length="0.08"/></geometry><material name="m_gripper_link"><color rgba="1 .72 .08 1"/></material></visual><collision><origin xyz="0.04 0 0" rpy="0 1.5708 0"/><geometry><cylinder radius="0.014" length="0.08"/></geometry></collision></link>
|
|
8
|
+
<link name="tool_link"><inertial><mass value=".04"/><inertia ixx=".00005" ixy="0" ixz="0" iyy=".00005" iyz="0" izz=".00005"/></inertial><visual><origin xyz=".02 0 0"/><geometry><box size=".04 .045 .02"/></geometry><material name="black"><color rgba=".1 .1 .12 1"/></material></visual><collision><origin xyz=".02 0 0"/><geometry><box size=".04 .045 .02"/></geometry></collision></link>
|
|
9
|
+
<link name="moving_jaw"><inertial><mass value=".02"/><inertia ixx=".00001" ixy="0" ixz="0" iyy=".00001" iyz="0" izz=".00001"/></inertial><visual><origin xyz=".035 0 0"/><geometry><box size=".07 .012 .016"/></geometry><material name="jaw"><color rgba=".85 .85 .88 1"/></material></visual><collision><origin xyz=".035 0 0"/><geometry><box size=".07 .012 .016"/></geometry></collision></link>
|
|
10
|
+
<joint name="shoulder_pan" type="revolute"><parent link="base_link"/><child link="shoulder_link"/><origin xyz="0 0 .06"/><axis xyz="0 0 1"/><limit lower="-1.91986" upper="1.91986" effort="10" velocity="10"/><dynamics damping="0.08" friction="0.02"/></joint>
|
|
11
|
+
<joint name="shoulder_lift" type="revolute"><parent link="shoulder_link"/><child link="upper_arm_link"/><origin xyz="0 0 .065"/><axis xyz="0 1 0"/><limit lower="-1.74533" upper="1.74533" effort="10" velocity="10"/><dynamics damping="0.08" friction="0.02"/></joint>
|
|
12
|
+
<joint name="elbow_flex" type="revolute"><parent link="upper_arm_link"/><child link="lower_arm_link"/><origin xyz=".115 0 0"/><axis xyz="0 1 0"/><limit lower="-1.69" upper="1.69" effort="10" velocity="10"/><dynamics damping="0.08" friction="0.02"/></joint>
|
|
13
|
+
<joint name="wrist_flex" type="revolute"><parent link="lower_arm_link"/><child link="wrist_link"/><origin xyz=".135 0 0"/><axis xyz="0 1 0"/><limit lower="-1.65806" upper="1.65806" effort="10" velocity="10"/><dynamics damping="0.08" friction="0.02"/></joint>
|
|
14
|
+
<joint name="wrist_roll" type="revolute"><parent link="wrist_link"/><child link="gripper_link"/><origin xyz=".065 0 0"/><axis xyz="1 0 0"/><limit lower="-2.74385" upper="2.84121" effort="10" velocity="10"/><dynamics damping="0.08" friction="0.02"/></joint>
|
|
15
|
+
<joint name="tool_fixed" type="fixed"><parent link="gripper_link"/><child link="tool_link"/><origin xyz=".08 0 0"/></joint>
|
|
16
|
+
<joint name="gripper" type="prismatic"><parent link="tool_link"/><child link="moving_jaw"/><origin xyz=".015 .01 0"/><axis xyz="0 1 0"/><limit lower="0" upper=".035" effort="10" velocity=".2"/></joint>
|
|
17
|
+
</robot>
|
jolly/benchmark.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from jolly.challenges import list_challenges
|
|
7
|
+
from jolly.core.models import MODELS
|
|
8
|
+
from jolly.core.physics import PhysicsEngine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_benchmark() -> dict[str, object]:
|
|
12
|
+
"""Run deterministic engine checks, not an agent policy."""
|
|
13
|
+
checks: list[dict[str, object]] = []
|
|
14
|
+
started = time.perf_counter()
|
|
15
|
+
for model_id in MODELS:
|
|
16
|
+
case_started = time.perf_counter()
|
|
17
|
+
try:
|
|
18
|
+
with PhysicsEngine(model=model_id, scene="empty") as engine:
|
|
19
|
+
state = engine.state()
|
|
20
|
+
home = [joint["position_degrees"] for joint in state["joints"]]
|
|
21
|
+
fk = engine.forward_kinematics(home)
|
|
22
|
+
finite = all(math.isfinite(value) for value in fk["position"])
|
|
23
|
+
passed = len(home) == engine.model.dof and finite
|
|
24
|
+
checks.append(
|
|
25
|
+
{
|
|
26
|
+
"name": f"{model_id}:load-fk",
|
|
27
|
+
"passed": passed,
|
|
28
|
+
"duration_ms": round((time.perf_counter() - case_started) * 1000, 3),
|
|
29
|
+
"metrics": {"dof": len(home), "end_effector": fk["position"]},
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
except Exception as exc: # benchmark reports errors instead of hiding remaining cases
|
|
33
|
+
checks.append(
|
|
34
|
+
{
|
|
35
|
+
"name": f"{model_id}:load-fk",
|
|
36
|
+
"passed": False,
|
|
37
|
+
"duration_ms": round((time.perf_counter() - case_started) * 1000, 3),
|
|
38
|
+
"error": str(exc),
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
for scene in ("blocks", "shelf", "obstacles"):
|
|
42
|
+
case_started = time.perf_counter()
|
|
43
|
+
try:
|
|
44
|
+
with PhysicsEngine(scene=scene) as engine:
|
|
45
|
+
state = engine.state()
|
|
46
|
+
checks.append(
|
|
47
|
+
{
|
|
48
|
+
"name": f"scene:{scene}",
|
|
49
|
+
"passed": len(state["objects"]) > 0,
|
|
50
|
+
"duration_ms": round((time.perf_counter() - case_started) * 1000, 3),
|
|
51
|
+
"metrics": {"objects": len(state["objects"])},
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
checks.append(
|
|
56
|
+
{
|
|
57
|
+
"name": f"scene:{scene}",
|
|
58
|
+
"passed": False,
|
|
59
|
+
"duration_ms": round((time.perf_counter() - case_started) * 1000, 3),
|
|
60
|
+
"error": str(exc),
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
passed = sum(1 for check in checks if check["passed"])
|
|
64
|
+
return {
|
|
65
|
+
"ok": passed == len(checks),
|
|
66
|
+
"benchmark": "jolly-engine-v1",
|
|
67
|
+
"score": round(100 * passed / len(checks), 2),
|
|
68
|
+
"passed": passed,
|
|
69
|
+
"total": len(checks),
|
|
70
|
+
"duration_ms": round((time.perf_counter() - started) * 1000, 3),
|
|
71
|
+
"checks": checks,
|
|
72
|
+
"agent_challenges": list_challenges(),
|
|
73
|
+
}
|
jolly/challenges.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from jolly.core.errors import ConfigurationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Challenge:
|
|
11
|
+
id: str
|
|
12
|
+
name: str
|
|
13
|
+
scene: str
|
|
14
|
+
description: str
|
|
15
|
+
success: str
|
|
16
|
+
max_commands: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
CHALLENGES: dict[str, Challenge] = {
|
|
20
|
+
"reach-center": Challenge(
|
|
21
|
+
id="reach-center",
|
|
22
|
+
name="Reach Center",
|
|
23
|
+
scene="empty",
|
|
24
|
+
description="Move the tool to (0.35, 0.00, 0.18) without a collision.",
|
|
25
|
+
success="End-effector error <= 3 cm and no collision.",
|
|
26
|
+
max_commands=3,
|
|
27
|
+
),
|
|
28
|
+
"sort-red": Challenge(
|
|
29
|
+
id="sort-red",
|
|
30
|
+
name="Sort Red Block",
|
|
31
|
+
scene="blocks",
|
|
32
|
+
description="Place red_block on left_target.",
|
|
33
|
+
success="Red block center is within 7 cm of the left target center.",
|
|
34
|
+
max_commands=12,
|
|
35
|
+
),
|
|
36
|
+
"shelf-load": Challenge(
|
|
37
|
+
id="shelf-load",
|
|
38
|
+
name="Shelf Load",
|
|
39
|
+
scene="shelf",
|
|
40
|
+
description="Place cargo on the shelf surface.",
|
|
41
|
+
success="Cargo rests inside the shelf bounds above 12 cm.",
|
|
42
|
+
max_commands=14,
|
|
43
|
+
),
|
|
44
|
+
"obstacle-reach": Challenge(
|
|
45
|
+
id="obstacle-reach",
|
|
46
|
+
name="Obstacle Reach",
|
|
47
|
+
scene="obstacles",
|
|
48
|
+
description="Reach within 7 cm of goal while remaining collision-free.",
|
|
49
|
+
success="Tool-to-goal error <= 7 cm and no collision.",
|
|
50
|
+
max_commands=10,
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_challenge(challenge_id: str) -> Challenge:
|
|
56
|
+
try:
|
|
57
|
+
return CHALLENGES[challenge_id]
|
|
58
|
+
except KeyError as exc:
|
|
59
|
+
available = ", ".join(sorted(CHALLENGES))
|
|
60
|
+
raise ConfigurationError(f"Unknown challenge '{challenge_id}'. Available: {available}") from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def list_challenges() -> list[dict[str, object]]:
|
|
64
|
+
return [vars(CHALLENGES[key]) for key in sorted(CHALLENGES)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def evaluate(challenge_id: str, state: dict[str, object]) -> dict[str, object]:
|
|
68
|
+
challenge = get_challenge(challenge_id)
|
|
69
|
+
if state.get("scene") != challenge.scene:
|
|
70
|
+
raise ConfigurationError(
|
|
71
|
+
f"Challenge '{challenge_id}' requires scene '{challenge.scene}', not '{state.get('scene')}'. "
|
|
72
|
+
f"Run 'jolly challenge start {challenge_id}'."
|
|
73
|
+
)
|
|
74
|
+
ee = state["end_effector"]["position"]
|
|
75
|
+
objects = {item["name"]: item for item in state.get("objects", [])}
|
|
76
|
+
collision = bool(state["collisions"]["collision"])
|
|
77
|
+
metrics: dict[str, object]
|
|
78
|
+
success = False
|
|
79
|
+
if challenge_id == "reach-center":
|
|
80
|
+
error = math.dist(ee, [0.35, 0.0, 0.18])
|
|
81
|
+
success = error <= 0.03 and not collision
|
|
82
|
+
metrics = {"target_error_meters": round(error, 6), "collision_free": not collision}
|
|
83
|
+
elif challenge_id == "sort-red":
|
|
84
|
+
position = objects["red_block"]["position"]
|
|
85
|
+
error = math.dist(position[:2], [0.16, -0.27])
|
|
86
|
+
success = error <= 0.07 and position[2] <= 0.09
|
|
87
|
+
metrics = {"target_xy_error_meters": round(error, 6), "block_height_meters": position[2]}
|
|
88
|
+
elif challenge_id == "shelf-load":
|
|
89
|
+
position = objects["cargo"]["position"]
|
|
90
|
+
inside = 0.16 <= position[0] <= 0.40 and 0.10 <= position[1] <= 0.26
|
|
91
|
+
success = inside and position[2] >= 0.12
|
|
92
|
+
metrics = {"inside_shelf_xy": inside, "cargo_height_meters": position[2]}
|
|
93
|
+
else:
|
|
94
|
+
goal = objects["goal"]["position"]
|
|
95
|
+
error = math.dist(ee, goal)
|
|
96
|
+
success = error <= 0.07 and not collision
|
|
97
|
+
metrics = {"goal_error_meters": round(error, 6), "collision_free": not collision}
|
|
98
|
+
command_count = int(state.get("challenge_commands", 0))
|
|
99
|
+
within_budget = command_count <= challenge.max_commands
|
|
100
|
+
success = success and within_budget
|
|
101
|
+
metrics["commands"] = command_count
|
|
102
|
+
metrics["within_command_budget"] = within_budget
|
|
103
|
+
return {
|
|
104
|
+
"ok": True,
|
|
105
|
+
"challenge": vars(challenge),
|
|
106
|
+
"success": success,
|
|
107
|
+
"score": 100 if success else 0,
|
|
108
|
+
"metrics": metrics,
|
|
109
|
+
}
|
jolly/cli.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from jolly import __version__
|
|
14
|
+
from jolly.benchmark import run_benchmark
|
|
15
|
+
from jolly.challenges import evaluate, get_challenge, list_challenges
|
|
16
|
+
from jolly.core.errors import ConfigurationError, JollyError
|
|
17
|
+
from jolly.core.models import get_model, list_models
|
|
18
|
+
from jolly.core.physics import PhysicsEngine
|
|
19
|
+
from jolly.core.scenes import get_scene, list_scenes
|
|
20
|
+
from jolly.core.store import load_state, save_state, state_lock
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def emit(data: Any, *, json_output: bool, message: str | None = None) -> None:
|
|
26
|
+
if json_output:
|
|
27
|
+
click.echo(json.dumps(data, indent=2, sort_keys=True))
|
|
28
|
+
elif message:
|
|
29
|
+
click.echo(message)
|
|
30
|
+
elif isinstance(data, dict):
|
|
31
|
+
click.echo(json.dumps(data, indent=2, sort_keys=True))
|
|
32
|
+
else:
|
|
33
|
+
click.echo(str(data))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_joints(_: click.Context, __: click.Parameter, value: str | None) -> list[float] | None:
|
|
37
|
+
if value is None:
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
values = [float(part.strip()) for part in value.split(",") if part.strip()]
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
raise click.BadParameter("Use comma-separated numbers in degrees.") from exc
|
|
43
|
+
if not values:
|
|
44
|
+
raise click.BadParameter("Provide at least one joint angle.")
|
|
45
|
+
return values
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def current_engine(
|
|
50
|
+
*, model: str | None = None, scene: str | None = None, gui: bool = False, recover: bool = False
|
|
51
|
+
) -> Iterator[PhysicsEngine]:
|
|
52
|
+
with state_lock():
|
|
53
|
+
try:
|
|
54
|
+
saved = load_state()
|
|
55
|
+
except ConfigurationError:
|
|
56
|
+
if not recover:
|
|
57
|
+
raise
|
|
58
|
+
saved = None
|
|
59
|
+
selected_model = model or (str(saved["model"]["id"]) if saved else "jolly6")
|
|
60
|
+
selected_scene = scene or (str(saved.get("scene", "empty")) if saved else "empty")
|
|
61
|
+
with PhysicsEngine(model=selected_model, scene=selected_scene, gui=gui, realtime=gui) as engine:
|
|
62
|
+
if saved and saved.get("model", {}).get("id") == selected_model and saved.get("scene") == selected_scene:
|
|
63
|
+
engine.restore(saved)
|
|
64
|
+
yield engine
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def persist(
|
|
68
|
+
engine: PhysicsEngine,
|
|
69
|
+
*,
|
|
70
|
+
extra: dict[str, object] | None = None,
|
|
71
|
+
count_challenge_command: bool = False,
|
|
72
|
+
) -> dict[str, object]:
|
|
73
|
+
state = engine.state()
|
|
74
|
+
previous = load_state() or {}
|
|
75
|
+
if "active_challenge" in previous:
|
|
76
|
+
state["active_challenge"] = previous["active_challenge"]
|
|
77
|
+
state["challenge_commands"] = int(previous.get("challenge_commands", 0)) + int(count_challenge_command)
|
|
78
|
+
if extra:
|
|
79
|
+
state.update(extra)
|
|
80
|
+
save_state(state)
|
|
81
|
+
return state
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def json_error(exc: Exception) -> str:
|
|
85
|
+
return json.dumps({"ok": False, "error": {"type": exc.__class__.__name__, "message": str(exc)}}, sort_keys=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SafeGroup(click.Group):
|
|
89
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
90
|
+
ctx.meta["json_requested"] = "--json" in args or os.environ.get("JOLLY_JSON") == "1"
|
|
91
|
+
return super().parse_args(ctx, args)
|
|
92
|
+
|
|
93
|
+
def invoke(self, ctx: click.Context) -> Any:
|
|
94
|
+
try:
|
|
95
|
+
return super().invoke(ctx)
|
|
96
|
+
except JollyError as exc:
|
|
97
|
+
if ctx.meta.get("json_requested"):
|
|
98
|
+
click.echo(json_error(exc))
|
|
99
|
+
ctx.exit(2)
|
|
100
|
+
raise click.ClickException(str(exc)) from exc
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@click.group(cls=SafeGroup, context_settings={"help_option_names": ["-h", "--help"]})
|
|
104
|
+
@click.version_option(__version__)
|
|
105
|
+
def main() -> None:
|
|
106
|
+
"""Jolly: local robot-arm physics for terminal users and LLM agents."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@main.command("models")
|
|
110
|
+
@click.option("json_output", "--json", is_flag=True, help="Return structured JSON.")
|
|
111
|
+
def models_command(json_output: bool) -> None:
|
|
112
|
+
"""List bundled robot models."""
|
|
113
|
+
data = {"ok": True, "models": list_models()}
|
|
114
|
+
if json_output:
|
|
115
|
+
emit(data, json_output=True)
|
|
116
|
+
return
|
|
117
|
+
table = Table(title="Jolly robot models")
|
|
118
|
+
table.add_column("ID")
|
|
119
|
+
table.add_column("DOF", justify="right")
|
|
120
|
+
table.add_column("Name")
|
|
121
|
+
table.add_column("License")
|
|
122
|
+
for model in data["models"]:
|
|
123
|
+
table.add_row(str(model["id"]), str(model["dof"]), str(model["name"]), str(model["license"]))
|
|
124
|
+
console.print(table)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@main.command("state")
|
|
128
|
+
@click.option("model", "--model", type=click.Choice([item["id"] for item in list_models()]))
|
|
129
|
+
@click.option("json_output", "--json", is_flag=True, help="Return structured JSON.")
|
|
130
|
+
def state_command(model: str | None, json_output: bool) -> None:
|
|
131
|
+
"""Return joints, tool pose, objects, and collisions."""
|
|
132
|
+
with current_engine(model=model) as engine:
|
|
133
|
+
data = persist(engine)
|
|
134
|
+
emit(data, json_output=json_output, message=f"{data['model']['name']}: tool={data['end_effector']['position']} collision={data['collisions']['collision']}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@main.command("move")
|
|
138
|
+
@click.option("joints", "--joints", required=True, callback=parse_joints, help="Comma-separated joint angles in degrees.")
|
|
139
|
+
@click.option("gripper", "--gripper", type=click.FloatRange(0.0, 1.0))
|
|
140
|
+
@click.option("steps", "--steps", type=click.IntRange(1, 2400), default=120, show_default=True)
|
|
141
|
+
@click.option("allow_collision", "--allow-collision", is_flag=True, help="Do not roll back a colliding motion.")
|
|
142
|
+
@click.option("json_output", "--json", is_flag=True, help="Return structured JSON.")
|
|
143
|
+
def move_command(joints: list[float], gripper: float | None, steps: int, allow_collision: bool, json_output: bool) -> None:
|
|
144
|
+
"""Move to exact joint angles."""
|
|
145
|
+
with current_engine() as engine:
|
|
146
|
+
data = engine.move_joints(joints, gripper=gripper, steps=steps, allow_collision=allow_collision)
|
|
147
|
+
data = persist(
|
|
148
|
+
engine,
|
|
149
|
+
extra={"ik": data.get("ik")} if data.get("ik") else None,
|
|
150
|
+
count_challenge_command=True,
|
|
151
|
+
)
|
|
152
|
+
emit(data, json_output=json_output, message=f"Moved {len(joints)} joints. Tool={data['end_effector']['position']}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@main.command("reach")
|
|
156
|
+
@click.option("x", "--x", required=True, type=float)
|
|
157
|
+
@click.option("y", "--y", required=True, type=float)
|
|
158
|
+
@click.option("z", "--z", required=True, type=float)
|
|
159
|
+
@click.option("gripper", "--gripper", type=click.FloatRange(0.0, 1.0))
|
|
160
|
+
@click.option("steps", "--steps", type=click.IntRange(1, 2400), default=160, show_default=True)
|
|
161
|
+
@click.option("allow_collision", "--allow-collision", is_flag=True)
|
|
162
|
+
@click.option("json_output", "--json", is_flag=True, help="Return structured JSON.")
|
|
163
|
+
def reach_command(x: float, y: float, z: float, gripper: float | None, steps: int, allow_collision: bool, json_output: bool) -> None:
|
|
164
|
+
"""Solve inverse kinematics and move the tool."""
|
|
165
|
+
with current_engine() as engine:
|
|
166
|
+
data = engine.reach(x, y, z, gripper=gripper, steps=steps, allow_collision=allow_collision)
|
|
167
|
+
ik = data["ik"]
|
|
168
|
+
data = persist(engine, extra={"ik": ik}, count_challenge_command=True)
|
|
169
|
+
emit(data, json_output=json_output, message=f"Reached {data['end_effector']['position']} with {data['ik']['error_meters']:.4f} m error.")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@main.command("fk")
|
|
173
|
+
@click.option("joints", "--joints", callback=parse_joints, help="Optional comma-separated angles in degrees.")
|
|
174
|
+
@click.option("json_output", "--json", is_flag=True)
|
|
175
|
+
def fk_command(joints: list[float] | None, json_output: bool) -> None:
|
|
176
|
+
"""Compute forward kinematics without changing saved state."""
|
|
177
|
+
with current_engine() as engine:
|
|
178
|
+
data = {"ok": True, "model": engine.model.id, "forward_kinematics": engine.forward_kinematics(joints)}
|
|
179
|
+
emit(data, json_output=json_output)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@main.command("reset")
|
|
183
|
+
@click.option("model", "--model", type=click.Choice([item["id"] for item in list_models()]), default="jolly6")
|
|
184
|
+
@click.option("scene", "--scene", type=click.Choice([item["id"] for item in list_scenes()]), default="empty")
|
|
185
|
+
@click.option("json_output", "--json", is_flag=True)
|
|
186
|
+
def reset_command(model: str, scene: str, json_output: bool) -> None:
|
|
187
|
+
"""Reset the world and home the selected robot."""
|
|
188
|
+
with current_engine(model=model, scene=scene, recover=True) as engine:
|
|
189
|
+
data = engine.reset()
|
|
190
|
+
save_state(data)
|
|
191
|
+
emit(data, json_output=json_output, message=f"Reset {model} in scene '{scene}'.")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@main.command("render")
|
|
195
|
+
@click.option("json_output", "--json", is_flag=True, help="Return pose points instead of a table.")
|
|
196
|
+
def render_command(json_output: bool) -> None:
|
|
197
|
+
"""Render a compact terminal status table and stick figure."""
|
|
198
|
+
with current_engine() as engine:
|
|
199
|
+
state = engine.state()
|
|
200
|
+
points = engine.joint_positions()
|
|
201
|
+
state = persist(engine)
|
|
202
|
+
if json_output:
|
|
203
|
+
emit({"ok": True, "state": state, "skeleton_points": points}, json_output=True)
|
|
204
|
+
return
|
|
205
|
+
table = Table(title=f"Jolly · {state['model']['name']} · {state['scene']}")
|
|
206
|
+
table.add_column("Joint")
|
|
207
|
+
table.add_column("Angle", justify="right")
|
|
208
|
+
table.add_column("Limits", justify="right")
|
|
209
|
+
for joint in state["joints"]:
|
|
210
|
+
limits = joint["limits_degrees"]
|
|
211
|
+
table.add_row(joint["name"], f"{joint['position_degrees']:.2f}°", f"{limits[0]:.0f}° … {limits[1]:.0f}°")
|
|
212
|
+
console.print(table)
|
|
213
|
+
ee = state["end_effector"]["position"]
|
|
214
|
+
collision = "COLLISION" if state["collisions"]["collision"] else "clear"
|
|
215
|
+
console.print(f"base ●──●──●──●──●──◆ tool xyz=({ee[0]:.3f}, {ee[1]:.3f}, {ee[2]:.3f})")
|
|
216
|
+
console.print(f"gripper={state['gripper']:.2f} held={state['held_object'] or 'none'} collision={collision}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@main.group("scene")
|
|
220
|
+
def scene_group() -> None:
|
|
221
|
+
"""List or load simulation scenes."""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@scene_group.command("list")
|
|
225
|
+
@click.option("json_output", "--json", is_flag=True)
|
|
226
|
+
def scene_list_command(json_output: bool) -> None:
|
|
227
|
+
data = {"ok": True, "scenes": list_scenes()}
|
|
228
|
+
emit(data, json_output=json_output)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@scene_group.command("load")
|
|
232
|
+
@click.argument("scene_id", type=click.Choice([item["id"] for item in list_scenes()]))
|
|
233
|
+
@click.option("json_output", "--json", is_flag=True)
|
|
234
|
+
def scene_load_command(scene_id: str, json_output: bool) -> None:
|
|
235
|
+
get_scene(scene_id)
|
|
236
|
+
saved = load_state()
|
|
237
|
+
model = str(saved["model"]["id"]) if saved else "jolly6"
|
|
238
|
+
with current_engine(model=model, scene=scene_id) as engine:
|
|
239
|
+
data = engine.reset()
|
|
240
|
+
save_state(data)
|
|
241
|
+
emit(data, json_output=json_output, message=f"Loaded scene '{scene_id}'.")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@main.group("challenge")
|
|
245
|
+
def challenge_group() -> None:
|
|
246
|
+
"""Run repeatable manipulation challenges."""
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@challenge_group.command("list")
|
|
250
|
+
@click.option("json_output", "--json", is_flag=True)
|
|
251
|
+
def challenge_list_command(json_output: bool) -> None:
|
|
252
|
+
emit({"ok": True, "challenges": list_challenges()}, json_output=json_output)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@challenge_group.command("start")
|
|
256
|
+
@click.argument("challenge_id", type=click.Choice([item["id"] for item in list_challenges()]))
|
|
257
|
+
@click.option("model", "--model", type=click.Choice([item["id"] for item in list_models()]), default="jolly6")
|
|
258
|
+
@click.option("json_output", "--json", is_flag=True)
|
|
259
|
+
def challenge_start_command(challenge_id: str, model: str, json_output: bool) -> None:
|
|
260
|
+
challenge = get_challenge(challenge_id)
|
|
261
|
+
with current_engine(model=model, scene=challenge.scene) as engine:
|
|
262
|
+
state = engine.reset()
|
|
263
|
+
state["active_challenge"] = challenge_id
|
|
264
|
+
state["challenge_commands"] = 0
|
|
265
|
+
save_state(state)
|
|
266
|
+
emit({"ok": True, "challenge": vars(challenge), "state": state}, json_output=json_output, message=f"Started '{challenge.name}' in scene '{challenge.scene}'.")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@challenge_group.command("status")
|
|
270
|
+
@click.option("challenge_id", "--challenge", type=click.Choice([item["id"] for item in list_challenges()]))
|
|
271
|
+
@click.option("json_output", "--json", is_flag=True)
|
|
272
|
+
def challenge_status_command(challenge_id: str | None, json_output: bool) -> None:
|
|
273
|
+
saved = load_state() or {}
|
|
274
|
+
selected = challenge_id or saved.get("active_challenge")
|
|
275
|
+
if not selected:
|
|
276
|
+
raise ConfigurationError("No active challenge. Run 'jolly challenge start ID'.")
|
|
277
|
+
with current_engine() as engine:
|
|
278
|
+
state = persist(engine)
|
|
279
|
+
data = evaluate(str(selected), state)
|
|
280
|
+
emit(data, json_output=json_output, message=f"{data['challenge']['name']}: {'PASS' if data['success'] else 'IN PROGRESS'} · score={data['score']}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@main.command("benchmark")
|
|
284
|
+
@click.option("json_output", "--json", is_flag=True)
|
|
285
|
+
def benchmark_command(json_output: bool) -> None:
|
|
286
|
+
"""Run the deterministic local engine benchmark."""
|
|
287
|
+
data = run_benchmark()
|
|
288
|
+
emit(data, json_output=json_output, message=f"Benchmark score: {data['score']:.1f}/100 ({data['passed']}/{data['total']} checks)")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@main.command("viewer")
|
|
292
|
+
@click.option("model", "--model", type=click.Choice([item["id"] for item in list_models()]))
|
|
293
|
+
@click.option("scene", "--scene", type=click.Choice([item["id"] for item in list_scenes()]))
|
|
294
|
+
def viewer_command(model: str | None, scene: str | None) -> None:
|
|
295
|
+
"""Open the native PyBullet 3D window."""
|
|
296
|
+
with current_engine(model=model, scene=scene, gui=True) as engine:
|
|
297
|
+
click.echo("Viewer opened. Close the PyBullet window to exit.")
|
|
298
|
+
engine.run_viewer()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@main.command("serve")
|
|
302
|
+
@click.option("host", "--host", default="127.0.0.1", show_default=True)
|
|
303
|
+
@click.option("port", "--port", type=click.IntRange(1, 65535), default=8765, show_default=True)
|
|
304
|
+
@click.option("unsafe_public", "--unsafe-public", is_flag=True, help="Allow a non-loopback bind without authentication.")
|
|
305
|
+
def serve_command(host: str, port: int, unsafe_public: bool) -> None:
|
|
306
|
+
"""Start the optional local web viewer and JSON API."""
|
|
307
|
+
if host not in {"127.0.0.1", "localhost", "::1"} and not unsafe_public:
|
|
308
|
+
raise click.ClickException("Non-loopback web control requires explicit --unsafe-public.")
|
|
309
|
+
try:
|
|
310
|
+
import uvicorn
|
|
311
|
+
except ImportError as exc:
|
|
312
|
+
raise click.ClickException("Install web support with: pip install 'jolly-cli[web]'") from exc
|
|
313
|
+
uvicorn.run("jolly.web.app:app", host=host, port=port, reload=False)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
main()
|
jolly/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Physics and simulation primitives for Jolly."""
|
jolly/core/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class JollyError(Exception):
|
|
2
|
+
"""Base error that can be safely returned to CLI clients."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigurationError(JollyError):
|
|
6
|
+
"""Raised for an invalid robot or scene configuration."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MotionError(JollyError):
|
|
10
|
+
"""Raised when a requested motion is invalid or unsafe."""
|