feat(llm-plan-executor): constrain planned trajectories by J7 and TCP tilt

This commit is contained in:
Даниил Грабарь
2026-11-11 00:17:13 +10:00
parent 9ca08c01c2
commit 30c355ba6a
6 changed files with 1080 additions and 0 deletions
@@ -0,0 +1,25 @@
# Hard limits for the constrained MoveItPy adapter. They are deliberately
# separate from the reference executor: the graph executor never falls back to
# an unconstrained MoveToPose goal.
schema_version: 1
joint7:
# The neutral work pose has joint_7 = 0 rad. The first-stage hard corridor
# is +/-90 degrees around it; the -180 degree IK branch is forbidden.
work_reference_rad: 0.0
corridor_rad: 1.5707963267948966
tcp:
# The working tip points down; a working motion may tilt at most 10 degrees.
max_tilt_deg: 10.0
trajectory:
# A near-pi jump indicates an IK branch change. Reject it before execution.
max_joint_jump_rad: 0.6
max_cartesian_jump_m: 0.15
# The planned first point must match the measured joints used as IK seed.
start_tolerance_rad: 0.35
freshness:
joint_state_max_age_sec: 1.0
scene_max_age_sec: 2.0
@@ -88,3 +88,35 @@ Important pre-movement rules:
* Screws are handed to the virtual operator at a hole; the hole stays empty
and no insertion/tightening occurs.
* No task is inferred from `experiment_id`; only the initial scene is selected.
## Constrained motion adapter
`MotionPolicy` (`config/motion_limits.yaml`) checks every point before the
controller sees a plan:
* `joint_7` must stay inside `work_reference_rad +/- corridor_rad`
(0 +/- 90 deg; boundaries inclusive).
* The tcp local +Z (working direction) must point down within
`max_tilt_deg` (10 deg).
* Consecutive points may not jump more than `max_joint_jump_rad` (0.6 rad) or
`max_cartesian_jump_m` (0.15 m); the first point must match the measured
joints used as IK seed.
* `joint_states` and the planning scene freshness are checked before planning.
`MoveItPyPlanner` uses the installed MoveItPy 2.12 API:
```python
arm.set_start_state_to_current_state() # IK seed from current joints
arm.set_path_constraints(constraints) # joint_7 + orientation
arm.set_goal_state(pose_stamped_msg=..., pose_link='tcp')
result = arm.plan(single_plan_parameters=params)
result.trajectory.get_robot_trajectory_msg() # every point checked
moveit.execute(result.trajectory, controllers=[])
```
The path constraints are a `JointConstraint` on `joint_7` with
`tolerance_below/above = corridor_rad` and an `OrientationConstraint` on
`tcp` with the goal quaternion and 10 deg tolerance on each axis. The planner
never falls back to an unconstrained `MoveToPose` goal: a rejected constrained
plan, a limit violation or an execution failure stops the backend.
@@ -0,0 +1,258 @@
"""Trajectory-level TCP/J7 limits applied before anything reaches the controller.
The MoveToPose action cannot express a joint-7 corridor or a working tilt
limit, so every plan produced by the planner adapter is checked here, point
by point, before execution.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Sequence
import numpy as np
import yaml
from iiwa_experement.geometry import Transform
class MotionPolicyError(ValueError):
"""The limits file is missing or inconsistent."""
@dataclass(frozen=True)
class MotionLimits:
work_joint7_rad: float
joint7_corridor_rad: float
max_tilt_deg: float
max_joint_jump_rad: float
max_cartesian_jump_m: float
joint_state_max_age_sec: float
scene_max_age_sec: float
start_tolerance_rad: float = 0.35
@property
def joint7_min_rad(self) -> float:
return self.work_joint7_rad - self.joint7_corridor_rad
@property
def joint7_max_rad(self) -> float:
return self.work_joint7_rad + self.joint7_corridor_rad
def _finite(value: Any, label: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise MotionPolicyError(f'{label} must be a finite number')
number = float(value)
if not math.isfinite(number):
raise MotionPolicyError(f'{label} must be a finite number')
return number
def load_limits(path: Path | str) -> MotionLimits:
source = Path(path)
if not source.is_file():
raise FileNotFoundError(f'motion limits file does not exist: {source}')
raw = yaml.safe_load(source.read_text(encoding='utf-8'))
if not isinstance(raw, dict):
raise MotionPolicyError('motion limits must be a mapping')
if raw.get('schema_version') != 1:
raise MotionPolicyError('motion limits schema_version must be 1')
joint7 = raw.get('joint7') or {}
tcp = raw.get('tcp') or {}
trajectory = raw.get('trajectory') or {}
freshness = raw.get('freshness') or {}
corridor = _finite(joint7.get('corridor_rad'), 'joint7.corridor_rad')
tilt = _finite(tcp.get('max_tilt_deg'), 'tcp.max_tilt_deg')
jump = _finite(
trajectory.get('max_joint_jump_rad'), 'trajectory.max_joint_jump_rad',
)
cartesian = _finite(
trajectory.get('max_cartesian_jump_m'),
'trajectory.max_cartesian_jump_m',
)
joint_age = _finite(
freshness.get('joint_state_max_age_sec'),
'freshness.joint_state_max_age_sec',
)
scene_age = _finite(
freshness.get('scene_max_age_sec'), 'freshness.scene_max_age_sec',
)
if corridor <= 0 or tilt <= 0 or jump <= 0 or cartesian <= 0:
raise MotionPolicyError('motion limits must be positive')
if joint_age <= 0 or scene_age <= 0:
raise MotionPolicyError('freshness limits must be positive')
return MotionLimits(
work_joint7_rad=_finite(
joint7.get('work_reference_rad'), 'joint7.work_reference_rad',
),
joint7_corridor_rad=corridor,
max_tilt_deg=tilt,
max_joint_jump_rad=jump,
max_cartesian_jump_m=cartesian,
joint_state_max_age_sec=joint_age,
scene_max_age_sec=scene_age,
start_tolerance_rad=_finite(
trajectory.get('start_tolerance_rad', 0.35),
'trajectory.start_tolerance_rad',
),
)
@dataclass(frozen=True)
class MotionCheck:
ok: bool
reason: str = ''
details: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {'ok': self.ok, 'reason': self.reason, 'details': dict(self.details)}
def _tcp_tilt_deg(pose: Transform) -> float:
matrix = pose.matrix
# The working direction is the tcp local +Z axis; it must point down.
z_axis = matrix[:, 2]
dot = float(np.clip(-z_axis[2], -1.0, 1.0))
return math.degrees(math.acos(dot))
class MotionPolicy:
def __init__(self, limits: MotionLimits) -> None:
self.limits = limits
@property
def work_joint7(self) -> float:
return self.limits.work_joint7_rad
def check_sources(
self,
*,
joint_state_age: float | None,
scene_age: float | None,
) -> MotionCheck:
for name, age, limit in (
('joint_states', joint_state_age, self.limits.joint_state_max_age_sec),
('planning_scene', scene_age, self.limits.scene_max_age_sec),
):
if age is None:
return MotionCheck(False, f'{name} freshness is unknown')
if not math.isfinite(float(age)) or float(age) < 0.0:
return MotionCheck(False, f'{name} freshness is invalid')
if float(age) > limit:
return MotionCheck(
False,
f'{name} is stale ({float(age):.2f} s > {limit:.2f} s)',
)
return MotionCheck(True, 'fresh')
def validate_trajectory(
self,
points: Sequence[Sequence[float]],
tcp_poses: Sequence[Transform],
current_joints: Sequence[float] | None = None,
) -> MotionCheck:
if not points:
return MotionCheck(False, 'trajectory is empty')
if len(points) != len(tcp_poses):
return MotionCheck(
False,
'every trajectory point needs a matching tcp pose',
)
parsed: list[tuple[float, ...]] = []
for index, point in enumerate(points):
values = tuple(float(value) for value in point)
if len(values) != 7:
return MotionCheck(
False, f'point {index} must contain 7 joint values',
)
if any(not math.isfinite(value) for value in values):
return MotionCheck(False, f'point {index} has a non-finite value')
parsed.append(values)
if current_joints is not None:
current = tuple(float(value) for value in current_joints)
if len(current) != 7:
return MotionCheck(False, 'current joint state must have 7 values')
if any(
abs(a - b) > self.limits.start_tolerance_rad
for a, b in zip(parsed[0], current)
):
return MotionCheck(
False,
'trajectory does not start at the current joint state',
)
min_j7 = min(point[6] for point in parsed)
max_j7 = max(point[6] for point in parsed)
epsilon = 1e-9
if (
min_j7 < self.limits.joint7_min_rad - epsilon
or max_j7 > self.limits.joint7_max_rad + epsilon
):
return MotionCheck(
False,
'joint7 leaves the hard corridor '
f'[{self.limits.joint7_min_rad:.3f}, '
f'{self.limits.joint7_max_rad:.3f}] rad '
f'(trajectory {min_j7:.3f}..{max_j7:.3f})',
{'min_j7': min_j7, 'max_j7': max_j7},
)
max_tilt = 0.0
for index, pose in enumerate(tcp_poses):
if not isinstance(pose, Transform):
return MotionCheck(False, f'tcp pose {index} is not a Transform')
tilt = _tcp_tilt_deg(pose)
max_tilt = max(max_tilt, tilt)
if tilt > self.limits.max_tilt_deg + epsilon:
return MotionCheck(
False,
f'tcp tilt at point {index} is {tilt:.2f} deg '
f'(limit {self.limits.max_tilt_deg:.2f})',
{'max_tilt_deg': tilt, 'point': index},
)
max_jump = 0.0
for index in range(1, len(parsed)):
jump = max(
abs(a - b) for a, b in zip(parsed[index], parsed[index - 1])
)
max_jump = max(max_jump, jump)
if jump > self.limits.max_joint_jump_rad + epsilon:
return MotionCheck(
False,
f'joint jump of {jump:.3f} rad before point {index} '
f'(limit {self.limits.max_joint_jump_rad:.3f})',
{'max_jump_rad': jump, 'point': index},
)
max_step = 0.0
for index in range(1, len(tcp_poses)):
step = float(
np.linalg.norm(
tcp_poses[index].position - tcp_poses[index - 1].position
)
)
max_step = max(max_step, step)
if step > self.limits.max_cartesian_jump_m + epsilon:
return MotionCheck(
False,
f'cartesian jump of {step:.3f} m before point {index} '
f'(limit {self.limits.max_cartesian_jump_m:.3f})',
{'max_cartesian_step_m': step, 'point': index},
)
return MotionCheck(
True,
'trajectory accepted',
{
'points': len(parsed),
'min_j7': min_j7,
'max_j7': max_j7,
'max_tilt_deg': max_tilt,
'max_joint_jump_rad': max_jump,
'max_cartesian_step_m': max_step,
},
)
@@ -0,0 +1,430 @@
"""Constrained MoveItPy adapter used by every graph skill.
The adapter differs from the existing ``MoveToPose`` action on purpose: it
seeds IK from the measured current joints, sets a joint-7 corridor and a
working-orientation path constraint, checks the *whole* planned trajectory
with :class:`MotionPolicy` and only then asks the trajectory execution manager
to run it.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Any, Callable
import numpy as np
from scipy.spatial.transform import Rotation
from iiwa_experement.geometry import GeometrySpec, Transform
from .motion_policy import MotionCheck, MotionPolicy
class PlannerError(RuntimeError):
"""The MoveIt plan could not be produced or executed."""
@dataclass
class PlannedMotion:
points: list[tuple[float, ...]]
tcp_poses: list[Transform]
current_joints: tuple[float, ...] | None = None
handle: Any = None
details: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class MotionOutcome:
success: bool
code: str
message: str
details: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {
'success': self.success,
'code': self.code,
'message': self.message,
'details': dict(self.details),
}
_POSE_TO_MATRIX = None
def _pose_message(transform: Transform, frame_id: str):
from geometry_msgs.msg import Pose, PoseStamped
quaternion = Rotation.from_matrix(transform.matrix).as_quat()
pose = Pose()
pose.position.x, pose.position.y, pose.position.z = (
float(transform.xyz[0]),
float(transform.xyz[1]),
float(transform.xyz[2]),
)
pose.orientation.x = float(quaternion[0])
pose.orientation.y = float(quaternion[1])
pose.orientation.z = float(quaternion[2])
pose.orientation.w = float(quaternion[3])
stamped = PoseStamped()
stamped.header.frame_id = frame_id
stamped.pose = pose
return stamped
def _matrix_transform(matrix, parent: str, child: str) -> Transform:
values = np.asarray(matrix, dtype=float)
return Transform(
xyz=tuple(float(value) for value in values[:3, 3]),
rotation=tuple(
tuple(float(value) for value in row)
for row in values[:3, :3]
),
parent=parent,
child=child,
)
class MoveItPyPlanner:
"""Thin wrapper around MoveItPy with the constrained plan request."""
PHASES = {
'transfer': ('pilz_industrial_motion_planner', 'PTP', 0.35, 2.0),
'approach': ('pilz_industrial_motion_planner', 'PTP', 0.18, 2.0),
'retreat': ('pilz_industrial_motion_planner', 'PTP', 0.18, 2.0),
'observe': ('pilz_industrial_motion_planner', 'PTP', 0.18, 2.0),
'contact': ('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),
'dispense': ('pilz_industrial_motion_planner', 'LIN', 0.09, 2.0),
'escape': ('ompl', 'RRTConnectkConfigDefault', 0.20, 10.0),
}
def __init__(
self,
geometry: GeometrySpec,
policy: MotionPolicy,
*,
planning_group: str = 'iiwa_arm',
pose_link: str = 'tcp',
pose_frame: str = 'base_link',
moveit_factory: Callable[[], Any] | None = None,
) -> None:
self.geometry = geometry
self.policy = policy
self.planning_group = planning_group
self.pose_link = pose_link
self.pose_frame = pose_frame
self._moveit_factory = moveit_factory
self._moveit = None
self._arm = None
self._model = None
self._group = None
self._stopped = False
# ------------------------------------------------------------------ setup
def _ensure(self) -> None:
if self._arm is not None:
return
try:
if self._moveit_factory is not None:
self._moveit = self._moveit_factory()
else:
from moveit.planning import MoveItPy
self._moveit = MoveItPy(node_name='iiwa_llm_plan_executor_moveit')
self._model = self._moveit.get_robot_model()
self._arm = self._moveit.get_planning_component(self.planning_group)
self._group = self._model.get_joint_model_group(self.planning_group)
except Exception as error:
raise PlannerError(f'MoveItPy is unavailable: {error}') from error
def _goal_in_pose_frame(self, goal: Transform) -> Transform:
if goal.parent == self.pose_frame:
return goal
if goal.parent == 'world':
return self.geometry.world_to_base.inverse().compose_relaxed(goal)
raise PlannerError(
f'goal frame {goal.parent!r} is not world or {self.pose_frame!r}'
)
def _constraints(self, goal: Transform):
from moveit_msgs.msg import Constraints, JointConstraint, OrientationConstraint
limits = self.policy.limits
constraints = Constraints()
joint = JointConstraint()
joint.joint_name = 'joint_7'
joint.position = float(limits.work_joint7_rad)
joint.tolerance_above = float(limits.joint7_corridor_rad)
joint.tolerance_below = float(limits.joint7_corridor_rad)
joint.weight = 1.0
constraints.joint_constraints.append(joint)
# The model uses the same joint order as the Webots controller. With
# a hard joint-7 corridor, this constraint prevents the IK branch that
# would flip joint 7 by about -180 degrees.
orientation = OrientationConstraint()
orientation.header.frame_id = self.pose_frame
orientation.link_name = self.pose_link
quaternion = Rotation.from_matrix(goal.matrix).as_quat()
orientation.orientation.x = float(quaternion[0])
orientation.orientation.y = float(quaternion[1])
orientation.orientation.z = float(quaternion[2])
orientation.orientation.w = float(quaternion[3])
tilt = math.radians(float(limits.max_tilt_deg))
orientation.absolute_x_axis_tolerance = tilt
orientation.absolute_y_axis_tolerance = tilt
orientation.absolute_z_axis_tolerance = tilt
orientation.weight = 1.0
constraints.orientation_constraints.append(orientation)
return constraints
def _parameters(self, phase: str):
from moveit.planning import PlanRequestParameters
pipeline, planner_id, speed, plan_time = self.PHASES.get(
phase, self.PHASES['transfer'],
)
parameters = PlanRequestParameters(self._moveit, self.planning_group)
parameters.planning_pipeline = pipeline
parameters.planner_id = planner_id
parameters.planning_time = plan_time
parameters.planning_attempts = 3
parameters.max_velocity_scaling_factor = speed
parameters.max_acceleration_scaling_factor = speed
return parameters
# ------------------------------------------------------------------ plan
def plan(
self,
goal: Transform,
phase: str = 'transfer',
held_object: str | None = None,
) -> PlannedMotion:
del held_object # attached geometry is maintained by the scene bridge
self._ensure()
if self._stopped:
raise PlannerError('planner is stopped')
base_goal = self._goal_in_pose_frame(goal)
try:
self._arm.set_start_state_to_current_state()
start_state = self._arm.get_start_state()
current = tuple(
float(value)
for value in start_state.get_joint_group_positions(
self.planning_group,
)
)
self._arm.set_path_constraints(self._constraints(base_goal))
self._arm.set_goal_state(
pose_stamped_msg=_pose_message(base_goal, self.pose_frame),
pose_link=self.pose_link,
)
result = self._arm.plan(
single_plan_parameters=self._parameters(phase),
)
except PlannerError:
raise
except Exception as error:
raise PlannerError(f'constrained planning failed: {error}') from error
if not result or result.trajectory is None:
raise PlannerError(
f'no constrained trajectory for phase {phase!r} '
f'(joint7 corridor {self.policy.limits.joint7_min_rad:.3f}..'
f'{self.policy.limits.joint7_max_rad:.3f} rad)'
)
message = result.trajectory.get_robot_trajectory_msg()
names = list(message.joint_trajectory.joint_names)
expected = list(self._group.active_joint_model_names)
if set(expected) - set(names):
raise PlannerError(
f'planned trajectory misses joints {sorted(set(expected) - set(names))}'
)
index = {name: position for position, name in enumerate(names)}
points: list[tuple[float, ...]] = []
poses: list[Transform] = []
from moveit.core.robot_state import RobotState
state = RobotState(self._model)
for waypoint in message.joint_trajectory.points:
ordered = tuple(
float(waypoint.positions[index[name]]) for name in expected
)
points.append(ordered)
state.set_joint_group_positions(self.planning_group, ordered)
state.update()
matrix = state.get_global_link_transform(self.pose_link)
poses.append(_matrix_transform(matrix, self.pose_frame, self.pose_link))
return PlannedMotion(
points=points,
tcp_poses=poses,
current_joints=current,
handle=result,
details={
'phase': phase,
'planner': self.PHASES.get(phase, self.PHASES['transfer'])[:2],
},
)
def execute(self, planned: PlannedMotion) -> bool:
self._ensure()
try:
execution = self._moveit.execute(
planned.handle.trajectory, controllers=[],
)
return bool(execution)
except Exception as error:
raise PlannerError(f'trajectory execution failed: {error}') from error
def stop(self) -> None:
self._stopped = True
if self._moveit is None:
return
try:
self._moveit.get_trajectory_execution_manager().stop_execution()
except Exception:
pass
def reset_stop(self) -> None:
self._stopped = False
class MoveItBackend:
"""The single execution path used by every skill."""
def __init__(
self,
geometry: GeometrySpec,
policy: MotionPolicy,
*,
planner: Any | None = None,
freshness_guard: Callable[[], tuple[bool, str]] | None = None,
scene_guard: Callable[[], tuple[bool, str]] | None = None,
current_joints: Callable[[], tuple[float, ...] | None] | None = None,
on_event: Callable[[dict[str, Any]], None] | None = None,
) -> None:
self.geometry = geometry
self.policy = policy
self.planner = planner or MoveItPyPlanner(geometry, policy)
self._freshness_guard = freshness_guard
self._scene_guard = scene_guard
self._current_joints = current_joints
self._on_event = on_event
self.stopped = False
self.stop_reason: str | None = None
def _emit(self, **payload) -> None:
if self._on_event is not None:
self._on_event(payload)
def _guard(self) -> MotionOutcome | None:
if self.stopped:
return MotionOutcome(
False, 'stopped',
f'motion is blocked after stop: {self.stop_reason}',
)
for name, guard in (
('joint_states', self._freshness_guard),
('planning_scene', self._scene_guard),
):
if guard is None:
continue
try:
ok, message = guard()
except Exception as error:
return self._fail(f'{name} guard failed: {error}', 'stale_state')
if not ok:
return self._fail(message, 'stale_state')
return None
def _fail(self, message: str, code: str = 'failed', **details) -> MotionOutcome:
self.stop(message)
self._emit(event='motion_failed', code=code, message=message, **details)
return MotionOutcome(False, code, message, details)
def plan_and_execute(
self,
goal: Transform,
phase: str = 'transfer',
held_object: str | None = None,
) -> MotionOutcome:
blocked = self._guard()
if blocked is not None:
return blocked
try:
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:
current = self._current_joints()
except Exception:
current = None
if current is None:
current = planned.current_joints
check: MotionCheck = self.policy.validate_trajectory(
planned.points, planned.tcp_poses, current,
)
if not check.ok:
try:
self.planner.stop()
except Exception:
pass
return self._fail(
f'motion policy rejected the trajectory: {check.reason}',
'motion_limit',
**check.details,
)
target = (
goal
if goal.parent == 'base_link'
else self.geometry.world_to_base.inverse().compose_relaxed(goal)
)
final = planned.tcp_poses[-1]
distance = float(np.linalg.norm(final.position - target.position))
cosine = (np.trace(final.matrix.T @ target.matrix) - 1.0) / 2.0
angle = float(math.acos(max(-1.0, min(1.0, cosine))))
if (
distance > self.geometry.position_tolerance
or angle > self.geometry.orientation_tolerance_rad
):
return self._fail(
'planned final tcp is outside the goal tolerance '
f'({distance:.4f} m, {angle:.4f} rad)',
'goal_mismatch',
)
self._emit(
event='motion_planned', phase=phase, held_object=held_object,
**check.details,
)
try:
executed = self.planner.execute(planned)
except Exception as error:
return self._fail(str(error), 'execution_failed')
if not executed:
return self._fail('trajectory execution did not succeed', 'execution_failed')
self._emit(event='motion_executed', phase=phase, **check.details)
return MotionOutcome(
True,
'ok',
'motion executed',
{'phase': phase, **check.details},
)
def stop(self, reason: str = 'stop requested') -> None:
self.stopped = True
if self.stop_reason is None:
self.stop_reason = reason
try:
self.planner.stop()
except Exception:
pass
def reset_stop(self) -> None:
self.stopped = False
self.stop_reason = None
reset = getattr(self.planner, 'reset_stop', None)
if reset is not None:
reset()
@@ -0,0 +1,136 @@
"""Trajectory-level limits: J7 corridor, tilt, jumps and freshness."""
from __future__ import annotations
import math
from pathlib import Path
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from iiwa_experement.geometry import Transform
from iiwa_llm_plan_executor.motion_policy import MotionPolicy, load_limits
PACKAGE = Path(__file__).resolve().parents[1]
LIMITS = PACKAGE / 'config' / 'motion_limits.yaml'
BASE = (0.0, 0.0, 0.0, -1.57, 0.0, 1.57, 0.0)
def policy() -> MotionPolicy:
return MotionPolicy(load_limits(LIMITS))
def point(j7: float, base=BASE):
values = list(base)
values[6] = j7
return tuple(values)
def down_pose(tilt_deg: float = 0.0, xyz=(0.4, 0.0, 1.2)) -> Transform:
rotation = (
Rotation.from_euler('x', 180.0, degrees=True).as_matrix()
@ Rotation.from_euler('y', tilt_deg, degrees=True).as_matrix()
)
return Transform(
xyz=tuple(float(value) for value in xyz),
rotation=tuple(tuple(float(value) for value in row) for row in rotation),
parent='world',
child='tcp',
)
def test_work_joint7_is_zero_and_corridor_is_inclusive():
limits = load_limits(LIMITS)
assert limits.work_joint7_rad == 0.0
assert limits.joint7_min_rad == -math.pi / 2
assert limits.joint7_max_rad == math.pi / 2
checker = MotionPolicy(limits)
for boundary in (-math.pi / 2, math.pi / 2):
result = checker.validate_trajectory(
[point(boundary)], [down_pose()],
)
assert result.ok, result.reason
def test_point_outside_the_corridor_is_rejected():
result = policy().validate_trajectory(
[point(-math.pi / 2 - 0.01)], [down_pose()],
)
assert result.ok is False
assert 'joint7' in result.reason
def test_intermediate_point_outside_the_corridor_rejects_the_whole_path():
result = policy().validate_trajectory(
[point(-1.2), point(-1.6), point(-1.2)],
[down_pose(), down_pose(), down_pose()],
)
assert result.ok is False
assert 'joint7' in result.reason
def test_near_pi_joint_jump_is_rejected():
result = policy().validate_trajectory(
[point(-1.4), point(1.4)],
[down_pose(), down_pose()],
)
assert result.ok is False
assert 'jump' in result.reason
def test_tcp_tilt_above_ten_degrees_is_rejected():
checker = policy()
assert checker.validate_trajectory(
[point(0.0)], [down_pose(9.9)],
).ok
rejected = checker.validate_trajectory(
[point(0.0)], [down_pose(10.5)],
)
assert rejected.ok is False
assert 'tilt' in rejected.reason
def test_stale_joint_state_and_scene_are_rejected_before_motion():
checker = policy()
assert checker.check_sources(
joint_state_age=0.1, scene_age=0.2,
).ok
assert checker.check_sources(
joint_state_age=1.5, scene_age=0.2,
).ok is False
assert checker.check_sources(
joint_state_age=0.1, scene_age=2.5,
).ok is False
assert checker.check_sources(
joint_state_age=None, scene_age=0.2,
).ok is False
def test_malformed_or_jumping_trajectories_are_rejected():
checker = policy()
assert checker.validate_trajectory(
[point(0.0)[:6]], [down_pose()],
).ok is False
assert checker.validate_trajectory(
[point(0.0)], [],
).ok is False
assert checker.validate_trajectory(
[point(0.0), point(0.0)], [down_pose(), down_pose()],
current_joints=(0.5, 0.0, 0.0, -1.57, 0.0, 1.57, 0.0),
).ok is False
far = down_pose(xyz=(0.9, 0.0, 1.2))
assert checker.validate_trajectory(
[point(0.0), point(0.0)], [down_pose(), far],
).ok is False
def test_details_report_the_checked_extremes():
result = policy().validate_trajectory(
[point(-0.2), point(0.1)],
[down_pose(2.0), down_pose(4.0)],
)
assert result.ok
assert result.details['min_j7'] == pytest.approx(-0.2)
assert result.details['max_j7'] == pytest.approx(0.1)
assert result.details['max_tilt_deg'] == pytest.approx(4.0, abs=1e-6)
@@ -0,0 +1,199 @@
"""The MoveIt backend rejects unsafe plans before asking for execution."""
from __future__ import annotations
import math
from pathlib import Path
from scipy.spatial.transform import Rotation
from iiwa_experement.geometry import Transform, load_geometry
from iiwa_llm_plan_executor.motion_policy import MotionPolicy, load_limits
from iiwa_llm_plan_executor.moveit_backend import (
MotionOutcome,
MoveItBackend,
PlannedMotion,
)
PACKAGE = Path(__file__).resolve().parents[1]
ASSETS = PACKAGE.parent / 'iiwa_experement' / 'config'
LIMITS = load_limits(PACKAGE / 'config' / 'motion_limits.yaml')
GEOMETRY = load_geometry(ASSETS / 'geometry.yaml')
BASE = (0.0, 0.0, 0.0, -1.57, 0.0, 1.57, 0.0)
def point(j7: float):
values = list(BASE)
values[6] = j7
return tuple(values)
def down_pose(tilt=0.0, xyz=(0.4, 0.0, 1.2)) -> Transform:
rotation = (
Rotation.from_euler('x', 180.0, degrees=True).as_matrix()
@ Rotation.from_euler('y', tilt, degrees=True).as_matrix()
)
return Transform(
xyz=tuple(float(value) for value in xyz),
rotation=tuple(tuple(float(value) for value in row) for row in rotation),
parent='world',
child='tcp',
)
GOAL = down_pose()
BASE_GOAL = GEOMETRY.world_to_base.inverse().compose_relaxed(GOAL)
class FakePlanner:
def __init__(self, *, planned=None, execute_ok=True, plan_error=None,
execute_error=None):
self.planned = planned
self.execute_ok = execute_ok
self.plan_error = plan_error
self.execute_error = execute_error
self.plan_calls = []
self.execute_calls = []
self.stopped = 0
self.reset_calls = 0
def plan(self, goal, phase, held_object):
self.plan_calls.append((goal, phase, held_object))
if self.plan_error is not None:
raise self.plan_error
return self.planned
def execute(self, planned):
self.execute_calls.append(planned)
if self.execute_error is not None:
raise self.execute_error
return self.execute_ok
def stop(self):
self.stopped += 1
def reset_stop(self):
self.reset_calls += 1
def planned(points, poses, current=None):
if current is None:
current = tuple(points[0])
return PlannedMotion(
points=list(points),
tcp_poses=list(poses),
current_joints=current,
handle='handle',
)
def backend(planner, **kwargs):
checker = MotionPolicy(LIMITS)
return MoveItBackend(
GEOMETRY,
checker,
planner=planner,
freshness_guard=kwargs.pop('freshness_guard', lambda: (True, 'ok')),
scene_guard=kwargs.pop('scene_guard', lambda: (True, 'ok')),
**kwargs,
)
def test_safe_plan_is_executed_once_and_the_final_pose_is_verified():
planner = FakePlanner(planned=planned(
[point(0.0), point(0.1)],
[BASE_GOAL, BASE_GOAL],
))
result = backend(planner).plan_and_execute(GOAL, 'transfer', 'vacuum')
assert result.success is True
assert result.code == 'ok'
assert len(planner.plan_calls) == 1
assert planner.plan_calls[0][2] == 'vacuum'
assert len(planner.execute_calls) == 1
def test_intermediate_out_of_corridor_point_never_reaches_execution():
planner = FakePlanner(planned=planned(
[point(-1.0), point(-1.8), point(-1.0)],
[BASE_GOAL, BASE_GOAL, BASE_GOAL],
))
result = backend(planner).plan_and_execute(GOAL, 'transfer')
assert result.success is False
assert result.code == 'motion_limit'
assert 'joint7' in result.message
assert planner.execute_calls == []
assert planner.stopped >= 1
def test_excessive_tilt_and_jump_are_rejected_without_execution():
tilted_goal = GEOMETRY.world_to_base.inverse().compose_relaxed(down_pose(20.0))
for points, poses in (
([point(0.0)], [tilted_goal]),
([point(-1.4), point(1.4)], [BASE_GOAL, BASE_GOAL]),
):
planner = FakePlanner(planned=planned(points, poses))
result = backend(planner).plan_and_execute(GOAL, 'transfer')
assert result.code == 'motion_limit'
assert planner.execute_calls == []
def test_stale_sources_reject_before_planning():
planner = FakePlanner(planned=planned([point(0.0)], [BASE_GOAL]))
result = backend(
planner, freshness_guard=lambda: (False, 'joint_states is stale'),
).plan_and_execute(GOAL, 'transfer')
assert result.success is False
assert result.code == 'stale_state'
assert planner.plan_calls == []
assert planner.execute_calls == []
def test_planning_and_execution_failures_are_reported_and_stop_motion():
planner = FakePlanner(plan_error=RuntimeError('no IK branch'))
result = backend(planner).plan_and_execute(GOAL, 'transfer')
assert result.code == 'planning_failed'
assert planner.execute_calls == []
assert planner.stopped >= 1
planner = FakePlanner(
planned=planned([point(0.0)], [BASE_GOAL]), execute_ok=False,
)
result = backend(planner).plan_and_execute(GOAL, 'transfer')
assert result.code == 'execution_failed'
planner = FakePlanner(
planned=planned([point(0.0)], [BASE_GOAL]),
execute_error=RuntimeError('controller lost'),
)
result = backend(planner).plan_and_execute(GOAL, 'transfer')
assert result.code == 'execution_failed'
def test_mismatched_final_pose_is_rejected():
wrong = down_pose(tilt=0.0, xyz=(0.42, 0.0, 1.2))
planner = FakePlanner(planned=planned([point(0.0)], [wrong]))
result = backend(planner).plan_and_execute(GOAL, 'transfer')
assert result.code == 'goal_mismatch'
assert planner.execute_calls == []
def test_stop_blocks_all_later_motion():
planner = FakePlanner(planned=planned([point(0.0)], [BASE_GOAL]))
path = backend(planner)
path.stop('operator pressed stop')
result = path.plan_and_execute(GOAL, 'transfer')
assert result.success is False
assert result.code == 'stopped'
assert planner.plan_calls == []
path.reset_stop()
assert path.plan_and_execute(GOAL, 'transfer').success is True
def test_source_freshness_is_checked_for_non_finite_values():
checker = MotionPolicy(LIMITS)
assert checker.check_sources(
joint_state_age=math.nan, scene_age=0.1,
).ok is False
assert checker.check_sources(
joint_state_age=0.1, scene_age=-1.0,
).ok is False