fix(experement): honest collision poses, mandatory lifts, rest-to-rest waits; 20/20 verified

- forbid the transport pose; recovery reconfigures through work only
- mandatory axial lift after every contact (handoff, tighten, dock, pick)
- untwist the flange after simulated tightening so joint 7 never winds up
- attach carried tool/cap/screw to the MoveIt scene; explicit REMOVE diffs
- add_world receives box-centre poses (placed_cap_pose, restore_world)
- table top lowered below the base_link collision mesh bottom (1 mm gap bug)
- 1 mm clearances for cap/screw boxes; thinner cap world box (cup clearance)
- wait for a stationary arm before every planning attempt (rest-to-rest)
- loosen stopped_velocity_tolerance to 0.08 for reference launches only
- retry a rejected named move once after settling (startup telemetry race)
- adapter logs the exact reason and max joint velocity on start-state rejects
- verified: all 20 experiments completed in Webots execute mode (10.11.2026)
This commit is contained in:
Даниил Грабарь
2026-11-10 15:11:06 +10:00
parent 6bd288dcaf
commit b26edc3cfc
15 changed files with 824 additions and 60 deletions
+6 -4
View File
@@ -146,12 +146,14 @@ safe_poses:
# Webots bounding objects so MoveIt and the simulator agree.
scene_collisions:
table:
# Top at Z = 0.789, deliberately 1 mm below the robot base (Z = 0.790)
# and 4 mm below the work surface (Z = 0.793), so the base is not in
# collision while downward motion is still blocked.
# Top at Z = 0.786. The base_link collision mesh reaches 2.05 mm below
# its frame origin (world Z = 0.788), so a table top at 0.789 was actually
# a permanent 1 mm penetration that the contact check flagged only
# intermittently. The 2 mm gap below the mesh bottom keeps the base
# collision-free while the slab still blocks downward motion.
type: box
size: [1.28, 1.08, 0.045]
offset_z: 0.7665
offset_z: 0.7635
# The fixture is a frame, not a solid block: a low base plate and two
# side rails. A full box would cover the hatch seat and make placement
# impossible.
+29 -10
View File
@@ -46,11 +46,19 @@ ros2 launch iiwa_experement experiment.launch.py experiment_id:=1 \
## Модели столкновений
- Стол: плита 1,28 × 1,08 × 0,045 м с верхом 0,789 м.
- Стол: плита 1,28 × 1,08 × 0,045 м с верхом 0,786 м. Меш коллизии
`base_link` доходит до 2,05 мм ниже фрейма (мир: Z = 0,788); прежний верх
0,789 м давал постоянное проникновение на 1 мм, которое детектор контактов
замечал нестабильно и блокировал планирование (опыт 2, отказ
`place_cap:retreat`).
- Стапель: нижняя плита 0,35 × 0,22 × 0,01 м и две боковые направляющие
0,014 × 0,156 × 0,016 м. Сплошной блок не используется, иначе он перекрывает
посадочное место люка.
- Корпус: 0,30 × 0,17 × 0,00915 м; люк: 0,1384 × 0,0984 × 0,00367 м.
Бокс люка поднят на 1 мм над боксом корпуса (`CAP_BOX_LIFT`), бокс винта —
на 1 мм над дном лотка (`SCREW_BOX_LIFT`): касание с нулевым зазором
считается контактом сцены и блокирует все последующие планы из этого
состояния (например, `grant_body - grant_cap` после посадки люка).
- Стойка: плита 0,42 × 0,14 × 0,01 м ниже гнёзд.
- Лоток: дно и четыре стенки; винты — 0,0055 × 0,0055 × 0,009 м.
- Вакуумный инструмент: 0,0821 × 0,081 × 0,14 м; магнитный — тонкий стержень
@@ -89,16 +97,18 @@ ros2 launch iiwa_experement experiment.launch.py experiment_id:=1 \
## Результаты тестов
Финальный прогон: `colcon test` — 336 тестов, 0 ошибок, 0 падений. Тесты покрывают контракт
сцен, геометрию, supervisor, сцену MoveIt, движение, операции, маршруты и
журнал.
Финальный прогон: `colcon test` — 237 тестов (209 `iiwa_experement`, 28
`iiwa_planning`), 0 ошибок, 0 падений. Тесты покрывают контракт сцен,
геометрию, supervisor, сцену MoveIt (включая явное снятие прикреплённых
объектов), движение, операции, маршруты и журнал.
## Результаты 20 опытов
> Таблица ниже относится к предыдущей редакции маршрутов. После правок
> (запрет `transport`, обязательный осевой подъём после каждого контакта,
> untwist фланца, прикрепление переносимых объектов в сцене MoveIt) все 20
> опытов требуют повторной верификации в Webots в режиме `execute`.
> Повторная верификация после правок (запрет `transport`, обязательный
> осевой подъём, untwist фланца, прикрепление переносимых объектов, честные
> позы боксов, зазоры боксов, rest-to-rest) выполнена 10.11.2026: все 20
> опытов завершились со статусом `completed` и `home_reached=true` в режиме
> `execute`. Журналы: `~/.ros/iiwa_experement/runs/experiment_<id>_20261110T*.jsonl`.
Каждый сценарий должен хотя бы один раз завершиться со статусом `completed`.
При транзиентном отказе планировщика (`Планирование не удалось`) запуск
@@ -190,8 +200,17 @@ ros2 launch iiwa_experement experiment.launch.py experiment_id:=1 \
- Прикреплённые объекты MoveIt моделируются: захваченные инструмент, люк и
винт переносятся из мировой сцены в `attached_collision_objects` с
разрешённым контактом звеньев `tcp`, `patron`, `link7` и держателя камеры.
Если в установленной версии MoveItPy снова появятся ложные касания,
первым делом проверяются touch-звенья и габариты боксов в `geometry.yaml`.
Прикреплённые боксы люка и винта укорочены так, чтобы на позе захвата их
низ оставался на 1 мм выше опорной поверхности (корпус/стол/лоток): касание
с нулевым зазором считается контактом стартового состояния и блокирует
обязательный осевой подъём.
- Поза бокса при возврате объекта в мир — это поза **центра бокса** с
плоской yaw-ориентацией (конвенция `build_world_objects`), а не поза узла
OBJ с поворотом Rx90. Док инструмента и восстановление винта/люка после
имитированного срыва используют `bridge.restore_world` с калиброванной
начальной позой; поставленный люк добавляется через `placed_cap_pose`.
Нарушение этой конвенции было причиной «ложных столкновений» (поставленный
люк становился вертикальной плитой, задокованный инструмент — лежачим).
- MoveIt/MoveItPy может аварийно завершиться после очень длинной серии
планирований; один запуск выполняет один опыт, поэтому серия ограничена.
- `Node.remove()` для винта в Webots R2025a иногда приводит к падению
@@ -26,6 +26,8 @@ from .planning_scene import (
attached_cap,
attached_screw,
attached_tool,
cap_world_box,
placed_cap_pose,
screw_object_id,
)
from .scenario import ScenarioSpec
@@ -243,6 +245,11 @@ class ReferenceOperations:
result = None
planning_failed = False
for attempt in range(attempts):
# Rest-to-rest planning: the server's response adapter rejects any
# plan whose start state is still moving. Wait for the arm to
# settle before every attempt instead of rapid-firing retries
# into the same non-stationary window.
self.motion.wait_stationary()
result = self.motion.move_pose(
goal,
planner=planner,
@@ -268,6 +275,7 @@ class ReferenceOperations:
continue
if allow_fallback and planner != 'ompl' and planning_failed:
self.motion.reset_stop()
self.motion.wait_stationary()
fallback = self.motion.move_pose(
goal, planner='ompl', speed=speed, label=f'{label}:ompl',
)
@@ -339,6 +347,7 @@ class ReferenceOperations:
operation stops instead of continuing at contact depth.
"""
lifted = False
last_error: OperationError | None = None
for index, gap in enumerate((self.geometry.approach_gap, 0.03, 0.015)):
lift_label = f'{label}:retreat' if index == 0 else f'{label}:retreat:{gap:.3f}'
try:
@@ -351,9 +360,12 @@ class ReferenceOperations:
except OperationError as error:
if 'Планирование' not in str(error):
raise
last_error = error
self.motion.reset_stop()
if not lifted:
raise OperationError(f'{label}: the axial retreat was rejected')
raise OperationError(
f'{label}: the axial retreat was rejected ({last_error})'
)
self._move(
self._above(goal, self.geometry.transfer_gap), 'ompl',
self.geometry.speeds['transfer'], f'{label}:clear',
@@ -367,6 +379,7 @@ class ReferenceOperations:
The sideways step starts from the height that was actually reached.
"""
height = None
last_error: OperationError | None = None
for index, gap in enumerate((
self.geometry.transfer_gap, self.geometry.approach_gap, 0.03, 0.015,
)):
@@ -381,10 +394,12 @@ class ReferenceOperations:
except OperationError as error:
if 'Планирование' not in str(error):
raise
last_error = error
self.motion.reset_stop()
if height is None:
raise OperationError(
f'{label}: the axial lift out of the rack was rejected'
f'{label}: the axial lift out of the rack was rejected '
f'({last_error})'
)
base = self._above(goal, height)
for offset in (0.10, 0.06, 0.03):
@@ -403,8 +418,11 @@ class ReferenceOperations:
except OperationError as error:
if 'Планирование' not in str(error):
raise
last_error = error
self.motion.reset_stop()
raise OperationError(f'{label}: no lateral rack exit was plannable')
raise OperationError(
f'{label}: no lateral rack exit was plannable ({last_error})'
)
def _contact_with_recovery(
self, goal: Transform, label: str, *, prepare=None, **kwargs,
@@ -495,11 +513,9 @@ class ReferenceOperations:
self._require_synchronized()
self._contact_with_recovery(goal, f'dock_tool({tool_name})')
self.supervisor.command('dock_tool', tool_name)
self.bridge.add_world(
f'grant_{tool_name}',
self.geometry.tool_collisions[tool_name],
rack_pose,
)
# The tool is back in its calibrated socket: restore the exact
# initial box pose instead of reusing the rotated node pose.
self.bridge.restore_world(f'grant_{tool_name}')
self._require_synchronized()
self._exit_rack(goal, f'dock_tool({tool_name})')
self._emit('dock_tool', object=tool_name, phase='done')
@@ -551,8 +567,12 @@ class ReferenceOperations:
remove_phase='contact',
)
self.supervisor.command('place_cap', 'cap', target=target)
# The collision box of the installed hatch is flat, thinner than
# the plate and centred on it: it must clear both the body box
# below and the suction-cup box above at the contact pose.
self.bridge.add_world(
'grant_cap', self.geometry.cap_collision, target,
'grant_cap', cap_world_box(self.geometry),
placed_cap_pose(target, self.spec.body.yaw_deg),
)
self._require_synchronized()
self._retreat(goal, 'place_cap')
@@ -639,10 +659,9 @@ class ReferenceOperations:
goal = tcp_goal(screw_pose, self.geometry.screw_tcp_to_node)
def restore_screw() -> None:
# The first attempt removed the screw from the planning scene
# before its transfer was rejected. Restore it for the retry.
self.bridge.add_world(
def_name, self.geometry.screw_collision, screw_pose,
)
# before its transfer was rejected. Restore its calibrated
# tray pose for the retry.
self.bridge.restore_world(def_name)
self._require_synchronized()
self._contact_with_recovery(
@@ -820,9 +839,7 @@ class ReferenceOperations:
goal = tcp_goal(pose, self.geometry.cap_tcp_to_node)
self._contact(goal, label, remove_before_contact='grant_cap')
self._retreat(goal, label)
self.bridge.add_world(
'grant_cap', self.geometry.cap_collision, pose,
)
self.bridge.restore_world('grant_cap')
self._require_synchronized()
elif kind == 'screw':
def_name = screw_object_id(name)
@@ -830,9 +847,7 @@ class ReferenceOperations:
goal = tcp_goal(pose, self.geometry.screw_tcp_to_node)
self._contact(goal, label, remove_before_contact=def_name)
self._retreat(goal, label)
self.bridge.add_world(
def_name, self.geometry.screw_collision, pose,
)
self.bridge.restore_world(def_name)
self._require_synchronized()
else:
raise OperationError(f'unknown grasp target {kind!r}')
@@ -102,6 +102,45 @@ def screw_object_id(screw_id: str) -> str:
return f'grant_screw_{int(screw_id.split("_")[1]):02d}'
# Lift from an object node pose to the collision-box centre, matching
# build_world_objects. A node origin sits on the lower face/axis of the OBJ
# and is rotated so the local Y axis becomes world Z; the collision box is a
# flat, yaw-only box centred above the support surface.
#
# The installed hatch is sandwiched between two hard constraints: its box
# must clear the conservative body box below AND the suction-cup box above at
# the place contact pose. A zero-distance touch on either side reads as a
# scene contact and blocks every later plan from that state, so the world
# model of the hatch is 1 mm thinner than the real plate, leaving 0.5 mm of
# clearance on both sides.
CAP_BOX_LIFT = 0.004165
CAP_BOX_THICKNESS = 0.00267
SCREW_BOX_LIFT = 0.0055
TOOL_BOX_LIFT = 0.070
def cap_world_box(geometry: GeometrySpec) -> Box:
"""Collision box of the hatch as a world object (thinner than the plate)."""
return Box(
size=(
geometry.cap_collision.size[0],
geometry.cap_collision.size[1],
CAP_BOX_THICKNESS,
),
offset=geometry.cap_collision.offset,
)
def placed_cap_pose(seat: Transform, body_yaw_deg: float) -> Transform:
"""Collision-box pose of the installed hatch: flat, centred on the plate."""
return Transform.from_xyz_rpy(
[seat.xyz[0], seat.xyz[1], seat.xyz[2] + CAP_BOX_LIFT],
[0.0, 0.0, body_yaw_deg],
parent='world',
child='cap',
)
@dataclass(frozen=True)
class SceneObject:
object_id: str
@@ -242,7 +281,7 @@ def build_world_objects(spec: ScenarioSpec, geometry: GeometrySpec) -> list[Scen
_rotated_pose(
screw.pose.x,
screw.pose.y,
screw.pose.z + 0.0045,
screw.pose.z + SCREW_BOX_LIFT,
screw.pose.yaw_deg,
),
))
@@ -254,7 +293,7 @@ def build_world_objects(spec: ScenarioSpec, geometry: GeometrySpec) -> list[Scen
f'grant_{tool.name}',
Box(size=size, offset=(0.0, 0.0, 0.070)),
_rotated_pose(
tool.pose.x, tool.pose.y, tool.pose.z + 0.070,
tool.pose.x, tool.pose.y, tool.pose.z + TOOL_BOX_LIFT,
tool.pose.yaw_deg + 180.0,
),
))
@@ -264,9 +303,9 @@ def build_world_objects(spec: ScenarioSpec, geometry: GeometrySpec) -> list[Scen
assert cap_pose is not None
objects.append(SceneObject(
'grant_cap',
geometry.cap_collision,
cap_world_box(geometry),
_rotated_pose(
cap_pose.x, cap_pose.y, cap_pose.z + 0.004165,
cap_pose.x, cap_pose.y, cap_pose.z + CAP_BOX_LIFT,
cap_pose.yaw_deg,
),
))
@@ -292,11 +331,19 @@ def attached_tool(name: str, geometry: GeometrySpec) -> AttachedSpec:
def attached_cap(geometry: GeometrySpec) -> AttachedSpec:
# The plate box stops 1 mm short of the support surface (body/table at
# the grasp pose): a zero-distance touch reads as a scene contact and
# blocks the mandatory axial retreat right after the grasp.
box = Box(
size=(geometry.cap_collision.size[0], geometry.cap_collision.size[1],
0.00267),
offset=(0.0, 0.0, 0.141335),
)
return AttachedSpec(
object_id='grant_cap',
box=geometry.cap_collision,
box=box,
relative_pose=Transform.from_xyz_rpy(
[0.0, 0.0, geometry.cap_collision.offset[2] + 0.0552],
[0.0, 0.0, 0.141335 + 0.0552],
[0.0, 0.0, 0.0],
parent='patron',
child='grant_cap',
@@ -306,12 +353,14 @@ def attached_cap(geometry: GeometrySpec) -> AttachedSpec:
def attached_screw(screw_id: str, geometry: GeometrySpec) -> AttachedSpec:
# Same 1 mm clearance at the tray floor as in attached_cap.
box = Box(size=(0.0055, 0.0055, 0.008), offset=(0.0, 0.0, 0.144))
object_id = screw_object_id(screw_id)
return AttachedSpec(
object_id=object_id,
box=geometry.screw_collision,
box=box,
relative_pose=Transform.from_xyz_rpy(
[0.0, 0.0, geometry.screw_collision.offset[2] + 0.0552],
[0.0, 0.0, 0.144 + 0.0552],
[0.0, 0.0, 0.0],
parent='patron',
child=object_id,
@@ -445,6 +494,7 @@ class PlanningSceneBridge:
GetPlanningScene, GET_SCENE_SERVICE,
)
self._world_objects: dict[str, SceneObject] = {}
self._initial_world: dict[str, SceneObject] = {}
self._attached: dict[str, AttachedSpec] = {}
self._seen_world: set[str] = set()
self._seen_attached: set[str] = set()
@@ -694,6 +744,7 @@ class PlanningSceneBridge:
self._world_objects = {
obj.object_id: obj for obj in build_world_objects(spec, geometry)
}
self._initial_world = dict(self._world_objects)
self._attached.clear()
extra = []
for spec in self._attached.values():
@@ -708,6 +759,26 @@ class PlanningSceneBridge:
self._world_objects, (), seen_world=set(self._world_objects),
)
def _publish_attached_removal(self, object_id: str) -> None:
"""Send an explicit attached-object removal diff.
Full scenes carry the attached list inside ``robot_state`` with
``is_diff=True``; a consumer that applies that list as a diff never
treats an omission as a removal and keeps a ghost attachment. The
explicit REMOVE entry is unambiguous for every consumer.
"""
publisher = getattr(self, '_publisher', None)
if publisher is None or self.node is None:
return
scene = self._diff()
removal = AttachedCollisionObject()
removal.link_name = 'patron'
removal.object.id = object_id
removal.object.operation = CollisionObject.REMOVE
scene.robot_state.attached_collision_objects.append(removal)
publisher.publish(scene)
self._spin()
def attach(self, spec: AttachedSpec) -> None:
self._world_objects.pop(spec.object_id, None)
self._attached[spec.object_id] = spec
@@ -720,23 +791,45 @@ class PlanningSceneBridge:
f'cannot detach {object_id!r}: it is not attached'
)
spec = self._attached.pop(object_id)
self._publish_attached_removal(object_id)
object_spec = SceneObject(object_id, spec.box, world_pose)
self._world_objects[object_id] = object_spec
self._rebuild_acm()
self._publish_full()
def remove(self, object_id: str) -> None:
self._attached.pop(object_id, None)
if self._attached.pop(object_id, None) is not None:
self._publish_attached_removal(object_id)
self._world_objects.pop(object_id, None)
self._rebuild_acm()
self._publish_full()
def add_world(self, object_id: str, box: Box, world_pose: Transform) -> None:
self._attached.pop(object_id, None)
if self._attached.pop(object_id, None) is not None:
self._publish_attached_removal(object_id)
self._world_objects[object_id] = SceneObject(object_id, box, world_pose)
self._rebuild_acm()
self._publish_full()
def restore_world(self, object_id: str) -> None:
"""Return an object to the exact box pose it had at scene initialisation.
Use this when an object physically returns to its calibrated support
pose (a tool docked in its socket, a part restored after a simulated
grasp failure): the initial pose already carries the correct
box-centre lift and flat orientation.
"""
original = self._initial_world.get(object_id)
if original is None:
raise SceneSynchronizationError(
f'cannot restore {object_id!r}: it was not in the initial scene'
)
if self._attached.pop(object_id, None) is not None:
self._publish_attached_removal(object_id)
self._world_objects[object_id] = original
self._rebuild_acm()
self._publish_full()
def allow_contact(self, first: str, second: str, allowed: bool = True) -> None:
"""Change exactly one ACM pair; all other checks stay active."""
if self.last_scene is None or not self.last_scene.allowed_collision_matrix.entry_names:
@@ -200,6 +200,11 @@ def build_reference_sequence(spec: ScenarioSpec) -> tuple[ReferenceStep, ...]:
return tuple(cleaned)
def _planning_failure(message: str) -> bool:
text = message.lower()
return 'планирование' in text or 'planning' in text or 'недостижима' in text
def execute_sequence(sequence: tuple[ReferenceStep, ...], context) -> RunResult:
"""Execute a route, stopping at the first unsafe or unexpected outcome."""
operations = context.operations
@@ -216,6 +221,15 @@ def execute_sequence(sequence: tuple[ReferenceStep, ...], context) -> RunResult:
trace.event({'event': 'step', 'step': step.as_dict(), 'phase': 'start'})
if step.kind == 'move_named':
result = motion.move_named(*step.args, **step.kwargs)
if not result.success and _planning_failure(result.message):
# A rejected plan executed no motion. The start-state
# guard can trip on a startup clock/telemetry race, so
# wait for the arm to settle and retry exactly once.
trace.event({'event': 'named_retry', 'step': step.as_dict(),
'reason': result.message})
motion.wait_stationary(timeout_sec=5.0)
motion.reset_stop()
result = motion.move_named(*step.args, **step.kwargs)
if not result.success:
raise SequenceError(result.message)
if step.args and step.args[0] == 'home':
@@ -135,6 +135,15 @@ def _materialize_setting(
tool = _mapping(data.setdefault('tool', {}), 'tool')
tool['active'] = 'patron'
# The reference executor runs many rest-to-rest plans back to back. The
# Webots position controller keeps a heavy tool oscillating at a few
# hundredths of rad/s for seconds after a stop, which trips the default
# 0.01 rad/s start-state guard and aborts otherwise valid plans. Loosen
# the guard for these launches only; the runner still waits for the arm
# to settle before every plan and verifies the reached pose afterwards.
planning = _mapping(data.setdefault('planning', {}), 'planning')
planning['stopped_velocity_tolerance'] = 0.08
if moveit_cpp_path is not None:
controller = _mapping(data.setdefault('controller', {}), 'controller')
moveit = _mapping(controller.setdefault('moveit', {}), 'controller.moveit')
+9 -1
View File
@@ -50,6 +50,7 @@ class FakeBridge:
self.attached = []
self.detached = []
self.removed = []
self.restored = []
def attach(self, spec):
self.attached.append(spec.object_id)
@@ -62,7 +63,14 @@ class FakeBridge:
if object_id in self.attached:
self.attached.remove(object_id)
def restore_world(self, object_id):
if object_id in self.attached:
self.attached.remove(object_id)
self.restored.append(object_id)
def add_world(self, object_id, box, pose):
if object_id in self.attached:
self.attached.remove(object_id)
self.added_world = getattr(self, 'added_world', [])
self.added_world.append((object_id, pose))
@@ -157,7 +165,7 @@ def test_dock_tool_returns_the_tool_to_its_socket():
result = operations.dock_tool('vacuum')
assert result.success
assert supervisor.commands[0][:2] == ('dock_tool', 'vacuum')
assert bridge.added_world[0][0] == 'grant_vacuum'
assert bridge.restored == ['grant_vacuum']
assert labels(motion)[-1] == 'dock_tool(vacuum):exit'
@@ -5,7 +5,7 @@ import numpy as np
import pytest
from moveit_msgs.msg import CollisionObject, PlanningScene
from iiwa_experement.geometry import Box, Transform, load_geometry
from iiwa_experement.geometry import Box, Transform, load_geometry, world_target
from iiwa_experement.planning_scene import (
PlanningSceneBridge,
SceneObject,
@@ -16,6 +16,7 @@ from iiwa_experement.planning_scene import (
attached_tool,
build_world_objects,
collision_object,
placed_cap_pose,
scene_ids,
update_contact_entry,
)
@@ -41,6 +42,7 @@ class FakeBridge(PlanningSceneBridge):
self._world_objects = {
obj.object_id: obj for obj in build_world_objects(scenario, geometry)
}
self._initial_world = dict(self._world_objects)
self._attached = {}
self._disabled_pairs = []
self._acm_names = []
@@ -87,7 +89,7 @@ def test_collision_boxes_match_measured_meshes():
objects = {obj.object_id: obj for obj in build_world_objects(
spec(16), load_geometry(GEOMETRY))}
assert objects['grant_body'].box.size == pytest.approx((0.30, 0.17, 0.00915))
assert objects['grant_cap'].box.size == pytest.approx((0.1384, 0.0984, 0.00367))
assert objects['grant_cap'].box.size == pytest.approx((0.1384, 0.0984, 0.00267))
assert objects['grant_screw_01'].box.size == pytest.approx((0.0055, 0.0055, 0.009))
assert objects['grant_vacuum'].box.size[2] == pytest.approx(0.140)
@@ -144,9 +146,11 @@ def test_cap_and_screw_attachment_geometry_uses_tcp_offsets():
geometry = load_geometry(GEOMETRY)
cap = attached_cap(geometry)
screw = attached_screw('screw_3', geometry)
assert cap.relative_pose.xyz == pytest.approx((0.0, 0.0, 0.141835 + 0.0552))
assert cap.box.size == pytest.approx((0.1384, 0.0984, 0.00367))
assert screw.relative_pose.xyz == pytest.approx((0.0, 0.0, 0.1445 + 0.0552))
# The boxes stop 1 mm short of the support surface at the grasp pose.
assert cap.relative_pose.xyz == pytest.approx((0.0, 0.0, 0.141335 + 0.0552))
assert cap.box.size == pytest.approx((0.1384, 0.0984, 0.00267))
assert screw.relative_pose.xyz == pytest.approx((0.0, 0.0, 0.144 + 0.0552))
assert screw.box.size == pytest.approx((0.0055, 0.0055, 0.008))
def test_contact_permission_changes_exactly_one_pair():
@@ -159,6 +163,40 @@ def test_contact_permission_changes_exactly_one_pair():
assert matrix[0][1] is False
def test_restore_world_returns_the_calibrated_box_pose():
bridge = FakeBridge(16)
tool = attached_tool('vacuum', bridge.geometry)
bridge.attach(tool)
bridge.restore_world(tool.object_id)
assert tool.object_id not in bridge._attached
restored = bridge._world_objects[tool.object_id]
expected = {
obj.object_id: obj
for obj in build_world_objects(bridge.spec, bridge.geometry)
}[tool.object_id]
assert restored.pose.xyz == pytest.approx(expected.pose.xyz)
assert restored.pose.rotation == expected.pose.rotation
with pytest.raises(Exception):
bridge.restore_world('grant_unknown')
def test_placed_cap_box_is_flat_and_matches_the_preinstalled_cap():
geometry = load_geometry(GEOMETRY)
scenario = spec(1)
seat = world_target(scenario, geometry, 'cap_seat')
placed = placed_cap_pose(seat, scenario.body.yaw_deg)
# The box is flat (yaw only) and centred on the plate, like the
# pre-installed hatch of experiments 11-15.
preinstalled = {
obj.object_id: obj
for obj in build_world_objects(spec(11), geometry)
}['grant_cap']
assert placed.xyz == pytest.approx(preinstalled.pose.xyz)
assert np.asarray(placed.rotation) == pytest.approx(
np.asarray(preinstalled.pose.rotation)
)
def test_srdf_collision_matrix_allows_only_disabled_pairs():
root = Path(__file__).resolve().parents[2]
srdf = root / 'iiwa_config' / 'config' / 'moveit' / 'iiwa7.srdf'
@@ -37,7 +37,7 @@ public:
const sensor_msgs::msg::JointState &requested) const {
// Explicit nonzero requested initial dynamics are never silently discarded.
if (!requested.velocity.empty() && !velocities_ok(names, requested))
return false;
return diagnose(names, requested, "request start_state has moving joints");
auto node = node_.lock();
if (!node)
return velocities_ok(names,
@@ -46,15 +46,39 @@ public:
if (!latest_ || std::chrono::duration<double>(
std::chrono::steady_clock::now() - received_)
.count() > timeout_)
return false;
return diagnose(names, requested, "no fresh /joint_states (wall clock)");
const double age =
node->now().seconds() -
(latest_->header.stamp.sec + latest_->header.stamp.nanosec * 1e-9);
return std::isfinite(age) && age >= 0 && age <= timeout_ &&
velocities_ok(names, *latest_);
if (!(std::isfinite(age) && age >= 0 && age <= timeout_))
return diagnose(names, requested, "stale /joint_states stamp");
return velocities_ok(names, *latest_)
? true
: diagnose(names, *latest_, "joints still moving");
}
private:
bool diagnose(const std::vector<std::string> &names,
const sensor_msgs::msg::JointState &msg,
const char *why) const {
double worst = -1.0;
std::string worst_joint;
for (const auto &name : names) {
auto found = std::find(msg.name.begin(), msg.name.end(), name);
if (found == msg.name.end())
continue;
const size_t i = found - msg.name.begin();
if (i < msg.velocity.size() && std::isfinite(msg.velocity[i]) &&
std::abs(msg.velocity[i]) > worst) {
worst = std::abs(msg.velocity[i]);
worst_joint = name;
}
}
RCLCPP_WARN(rclcpp::get_logger("iiwa_spline_limits"),
"Start state rejected: %s; max |velocity| = %.4f rad/s at %s",
why, worst, worst_joint.c_str());
return false;
}
bool velocities_ok(const std::vector<std::string> &names,
const sensor_msgs::msg::JointState &msg) const {
for (const auto &name : names) {