feat(llm-plan-executor): constrain planned trajectories by J7 and TCP tilt
This commit is contained in:
@@ -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
|
* Screws are handed to the virtual operator at a hole; the hole stays empty
|
||||||
and no insertion/tightening occurs.
|
and no insertion/tightening occurs.
|
||||||
* No task is inferred from `experiment_id`; only the initial scene is selected.
|
* 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
|
||||||
Reference in New Issue
Block a user