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 ADDED
@@ -0,0 +1,4 @@
1
+ """Jolly robot-arm simulator."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
jolly/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from jolly.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1 @@
1
+ """Bundled robot models."""
@@ -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>
@@ -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."""