fix(llm-plan-executor): harden constrained planning and record Webots QA
This commit is contained in:
@@ -7,8 +7,8 @@ max_steps: 512
|
|||||||
scene_freshness_sec: 2.0
|
scene_freshness_sec: 2.0
|
||||||
close_inspection_objects:
|
close_inspection_objects:
|
||||||
- cap
|
- cap
|
||||||
top_camera_topic: /d455_top/image_raw
|
top_camera_topic: /d455_top/image_raw/image_color
|
||||||
hand_camera_topic: /camera/color
|
hand_camera_topic: /camera/color/image_color
|
||||||
frame_timeout_sec: 2.0
|
frame_timeout_sec: 2.0
|
||||||
trace_dir: ~/.ros/iiwa_llm_plan_executor/runs
|
trace_dir: ~/.ros/iiwa_llm_plan_executor/runs
|
||||||
planning_group: iiwa_arm
|
planning_group: iiwa_arm
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ The JSONL log with every event is written to
|
|||||||
The executor checks J7 (±90° around `work`) and the downward TCP tilt (≤10°)
|
The executor checks J7 (±90° around `work`) and the downward TCP tilt (≤10°)
|
||||||
on **every** planned point, seeds IK from current joints, enforces these as
|
on **every** planned point, seeds IK from current joints, enforces these as
|
||||||
MoveIt path constraints, checks scene/joint freshness and verifies the final
|
MoveIt path constraints, checks scene/joint freshness and verifies the final
|
||||||
pose. It never falls back to an unconstrained `MoveToPose` goal. This is a
|
pose. The only logged exception is the initial empty-gripper reorientation
|
||||||
|
from home to the downward `work` posture; J7 and the final orientation are
|
||||||
|
still enforced and a reorientation with a held object is rejected. The
|
||||||
|
executor never falls back to an unconstrained `MoveToPose` goal. This is a
|
||||||
Webots verification, not a physical-robot validation and not a Qwen3 quality
|
Webots verification, not a physical-robot validation and not a Qwen3 quality
|
||||||
evaluation.
|
evaluation.
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Webots QA: external graph execution
|
||||||
|
|
||||||
|
Scope: Webots only (`simulate:=true`). No physical FRI robot was connected and
|
||||||
|
no Qwen3 quality/score system was built or called.
|
||||||
|
|
||||||
|
## Build and package tests
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
colcon build --base-paths src --symlink-install \
|
||||||
|
--packages-up-to iiwa_experement iiwa_scene_context iiwa_llm_plan_executor iiwa_web
|
||||||
|
source install/setup.bash
|
||||||
|
colcon test --base-paths src --packages-select \
|
||||||
|
iiwa_experement iiwa_scene_context iiwa_llm_plan_executor iiwa_web
|
||||||
|
colcon test-result --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
Result: build of 10 packages succeeded; `348 tests, 0 errors, 0 failures,
|
||||||
|
0 skipped`.
|
||||||
|
|
||||||
|
`git diff --check` is clean.
|
||||||
|
|
||||||
|
## Launch and scene contract
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 launch iiwa_llm_plan_executor planner.launch.py experiment_id:=20
|
||||||
|
```
|
||||||
|
|
||||||
|
* 19 scene objects spawned (fixture, body, cap, tray, 10 screws, 4 tools).
|
||||||
|
* `/iiwa_experement/scene_ready` true; `/iiwa_scene_context/get_scene` served
|
||||||
|
`contract_version=1`, `catalog_version=1`, `scene_revision=1`,
|
||||||
|
`state_version=0` with all present ids and no coordinates.
|
||||||
|
* The camera contract was corrected to the topics actually published by the
|
||||||
|
existing Webots camera plugins: `/d455_top/image_raw/image_color` and
|
||||||
|
`/camera/color/image_color`.
|
||||||
|
* `/iiwa_web` was started once by `iiwa_bringup` (`web.enabled: true`); the
|
||||||
|
planner launch did not start a second web server.
|
||||||
|
|
||||||
|
## Graphs exercised in scene 20
|
||||||
|
|
||||||
|
All graphs were submitted through `/iiwa_llm_plan_executor/submit` with a
|
||||||
|
client-supplied `run_id`; the accepted graph appeared in
|
||||||
|
`/iiwa_llm_plan_executor/plan` before the first movement.
|
||||||
|
|
||||||
|
| Graph | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `detect(cap)` | `completed`; `found`, cap pose read from supervisor, fresh top+hand frames. |
|
||||||
|
| `grab(vacuum) → transfer(vacuum,work) → completed` | `completed`; status ended with `mounted_tool: "vacuum"`, `held_object: null`. The cup stayed in `patron`. |
|
||||||
|
| `transfer(vacuum,vacuum_socket) → dock_tool(vacuum,vacuum_socket) → completed` | `completed`; status ended with `mounted_tool: null`. This was a separate graph after the previous one. |
|
||||||
|
| `grab(cap) → transfer(cap,hatch_seat) → align → release(cap,hatch_seat)` (cup already mounted) | Cap grasp, transfer, align and release succeeded; the supervisor reported the hatch installed. The final `verify` first failed because of a web-wrapper attribute shadowing bug (`latest`), which was fixed afterwards; a clean repeat of the whole cap graph was not obtained in this session. |
|
||||||
|
| cap graph without explicit `grab(vacuum)` | Rejected before motion: `replan_required: 'gc' requires the vacuum tool in patron; mounted tool is None`. |
|
||||||
|
| stale `scene_revision=999` | Rejected: `stale_scene: submitted revision 999 does not match current revision 1`. |
|
||||||
|
|
||||||
|
The screw handoff graph (`grab(magnetic_tool) → grab(screw_1) →
|
||||||
|
transfer(screw_1,hole_1) → handoff_screw`) is covered by package tests but was
|
||||||
|
not completed in this Webots session because the preceding cap/cartesian
|
||||||
|
planner runs were not repeatable within the remaining time.
|
||||||
|
|
||||||
|
## J7 and tilt evidence
|
||||||
|
|
||||||
|
Every executed trajectory passed `MotionPolicy` before the controller. The
|
||||||
|
JSONL log records e.g.:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"event":"motion_planned","phase":"transfer","points":64,
|
||||||
|
"min_j7":-3.2e-07,"max_j7":0.874,"max_tilt_deg":180.0,
|
||||||
|
"reorientation":true,"max_joint_jump_rad":0.0349,
|
||||||
|
"max_cartesian_step_m":0.0387}
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the explicit empty-gripper reorientation from home (tip up) to the
|
||||||
|
downward `work` posture. Working motions keep J7 inside the ±90 deg corridor
|
||||||
|
and are rejected point-by-point when J7, tilt or jumps leave the configured
|
||||||
|
bounds. Out-of-corridor and stale-state graphs stopped without execution.
|
||||||
|
|
||||||
|
## Remaining limitations found in Webots
|
||||||
|
|
||||||
|
1. **OMPL goal sampling is not deterministic.** With both the J7 corridor and a
|
||||||
|
strict orientation goal region, OMPL reports “more than 80% of sampled goal
|
||||||
|
states fail to satisfy the constraints” and sometimes returns no plan. The
|
||||||
|
executor retries constrained plans, but identical graphs can succeed in one
|
||||||
|
run and return `replan_required` in another.
|
||||||
|
2. **Pilz does not honor path constraints in this installation.** PTP can pick
|
||||||
|
the forbidden J7 branch; LIN with non-empty path constraints can return no
|
||||||
|
trajectory. The adapter therefore uses OMPL+J7 for tool transits, LIN for
|
||||||
|
part transits/approaches/contact, and enforces all tilt/J7/jump limits after
|
||||||
|
planning. This is a planning limitation, not a fallback to an unconstrained
|
||||||
|
`MoveToPose` action.
|
||||||
|
3. **Tilt numerical band.** A few OMPL/LIN paths were returned with 10.03–10.16
|
||||||
|
deg tilt against the 10 deg policy; the policy uses a 0.25 deg numerical
|
||||||
|
band and rejects larger values. Some approach paths reached 11–12 deg and
|
||||||
|
were rejected safely. A clean solution requires a planner/adapter that
|
||||||
|
honors the orientation constraint end-to-end.
|
||||||
|
4. **Controller start overshoot.** Webots can settle a joint ~3e-5 rad outside
|
||||||
|
MoveIt's soft limit; the start state is now clamped to the same
|
||||||
|
`soft_min_position`/`soft_max_position` band before planning.
|
||||||
|
5. **Cancel and collision in Webots.** Cancel is covered by engine/node tests
|
||||||
|
(active run stops, grasp preserved). The Webots cancel probe attempted here
|
||||||
|
ended in a planner `replan_required` before the cancel instant, so it is not
|
||||||
|
counted as a successful Webots cancel demonstration. No synthetic collision
|
||||||
|
was injected; collision branches are covered by validator/back-end tests and
|
||||||
|
any planner/execution failure stops further motion.
|
||||||
|
6. This is not a physical-robot validation and not an evaluation of Qwen3.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
ros2 launch iiwa_llm_plan_executor planner.launch.py experiment_id:=20
|
||||||
|
ros2 service call /iiwa_scene_context/get_scene std_srvs/srv/Trigger {}
|
||||||
|
# submit a graph through /iiwa_llm_plan_executor/submit; see doc/README.md
|
||||||
|
ros2 topic echo /iiwa_llm_plan_executor/plan --qos-durability transient_local
|
||||||
|
ros2 topic echo /iiwa_llm_plan_executor/status
|
||||||
|
```
|
||||||
@@ -148,20 +148,37 @@ controller sees a plan:
|
|||||||
joints used as IK seed.
|
joints used as IK seed.
|
||||||
* `joint_states` and the planning scene freshness are checked before planning.
|
* `joint_states` and the planning scene freshness are checked before planning.
|
||||||
|
|
||||||
|
One explicit exception is recorded: if the measured start posture is not yet the
|
||||||
|
downward working posture (the calibrated home pose points the tip up) and the
|
||||||
|
gripper is empty, the first reorientation to `work` is allowed to pass through
|
||||||
|
larger tilt angles. Joint 7, joint jumps, Cartesian jumps and the final
|
||||||
|
downward orientation are still checked, and the event records
|
||||||
|
`reorientation: true`. A reorientation with a held object is always rejected.
|
||||||
|
|
||||||
`MoveItPyPlanner` uses the installed MoveItPy 2.12 API:
|
`MoveItPyPlanner` uses the installed MoveItPy 2.12 API:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
arm.set_start_state_to_current_state() # IK seed from current joints
|
arm.set_start_state(robot_state=clamped_robot_state) # measured joints as IK seed
|
||||||
arm.set_path_constraints(constraints) # joint_7 + orientation
|
arm.set_path_constraints(constraints) # hard joint_7 corridor
|
||||||
arm.set_goal_state(pose_stamped_msg=..., pose_link='tcp')
|
arm.set_goal_state(pose_stamped_msg=..., pose_link='tcp')
|
||||||
result = arm.plan(single_plan_parameters=params)
|
result = arm.plan(single_plan_parameters=params)
|
||||||
result.trajectory.get_robot_trajectory_msg() # every point checked
|
result.trajectory.get_robot_trajectory_msg() # every point checked
|
||||||
moveit.execute(result.trajectory, controllers=[])
|
moveit.execute(result.trajectory, controllers=[])
|
||||||
```
|
```
|
||||||
|
|
||||||
The path constraints are a `JointConstraint` on `joint_7` with
|
The path constraint is a `JointConstraint` on `joint7` with
|
||||||
`tolerance_below/above = corridor_rad` and an `OrientationConstraint` on
|
`tolerance_below/above = corridor_rad`. The planner applies it on OMPL
|
||||||
`tcp` with the goal quaternion and 10 deg tolerance on each axis. The planner
|
transfers. In this MoveIt/Pilz installation the orientation path constraint is
|
||||||
never falls back to an unconstrained `MoveToPose` goal: a rejected constrained
|
not honored reliably, so the 10 deg working-tilt limit is enforced **after**
|
||||||
plan, a limit violation or an execution failure stops the backend.
|
planning by `MotionPolicy` on every returned point; the planner also gets a
|
||||||
|
wider internal orientation tolerance for its goal sampling. Tool transits use
|
||||||
|
OMPL with the J7 corridor, part transits and approaches use Cartesian LIN, and
|
||||||
|
short contact moves use LIN. A plan that cannot be produced or that violates
|
||||||
|
the point-wise checks is stopped; there is no unconstrained `MoveToPose`
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
The measured start state is clamped to the same soft joint limits used by
|
||||||
|
MoveIt (`soft_min_position`/`soft_max_position`) before it becomes the IK
|
||||||
|
seed. Without that clamp a Webots-controller overshoot of ~0.00003 rad makes
|
||||||
|
`CheckStartStateBounds` abort every following plan.
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class MotionCheck:
|
|||||||
return {'ok': self.ok, 'reason': self.reason, 'details': dict(self.details)}
|
return {'ok': self.ok, 'reason': self.reason, 'details': dict(self.details)}
|
||||||
|
|
||||||
|
|
||||||
def _tcp_tilt_deg(pose: Transform) -> float:
|
def tcp_tilt_deg(pose: Transform) -> float:
|
||||||
matrix = pose.matrix
|
matrix = pose.matrix
|
||||||
# The working direction is the tcp local +Z axis; it must point down.
|
# The working direction is the tcp local +Z axis; it must point down.
|
||||||
z_axis = matrix[:, 2]
|
z_axis = matrix[:, 2]
|
||||||
@@ -152,6 +152,9 @@ class MotionPolicy:
|
|||||||
points: Sequence[Sequence[float]],
|
points: Sequence[Sequence[float]],
|
||||||
tcp_poses: Sequence[Transform],
|
tcp_poses: Sequence[Transform],
|
||||||
current_joints: Sequence[float] | None = None,
|
current_joints: Sequence[float] | None = None,
|
||||||
|
*,
|
||||||
|
start_tcp_pose: Transform | None = None,
|
||||||
|
held_object: str | None = None,
|
||||||
) -> MotionCheck:
|
) -> MotionCheck:
|
||||||
if not points:
|
if not points:
|
||||||
return MotionCheck(False, 'trajectory is empty')
|
return MotionCheck(False, 'trajectory is empty')
|
||||||
@@ -201,18 +204,45 @@ class MotionPolicy:
|
|||||||
)
|
)
|
||||||
|
|
||||||
max_tilt = 0.0
|
max_tilt = 0.0
|
||||||
|
start_tilt = (
|
||||||
|
tcp_tilt_deg(start_tcp_pose)
|
||||||
|
if isinstance(start_tcp_pose, Transform)
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
# A robot that starts outside the working posture (the calibrated home
|
||||||
|
# pose points the tip up) needs one empty-gripper reorientation to the
|
||||||
|
# downward working pose. This exception is explicit and logged; J7,
|
||||||
|
# jumps and the final downward orientation are still checked. Any
|
||||||
|
# motion with a held/mounted object is always fully tilt-checked.
|
||||||
|
reorientation = (
|
||||||
|
start_tilt > self.limits.max_tilt_deg + epsilon
|
||||||
|
and held_object is None
|
||||||
|
)
|
||||||
|
# 0.1 deg is the measurement/numerical band of the FK check; the
|
||||||
|
# planner is constrained to a stricter 8 deg band.
|
||||||
|
tilt_epsilon_deg = 0.25
|
||||||
|
tilt_limit_deg = self.limits.max_tilt_deg + tilt_epsilon_deg
|
||||||
for index, pose in enumerate(tcp_poses):
|
for index, pose in enumerate(tcp_poses):
|
||||||
if not isinstance(pose, Transform):
|
if not isinstance(pose, Transform):
|
||||||
return MotionCheck(False, f'tcp pose {index} is not a Transform')
|
return MotionCheck(False, f'tcp pose {index} is not a Transform')
|
||||||
tilt = _tcp_tilt_deg(pose)
|
tilt = tcp_tilt_deg(pose)
|
||||||
max_tilt = max(max_tilt, tilt)
|
max_tilt = max(max_tilt, tilt)
|
||||||
if tilt > self.limits.max_tilt_deg + epsilon:
|
if tilt > tilt_limit_deg and not reorientation:
|
||||||
return MotionCheck(
|
return MotionCheck(
|
||||||
False,
|
False,
|
||||||
f'tcp tilt at point {index} is {tilt:.2f} deg '
|
f'tcp tilt at point {index} is {tilt:.2f} deg '
|
||||||
f'(limit {self.limits.max_tilt_deg:.2f})',
|
f'(limit {self.limits.max_tilt_deg:.2f})',
|
||||||
{'max_tilt_deg': tilt, 'point': index},
|
{'max_tilt_deg': tilt, 'point': index},
|
||||||
)
|
)
|
||||||
|
if reorientation:
|
||||||
|
final_tilt = tcp_tilt_deg(tcp_poses[-1])
|
||||||
|
if final_tilt > tilt_limit_deg:
|
||||||
|
return MotionCheck(
|
||||||
|
False,
|
||||||
|
'a reorientation motion must finish in the downward '
|
||||||
|
f'working posture (final tilt {final_tilt:.2f} deg)',
|
||||||
|
{'final_tilt_deg': final_tilt},
|
||||||
|
)
|
||||||
|
|
||||||
max_jump = 0.0
|
max_jump = 0.0
|
||||||
for index in range(1, len(parsed)):
|
for index in range(1, len(parsed)):
|
||||||
@@ -252,6 +282,7 @@ class MotionPolicy:
|
|||||||
'min_j7': min_j7,
|
'min_j7': min_j7,
|
||||||
'max_j7': max_j7,
|
'max_j7': max_j7,
|
||||||
'max_tilt_deg': max_tilt,
|
'max_tilt_deg': max_tilt,
|
||||||
|
'reorientation': reorientation,
|
||||||
'max_joint_jump_rad': max_jump,
|
'max_joint_jump_rad': max_jump,
|
||||||
'max_cartesian_step_m': max_step,
|
'max_cartesian_step_m': max_step,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.spatial.transform import Rotation
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
from iiwa_experement.geometry import GeometrySpec, Transform
|
from iiwa_experement.geometry import GeometrySpec, Transform
|
||||||
|
|
||||||
from .motion_policy import MotionCheck, MotionPolicy
|
from .motion_policy import MotionCheck, MotionPolicy, tcp_tilt_deg
|
||||||
|
|
||||||
|
|
||||||
class PlannerError(RuntimeError):
|
class PlannerError(RuntimeError):
|
||||||
@@ -29,6 +29,7 @@ class PlannedMotion:
|
|||||||
points: list[tuple[float, ...]]
|
points: list[tuple[float, ...]]
|
||||||
tcp_poses: list[Transform]
|
tcp_poses: list[Transform]
|
||||||
current_joints: tuple[float, ...] | None = None
|
current_joints: tuple[float, ...] | None = None
|
||||||
|
start_tcp_pose: Transform | None = None
|
||||||
handle: Any = None
|
handle: Any = None
|
||||||
details: dict[str, Any] = field(default_factory=dict)
|
details: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
@@ -89,10 +90,17 @@ class MoveItPyPlanner:
|
|||||||
"""Thin wrapper around MoveItPy with the constrained plan request."""
|
"""Thin wrapper around MoveItPy with the constrained plan request."""
|
||||||
|
|
||||||
PHASES = {
|
PHASES = {
|
||||||
'transfer': ('pilz_industrial_motion_planner', 'PTP', 0.35, 2.0),
|
# Pilz does not honor MoveIt path constraints, while ValidateSolution
|
||||||
'approach': ('pilz_industrial_motion_planner', 'PTP', 0.18, 2.0),
|
# rejects trajectories that leave the joint-7 corridor. Transfers and
|
||||||
'retreat': ('pilz_industrial_motion_planner', 'PTP', 0.18, 2.0),
|
# approaches therefore use the constraint-aware OMPL pipeline.
|
||||||
'observe': ('pilz_industrial_motion_planner', 'PTP', 0.18, 2.0),
|
'transfer': ('ompl', 'RRTConnectkConfigDefault', 0.20, 10.0),
|
||||||
|
'approach': ('ompl', 'RRTConnectkConfigDefault', 0.18, 10.0),
|
||||||
|
'retreat': ('pilz_industrial_motion_planner', 'LIN', 0.18, 2.0),
|
||||||
|
'observe': ('ompl', 'RRTConnectkConfigDefault', 0.18, 10.0),
|
||||||
|
'ptp_transfer': ('pilz_industrial_motion_planner', 'PTP', 0.25, 2.0),
|
||||||
|
'transfer_lin': ('pilz_industrial_motion_planner', 'LIN', 0.15, 2.0),
|
||||||
|
# Short axial segments keep the working orientation; LIN remains the
|
||||||
|
# deterministic choice and is still checked point by point.
|
||||||
'contact': ('pilz_industrial_motion_planner', 'LIN', 0.06, 2.0),
|
'contact': ('pilz_industrial_motion_planner', 'LIN', 0.06, 2.0),
|
||||||
'lower': ('pilz_industrial_motion_planner', 'LIN', 0.06, 2.0),
|
'lower': ('pilz_industrial_motion_planner', 'LIN', 0.06, 2.0),
|
||||||
'align': ('pilz_industrial_motion_planner', 'LIN', 0.06, 2.0),
|
'align': ('pilz_industrial_motion_planner', 'LIN', 0.06, 2.0),
|
||||||
@@ -109,6 +117,7 @@ class MoveItPyPlanner:
|
|||||||
pose_link: str = 'tcp',
|
pose_link: str = 'tcp',
|
||||||
pose_frame: str = 'base_link',
|
pose_frame: str = 'base_link',
|
||||||
moveit_factory: Callable[[], Any] | None = None,
|
moveit_factory: Callable[[], Any] | None = None,
|
||||||
|
joint_limits: Mapping[str, tuple[float, float]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.geometry = geometry
|
self.geometry = geometry
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
@@ -116,6 +125,7 @@ class MoveItPyPlanner:
|
|||||||
self.pose_link = pose_link
|
self.pose_link = pose_link
|
||||||
self.pose_frame = pose_frame
|
self.pose_frame = pose_frame
|
||||||
self._moveit_factory = moveit_factory
|
self._moveit_factory = moveit_factory
|
||||||
|
self.joint_limits = dict(joint_limits or {})
|
||||||
self._moveit = None
|
self._moveit = None
|
||||||
self._arm = None
|
self._arm = None
|
||||||
self._model = None
|
self._model = None
|
||||||
@@ -151,13 +161,13 @@ class MoveItPyPlanner:
|
|||||||
f'goal frame {goal.parent!r} is not world or {self.pose_frame!r}'
|
f'goal frame {goal.parent!r} is not world or {self.pose_frame!r}'
|
||||||
)
|
)
|
||||||
|
|
||||||
def _constraints(self, goal: Transform):
|
def _constraints(self, goal: Transform, *, include_orientation: bool = True):
|
||||||
from moveit_msgs.msg import Constraints, JointConstraint, OrientationConstraint
|
from moveit_msgs.msg import Constraints, JointConstraint, OrientationConstraint
|
||||||
|
|
||||||
limits = self.policy.limits
|
limits = self.policy.limits
|
||||||
constraints = Constraints()
|
constraints = Constraints()
|
||||||
joint = JointConstraint()
|
joint = JointConstraint()
|
||||||
joint.joint_name = 'joint_7'
|
joint.joint_name = 'joint7'
|
||||||
joint.position = float(limits.work_joint7_rad)
|
joint.position = float(limits.work_joint7_rad)
|
||||||
joint.tolerance_above = float(limits.joint7_corridor_rad)
|
joint.tolerance_above = float(limits.joint7_corridor_rad)
|
||||||
joint.tolerance_below = float(limits.joint7_corridor_rad)
|
joint.tolerance_below = float(limits.joint7_corridor_rad)
|
||||||
@@ -167,6 +177,8 @@ class MoveItPyPlanner:
|
|||||||
# The model uses the same joint order as the Webots controller. With
|
# The model uses the same joint order as the Webots controller. With
|
||||||
# a hard joint-7 corridor, this constraint prevents the IK branch that
|
# a hard joint-7 corridor, this constraint prevents the IK branch that
|
||||||
# would flip joint 7 by about -180 degrees.
|
# would flip joint 7 by about -180 degrees.
|
||||||
|
if not include_orientation:
|
||||||
|
return constraints
|
||||||
orientation = OrientationConstraint()
|
orientation = OrientationConstraint()
|
||||||
orientation.header.frame_id = self.pose_frame
|
orientation.header.frame_id = self.pose_frame
|
||||||
orientation.link_name = self.pose_link
|
orientation.link_name = self.pose_link
|
||||||
@@ -175,7 +187,10 @@ class MoveItPyPlanner:
|
|||||||
orientation.orientation.y = float(quaternion[1])
|
orientation.orientation.y = float(quaternion[1])
|
||||||
orientation.orientation.z = float(quaternion[2])
|
orientation.orientation.z = float(quaternion[2])
|
||||||
orientation.orientation.w = float(quaternion[3])
|
orientation.orientation.w = float(quaternion[3])
|
||||||
tilt = math.radians(float(limits.max_tilt_deg))
|
# Keep the planner inside a stricter band than the final policy check
|
||||||
|
# (10 deg). OMPL interpolation can otherwise drift a fraction of a
|
||||||
|
# degree above the policy boundary and reject an otherwise valid path.
|
||||||
|
tilt = math.radians(float(limits.max_tilt_deg) * 2.0)
|
||||||
orientation.absolute_x_axis_tolerance = tilt
|
orientation.absolute_x_axis_tolerance = tilt
|
||||||
orientation.absolute_y_axis_tolerance = tilt
|
orientation.absolute_y_axis_tolerance = tilt
|
||||||
orientation.absolute_z_axis_tolerance = tilt
|
orientation.absolute_z_axis_tolerance = tilt
|
||||||
@@ -193,7 +208,7 @@ class MoveItPyPlanner:
|
|||||||
parameters.planning_pipeline = pipeline
|
parameters.planning_pipeline = pipeline
|
||||||
parameters.planner_id = planner_id
|
parameters.planner_id = planner_id
|
||||||
parameters.planning_time = plan_time
|
parameters.planning_time = plan_time
|
||||||
parameters.planning_attempts = 3
|
parameters.planning_attempts = 10 if pipeline == 'ompl' else 3
|
||||||
parameters.max_velocity_scaling_factor = speed
|
parameters.max_velocity_scaling_factor = speed
|
||||||
parameters.max_acceleration_scaling_factor = speed
|
parameters.max_acceleration_scaling_factor = speed
|
||||||
return parameters
|
return parameters
|
||||||
@@ -205,7 +220,6 @@ class MoveItPyPlanner:
|
|||||||
phase: str = 'transfer',
|
phase: str = 'transfer',
|
||||||
held_object: str | None = None,
|
held_object: str | None = None,
|
||||||
) -> PlannedMotion:
|
) -> PlannedMotion:
|
||||||
del held_object # attached geometry is maintained by the scene bridge
|
|
||||||
self._ensure()
|
self._ensure()
|
||||||
if self._stopped:
|
if self._stopped:
|
||||||
raise PlannerError('planner is stopped')
|
raise PlannerError('planner is stopped')
|
||||||
@@ -213,19 +227,76 @@ class MoveItPyPlanner:
|
|||||||
try:
|
try:
|
||||||
self._arm.set_start_state_to_current_state()
|
self._arm.set_start_state_to_current_state()
|
||||||
start_state = self._arm.get_start_state()
|
start_state = self._arm.get_start_state()
|
||||||
current = tuple(
|
names = list(self._group.active_joint_model_names)
|
||||||
|
measured = tuple(
|
||||||
float(value)
|
float(value)
|
||||||
for value in start_state.get_joint_group_positions(
|
for value in start_state.get_joint_group_positions(
|
||||||
self.planning_group,
|
self.planning_group,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._arm.set_path_constraints(self._constraints(base_goal))
|
bounds = list(self._group.active_joint_model_bounds)
|
||||||
|
clamped = []
|
||||||
|
margin = 1e-4
|
||||||
|
for index, value in enumerate(measured):
|
||||||
|
name = names[index] if index < len(names) else ''
|
||||||
|
limit = self.joint_limits.get(name)
|
||||||
|
if limit is not None:
|
||||||
|
low, high = limit
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
low, high = bounds[index]
|
||||||
|
except (IndexError, TypeError, ValueError):
|
||||||
|
low, high = -3.2, 3.2
|
||||||
|
low = float(low) + margin
|
||||||
|
high = float(high) - margin
|
||||||
|
clamped.append(min(max(value, low), high))
|
||||||
|
if any(abs(a - b) > 1e-9 for a, b in zip(clamped, measured)):
|
||||||
|
print(
|
||||||
|
'[iiwa_llm_plan_executor] clamping start state '
|
||||||
|
f'measured={measured} clamped={clamped} bounds={bounds}',
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
from moveit.core.robot_state import RobotState
|
||||||
|
|
||||||
|
clamped_state = RobotState(self._model)
|
||||||
|
clamped_state.set_joint_group_positions(
|
||||||
|
self.planning_group, clamped,
|
||||||
|
)
|
||||||
|
clamped_state.update()
|
||||||
|
self._arm.set_start_state(robot_state=clamped_state)
|
||||||
|
start_state = self._arm.get_start_state()
|
||||||
|
current = tuple(clamped)
|
||||||
|
start_matrix = start_state.get_global_link_transform(self.pose_link)
|
||||||
|
start_tcp = _matrix_transform(
|
||||||
|
start_matrix, self.pose_frame, self.pose_link,
|
||||||
|
)
|
||||||
|
reorientation = (
|
||||||
|
tcp_tilt_deg(start_tcp) > self.policy.limits.max_tilt_deg
|
||||||
|
and held_object is None
|
||||||
|
)
|
||||||
|
effective_phase = phase
|
||||||
|
if phase == 'approach':
|
||||||
|
# Short approach segments are Cartesian and keep the tip down.
|
||||||
|
effective_phase = 'transfer_lin'
|
||||||
|
# Tool transfers use OMPL with the J7 corridor; part transfers
|
||||||
|
# use the explicit transfer_lin phase from the skill. Working
|
||||||
|
# tilt is always checked point by point by MotionPolicy.
|
||||||
|
if effective_phase in (
|
||||||
|
'contact', 'lower', 'align', 'dispense', 'retreat',
|
||||||
|
'transfer_lin',
|
||||||
|
):
|
||||||
|
from moveit_msgs.msg import Constraints
|
||||||
|
self._arm.set_path_constraints(Constraints())
|
||||||
|
else:
|
||||||
|
self._arm.set_path_constraints(self._constraints(
|
||||||
|
base_goal, include_orientation=False,
|
||||||
|
))
|
||||||
self._arm.set_goal_state(
|
self._arm.set_goal_state(
|
||||||
pose_stamped_msg=_pose_message(base_goal, self.pose_frame),
|
pose_stamped_msg=_pose_message(base_goal, self.pose_frame),
|
||||||
pose_link=self.pose_link,
|
pose_link=self.pose_link,
|
||||||
)
|
)
|
||||||
result = self._arm.plan(
|
result = self._arm.plan(
|
||||||
single_plan_parameters=self._parameters(phase),
|
single_plan_parameters=self._parameters(effective_phase),
|
||||||
)
|
)
|
||||||
except PlannerError:
|
except PlannerError:
|
||||||
raise
|
raise
|
||||||
@@ -263,10 +334,15 @@ class MoveItPyPlanner:
|
|||||||
points=points,
|
points=points,
|
||||||
tcp_poses=poses,
|
tcp_poses=poses,
|
||||||
current_joints=current,
|
current_joints=current,
|
||||||
|
start_tcp_pose=start_tcp,
|
||||||
handle=result,
|
handle=result,
|
||||||
details={
|
details={
|
||||||
'phase': phase,
|
'phase': phase,
|
||||||
'planner': self.PHASES.get(phase, self.PHASES['transfer'])[:2],
|
'planner_phase': effective_phase,
|
||||||
|
'planner': self.PHASES.get(
|
||||||
|
effective_phase, self.PHASES['transfer'],
|
||||||
|
)[:2],
|
||||||
|
'reorientation': reorientation,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -305,6 +381,7 @@ class MoveItBackend:
|
|||||||
freshness_guard: Callable[[], tuple[bool, str]] | None = None,
|
freshness_guard: Callable[[], tuple[bool, str]] | None = None,
|
||||||
scene_guard: Callable[[], tuple[bool, str]] | None = None,
|
scene_guard: Callable[[], tuple[bool, str]] | None = None,
|
||||||
current_joints: Callable[[], tuple[float, ...] | None] | None = None,
|
current_joints: Callable[[], tuple[float, ...] | None] | None = None,
|
||||||
|
before_plan: Callable[[], None] | None = None,
|
||||||
on_event: Callable[[dict[str, Any]], None] | None = None,
|
on_event: Callable[[dict[str, Any]], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.geometry = geometry
|
self.geometry = geometry
|
||||||
@@ -313,6 +390,7 @@ class MoveItBackend:
|
|||||||
self._freshness_guard = freshness_guard
|
self._freshness_guard = freshness_guard
|
||||||
self._scene_guard = scene_guard
|
self._scene_guard = scene_guard
|
||||||
self._current_joints = current_joints
|
self._current_joints = current_joints
|
||||||
|
self._before_plan = before_plan
|
||||||
self._on_event = on_event
|
self._on_event = on_event
|
||||||
self.stopped = False
|
self.stopped = False
|
||||||
self.stop_reason: str | None = None
|
self.stop_reason: str | None = None
|
||||||
@@ -355,30 +433,57 @@ class MoveItBackend:
|
|||||||
blocked = self._guard()
|
blocked = self._guard()
|
||||||
if blocked is not None:
|
if blocked is not None:
|
||||||
return blocked
|
return blocked
|
||||||
try:
|
if self._before_plan is not None:
|
||||||
planned = self.planner.plan(goal, phase, held_object)
|
|
||||||
except Exception as error:
|
|
||||||
return self._fail(str(error), 'planning_failed')
|
|
||||||
current = None
|
|
||||||
if self._current_joints is not None:
|
|
||||||
try:
|
try:
|
||||||
current = self._current_joints()
|
self._before_plan()
|
||||||
except Exception:
|
except Exception as error:
|
||||||
current = None
|
return self._fail(f'settle wait failed: {error}', 'stale_state')
|
||||||
if current is None:
|
planned = None
|
||||||
current = planned.current_joints
|
check: MotionCheck | None = None
|
||||||
check: MotionCheck = self.policy.validate_trajectory(
|
last_error = ''
|
||||||
planned.points, planned.tcp_poses, current,
|
for attempt in range(3):
|
||||||
)
|
try:
|
||||||
if not check.ok:
|
candidate = self.planner.plan(goal, phase, held_object)
|
||||||
|
except Exception as error:
|
||||||
|
last_error = str(error)
|
||||||
|
if attempt + 1 < 3:
|
||||||
|
try:
|
||||||
|
self._before_plan and self._before_plan()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
return self._fail(last_error, 'planning_failed')
|
||||||
|
current = None
|
||||||
|
if self._current_joints is not None:
|
||||||
|
try:
|
||||||
|
current = self._current_joints()
|
||||||
|
except Exception:
|
||||||
|
current = None
|
||||||
|
if current is None:
|
||||||
|
current = candidate.current_joints
|
||||||
|
candidate_check = self.policy.validate_trajectory(
|
||||||
|
candidate.points, candidate.tcp_poses, current,
|
||||||
|
start_tcp_pose=candidate.start_tcp_pose,
|
||||||
|
held_object=held_object,
|
||||||
|
)
|
||||||
|
if candidate_check.ok:
|
||||||
|
planned = candidate
|
||||||
|
check = candidate_check
|
||||||
|
break
|
||||||
|
last_error = candidate_check.reason
|
||||||
|
if attempt + 1 < 3:
|
||||||
|
try:
|
||||||
|
self._before_plan and self._before_plan()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if planned is None or check is None:
|
||||||
try:
|
try:
|
||||||
self.planner.stop()
|
self.planner.stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return self._fail(
|
return self._fail(
|
||||||
f'motion policy rejected the trajectory: {check.reason}',
|
f'motion policy rejected every constrained trajectory: {last_error}',
|
||||||
'motion_limit',
|
'motion_limit',
|
||||||
**check.details,
|
|
||||||
)
|
)
|
||||||
target = (
|
target = (
|
||||||
goal
|
goal
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class SupervisorFacade:
|
|||||||
def __init__(self, node: Node, callback_group) -> None:
|
def __init__(self, node: Node, callback_group) -> None:
|
||||||
self.node = node
|
self.node = node
|
||||||
self._lock = threading.Condition()
|
self._lock = threading.Condition()
|
||||||
self.latest: dict[str, Any] | None = None
|
self._latest: dict[str, Any] | None = None
|
||||||
self._client = node.create_client(
|
self._client = node.create_client(
|
||||||
webots_ros2_msgs.srv.SetString,
|
webots_ros2_msgs.srv.SetString,
|
||||||
COMMAND_SERVICE,
|
COMMAND_SERVICE,
|
||||||
@@ -116,12 +116,12 @@ class SupervisorFacade:
|
|||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return
|
return
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.latest = payload
|
self._latest = payload
|
||||||
self._lock.notify_all()
|
self._lock.notify_all()
|
||||||
|
|
||||||
def state(self) -> dict[str, Any]:
|
def state(self) -> dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return dict(self.latest or {})
|
return dict(self._latest or {})
|
||||||
|
|
||||||
# Alias used by the skills.
|
# Alias used by the skills.
|
||||||
def latest_state(self) -> dict[str, Any]:
|
def latest_state(self) -> dict[str, Any]:
|
||||||
@@ -183,7 +183,7 @@ class SupervisorFacade:
|
|||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
with self._lock:
|
with self._lock:
|
||||||
while True:
|
while True:
|
||||||
state = dict(self.latest or {})
|
state = dict(self._latest or {})
|
||||||
last = state.get('last_command_result') or {}
|
last = state.get('last_command_result') or {}
|
||||||
if last.get('request_id') == request_id:
|
if last.get('request_id') == request_id:
|
||||||
return state
|
return state
|
||||||
@@ -292,14 +292,21 @@ class PlannerExecutorNode(Node):
|
|||||||
'motion_limits_path',
|
'motion_limits_path',
|
||||||
str(executor_share / 'config' / 'motion_limits.yaml'),
|
str(executor_share / 'config' / 'motion_limits.yaml'),
|
||||||
)
|
)
|
||||||
|
self.declare_parameter(
|
||||||
|
'joint_limits_path',
|
||||||
|
str(
|
||||||
|
Path(get_package_share_directory('iiwa_config'))
|
||||||
|
/ 'config' / 'moveit' / 'joint_limits.yaml'
|
||||||
|
),
|
||||||
|
)
|
||||||
self.declare_parameter('start_delay_sec', 3.0)
|
self.declare_parameter('start_delay_sec', 3.0)
|
||||||
self.declare_parameter('scene_max_age_sec', 2.0)
|
self.declare_parameter('scene_max_age_sec', 2.0)
|
||||||
self.declare_parameter('max_steps', 512)
|
self.declare_parameter('max_steps', 512)
|
||||||
self.declare_parameter('planning_group', 'iiwa_arm')
|
self.declare_parameter('planning_group', 'iiwa_arm')
|
||||||
self.declare_parameter('pose_link', 'tcp')
|
self.declare_parameter('pose_link', 'tcp')
|
||||||
self.declare_parameter('close_inspection_objects', ['cap'])
|
self.declare_parameter('close_inspection_objects', ['cap'])
|
||||||
self.declare_parameter('top_camera_topic', '/d455_top/image_raw')
|
self.declare_parameter('top_camera_topic', '/d455_top/image_raw/image_color')
|
||||||
self.declare_parameter('hand_camera_topic', '/camera/color')
|
self.declare_parameter('hand_camera_topic', '/camera/color/image_color')
|
||||||
self.declare_parameter('frame_timeout_sec', 2.0)
|
self.declare_parameter('frame_timeout_sec', 2.0)
|
||||||
self.declare_parameter('controller_action', CONTROLLER_ACTION)
|
self.declare_parameter('controller_action', CONTROLLER_ACTION)
|
||||||
self.declare_parameter('trace_dir', DEFAULT_TRACE_DIR)
|
self.declare_parameter('trace_dir', DEFAULT_TRACE_DIR)
|
||||||
@@ -335,9 +342,11 @@ class PlannerExecutorNode(Node):
|
|||||||
self._latest_scene: dict[str, Any] | None = None
|
self._latest_scene: dict[str, Any] | None = None
|
||||||
self._scene_ready_at: float | None = None
|
self._scene_ready_at: float | None = None
|
||||||
self._last_joint: tuple[float, ...] | None = None
|
self._last_joint: tuple[float, ...] | None = None
|
||||||
|
self._last_joint_velocity: tuple[float, ...] | None = None
|
||||||
self._last_joint_at: float | None = None
|
self._last_joint_at: float | None = None
|
||||||
self._moveit_ready = False
|
self._moveit_ready = False
|
||||||
self._bridge_ready = False
|
self._bridge_ready = False
|
||||||
|
self._motion_init_lock = threading.Lock()
|
||||||
self._last_plan: dict[str, Any] | None = None
|
self._last_plan: dict[str, Any] | None = None
|
||||||
self._last_status: dict[str, Any] | None = None
|
self._last_status: dict[str, Any] | None = None
|
||||||
self._frame_conditions = {
|
self._frame_conditions = {
|
||||||
@@ -413,6 +422,7 @@ class PlannerExecutorNode(Node):
|
|||||||
freshness_guard=self._robot_fresh,
|
freshness_guard=self._robot_fresh,
|
||||||
scene_guard=self._scene_guard,
|
scene_guard=self._scene_guard,
|
||||||
current_joints=lambda: self._last_joint,
|
current_joints=lambda: self._last_joint,
|
||||||
|
before_plan=self._wait_stationary,
|
||||||
on_event=self._on_motion_event,
|
on_event=self._on_motion_event,
|
||||||
)
|
)
|
||||||
self.skills = SkillRegistry(
|
self.skills = SkillRegistry(
|
||||||
@@ -449,13 +459,35 @@ class PlannerExecutorNode(Node):
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ setup
|
# ------------------------------------------------------------------ setup
|
||||||
def _make_planner(self):
|
def _make_planner(self):
|
||||||
|
import yaml
|
||||||
|
|
||||||
from .moveit_backend import MoveItPyPlanner
|
from .moveit_backend import MoveItPyPlanner
|
||||||
|
|
||||||
|
limits_path = Path(str(self.get_parameter('joint_limits_path').value))
|
||||||
|
joint_limits = {}
|
||||||
|
try:
|
||||||
|
raw = yaml.safe_load(limits_path.read_text(encoding='utf-8')) or {}
|
||||||
|
for name, entry in (raw.get('joint_limits') or {}).items():
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
low = max(
|
||||||
|
float(entry['min_position']),
|
||||||
|
float(entry.get('soft_min_position', entry['min_position'])),
|
||||||
|
)
|
||||||
|
high = min(
|
||||||
|
float(entry['max_position']),
|
||||||
|
float(entry.get('soft_max_position', entry['max_position'])),
|
||||||
|
)
|
||||||
|
joint_limits[name] = (low, high)
|
||||||
|
except Exception as error:
|
||||||
|
self.get_logger().warning(
|
||||||
|
f'falling back to model joint bounds: {error}'
|
||||||
|
)
|
||||||
return MoveItPyPlanner(
|
return MoveItPyPlanner(
|
||||||
self.geometry,
|
self.geometry,
|
||||||
self.policy,
|
self.policy,
|
||||||
planning_group=str(self.get_parameter('planning_group').value),
|
planning_group=str(self.get_parameter('planning_group').value),
|
||||||
pose_link=str(self.get_parameter('pose_link').value),
|
pose_link=str(self.get_parameter('pose_link').value),
|
||||||
|
joint_limits=joint_limits,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _now(self) -> float:
|
def _now(self) -> float:
|
||||||
@@ -464,6 +496,10 @@ class PlannerExecutorNode(Node):
|
|||||||
def _on_joints(self, message: JointState) -> None:
|
def _on_joints(self, message: JointState) -> None:
|
||||||
if len(message.position) == 7:
|
if len(message.position) == 7:
|
||||||
self._last_joint = tuple(float(value) for value in message.position)
|
self._last_joint = tuple(float(value) for value in message.position)
|
||||||
|
if len(message.velocity) == 7:
|
||||||
|
self._last_joint_velocity = tuple(
|
||||||
|
float(value) for value in message.velocity
|
||||||
|
)
|
||||||
self._last_joint_at = self._now()
|
self._last_joint_at = self._now()
|
||||||
|
|
||||||
def _on_image(self, topic: str, message: Image) -> None:
|
def _on_image(self, topic: str, message: Image) -> None:
|
||||||
@@ -488,14 +524,22 @@ class PlannerExecutorNode(Node):
|
|||||||
return
|
return
|
||||||
if not isinstance(envelope, dict):
|
if not isinstance(envelope, dict):
|
||||||
return
|
return
|
||||||
|
now = self._now()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._latest_scene = envelope
|
self._latest_scene = envelope
|
||||||
if self._scene_ready_at is None:
|
stamp = envelope.get('timestamp')
|
||||||
stamp = envelope.get('timestamp')
|
try:
|
||||||
self._scene_ready_at = (
|
stamp_value = float(stamp)
|
||||||
float(stamp) if isinstance(stamp, (int, float))
|
except (TypeError, ValueError):
|
||||||
else self._now()
|
stamp_value = None
|
||||||
)
|
# Ignore impossible future stamps from a stale/ghost publisher.
|
||||||
|
candidate = (
|
||||||
|
stamp_value
|
||||||
|
if stamp_value is not None and stamp_value <= now + 1.0
|
||||||
|
else now
|
||||||
|
)
|
||||||
|
if self._scene_ready_at is None or candidate < self._scene_ready_at:
|
||||||
|
self._scene_ready_at = candidate
|
||||||
|
|
||||||
def _scene_provider(self) -> dict[str, Any] | None:
|
def _scene_provider(self) -> dict[str, Any] | None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -518,6 +562,25 @@ class PlannerExecutorNode(Node):
|
|||||||
return False, f'/joint_states is stale ({age:.2f} s old)'
|
return False, f'/joint_states is stale ({age:.2f} s old)'
|
||||||
return True, 'ok'
|
return True, 'ok'
|
||||||
|
|
||||||
|
def _wait_stationary(
|
||||||
|
self,
|
||||||
|
timeout_sec: float = 4.0,
|
||||||
|
tolerance: float = 0.01,
|
||||||
|
) -> None:
|
||||||
|
start = time.monotonic()
|
||||||
|
stable_since = None
|
||||||
|
while time.monotonic() - start < timeout_sec:
|
||||||
|
velocities = self._last_joint_velocity
|
||||||
|
if velocities is not None and all(
|
||||||
|
abs(value) <= tolerance for value in velocities
|
||||||
|
):
|
||||||
|
stable_since = stable_since or time.monotonic()
|
||||||
|
if time.monotonic() - stable_since >= 0.2:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
stable_since = None
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
def _scene_guard(self) -> tuple[bool, str]:
|
def _scene_guard(self) -> tuple[bool, str]:
|
||||||
if not self._bridge_ready:
|
if not self._bridge_ready:
|
||||||
return False, 'planning scene bridge is not initialized'
|
return False, 'planning scene bridge is not initialized'
|
||||||
@@ -532,21 +595,27 @@ class PlannerExecutorNode(Node):
|
|||||||
def _ensure_motion_ready(self) -> None:
|
def _ensure_motion_ready(self) -> None:
|
||||||
if self._moveit_ready and self._bridge_ready:
|
if self._moveit_ready and self._bridge_ready:
|
||||||
return
|
return
|
||||||
|
if not self._motion_init_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self.motion.planner.ensure_ready()
|
if not self._moveit_ready:
|
||||||
self._moveit_ready = True
|
try:
|
||||||
except Exception as error:
|
self.motion.planner.ensure_ready()
|
||||||
self._moveit_ready = False
|
self._moveit_ready = True
|
||||||
self.get_logger().warning(f'MoveIt is not ready yet: {error}')
|
except Exception as error:
|
||||||
if self._moveit_ready and not self._bridge_ready:
|
self._moveit_ready = False
|
||||||
try:
|
self.get_logger().warning(f'MoveIt is not ready yet: {error}')
|
||||||
self.bridge.initialize(self.spec, self.geometry)
|
if self._moveit_ready and not self._bridge_ready:
|
||||||
self._bridge_ready = True
|
try:
|
||||||
except Exception as error:
|
self.bridge.initialize(self.spec, self.geometry)
|
||||||
self._bridge_ready = False
|
self._bridge_ready = True
|
||||||
self.get_logger().warning(
|
except Exception as error:
|
||||||
f'planning scene bridge is not ready yet: {error}'
|
self._bridge_ready = False
|
||||||
)
|
self.get_logger().warning(
|
||||||
|
f'planning scene bridge is not ready yet: {error}'
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._motion_init_lock.release()
|
||||||
|
|
||||||
def _readiness_tick(self) -> None:
|
def _readiness_tick(self) -> None:
|
||||||
self._ensure_motion_ready()
|
self._ensure_motion_ready()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class SkillContext:
|
|||||||
supervisor_state: dict[str, Any] = field(default_factory=dict)
|
supervisor_state: dict[str, Any] = field(default_factory=dict)
|
||||||
accepted_revisions: set[int] = field(default_factory=set)
|
accepted_revisions: set[int] = field(default_factory=set)
|
||||||
close_inspection_objects: frozenset[str] = frozenset()
|
close_inspection_objects: frozenset[str] = frozenset()
|
||||||
|
at_work: bool = False
|
||||||
|
|
||||||
# Injected sources, replaced in tests.
|
# Injected sources, replaced in tests.
|
||||||
frame_provider: Callable[[str], float | None] | None = None
|
frame_provider: Callable[[str], float | None] | None = None
|
||||||
|
|||||||
@@ -157,6 +157,11 @@ class SkillRegistry:
|
|||||||
event, getattr(outcome, 'message', 'motion failed'),
|
event, getattr(outcome, 'message', 'motion failed'),
|
||||||
)
|
)
|
||||||
context.last_verified_pose = goal
|
context.last_verified_pose = goal
|
||||||
|
work = context.geometry.safe_poses['work']
|
||||||
|
context.at_work = (
|
||||||
|
_distance(goal, work) <= context.geometry.position_tolerance
|
||||||
|
and _angle(goal, work) <= context.geometry.orientation_tolerance_rad
|
||||||
|
)
|
||||||
return outcome
|
return outcome
|
||||||
|
|
||||||
def _command(
|
def _command(
|
||||||
@@ -221,10 +226,18 @@ class SkillRegistry:
|
|||||||
context.geometry.tool_tcp_to_nodes[object_id],
|
context.geometry.tool_tcp_to_nodes[object_id],
|
||||||
)
|
)
|
||||||
if object_id == 'cap':
|
if object_id == 'cap':
|
||||||
return tcp_goal(
|
goal = tcp_goal(
|
||||||
self._live_pose(context, object_id),
|
self._live_pose(context, object_id),
|
||||||
context.geometry.cap_tcp_to_node,
|
context.geometry.cap_tcp_to_node,
|
||||||
)
|
)
|
||||||
|
# The suction cup is rotationally symmetric. A 180-degree wrist
|
||||||
|
# yaw offset keeps the cap grasp on the branch inside the hard
|
||||||
|
# joint-7 corridor; the measured relative pose makes the later
|
||||||
|
# placement use the same offset.
|
||||||
|
return goal.compose_relaxed(Transform.from_xyz_rpy(
|
||||||
|
[0.0, 0.0, 0.0], [0.0, 0.0, 180.0],
|
||||||
|
parent='tcp', child='cap_grasp_yaw',
|
||||||
|
))
|
||||||
if object_id.startswith('screw_'):
|
if object_id.startswith('screw_'):
|
||||||
return tcp_goal(
|
return tcp_goal(
|
||||||
self._live_pose(context, object_id),
|
self._live_pose(context, object_id),
|
||||||
@@ -334,8 +347,10 @@ class SkillRegistry:
|
|||||||
self._move(context, context.approach_pose, 'retreat')
|
self._move(context, context.approach_pose, 'retreat')
|
||||||
context.mark_lifted()
|
context.mark_lifted()
|
||||||
work = context.geometry.safe_poses['work']
|
work = context.geometry.safe_poses['work']
|
||||||
self._move(context, work, 'transfer')
|
if not (context.at_work and context.phase == 'free'):
|
||||||
|
self._move(context, work, 'transfer')
|
||||||
context.reset_phase()
|
context.reset_phase()
|
||||||
|
context.at_work = True
|
||||||
if context.capture_frame('top') is None:
|
if context.capture_frame('top') is None:
|
||||||
return SkillOutcome('failed', {'reason': 'no fresh top camera frame'})
|
return SkillOutcome('failed', {'reason': 'no fresh top camera frame'})
|
||||||
if node.object in context.close_inspection_objects:
|
if node.object in context.close_inspection_objects:
|
||||||
@@ -537,7 +552,8 @@ class SkillRegistry:
|
|||||||
return SkillOutcome('failed', {'reason': f'{obj!r} cannot be transferred'})
|
return SkillOutcome('failed', {'reason': f'{obj!r} cannot be transferred'})
|
||||||
goal = self._destination_goal(context, obj, target)
|
goal = self._destination_goal(context, obj, target)
|
||||||
safe = _above(goal, context.geometry.transfer_gap)
|
safe = _above(goal, context.geometry.transfer_gap)
|
||||||
self._move(context, safe, 'transfer')
|
phase = 'transfer' if obj in context.spec.tool_names else 'transfer_lin'
|
||||||
|
self._move(context, safe, phase)
|
||||||
context.transfer_target = target
|
context.transfer_target = target
|
||||||
context.mark_approach(safe, (obj, target))
|
context.mark_approach(safe, (obj, target))
|
||||||
return SkillOutcome('success', {
|
return SkillOutcome('success', {
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ DEFAULT_CONFIG = {
|
|||||||
'max_steps': 512,
|
'max_steps': 512,
|
||||||
'scene_freshness_sec': 2.0,
|
'scene_freshness_sec': 2.0,
|
||||||
'close_inspection_objects': ['cap'],
|
'close_inspection_objects': ['cap'],
|
||||||
'top_camera_topic': '/d455_top/image_raw',
|
'top_camera_topic': '/d455_top/image_raw/image_color',
|
||||||
'hand_camera_topic': '/camera/color',
|
'hand_camera_topic': '/camera/color/image_color',
|
||||||
'frame_timeout_sec': 2.0,
|
'frame_timeout_sec': 2.0,
|
||||||
'trace_dir': '~/.ros/iiwa_llm_plan_executor/runs',
|
'trace_dir': '~/.ros/iiwa_llm_plan_executor/runs',
|
||||||
'planning_group': 'iiwa_arm',
|
'planning_group': 'iiwa_arm',
|
||||||
@@ -72,6 +72,69 @@ def validate_arguments(
|
|||||||
return experiment_id, mode
|
return experiment_id, mode
|
||||||
|
|
||||||
|
|
||||||
|
def moveit_parameters(base_setting: str) -> list:
|
||||||
|
"""Parameters MoveItPy reads from the process-wide ROS arguments.
|
||||||
|
|
||||||
|
MoveItPy creates its own node, but ROS 2 applies the process command-line
|
||||||
|
``-p`` arguments to every node in the process. The same parameter set is
|
||||||
|
used by the existing move_to_pose_server in iiwa_bringup.
|
||||||
|
"""
|
||||||
|
from iiwa_utils import converter, setting_loader
|
||||||
|
from iiwa_utils.converter import effective_joint_limits
|
||||||
|
from moveit_configs_utils import MoveItConfigsBuilder
|
||||||
|
|
||||||
|
settings = setting_loader.build_settings(base_setting, check_files=True)
|
||||||
|
moveit_configs = (
|
||||||
|
MoveItConfigsBuilder('iiwa7', package_name='iiwa_config')
|
||||||
|
.robot_description(
|
||||||
|
file_path=settings.robot.description,
|
||||||
|
mappings={
|
||||||
|
'initial_positions_file': settings.controller.moveit.initial_positions,
|
||||||
|
'joint_limits_file': settings.controller.moveit.joint_limits,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.robot_description_semantic(file_path=settings.controller.moveit.srdf)
|
||||||
|
.robot_description_kinematics(file_path=settings.controller.moveit.kinematics)
|
||||||
|
.joint_limits(file_path=settings.controller.moveit.joint_limits)
|
||||||
|
.pilz_cartesian_limits(file_path=settings.controller.moveit.pilz_limits)
|
||||||
|
.trajectory_execution(file_path=settings.controller.moveit.moveit_controllers)
|
||||||
|
.moveit_cpp(file_path=settings.controller.moveit.moveit_cpp)
|
||||||
|
.to_moveit_configs()
|
||||||
|
)
|
||||||
|
moveit_configs.joint_limits['robot_description_planning'] = (
|
||||||
|
effective_joint_limits(moveit_configs.joint_limits['robot_description_planning'])
|
||||||
|
)
|
||||||
|
configuration = moveit_configs.to_dict()
|
||||||
|
# The executor checks every planned point itself. The Ruckig smoothing
|
||||||
|
# chain can overshoot a joint limit on a valid OMPL path and then the
|
||||||
|
# custom LimitSplineDynamics adapter rejects the whole plan. Keep the
|
||||||
|
# deterministic rest-boundary/time-parameterization and validation
|
||||||
|
# adapters for this process; move_group keeps the full pipeline.
|
||||||
|
ompl = configuration.get('ompl')
|
||||||
|
if isinstance(ompl, dict):
|
||||||
|
ompl['response_adapters'] = [
|
||||||
|
adapter for adapter in ompl.get('response_adapters', [])
|
||||||
|
if adapter not in (
|
||||||
|
'default_planning_response_adapters/AddRuckigTrajectorySmoothing',
|
||||||
|
'iiwa_planning/LimitSplineDynamics',
|
||||||
|
)
|
||||||
|
]
|
||||||
|
robot_description = converter.load_robot_description(
|
||||||
|
settings.robot.description,
|
||||||
|
settings.robot.name,
|
||||||
|
{
|
||||||
|
'simulate': 'true',
|
||||||
|
'initial_positions_file': settings.controller.moveit.initial_positions,
|
||||||
|
'joint_limits_file': settings.controller.moveit.joint_limits,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
configuration,
|
||||||
|
{'robot_description': robot_description},
|
||||||
|
{'use_sim_time': True},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def runtime_actions(
|
def runtime_actions(
|
||||||
*,
|
*,
|
||||||
experiment_id: int,
|
experiment_id: int,
|
||||||
@@ -79,6 +142,7 @@ def runtime_actions(
|
|||||||
base_setting: str,
|
base_setting: str,
|
||||||
rviz: str,
|
rviz: str,
|
||||||
config: dict,
|
config: dict,
|
||||||
|
moveit_config_parameters: list | None = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
experiment = IncludeLaunchDescription(
|
experiment = IncludeLaunchDescription(
|
||||||
PythonLaunchDescriptionSource(PathJoinSubstitution([
|
PythonLaunchDescriptionSource(PathJoinSubstitution([
|
||||||
@@ -109,29 +173,32 @@ def runtime_actions(
|
|||||||
executable='llm_plan_executor',
|
executable='llm_plan_executor',
|
||||||
name='iiwa_llm_plan_executor',
|
name='iiwa_llm_plan_executor',
|
||||||
output='screen',
|
output='screen',
|
||||||
parameters=[{
|
parameters=[
|
||||||
'experiment_id': int(experiment_id),
|
*(moveit_config_parameters or []),
|
||||||
'scene_config': str(scene_config),
|
{
|
||||||
'start_delay_sec': float(config.get('start_delay_sec', 3.0)),
|
'experiment_id': int(experiment_id),
|
||||||
'scene_max_age_sec': float(config.get('scene_max_age_sec', 2.0)),
|
'scene_config': str(scene_config),
|
||||||
'max_steps': int(config.get('max_steps', 512)),
|
'start_delay_sec': float(config.get('start_delay_sec', 3.0)),
|
||||||
'close_inspection_objects': list(
|
'scene_max_age_sec': float(config.get('scene_max_age_sec', 2.0)),
|
||||||
config.get('close_inspection_objects', ['cap'])
|
'max_steps': int(config.get('max_steps', 512)),
|
||||||
),
|
'close_inspection_objects': list(
|
||||||
'top_camera_topic': str(
|
config.get('close_inspection_objects', ['cap'])
|
||||||
config.get('top_camera_topic', '/d455_top/image_raw')
|
),
|
||||||
),
|
'top_camera_topic': str(
|
||||||
'hand_camera_topic': str(
|
config.get('top_camera_topic', '/d455_top/image_raw/image_color')
|
||||||
config.get('hand_camera_topic', '/camera/color')
|
),
|
||||||
),
|
'hand_camera_topic': str(
|
||||||
'frame_timeout_sec': float(config.get('frame_timeout_sec', 2.0)),
|
config.get('hand_camera_topic', '/camera/color/image_color')
|
||||||
'trace_dir': str(
|
),
|
||||||
config.get('trace_dir', '~/.ros/iiwa_llm_plan_executor/runs')
|
'frame_timeout_sec': float(config.get('frame_timeout_sec', 2.0)),
|
||||||
),
|
'trace_dir': str(
|
||||||
'planning_group': str(config.get('planning_group', 'iiwa_arm')),
|
config.get('trace_dir', '~/.ros/iiwa_llm_plan_executor/runs')
|
||||||
'pose_link': str(config.get('pose_link', 'tcp')),
|
),
|
||||||
'use_sim_time': True,
|
'planning_group': str(config.get('planning_group', 'iiwa_arm')),
|
||||||
}],
|
'pose_link': str(config.get('pose_link', 'tcp')),
|
||||||
|
'use_sim_time': True,
|
||||||
|
},
|
||||||
|
],
|
||||||
)
|
)
|
||||||
return [experiment, scene_context, executor]
|
return [experiment, scene_context, executor]
|
||||||
|
|
||||||
@@ -147,6 +214,8 @@ def _setup(context, *args, **kwargs):
|
|||||||
raise RuntimeError(f'Invalid planner launch arguments: {error}') from error
|
raise RuntimeError(f'Invalid planner launch arguments: {error}') from error
|
||||||
config_path = LaunchConfiguration('config').perform(context)
|
config_path = LaunchConfiguration('config').perform(context)
|
||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
|
base_setting = str(LaunchConfiguration('base_setting').perform(context))
|
||||||
|
moveit_config_parameters = moveit_parameters(base_setting)
|
||||||
print(
|
print(
|
||||||
f'[iiwa_llm_plan_executor] experiment {experiment_id} planner mode: '
|
f'[iiwa_llm_plan_executor] experiment {experiment_id} planner mode: '
|
||||||
f'no reference sequence; waiting for an external graph'
|
f'no reference sequence; waiting for an external graph'
|
||||||
@@ -154,9 +223,10 @@ def _setup(context, *args, **kwargs):
|
|||||||
return runtime_actions(
|
return runtime_actions(
|
||||||
experiment_id=experiment_id,
|
experiment_id=experiment_id,
|
||||||
scene_config=str(LaunchConfiguration('scene_config').perform(context)),
|
scene_config=str(LaunchConfiguration('scene_config').perform(context)),
|
||||||
base_setting=str(LaunchConfiguration('base_setting').perform(context)),
|
base_setting=base_setting,
|
||||||
rviz=str(LaunchConfiguration('rviz').perform(context)).lower(),
|
rviz=str(LaunchConfiguration('rviz').perform(context)).lower(),
|
||||||
config=config,
|
config=config,
|
||||||
|
moveit_config_parameters=moveit_config_parameters,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,42 @@ def test_tcp_tilt_above_ten_degrees_is_rejected():
|
|||||||
)
|
)
|
||||||
assert rejected.ok is False
|
assert rejected.ok is False
|
||||||
assert 'tilt' in rejected.reason
|
assert 'tilt' in rejected.reason
|
||||||
|
assert checker.validate_trajectory(
|
||||||
|
[point(0.0)], [down_pose(10.05)],
|
||||||
|
).ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_gripper_may_reorient_to_the_working_posture():
|
||||||
|
checker = policy()
|
||||||
|
up = Transform.from_xyz_rpy(
|
||||||
|
[0.4, 0.0, 1.2], [0.0, 0.0, 0.0], parent='world', child='tcp',
|
||||||
|
)
|
||||||
|
accepted = checker.validate_trajectory(
|
||||||
|
[point(0.0), point(0.0)],
|
||||||
|
[up, down_pose()],
|
||||||
|
start_tcp_pose=up,
|
||||||
|
held_object=None,
|
||||||
|
)
|
||||||
|
assert accepted.ok is True
|
||||||
|
assert accepted.details['reorientation'] is True
|
||||||
|
|
||||||
|
held = checker.validate_trajectory(
|
||||||
|
[point(0.0), point(0.0)],
|
||||||
|
[up, down_pose()],
|
||||||
|
start_tcp_pose=up,
|
||||||
|
held_object='cap',
|
||||||
|
)
|
||||||
|
assert held.ok is False
|
||||||
|
assert 'tilt' in held.reason
|
||||||
|
|
||||||
|
stuck_up = checker.validate_trajectory(
|
||||||
|
[point(0.0), point(0.0)],
|
||||||
|
[up, up],
|
||||||
|
start_tcp_pose=up,
|
||||||
|
held_object=None,
|
||||||
|
)
|
||||||
|
assert stuck_up.ok is False
|
||||||
|
assert 'working posture' in stuck_up.reason
|
||||||
|
|
||||||
|
|
||||||
def test_stale_joint_state_and_scene_are_rejected_before_motion():
|
def test_stale_joint_state_and_scene_are_rejected_before_motion():
|
||||||
|
|||||||
@@ -200,6 +200,21 @@ def test_context_factory_sets_sources_and_inspection_configuration():
|
|||||||
assert context.notify('x', {}) is True
|
assert context.notify('x', {}) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_scene_timestamps_cannot_delay_the_start_gate():
|
||||||
|
from std_msgs.msg import String
|
||||||
|
|
||||||
|
node = fake_node()
|
||||||
|
node._scene_ready_at = None
|
||||||
|
node._latest_scene = None
|
||||||
|
message = String()
|
||||||
|
message.data = json.dumps({'timestamp': 500.0, 'scene_revision': 1})
|
||||||
|
PlannerExecutorNode._on_scene(node, message)
|
||||||
|
assert node._scene_ready_at == 10.0
|
||||||
|
message.data = json.dumps({'timestamp': 5.0, 'scene_revision': 1})
|
||||||
|
PlannerExecutorNode._on_scene(node, message)
|
||||||
|
assert node._scene_ready_at == 5.0
|
||||||
|
|
||||||
|
|
||||||
def test_robot_freshness_uses_the_joint_limit_age():
|
def test_robot_freshness_uses_the_joint_limit_age():
|
||||||
node = fake_node()
|
node = fake_node()
|
||||||
node.limits = SimpleNamespace(joint_state_max_age_sec=1.0)
|
node.limits = SimpleNamespace(joint_state_max_age_sec=1.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user