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:
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user