fix(llm-plan-executor): enforce safe motion and live scene state
This commit is contained in:
@@ -88,11 +88,13 @@ bounds. Out-of-corridor and stale-state graphs stopped without execution.
|
|||||||
part transits/approaches/contact, and enforces all tilt/J7/jump limits after
|
part transits/approaches/contact, and enforces all tilt/J7/jump limits after
|
||||||
planning. This is a planning limitation, not a fallback to an unconstrained
|
planning. This is a planning limitation, not a fallback to an unconstrained
|
||||||
`MoveToPose` action.
|
`MoveToPose` action.
|
||||||
3. **Tilt numerical band.** A few OMPL/LIN paths were returned with 10.03–10.16
|
3. **Tilt limit.** The original Webots runs returned some OMPL/LIN paths at
|
||||||
deg tilt against the 10 deg policy; the policy uses a 0.25 deg numerical
|
10.03–10.16 deg tilt against the 10 deg limit; the previous 0.25 deg
|
||||||
band and rejects larger values. Some approach paths reached 11–12 deg and
|
allowance accepted them. The review on 2026-11-11 replaced that allowance
|
||||||
were rejected safely. A clean solution requires a planner/adapter that
|
with floating-point roundoff only, so those paths are now rejected before
|
||||||
honors the orientation constraint end-to-end.
|
execution. This stricter policy passed package tests but has not yet been
|
||||||
|
rechecked in Webots. A planner/adapter that honors the orientation
|
||||||
|
constraint end-to-end is still needed for reliable execution.
|
||||||
4. **Controller start overshoot.** Webots can settle a joint ~3e-5 rad outside
|
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
|
MoveIt's soft limit; the start state is now clamped to the same
|
||||||
`soft_min_position`/`soft_max_position` band before planning.
|
`soft_min_position`/`soft_max_position` band before planning.
|
||||||
|
|||||||
@@ -218,9 +218,9 @@ class MotionPolicy:
|
|||||||
start_tilt > self.limits.max_tilt_deg + epsilon
|
start_tilt > self.limits.max_tilt_deg + epsilon
|
||||||
and held_object is None
|
and held_object is None
|
||||||
)
|
)
|
||||||
# 0.1 deg is the measurement/numerical band of the FK check; the
|
# Allow only floating-point roundoff; a planned path must obey the
|
||||||
# planner is constrained to a stricter 8 deg band.
|
# configured tilt limit rather than widening it by a visible angle.
|
||||||
tilt_epsilon_deg = 0.25
|
tilt_epsilon_deg = 1e-6
|
||||||
tilt_limit_deg = self.limits.max_tilt_deg + tilt_epsilon_deg
|
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):
|
||||||
|
|||||||
@@ -580,6 +580,9 @@ class PlannerExecutorNode(Node):
|
|||||||
else:
|
else:
|
||||||
stable_since = None
|
stable_since = None
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
raise RuntimeError(
|
||||||
|
f'robot did not become stationary within {timeout_sec:.2f} s'
|
||||||
|
)
|
||||||
|
|
||||||
def _scene_guard(self) -> tuple[bool, str]:
|
def _scene_guard(self) -> tuple[bool, str]:
|
||||||
if not self._bridge_ready:
|
if not self._bridge_ready:
|
||||||
@@ -694,16 +697,15 @@ class PlannerExecutorNode(Node):
|
|||||||
self.log.write(payload)
|
self.log.write(payload)
|
||||||
|
|
||||||
def _on_replan(self, envelope: dict[str, Any] | None) -> None:
|
def _on_replan(self, envelope: dict[str, Any] | None) -> None:
|
||||||
if envelope is not None:
|
run_id = (self._last_status or {}).get('run_id')
|
||||||
self._last_status = dict(envelope)
|
|
||||||
self.log.write({
|
self.log.write({
|
||||||
'run_id': (self._last_status or {}).get('run_id'),
|
'run_id': run_id,
|
||||||
'event': 'replan_required',
|
'event': 'replan_required',
|
||||||
'scene_revision': None if envelope is None else envelope.get('scene_revision'),
|
'scene_revision': None if envelope is None else envelope.get('scene_revision'),
|
||||||
})
|
})
|
||||||
if envelope is not None:
|
if envelope is not None:
|
||||||
self._publish_status({
|
self._publish_status({
|
||||||
'run_id': (self._last_status or {}).get('run_id'),
|
'run_id': run_id,
|
||||||
'state': 'replan_scene',
|
'state': 'replan_scene',
|
||||||
'reason': 'fresh scene snapshot for the external planner',
|
'reason': 'fresh scene snapshot for the external planner',
|
||||||
'scene_revision': envelope.get('scene_revision'),
|
'scene_revision': envelope.get('scene_revision'),
|
||||||
|
|||||||
@@ -149,7 +149,8 @@ class SkillRegistry:
|
|||||||
goal: Transform,
|
goal: Transform,
|
||||||
phase: str,
|
phase: str,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
outcome = self.motion.plan_and_execute(goal, phase, context.held_object)
|
carried = context.held_object or context.mounted_tool
|
||||||
|
outcome = self.motion.plan_and_execute(goal, phase, carried)
|
||||||
if not getattr(outcome, 'success', False):
|
if not getattr(outcome, 'success', False):
|
||||||
code = getattr(outcome, 'code', 'failed')
|
code = getattr(outcome, 'code', 'failed')
|
||||||
event = 'collision' if code == 'collision' else 'failed'
|
event = 'collision' if code == 'collision' else 'failed'
|
||||||
@@ -205,16 +206,12 @@ class SkillRegistry:
|
|||||||
)
|
)
|
||||||
if pose is not None:
|
if pose is not None:
|
||||||
return pose
|
return pose
|
||||||
if object_id == 'cap':
|
pose = _state_pose(
|
||||||
return world_target(context.spec, context.geometry, 'cap')
|
(state.get('tools') or {}).get(object_id, {}).get('pose'),
|
||||||
if object_id.startswith('screw_'):
|
object_id,
|
||||||
return world_target(
|
|
||||||
context.spec, context.geometry, f'screw:{object_id}',
|
|
||||||
)
|
|
||||||
if object_id in context.spec.tool_names:
|
|
||||||
return world_target(
|
|
||||||
context.spec, context.geometry, f'tool:{object_id}',
|
|
||||||
)
|
)
|
||||||
|
if pose is not None:
|
||||||
|
return pose
|
||||||
raise SkillError('failed', f'no live pose for {object_id!r}')
|
raise SkillError('failed', f'no live pose for {object_id!r}')
|
||||||
|
|
||||||
def _object_contact_goal(
|
def _object_contact_goal(
|
||||||
@@ -457,9 +454,16 @@ class SkillRegistry:
|
|||||||
return SkillOutcome('success', {'reused': True, 'tool': tool})
|
return SkillOutcome('success', {'reused': True, 'tool': tool})
|
||||||
adapter = self._adapter(context)
|
adapter = self._adapter(context)
|
||||||
prepare = getattr(adapter, 'prepare_grab_tool', None)
|
prepare = getattr(adapter, 'prepare_grab_tool', None)
|
||||||
|
self._ensure_approach(context, node)
|
||||||
if prepare is not None:
|
if prepare is not None:
|
||||||
prepare(tool)
|
prepare(tool)
|
||||||
|
try:
|
||||||
self._ensure_contact(context, node)
|
self._ensure_contact(context, node)
|
||||||
|
except Exception:
|
||||||
|
restore = getattr(adapter, 'restore_tool', None)
|
||||||
|
if prepare is not None and restore is not None:
|
||||||
|
restore(tool)
|
||||||
|
raise
|
||||||
try:
|
try:
|
||||||
self._command(
|
self._command(
|
||||||
context, 'attach_tool', tool, event='grasp_failed',
|
context, 'attach_tool', tool, event='grasp_failed',
|
||||||
@@ -500,9 +504,16 @@ class SkillRegistry:
|
|||||||
prepare = getattr(adapter, 'prepare_grab_cap', None)
|
prepare = getattr(adapter, 'prepare_grab_cap', None)
|
||||||
else:
|
else:
|
||||||
prepare = getattr(adapter, 'prepare_grab_screw', None)
|
prepare = getattr(adapter, 'prepare_grab_screw', None)
|
||||||
|
self._ensure_approach(context, node)
|
||||||
if prepare is not None:
|
if prepare is not None:
|
||||||
prepare(node.object)
|
prepare(node.object)
|
||||||
|
try:
|
||||||
contact = self._ensure_contact(context, node)
|
contact = self._ensure_contact(context, node)
|
||||||
|
except Exception:
|
||||||
|
restore = getattr(adapter, 'restore_part', None)
|
||||||
|
if prepare is not None and restore is not None:
|
||||||
|
restore(node.object)
|
||||||
|
raise
|
||||||
try:
|
try:
|
||||||
self._command(context, command, node.object, event='grasp_failed')
|
self._command(context, command, node.object, event='grasp_failed')
|
||||||
except SkillError as error:
|
except SkillError as error:
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ def test_tcp_tilt_above_ten_degrees_is_rejected():
|
|||||||
assert 'tilt' in rejected.reason
|
assert 'tilt' in rejected.reason
|
||||||
assert checker.validate_trajectory(
|
assert checker.validate_trajectory(
|
||||||
[point(0.0)], [down_pose(10.05)],
|
[point(0.0)], [down_pose(10.05)],
|
||||||
).ok
|
).ok is False
|
||||||
|
|
||||||
|
|
||||||
def test_an_empty_gripper_may_reorient_to_the_working_posture():
|
def test_an_empty_gripper_may_reorient_to_the_working_posture():
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from iiwa_experement.geometry import load_geometry
|
from iiwa_experement.geometry import load_geometry
|
||||||
from iiwa_experement.scenario import load_scenario
|
from iiwa_experement.scenario import load_scenario
|
||||||
from iiwa_llm_plan_executor.engine import AdmissionResult, RunStatus
|
from iiwa_llm_plan_executor.engine import AdmissionResult, RunStatus
|
||||||
@@ -78,6 +80,15 @@ def test_plan_qos_is_transient_local_depth_one():
|
|||||||
assert qos.depth == 1
|
assert qos.depth == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_stationary_wait_rejects_a_robot_that_keeps_moving():
|
||||||
|
node = object.__new__(PlannerExecutorNode)
|
||||||
|
node._last_joint_velocity = (0.2,) * 7
|
||||||
|
with pytest.raises(RuntimeError, match='did not become stationary'):
|
||||||
|
PlannerExecutorNode._wait_stationary(
|
||||||
|
node, timeout_sec=0.02, tolerance=0.01,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_safe_name_and_run_log_are_machine_readable(tmp_path):
|
def test_safe_name_and_run_log_are_machine_readable(tmp_path):
|
||||||
assert _safe_name('run/one 1') == 'run_one_1'
|
assert _safe_name('run/one 1') == 'run_one_1'
|
||||||
log = RunLog(str(tmp_path))
|
log = RunLog(str(tmp_path))
|
||||||
@@ -135,6 +146,17 @@ def test_rejected_submit_returns_false_and_publishes_the_reason():
|
|||||||
assert 'replan_required' in published[-1]['reason']
|
assert 'replan_required' in published[-1]['reason']
|
||||||
|
|
||||||
|
|
||||||
|
def test_replan_scene_status_keeps_the_run_id():
|
||||||
|
node = fake_node()
|
||||||
|
node._last_status = {'run_id': 'run-42', 'state': 'running'}
|
||||||
|
envelope = {'scene_revision': 7, 'scene_json': {'components': []}}
|
||||||
|
PlannerExecutorNode._on_replan(node, envelope)
|
||||||
|
payload = json.loads(node._status_publisher.messages[-1])
|
||||||
|
assert payload['run_id'] == 'run-42'
|
||||||
|
assert payload['scene_revision'] == 7
|
||||||
|
assert payload['scene'] == envelope
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_submit_json_is_rejected_without_calling_the_engine():
|
def test_invalid_submit_json_is_rejected_without_calling_the_engine():
|
||||||
node = fake_node()
|
node = fake_node()
|
||||||
request = SimpleNamespace(value='{broken')
|
request = SimpleNamespace(value='{broken')
|
||||||
|
|||||||
@@ -203,6 +203,69 @@ def test_approach_goes_above_the_target_and_saves_the_height():
|
|||||||
assert phases(motion) == ['approach']
|
assert phases(motion) == ['approach']
|
||||||
|
|
||||||
|
|
||||||
|
def test_mounted_tool_is_reported_as_carried_during_motion():
|
||||||
|
registry, context, motion, _ = make_registry()
|
||||||
|
context.mounted_tool = 'vacuum'
|
||||||
|
result = registry.execute(node('a', 'approach', 'cap'), context)
|
||||||
|
assert result.event == 'success'
|
||||||
|
assert motion.calls[0][2] == 'vacuum'
|
||||||
|
|
||||||
|
|
||||||
|
def test_approach_refuses_to_use_manifest_pose_when_supervisor_pose_is_missing():
|
||||||
|
registry, context, motion, supervisor = make_registry()
|
||||||
|
supervisor.state['cap']['pose'] = None
|
||||||
|
result = registry.execute(node('a', 'approach', 'cap'), context)
|
||||||
|
assert result.event == 'failed'
|
||||||
|
assert motion.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_keeps_target_in_collision_scene_during_approach():
|
||||||
|
class RecordingScene(FakeScene):
|
||||||
|
def prepare_grab_cap(self, object_id):
|
||||||
|
self.events.append(('prepare_grab_cap', object_id))
|
||||||
|
|
||||||
|
adapter = RecordingScene()
|
||||||
|
registry, context, motion, _ = make_registry(
|
||||||
|
motion=FakeMotion(success=False), adapter=adapter,
|
||||||
|
)
|
||||||
|
context.mounted_tool = 'vacuum'
|
||||||
|
result = registry.execute(node('g', 'grab', 'cap'), context)
|
||||||
|
assert result.event == 'failed'
|
||||||
|
assert phases(motion) == ['approach']
|
||||||
|
assert adapter.events == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_restores_target_when_lowering_fails():
|
||||||
|
class RecordingScene(FakeScene):
|
||||||
|
def prepare_grab_cap(self, object_id):
|
||||||
|
self.events.append(('prepare_grab_cap', object_id))
|
||||||
|
|
||||||
|
def restore_part(self, object_id):
|
||||||
|
self.events.append(('restore_part', object_id))
|
||||||
|
|
||||||
|
class FailOnLower(FakeMotion):
|
||||||
|
def plan_and_execute(self, goal, phase, held_object):
|
||||||
|
self.calls.append((goal, phase, held_object))
|
||||||
|
return MotionOutcome(
|
||||||
|
phase != 'lower',
|
||||||
|
'ok' if phase != 'lower' else 'motion_limit',
|
||||||
|
'ok' if phase != 'lower' else 'rejected by policy',
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter = RecordingScene()
|
||||||
|
registry, context, motion, _ = make_registry(
|
||||||
|
motion=FailOnLower(), adapter=adapter,
|
||||||
|
)
|
||||||
|
context.mounted_tool = 'vacuum'
|
||||||
|
result = registry.execute(node('g', 'grab', 'cap'), context)
|
||||||
|
assert result.event == 'failed'
|
||||||
|
assert phases(motion) == ['approach', 'lower']
|
||||||
|
assert adapter.events == [
|
||||||
|
('prepare_grab_cap', 'cap'),
|
||||||
|
('restore_part', 'cap'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_grab_cap_runs_missing_phases_and_lifts_to_saved_height():
|
def test_grab_cap_runs_missing_phases_and_lifts_to_saved_height():
|
||||||
registry, context, motion, supervisor = make_registry()
|
registry, context, motion, supervisor = make_registry()
|
||||||
context.mounted_tool = 'vacuum'
|
context.mounted_tool = 'vacuum'
|
||||||
|
|||||||
Reference in New Issue
Block a user