feat(experement): add calibrated grant working geometry
This commit is contained in:
@@ -0,0 +1,153 @@
|
|||||||
|
# Calibrated working geometry for the grant reference executor.
|
||||||
|
#
|
||||||
|
# All lengths are metres, all angles are degrees unless a key says rad.
|
||||||
|
# Every transform below names its parent and child frame. The numeric values
|
||||||
|
# were measured from the meshes in iiwa_description/objects/grant, from
|
||||||
|
# patron.xacro and from the actual Webots scene. They are deliberately kept
|
||||||
|
# in one file so the executor never hard-codes a working pose.
|
||||||
|
schema_version: 1
|
||||||
|
units: m
|
||||||
|
angle_units: deg
|
||||||
|
|
||||||
|
frames:
|
||||||
|
world: {parent: null}
|
||||||
|
base_link: {parent: world}
|
||||||
|
patron: {parent: base_link}
|
||||||
|
tcp: {parent: patron}
|
||||||
|
|
||||||
|
# Webots spawns the robot at this fixed world pose (setting.yaml transform).
|
||||||
|
world_to_base:
|
||||||
|
xyz: [-0.25, 0.0, 0.79]
|
||||||
|
rpy_deg: [0.0, 0.0, 0.0]
|
||||||
|
parent: world
|
||||||
|
child: base_link
|
||||||
|
|
||||||
|
# patron.xacro: patron_tcp origin is 55.2 mm along patron Z, rotated -90 deg.
|
||||||
|
patron_to_tcp:
|
||||||
|
xyz: [0.0, 0.0, 0.0552]
|
||||||
|
rpy_deg: [0.0, 0.0, -90.0]
|
||||||
|
parent: patron
|
||||||
|
child: tcp
|
||||||
|
|
||||||
|
# The mount (top of a working tool) sits exactly at tcp. The working tip is
|
||||||
|
# 0.140 m further along tcp +Z, which points towards the work piece when the
|
||||||
|
# arm is in a working pose.
|
||||||
|
tool_mounts:
|
||||||
|
vacuum: {xyz: [0.0, 0.0, 0.0], rpy_deg: [0.0, 0.0, 0.0]}
|
||||||
|
magnetic_tool: {xyz: [0.0, 0.0, 0.0], rpy_deg: [0.0, 0.0, 0.0]}
|
||||||
|
sealant_tool: {xyz: [0.0, 0.0, 0.0], rpy_deg: [0.0, 0.0, 0.0]}
|
||||||
|
|
||||||
|
tool_work_tips:
|
||||||
|
vacuum: {xyz: [0.0, 0.0, 0.140], rpy_deg: [0.0, 0.0, 0.0]}
|
||||||
|
magnetic_tool: {xyz: [0.0, 0.0, 0.140], rpy_deg: [0.0, 0.0, 0.0]}
|
||||||
|
sealant_tool: {xyz: [0.0, 0.0, 0.140], rpy_deg: [0.0, 0.0, 0.0]}
|
||||||
|
|
||||||
|
# Transform from tcp to the Webots node origin (bottom of the vertical OBJ).
|
||||||
|
# The OBJ Y axis is imported as Webots Z, so the node is Rx(-90) relative to a
|
||||||
|
# tcp that points down.
|
||||||
|
tool_tcp_to_nodes:
|
||||||
|
vacuum: {xyz: [0.0, 0.0, 0.140], rpy_deg: [-90.0, 0.0, 0.0]}
|
||||||
|
magnetic_tool: {xyz: [0.0, 0.0, 0.140], rpy_deg: [-90.0, 0.0, 0.0]}
|
||||||
|
sealant_tool: {xyz: [0.0, 0.0, 0.140], rpy_deg: [-90.0, 0.0, 0.0]}
|
||||||
|
|
||||||
|
# Boxes expressed in the tcp frame, used for the attached collision object.
|
||||||
|
tool_collisions:
|
||||||
|
vacuum: {size: [0.0821, 0.081, 0.140], offset: [0.0, 0.0, 0.070]}
|
||||||
|
magnetic_tool: {size: [0.036, 0.036, 0.140], offset: [0.0, 0.0, 0.070]}
|
||||||
|
sealant_tool: {size: [0.0561, 0.0561, 0.140], offset: [0.0, 0.0, 0.070]}
|
||||||
|
|
||||||
|
# Hatch (cap.obj) is 0.1384 x 0.0984 x 0.00367. Its node origin is the lower
|
||||||
|
# face; the suction cup touches the highest point (local Y = 0.006).
|
||||||
|
cap_tcp_to_node:
|
||||||
|
xyz: [0.0, 0.0, 0.146]
|
||||||
|
rpy_deg: [-90.0, 0.0, 0.0]
|
||||||
|
cap_collision:
|
||||||
|
# Expressed in tcp: the plate is horizontal, 3.67 mm thick.
|
||||||
|
size: [0.1384, 0.0984, 0.00367]
|
||||||
|
offset: [0.0, 0.0, 0.141835]
|
||||||
|
# Installed hatch pose relative to the body node (body local Y is world Z).
|
||||||
|
body_to_installed_cap:
|
||||||
|
xyz: [0.0, 0.00294, 0.0]
|
||||||
|
rpy_deg: [0.0, 0.0, 0.0]
|
||||||
|
|
||||||
|
# Screws stand with their head up. The magnet touches the head, 9 mm above
|
||||||
|
# the node origin; the screw node is therefore 149 mm below tcp.
|
||||||
|
screw_tcp_to_node:
|
||||||
|
xyz: [0.0, 0.0, 0.149]
|
||||||
|
rpy_deg: [-90.0, 0.0, 0.0]
|
||||||
|
screw_collision:
|
||||||
|
size: [0.0055, 0.0055, 0.009]
|
||||||
|
offset: [0.0, 0.0, 0.1445]
|
||||||
|
|
||||||
|
# Hole centres are measured in the world frame of the standard, unshifted
|
||||||
|
# body at 45 deg. They rotate with the body. The values agree with
|
||||||
|
# doc/experiment-scenes.md.
|
||||||
|
hole_reference_yaw_deg: 45.0
|
||||||
|
hole_z_above_body_origin: 0.00894
|
||||||
|
handoff_gap: 0.002
|
||||||
|
holes:
|
||||||
|
hole_1: {dx: -0.0757, dy: -0.0148}
|
||||||
|
hole_2: {dx: -0.0148, dy: -0.0757}
|
||||||
|
hole_3: {dx: 0.0757, dy: 0.0148}
|
||||||
|
hole_4: {dx: 0.0148, dy: 0.0757}
|
||||||
|
|
||||||
|
# Sealant ring: a closed rectangle just inside the hatch rim. Half sizes are
|
||||||
|
# in the hatch body plane; the ring is traversed counter-clockwise starting at
|
||||||
|
# the hole_1 side. Segment endpoints are derived from these measured values.
|
||||||
|
sealant_reference_yaw_deg: 45.0
|
||||||
|
sealant_rect:
|
||||||
|
half_x: 0.058
|
||||||
|
half_z: 0.037
|
||||||
|
sealant_z_above_body_top: 0.0015
|
||||||
|
sealant_sides:
|
||||||
|
side_1: {start: [-0.06718, -0.01485], end: [-0.01485, -0.06718]}
|
||||||
|
side_2: {start: [-0.01485, -0.06718], end: [0.06718, 0.01485]}
|
||||||
|
side_3: {start: [0.06718, 0.01485], end: [0.01485, 0.06718]}
|
||||||
|
side_4: {start: [0.01485, 0.06718], end: [-0.06718, -0.01485]}
|
||||||
|
|
||||||
|
# Approach / transfer clearances and limits.
|
||||||
|
approach_gap: 0.060
|
||||||
|
contact_gap: 0.003
|
||||||
|
transfer_gap: 0.080
|
||||||
|
seat_gap: 0.002
|
||||||
|
position_tolerance: 0.006
|
||||||
|
orientation_tolerance_rad: 0.08
|
||||||
|
speeds:
|
||||||
|
transfer: 0.35
|
||||||
|
approach: 0.18
|
||||||
|
contact: 0.06
|
||||||
|
contour: 0.09
|
||||||
|
|
||||||
|
# Conservative collision models for the static scene. They mirror the
|
||||||
|
# Webots bounding objects so MoveIt and the simulator agree.
|
||||||
|
scene_collisions:
|
||||||
|
table:
|
||||||
|
type: box
|
||||||
|
size: [1.28, 1.08, 0.045]
|
||||||
|
center_z: 0.7705
|
||||||
|
fixture:
|
||||||
|
type: box
|
||||||
|
size: [0.350, 0.220, 0.041]
|
||||||
|
offset_z: 0.0105
|
||||||
|
body:
|
||||||
|
type: box
|
||||||
|
size: [0.300, 0.170, 0.00915]
|
||||||
|
offset_z: 0.000695
|
||||||
|
rack:
|
||||||
|
type: box
|
||||||
|
size: [0.420, 0.140, 0.010]
|
||||||
|
offset_z: 0.005
|
||||||
|
tray_base:
|
||||||
|
type: box
|
||||||
|
size: [0.120, 0.070, 0.0024]
|
||||||
|
offset_z: 0.0012
|
||||||
|
tray_wall_x:
|
||||||
|
type: box
|
||||||
|
size: [0.002, 0.070, 0.010]
|
||||||
|
offset_z: 0.005
|
||||||
|
offset_x: 0.059
|
||||||
|
tray_wall_y:
|
||||||
|
type: box
|
||||||
|
size: [0.120, 0.002, 0.010]
|
||||||
|
offset_z: 0.005
|
||||||
|
offset_y: 0.034
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
"""Calibrated transforms, working tips and scene collision models.
|
||||||
|
|
||||||
|
This module is pure Python (numpy/scipy only) so the same numbers are used by
|
||||||
|
the unit tests, the MoveIt bridge, the motion adapter and the Webots
|
||||||
|
supervisor. Nothing here talks to ROS or Webots.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import yaml
|
||||||
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
|
from .scenario import SCREW_HOLES, SEALANT_SIDES, ScenarioSpec, Tool
|
||||||
|
|
||||||
|
|
||||||
|
TOOL_GEOMETRY_KEYS = ('vacuum', 'magnetic_tool', 'sealant_tool')
|
||||||
|
_SEALANT_SIDES = SEALANT_SIDES
|
||||||
|
|
||||||
|
|
||||||
|
class GeometryError(ValueError):
|
||||||
|
"""Raised when the geometry file is missing or inconsistent."""
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any, label: str) -> float:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise GeometryError(f'{label} must be a finite number')
|
||||||
|
result = float(value)
|
||||||
|
if not np.isfinite(result):
|
||||||
|
raise GeometryError(f'{label} must be a finite number')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _vector(value: Any, label: str, length: int) -> tuple[float, ...]:
|
||||||
|
if not isinstance(value, (list, tuple)) or len(value) != length:
|
||||||
|
raise GeometryError(f'{label} must be a list of {length} numbers')
|
||||||
|
return tuple(_number(item, f'{label}[{index}]') for index, item in enumerate(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any, label: str) -> Mapping[str, Any]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise GeometryError(f'{label} must be a mapping')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Box:
|
||||||
|
"""Axis-aligned box in a named frame."""
|
||||||
|
|
||||||
|
size: tuple[float, float, float]
|
||||||
|
offset: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def half_extents(self) -> tuple[float, float, float]:
|
||||||
|
return tuple(value / 2.0 for value in self.size)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Transform:
|
||||||
|
"""Rigid transform ``parent -> child``.
|
||||||
|
|
||||||
|
``rotation`` is a 3x3 row-major matrix. Composition follows the usual
|
||||||
|
convention: ``parent_to_child.compose(child_to_grandchild)``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
xyz: tuple[float, float, float]
|
||||||
|
rotation: tuple[tuple[float, float, float], ...]
|
||||||
|
parent: str
|
||||||
|
child: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_xyz_rpy(
|
||||||
|
cls,
|
||||||
|
xyz,
|
||||||
|
rpy_deg,
|
||||||
|
*,
|
||||||
|
parent: str,
|
||||||
|
child: str,
|
||||||
|
) -> 'Transform':
|
||||||
|
values = _vector(xyz, f'{parent}->{child}.xyz', 3)
|
||||||
|
angles = _vector(rpy_deg, f'{parent}->{child}.rpy_deg', 3)
|
||||||
|
matrix = Rotation.from_euler('xyz', angles, degrees=True).as_matrix()
|
||||||
|
return cls(
|
||||||
|
xyz=values,
|
||||||
|
rotation=tuple(tuple(float(v) for v in row) for row in matrix),
|
||||||
|
parent=parent,
|
||||||
|
child=child,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def matrix(self) -> np.ndarray:
|
||||||
|
return np.asarray(self.rotation, dtype=float)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def position(self) -> np.ndarray:
|
||||||
|
return np.asarray(self.xyz, dtype=float)
|
||||||
|
|
||||||
|
def compose_relaxed(self, other: 'Transform') -> 'Transform':
|
||||||
|
"""Compose two transforms whose frame labels are documentation only."""
|
||||||
|
rotation = self.matrix @ other.matrix
|
||||||
|
translation = self.position + self.matrix @ other.position
|
||||||
|
return Transform(
|
||||||
|
xyz=tuple(float(v) for v in translation),
|
||||||
|
rotation=tuple(tuple(float(v) for v in row) for row in rotation),
|
||||||
|
parent=self.parent,
|
||||||
|
child=other.child,
|
||||||
|
)
|
||||||
|
|
||||||
|
def compose(self, other: 'Transform') -> 'Transform':
|
||||||
|
if self.child != other.parent:
|
||||||
|
raise GeometryError(
|
||||||
|
f'cannot compose {self.parent}->{self.child} with '
|
||||||
|
f'{other.parent}->{other.child}'
|
||||||
|
)
|
||||||
|
rotation = self.matrix @ other.matrix
|
||||||
|
translation = self.position + self.matrix @ other.position
|
||||||
|
return Transform(
|
||||||
|
xyz=tuple(float(v) for v in translation),
|
||||||
|
rotation=tuple(tuple(float(v) for v in row) for row in rotation),
|
||||||
|
parent=self.parent,
|
||||||
|
child=other.child,
|
||||||
|
)
|
||||||
|
|
||||||
|
def inverse(self) -> 'Transform':
|
||||||
|
rotation = self.matrix.T
|
||||||
|
translation = -rotation @ self.position
|
||||||
|
return Transform(
|
||||||
|
xyz=tuple(float(v) for v in translation),
|
||||||
|
rotation=tuple(tuple(float(v) for v in row) for row in rotation),
|
||||||
|
parent=self.child,
|
||||||
|
child=self.parent,
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply(self, point) -> tuple[float, float, float]:
|
||||||
|
result = self.matrix @ np.asarray(point, dtype=float) + self.position
|
||||||
|
return tuple(float(v) for v in result)
|
||||||
|
|
||||||
|
def distance_to(self, other: 'Transform') -> float:
|
||||||
|
if self.parent != other.parent:
|
||||||
|
raise GeometryError('cannot compare transforms in different parents')
|
||||||
|
return float(np.linalg.norm(self.position - other.position))
|
||||||
|
|
||||||
|
def angle_to(self, other: 'Transform', *, parent: str | None = None) -> float:
|
||||||
|
matrix = self.matrix
|
||||||
|
other_matrix = other.matrix
|
||||||
|
if parent is not None:
|
||||||
|
matrix = _frame_matrix(self, parent) @ matrix
|
||||||
|
other_matrix = _frame_matrix(other, parent) @ other_matrix
|
||||||
|
cosine = (np.trace(matrix.T @ other_matrix) - 1.0) / 2.0
|
||||||
|
return float(np.arccos(max(-1.0, min(1.0, cosine))))
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_matrix(transform: Transform, frame: str) -> np.ndarray:
|
||||||
|
# Kept small and explicit: only world/base_link are ever needed here.
|
||||||
|
del transform
|
||||||
|
if frame in ('base_link', 'world'):
|
||||||
|
return np.eye(3)
|
||||||
|
raise GeometryError(f'unsupported comparison frame {frame!r}')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HoleSpec:
|
||||||
|
name: str
|
||||||
|
dx: float
|
||||||
|
dy: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SegmentSpec:
|
||||||
|
name: str
|
||||||
|
start: tuple[float, float]
|
||||||
|
end: tuple[float, float]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GeometrySpec:
|
||||||
|
world_to_base: Transform
|
||||||
|
patron_to_tcp: Transform
|
||||||
|
tool_mounts: dict[str, Transform]
|
||||||
|
tool_work_tips: dict[str, Transform]
|
||||||
|
tool_tcp_to_nodes: dict[str, Transform]
|
||||||
|
tool_collisions: dict[str, Box]
|
||||||
|
cap_tcp_to_node: Transform
|
||||||
|
cap_collision: Box
|
||||||
|
body_to_installed_cap: Transform
|
||||||
|
screw_tcp_to_node: Transform
|
||||||
|
screw_collision: Box
|
||||||
|
holes: dict[str, HoleSpec]
|
||||||
|
hole_reference_yaw_deg: float
|
||||||
|
hole_z_above_body_origin: float
|
||||||
|
handoff_gap: float
|
||||||
|
sealant_reference_yaw_deg: float
|
||||||
|
sealant_sides: dict[str, SegmentSpec]
|
||||||
|
sealant_z_above_body_top: float
|
||||||
|
approach_gap: float
|
||||||
|
contact_gap: float
|
||||||
|
transfer_gap: float
|
||||||
|
seat_gap: float
|
||||||
|
position_tolerance: float
|
||||||
|
orientation_tolerance_rad: float
|
||||||
|
speeds: dict[str, float]
|
||||||
|
scene_collisions: dict[str, Box]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def body_top_local(self) -> float:
|
||||||
|
body = self.scene_collisions['body']
|
||||||
|
return body.offset[2] + body.size[2] / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _load_transform(raw: Any, label: str, parent: str, child: str) -> Transform:
|
||||||
|
data = _mapping(raw, label)
|
||||||
|
return Transform.from_xyz_rpy(
|
||||||
|
data.get('xyz', [0.0, 0.0, 0.0]),
|
||||||
|
data.get('rpy_deg', [0.0, 0.0, 0.0]),
|
||||||
|
parent=parent,
|
||||||
|
child=child,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_box(raw: Any, label: str) -> Box:
|
||||||
|
data = _mapping(raw, label)
|
||||||
|
size = _vector(data.get('size'), f'{label}.size', 3)
|
||||||
|
offset = _vector(data.get('offset', [0.0, 0.0, 0.0]), f'{label}.offset', 3)
|
||||||
|
if any(value <= 0.0 for value in size):
|
||||||
|
raise GeometryError(f'{label}.size must be positive')
|
||||||
|
return Box(size=size, offset=offset)
|
||||||
|
|
||||||
|
|
||||||
|
def load_geometry(path: Path | str) -> GeometrySpec:
|
||||||
|
"""Load and validate ``config/geometry.yaml``."""
|
||||||
|
source = Path(path)
|
||||||
|
if not source.is_file():
|
||||||
|
raise FileNotFoundError(f'geometry file does not exist: {source}')
|
||||||
|
try:
|
||||||
|
raw = yaml.safe_load(source.read_text(encoding='utf-8'))
|
||||||
|
except yaml.YAMLError as error:
|
||||||
|
raise GeometryError(f'invalid YAML in {source}: {error}') from error
|
||||||
|
data = _mapping(raw, 'geometry')
|
||||||
|
if data.get('schema_version') != 1:
|
||||||
|
raise GeometryError('geometry schema_version must be 1')
|
||||||
|
|
||||||
|
world_to_base = _load_transform(
|
||||||
|
data.get('world_to_base'), 'world_to_base', 'world', 'base_link',
|
||||||
|
)
|
||||||
|
patron_to_tcp = _load_transform(
|
||||||
|
data.get('patron_to_tcp'), 'patron_to_tcp', 'patron', 'tcp',
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_mounts_raw = _mapping(data.get('tool_mounts'), 'tool_mounts')
|
||||||
|
tool_tips_raw = _mapping(data.get('tool_work_tips'), 'tool_work_tips')
|
||||||
|
tool_nodes_raw = _mapping(data.get('tool_tcp_to_nodes'), 'tool_tcp_to_nodes')
|
||||||
|
tool_collisions_raw = _mapping(data.get('tool_collisions'), 'tool_collisions')
|
||||||
|
missing = [name for name in TOOL_GEOMETRY_KEYS if name not in tool_mounts_raw]
|
||||||
|
if missing:
|
||||||
|
raise GeometryError(f'tool_mounts is missing {missing}')
|
||||||
|
tool_mounts = {
|
||||||
|
name: _load_transform(
|
||||||
|
tool_mounts_raw[name], f'tool_mounts.{name}', 'tcp', f'{name}_mount',
|
||||||
|
)
|
||||||
|
for name in TOOL_GEOMETRY_KEYS
|
||||||
|
}
|
||||||
|
tool_work_tips = {
|
||||||
|
name: _load_transform(
|
||||||
|
tool_tips_raw[name], f'tool_work_tips.{name}', 'tcp', f'{name}_tip',
|
||||||
|
)
|
||||||
|
for name in TOOL_GEOMETRY_KEYS
|
||||||
|
}
|
||||||
|
tool_tcp_to_nodes = {
|
||||||
|
name: _load_transform(
|
||||||
|
tool_nodes_raw[name], f'tool_tcp_to_nodes.{name}', 'tcp', f'tool_{name}',
|
||||||
|
)
|
||||||
|
for name in TOOL_GEOMETRY_KEYS
|
||||||
|
}
|
||||||
|
tool_collisions = {
|
||||||
|
name: _load_box(tool_collisions_raw[name], f'tool_collisions.{name}')
|
||||||
|
for name in TOOL_GEOMETRY_KEYS
|
||||||
|
}
|
||||||
|
|
||||||
|
holes_raw = _mapping(data.get('holes'), 'holes')
|
||||||
|
holes = {}
|
||||||
|
for name in SCREW_HOLES:
|
||||||
|
if name not in holes_raw:
|
||||||
|
raise GeometryError(f'holes is missing {name}')
|
||||||
|
entry = _mapping(holes_raw[name], f'holes.{name}')
|
||||||
|
holes[name] = HoleSpec(
|
||||||
|
name=name,
|
||||||
|
dx=_number(entry.get('dx'), f'holes.{name}.dx'),
|
||||||
|
dy=_number(entry.get('dy'), f'holes.{name}.dy'),
|
||||||
|
)
|
||||||
|
|
||||||
|
sides_raw = _mapping(data.get('sealant_sides'), 'sealant_sides')
|
||||||
|
sealant_sides = {}
|
||||||
|
for name in _SEALANT_SIDES:
|
||||||
|
entry = _mapping(sides_raw.get(name), f'sealant_sides.{name}')
|
||||||
|
sealant_sides[name] = SegmentSpec(
|
||||||
|
name=name,
|
||||||
|
start=_vector(entry.get('start'), f'sealant_sides.{name}.start', 2),
|
||||||
|
end=_vector(entry.get('end'), f'sealant_sides.{name}.end', 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
speeds_raw = _mapping(data.get('speeds'), 'speeds')
|
||||||
|
speeds = {
|
||||||
|
key: _number(speeds_raw.get(key), f'speeds.{key}')
|
||||||
|
for key in ('transfer', 'approach', 'contact', 'contour')
|
||||||
|
}
|
||||||
|
for key, value in speeds.items():
|
||||||
|
if not 0.0 < value <= 1.0:
|
||||||
|
raise GeometryError(f'speeds.{key} must be within (0, 1]')
|
||||||
|
|
||||||
|
scene_raw = _mapping(data.get('scene_collisions'), 'scene_collisions')
|
||||||
|
scene_collisions = {}
|
||||||
|
for name, entry in scene_raw.items():
|
||||||
|
box_data = _mapping(entry, f'scene_collisions.{name}')
|
||||||
|
size = _vector(box_data.get('size'), f'scene_collisions.{name}.size', 3)
|
||||||
|
offset = (
|
||||||
|
_number(box_data.get('offset_x', 0.0), f'scene_collisions.{name}.offset_x'),
|
||||||
|
_number(box_data.get('offset_y', 0.0), f'scene_collisions.{name}.offset_y'),
|
||||||
|
_number(box_data.get('offset_z', 0.0), f'scene_collisions.{name}.offset_z'),
|
||||||
|
)
|
||||||
|
scene_collisions[name] = Box(size=size, offset=offset)
|
||||||
|
|
||||||
|
positive = {
|
||||||
|
'approach_gap': data.get('approach_gap'),
|
||||||
|
'contact_gap': data.get('contact_gap'),
|
||||||
|
'transfer_gap': data.get('transfer_gap'),
|
||||||
|
'seat_gap': data.get('seat_gap'),
|
||||||
|
'position_tolerance': data.get('position_tolerance'),
|
||||||
|
}
|
||||||
|
values = {
|
||||||
|
key: _number(value, key) for key, value in positive.items()
|
||||||
|
}
|
||||||
|
for key, value in values.items():
|
||||||
|
if value <= 0.0:
|
||||||
|
raise GeometryError(f'{key} must be positive')
|
||||||
|
orientation_tolerance = _number(
|
||||||
|
data.get('orientation_tolerance_rad'), 'orientation_tolerance_rad'
|
||||||
|
)
|
||||||
|
if orientation_tolerance <= 0.0:
|
||||||
|
raise GeometryError('orientation_tolerance_rad must be positive')
|
||||||
|
|
||||||
|
cap_tcp_to_node = _load_transform(
|
||||||
|
data.get('cap_tcp_to_node'), 'cap_tcp_to_node', 'tcp', 'cap_node',
|
||||||
|
)
|
||||||
|
body_to_cap = _load_transform(
|
||||||
|
data.get('body_to_installed_cap'), 'body_to_installed_cap', 'body', 'cap_seat',
|
||||||
|
)
|
||||||
|
screw_tcp_to_node = _load_transform(
|
||||||
|
data.get('screw_tcp_to_node'), 'screw_tcp_to_node', 'tcp', 'screw_node',
|
||||||
|
)
|
||||||
|
|
||||||
|
return GeometrySpec(
|
||||||
|
world_to_base=world_to_base,
|
||||||
|
patron_to_tcp=patron_to_tcp,
|
||||||
|
tool_mounts=tool_mounts,
|
||||||
|
tool_work_tips=tool_work_tips,
|
||||||
|
tool_tcp_to_nodes=tool_tcp_to_nodes,
|
||||||
|
tool_collisions=tool_collisions,
|
||||||
|
cap_tcp_to_node=cap_tcp_to_node,
|
||||||
|
cap_collision=_load_box(data.get('cap_collision'), 'cap_collision'),
|
||||||
|
body_to_installed_cap=body_to_cap,
|
||||||
|
screw_tcp_to_node=screw_tcp_to_node,
|
||||||
|
screw_collision=_load_box(data.get('screw_collision'), 'screw_collision'),
|
||||||
|
holes=holes,
|
||||||
|
hole_reference_yaw_deg=_number(
|
||||||
|
data.get('hole_reference_yaw_deg'), 'hole_reference_yaw_deg'
|
||||||
|
),
|
||||||
|
hole_z_above_body_origin=_number(
|
||||||
|
data.get('hole_z_above_body_origin'), 'hole_z_above_body_origin'
|
||||||
|
),
|
||||||
|
handoff_gap=_number(data.get('handoff_gap'), 'handoff_gap'),
|
||||||
|
sealant_reference_yaw_deg=_number(
|
||||||
|
data.get('sealant_reference_yaw_deg'), 'sealant_reference_yaw_deg'
|
||||||
|
),
|
||||||
|
sealant_sides=sealant_sides,
|
||||||
|
sealant_z_above_body_top=_number(
|
||||||
|
data.get('sealant_z_above_body_top'), 'sealant_z_above_body_top'
|
||||||
|
),
|
||||||
|
approach_gap=values['approach_gap'],
|
||||||
|
contact_gap=values['contact_gap'],
|
||||||
|
transfer_gap=values['transfer_gap'],
|
||||||
|
seat_gap=values['seat_gap'],
|
||||||
|
position_tolerance=values['position_tolerance'],
|
||||||
|
orientation_tolerance_rad=orientation_tolerance,
|
||||||
|
speeds=speeds,
|
||||||
|
scene_collisions=scene_collisions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def object_pose_transform(
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
z: float,
|
||||||
|
yaw_deg: float,
|
||||||
|
*,
|
||||||
|
parent: str = 'world',
|
||||||
|
child: str,
|
||||||
|
) -> Transform:
|
||||||
|
"""Webots pose of an OBJ whose local Y axis becomes world Z."""
|
||||||
|
return Transform.from_xyz_rpy(
|
||||||
|
[x, y, z], [90.0, 0.0, yaw_deg], parent=parent, child=child,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_transform(tool: Tool, parent: str = 'world') -> Transform:
|
||||||
|
return object_pose_transform(
|
||||||
|
tool.pose.x,
|
||||||
|
tool.pose.y,
|
||||||
|
tool.pose.z,
|
||||||
|
tool.pose.yaw_deg + 180.0,
|
||||||
|
parent=parent,
|
||||||
|
child=f'tool_{tool.name}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _body_transform(spec: ScenarioSpec) -> Transform:
|
||||||
|
return object_pose_transform(
|
||||||
|
spec.body.x, spec.body.y, spec.body.z, spec.body.yaw_deg,
|
||||||
|
parent='world', child='body',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _down_transform(x: float, y: float, z: float, yaw_deg: float, child: str) -> Transform:
|
||||||
|
"""Work-point pose whose local Z points down (tool working direction)."""
|
||||||
|
return Transform.from_xyz_rpy(
|
||||||
|
[x, y, z], [180.0, 0.0, yaw_deg], parent='world', child=child,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rotate_xy(dx: float, dy: float, degrees: float) -> tuple[float, float]:
|
||||||
|
angle = np.radians(degrees)
|
||||||
|
cosine, sine = np.cos(angle), np.sin(angle)
|
||||||
|
return dx * cosine - dy * sine, dx * sine + dy * cosine
|
||||||
|
|
||||||
|
|
||||||
|
def world_target(spec: ScenarioSpec, geometry: GeometrySpec, name: str) -> Transform:
|
||||||
|
"""Return the named world pose from an already validated scenario.
|
||||||
|
|
||||||
|
Names:
|
||||||
|
``base_link``, ``tool:<name>``, ``cap``, ``cap_seat``,
|
||||||
|
``screw:<screw_i>``, ``hole:<hole_i>`` and ``sealant:<side_i>``.
|
||||||
|
"""
|
||||||
|
if name == 'base_link':
|
||||||
|
return geometry.world_to_base
|
||||||
|
if name.startswith('tool:'):
|
||||||
|
tool_name = name.split(':', 1)[1]
|
||||||
|
tools = {tool.name: tool for tool in spec.tools}
|
||||||
|
if tool_name not in tools:
|
||||||
|
raise GeometryError(f'experiment {spec.experiment_id} has no tool {tool_name!r}')
|
||||||
|
return _tool_transform(tools[tool_name])
|
||||||
|
if name == 'cap':
|
||||||
|
if spec.cap_pose is None:
|
||||||
|
raise GeometryError(f'experiment {spec.experiment_id} has no hatch')
|
||||||
|
pose = spec.cap_pose
|
||||||
|
return object_pose_transform(
|
||||||
|
pose.x, pose.y, pose.z, pose.yaw_deg, child='cap',
|
||||||
|
)
|
||||||
|
if name == 'cap_seat':
|
||||||
|
return _body_transform(spec).compose(geometry.body_to_installed_cap)
|
||||||
|
if name.startswith('screw:'):
|
||||||
|
screw_name = name.split(':', 1)[1]
|
||||||
|
screws = {screw.name: screw for screw in spec.screws}
|
||||||
|
if screw_name not in screws:
|
||||||
|
raise GeometryError(
|
||||||
|
f'experiment {spec.experiment_id} has no screw {screw_name!r}'
|
||||||
|
)
|
||||||
|
screw = screws[screw_name]
|
||||||
|
return object_pose_transform(
|
||||||
|
screw.pose.x, screw.pose.y, screw.pose.z,
|
||||||
|
screw.pose.yaw_deg, child=screw.name,
|
||||||
|
)
|
||||||
|
if name.startswith('hole:'):
|
||||||
|
hole_name = name.split(':', 1)[1]
|
||||||
|
if hole_name not in geometry.holes:
|
||||||
|
raise GeometryError(f'unknown hole {hole_name!r}')
|
||||||
|
hole = geometry.holes[hole_name]
|
||||||
|
dx, dy = _rotate_xy(
|
||||||
|
hole.dx, hole.dy, spec.body.yaw_deg - geometry.hole_reference_yaw_deg,
|
||||||
|
)
|
||||||
|
return _down_transform(
|
||||||
|
spec.body.x + dx,
|
||||||
|
spec.body.y + dy,
|
||||||
|
spec.body.z + geometry.hole_z_above_body_origin + geometry.handoff_gap,
|
||||||
|
spec.body.yaw_deg,
|
||||||
|
child=hole_name,
|
||||||
|
)
|
||||||
|
if name.startswith('sealant:'):
|
||||||
|
side_name = name.split(':', 1)[1]
|
||||||
|
return sealant_point(geometry, spec, side_name, 0.0)
|
||||||
|
raise GeometryError(f'unknown world target {name!r}')
|
||||||
|
|
||||||
|
|
||||||
|
def sealant_point(
|
||||||
|
geometry: GeometrySpec,
|
||||||
|
spec: ScenarioSpec,
|
||||||
|
side_name: str,
|
||||||
|
fraction: float,
|
||||||
|
) -> Transform:
|
||||||
|
"""Return a work-point transform along one named sealant segment."""
|
||||||
|
if side_name not in geometry.sealant_sides:
|
||||||
|
raise GeometryError(f'unknown sealant side {side_name!r}')
|
||||||
|
if not 0.0 <= fraction <= 1.0:
|
||||||
|
raise GeometryError('sealant fraction must be within 0..1')
|
||||||
|
segment = geometry.sealant_sides[side_name]
|
||||||
|
dx = segment.start[0] + (segment.end[0] - segment.start[0]) * fraction
|
||||||
|
dy = segment.start[1] + (segment.end[1] - segment.start[1]) * fraction
|
||||||
|
dx, dy = _rotate_xy(
|
||||||
|
dx, dy, spec.body.yaw_deg - geometry.sealant_reference_yaw_deg,
|
||||||
|
)
|
||||||
|
z = spec.body.z + geometry.body_top_local + geometry.sealant_z_above_body_top
|
||||||
|
return _down_transform(
|
||||||
|
spec.body.x + dx, spec.body.y + dy, z, spec.body.yaw_deg,
|
||||||
|
child=f'sealant_{side_name}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tcp_goal(target: Transform, tcp_to_work_tip: Transform) -> Transform:
|
||||||
|
"""Convert a work-point target into the required tcp pose."""
|
||||||
|
return target.compose_relaxed(tcp_to_work_tip.inverse())
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Calibrated geometry: tool mounting, hatch, contour and holes."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
|
||||||
|
from iiwa_experement.geometry import (
|
||||||
|
GeometryError,
|
||||||
|
Transform,
|
||||||
|
load_geometry,
|
||||||
|
object_pose_transform,
|
||||||
|
sealant_point,
|
||||||
|
tcp_goal,
|
||||||
|
world_target,
|
||||||
|
)
|
||||||
|
from iiwa_experement.scenario import load_scenario
|
||||||
|
|
||||||
|
|
||||||
|
PACKAGE = Path(__file__).resolve().parents[1]
|
||||||
|
SCENES = PACKAGE / 'config' / 'scenes.yaml'
|
||||||
|
GEOMETRY = PACKAGE / 'config' / 'geometry.yaml'
|
||||||
|
ASSET_ROOT = (
|
||||||
|
Path(get_package_share_directory('iiwa_description')) / 'objects' / 'grant'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def geometry():
|
||||||
|
return load_geometry(GEOMETRY)
|
||||||
|
|
||||||
|
|
||||||
|
def scenario(experiment_id):
|
||||||
|
return load_scenario(experiment_id, SCENES)
|
||||||
|
|
||||||
|
|
||||||
|
def obj_y_bounds(asset):
|
||||||
|
low, high = np.inf, -np.inf
|
||||||
|
for line in (ASSET_ROOT / asset).read_text(encoding='utf-8', errors='replace').splitlines():
|
||||||
|
if line.startswith('v '):
|
||||||
|
y = float(line.split()[2])
|
||||||
|
low, high = min(low, y), max(high, y)
|
||||||
|
return low, high
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_lengths_and_sealant_scale():
|
||||||
|
for asset in ('vacum-tool.obj', 'magnit-tool.obj', 'wranch-tool.obj'):
|
||||||
|
low, high = obj_y_bounds(asset)
|
||||||
|
assert low == pytest.approx(0.0, abs=1e-12)
|
||||||
|
assert high == pytest.approx(0.140, abs=1e-12)
|
||||||
|
low, high = obj_y_bounds('germetic-tool.obj')
|
||||||
|
assert low == pytest.approx(0.0, abs=1e-12)
|
||||||
|
assert high == pytest.approx(0.014, abs=1e-12)
|
||||||
|
assert high * 10.0 == pytest.approx(0.140)
|
||||||
|
|
||||||
|
|
||||||
|
def test_patron_tcp_offset_is_55_2_mm():
|
||||||
|
spec = geometry()
|
||||||
|
assert spec.patron_to_tcp.parent == 'patron'
|
||||||
|
assert spec.patron_to_tcp.child == 'tcp'
|
||||||
|
assert spec.patron_to_tcp.position == pytest.approx([0.0, 0.0, 0.0552])
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_tools_mount_at_tcp_and_end_fourteen_centimetres_away():
|
||||||
|
spec = geometry()
|
||||||
|
for name in ('vacuum', 'magnetic_tool', 'sealant_tool'):
|
||||||
|
mount = spec.tool_mounts[name]
|
||||||
|
tip = spec.tool_work_tips[name]
|
||||||
|
assert mount.parent == tip.parent == 'tcp'
|
||||||
|
assert mount.position == pytest.approx([0.0, 0.0, 0.0])
|
||||||
|
assert tip.position == pytest.approx([0.0, 0.0, 0.140])
|
||||||
|
node = spec.tool_tcp_to_nodes[name]
|
||||||
|
assert node.position == pytest.approx([0.0, 0.0, 0.140])
|
||||||
|
# The node transform must reproduce an upright OBJ.
|
||||||
|
world_tcp = Transform.from_xyz_rpy(
|
||||||
|
[0.0, 0.0, 0.93], [180.0, 0.0, 45.0], parent='world', child='tcp',
|
||||||
|
)
|
||||||
|
tool_mount = world_tcp.compose(mount)
|
||||||
|
assert tool_mount.parent == 'world'
|
||||||
|
assert tool_mount.child == f'{name}_mount'
|
||||||
|
node_world = world_tcp.compose(node)
|
||||||
|
# Node origin is the working tip, 0.14 below the mount.
|
||||||
|
assert node_world.position == pytest.approx([0.0, 0.0, 0.79], abs=1e-12)
|
||||||
|
assert np.linalg.det(node_world.matrix) == pytest.approx(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ten_screws_have_distinct_ids_and_poses():
|
||||||
|
for experiment_id in range(11, 21):
|
||||||
|
spec = scenario(experiment_id)
|
||||||
|
assert len(spec.screws) == 10
|
||||||
|
assert len({screw.name for screw in spec.screws}) == 10
|
||||||
|
assert len({(screw.pose.x, screw.pose.y) for screw in spec.screws}) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiment_three_moves_holes_with_the_body():
|
||||||
|
standard = scenario(1)
|
||||||
|
shifted = scenario(3)
|
||||||
|
g = geometry()
|
||||||
|
for hole in ('hole_1', 'hole_2', 'hole_3', 'hole_4'):
|
||||||
|
ref = world_target(standard, g, f'hole:{hole}')
|
||||||
|
moved = world_target(shifted, g, f'hole:{hole}')
|
||||||
|
assert moved.position[:2] == pytest.approx(
|
||||||
|
ref.position[:2] + np.array([-0.01, 0.02]), abs=1e-9,
|
||||||
|
)
|
||||||
|
assert moved.position[2] == pytest.approx(ref.position[2], abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_standard_holes_match_the_documented_coordinates():
|
||||||
|
g = geometry()
|
||||||
|
spec = scenario(11)
|
||||||
|
expected = {
|
||||||
|
'hole_1': (0.2243, 0.1052),
|
||||||
|
'hole_2': (0.2852, 0.0443),
|
||||||
|
'hole_3': (0.3757, 0.1348),
|
||||||
|
'hole_4': (0.3148, 0.1957),
|
||||||
|
}
|
||||||
|
for name, xy in expected.items():
|
||||||
|
target = world_target(spec, g, f'hole:{name}')
|
||||||
|
assert target.position[:2] == pytest.approx(xy, abs=1e-9)
|
||||||
|
assert target.position[2] == pytest.approx(0.823 + 0.00894 + 0.002, abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiment_nineteen_moves_screws_with_the_tray():
|
||||||
|
g = geometry()
|
||||||
|
standard = scenario(11)
|
||||||
|
shifted = scenario(19)
|
||||||
|
delta = np.array([-0.02, 0.02, 0.0])
|
||||||
|
for index in range(1, 11):
|
||||||
|
name = f'screw_{index}'
|
||||||
|
ref = world_target(standard, g, f'screw:{name}')
|
||||||
|
moved = world_target(shifted, g, f'screw:{name}')
|
||||||
|
assert moved.position == pytest.approx(ref.position + delta, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_seat_follows_the_body():
|
||||||
|
g = geometry()
|
||||||
|
for experiment_id in (1, 3, 11):
|
||||||
|
spec = scenario(experiment_id)
|
||||||
|
body = object_pose_transform(
|
||||||
|
spec.body.x, spec.body.y, spec.body.z, spec.body.yaw_deg, child='body',
|
||||||
|
)
|
||||||
|
seat = world_target(spec, g, 'cap_seat')
|
||||||
|
expected = body.compose(g.body_to_installed_cap)
|
||||||
|
assert seat.position == pytest.approx(expected.position, abs=1e-12)
|
||||||
|
assert seat.matrix == pytest.approx(expected.matrix, abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_collision_center_is_below_tcp():
|
||||||
|
g = geometry()
|
||||||
|
assert g.cap_collision.offset[2] == pytest.approx(0.146 - 0.004165, abs=1e-9)
|
||||||
|
assert g.cap_collision.size[2] == pytest.approx(0.00367, abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sealant_contour_is_closed_and_ordered():
|
||||||
|
g = geometry()
|
||||||
|
spec = scenario(18)
|
||||||
|
sides = ('side_1', 'side_2', 'side_3', 'side_4')
|
||||||
|
starts = [world_target(spec, g, f'sealant:{side}') for side in sides]
|
||||||
|
ends = [sealant_point(g, spec, side, 1.0) for side in sides]
|
||||||
|
for index in range(4):
|
||||||
|
assert starts[index].position[2] == pytest.approx(ends[index].position[2])
|
||||||
|
assert starts[(index + 1) % 4].position[:2] == pytest.approx(
|
||||||
|
ends[index].position[:2], abs=1e-9,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tcp_goal_places_tip_on_the_target_and_points_down():
|
||||||
|
g = geometry()
|
||||||
|
spec = scenario(18)
|
||||||
|
target = sealant_point(g, spec, 'side_1', 0.5)
|
||||||
|
tcp = tcp_goal(target, g.tool_work_tips['sealant_tool'])
|
||||||
|
assert tcp.position == pytest.approx(
|
||||||
|
target.position + np.array([0.0, 0.0, 0.140]), abs=1e-12,
|
||||||
|
)
|
||||||
|
assert tcp.matrix[2, 2] == pytest.approx(-1.0, abs=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_and_cap_node_targets_produce_downward_tcp():
|
||||||
|
g = geometry()
|
||||||
|
spec = scenario(1)
|
||||||
|
for target, relative in (
|
||||||
|
(world_target(spec, g, 'tool:vacuum'), g.tool_tcp_to_nodes['vacuum']),
|
||||||
|
(world_target(spec, g, 'cap'), g.cap_tcp_to_node),
|
||||||
|
):
|
||||||
|
tcp = tcp_goal(target, relative)
|
||||||
|
assert tcp.matrix[2, 2] == pytest.approx(-1.0, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_frames_are_rejected(tmp_path):
|
||||||
|
data = yaml.safe_load(GEOMETRY.read_text(encoding='utf-8'))
|
||||||
|
data['patron_to_tcp']['parent'] = 'missing'
|
||||||
|
data['patron_to_tcp'].pop('parent')
|
||||||
|
path = tmp_path / 'geometry.yaml'
|
||||||
|
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding='utf-8')
|
||||||
|
# The loader supplies parent/child itself, so a missing key is not an
|
||||||
|
# error; a missing transform block is.
|
||||||
|
data.pop('patron_to_tcp')
|
||||||
|
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding='utf-8')
|
||||||
|
with pytest.raises(GeometryError, match='patron_to_tcp'):
|
||||||
|
load_geometry(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonnumeric_and_nonpositive_values_are_rejected(tmp_path):
|
||||||
|
data = yaml.safe_load(GEOMETRY.read_text(encoding='utf-8'))
|
||||||
|
data['approach_gap'] = 'far'
|
||||||
|
path = tmp_path / 'geometry.yaml'
|
||||||
|
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding='utf-8')
|
||||||
|
with pytest.raises(GeometryError, match='approach_gap'):
|
||||||
|
load_geometry(path)
|
||||||
|
|
||||||
|
data = yaml.safe_load(GEOMETRY.read_text(encoding='utf-8'))
|
||||||
|
data['contact_gap'] = 0.0
|
||||||
|
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding='utf-8')
|
||||||
|
with pytest.raises(GeometryError, match='contact_gap'):
|
||||||
|
load_geometry(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_targets_are_rejected():
|
||||||
|
g = geometry()
|
||||||
|
with pytest.raises(GeometryError, match='unknown world target'):
|
||||||
|
world_target(scenario(1), g, 'nothing')
|
||||||
|
with pytest.raises(GeometryError, match='has no tool'):
|
||||||
|
world_target(scenario(1), g, 'tool:magnetic_tool')
|
||||||
|
with pytest.raises(GeometryError, match='has no screw'):
|
||||||
|
world_target(scenario(1), g, 'screw:screw_1')
|
||||||
Reference in New Issue
Block a user