feat: define deterministic grant scenarios

This commit is contained in:
Даниил Грабарь
2026-11-08 10:44:39 +10:00
parent f9b208e315
commit dd11119176
4 changed files with 864 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
# Machine-readable source for the twenty grant experiments.
#
# Units are metres and degrees in the Webots world frame. The robot is spawned
# by iiwa_bringup at (-0.25, 0, 0.79). The tabletop is at Z = 0.793.
#
# The shared poses below are copied from doc/experiment-scenes.md. Experiments
# only list the objects they add and the poses that differ. No value is chosen
# at run time. `patron` is part of the robot URDF in every experiment and is
# therefore not listed as a scene object.
schema_version: 1
poses:
fixture:
x: 0.30
y: 0.12
z: 0.803
yaw_deg: 45
body:
x: 0.30
y: 0.12
z: 0.823
yaw_deg: 45
rack:
x: 0.12
y: -0.42
z: 0.793
yaw_deg: 0
tray:
x: -0.08
y: 0.34
z: 0.793
yaw_deg: 0
cap_free:
x: 0.35
y: -0.22
z: 0.790667
yaw_deg: 45
cap_installed:
x: 0.30
y: 0.12
z: 0.82594
yaw_deg: 45
tools:
vacuum:
x: 0.255
y: -0.42
z: 0.803
yaw_deg: 0
magnetic_tool:
x: 0.165
y: -0.42
z: 0.803
yaw_deg: 0
sealant_tool:
x: 0.075
y: -0.42
z: 0.803
yaw_deg: 0
torque_tool:
x: -0.015
y: -0.42
z: 0.803
yaw_deg: 0
# Screw offsets are relative to the tray centre. The order is the screw_1 ...
# screw_10 order from doc/experiment-scenes.md; only the first four are at the
# grid corners. All screws rest with their lower point at Z = 0.7954.
screws:
z: 0.7954
layout:
- {name: screw_1, dx: -0.040, dy: -0.015}
- {name: screw_2, dx: 0.040, dy: -0.015}
- {name: screw_3, dx: -0.040, dy: 0.015}
- {name: screw_4, dx: 0.040, dy: 0.015}
- {name: screw_5, dx: -0.020, dy: -0.015}
- {name: screw_6, dx: 0.000, dy: -0.015}
- {name: screw_7, dx: 0.020, dy: -0.015}
- {name: screw_8, dx: -0.020, dy: 0.015}
- {name: screw_9, dx: 0.000, dy: 0.015}
- {name: screw_10, dx: 0.020, dy: 0.015}
experiments:
"1":
cap: free
tools: [vacuum]
"2":
cap: free
cap_pose: {x: 0.35, y: -0.22, z: 0.790667, yaw_deg: 135}
tools: [vacuum]
"3":
fixture: {x: 0.29, y: 0.14, z: 0.803, yaw_deg: 45}
body: {x: 0.29, y: 0.14, z: 0.823, yaw_deg: 45}
cap: free
cap_pose: {x: 0.33, y: -0.22, z: 0.790667, yaw_deg: 45}
tools: [vacuum]
"4":
cap: free
tools: [vacuum]
failures:
- {skill: grab, outcome: grasp_failed, attempt: 1}
"5":
cap: free
tools: [vacuum]
failures:
- skill: verify
outcome: not_ok
target: cap
attempt: 1
details: {after_first_install_x: 0.303}
"6":
cap: absent
tools: [sealant_tool]
"7":
cap: absent
tools: [sealant_tool]
"8":
cap: absent
tools: [sealant_tool]
"9":
cap: absent
tools: [sealant_tool]
failures:
- {skill: dispense, outcome: interrupted, target: side_3, attempt: 1}
"10":
cap: free
tools: [sealant_tool, vacuum]
"11":
cap: installed
tray: true
tools: [magnetic_tool]
"12":
cap: installed
tray: true
tools: [magnetic_tool]
"13":
cap: installed
tray: true
tools: [magnetic_tool]
"14":
cap: installed
tray: true
tools: [magnetic_tool, torque_tool]
"15":
cap: installed
tray: true
tools: [magnetic_tool, torque_tool]
failures:
- {skill: grab, outcome: grasp_failed, target: third_selected_screw, attempt: 1}
- {skill: tighten, outcome: torque_not_reached, target: hole_4, attempt: 1}
"16":
cap: free
tray: true
tools: [vacuum, magnetic_tool, sealant_tool, torque_tool]
"17":
cap: free
tray: true
tools: [vacuum, magnetic_tool, sealant_tool, torque_tool]
"18":
cap: free
tray: true
tools: [vacuum, magnetic_tool, sealant_tool, torque_tool]
"19":
cap: free
cap_pose: {x: 0.37, y: -0.20, z: 0.790667, yaw_deg: 60}
tray: true
tray_pose: {x: -0.10, y: 0.36, z: 0.793, yaw_deg: 0}
tools: [vacuum, magnetic_tool, sealant_tool, torque_tool]
"20":
cap: free
tray: true
tools: [vacuum, magnetic_tool, sealant_tool, torque_tool]
failures:
- {skill: dispense, outcome: interrupted, attempt: 1}
- {skill: grab, outcome: grasp_failed, attempt: 1}
- {skill: verify, outcome: not_ok, target: cap, attempt: 1}
@@ -0,0 +1,406 @@
"""Strict loader for the deterministic grant experiment manifest.
The manifest in ``config/scenes.yaml`` is the only source of object poses for
the twenty experiments. Everything in this module is pure Python so that the
same validation runs in unit tests and at launch time.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping
import yaml
CAP_STATES = ('absent', 'free', 'installed')
TOOL_NAMES = ('vacuum', 'magnetic_tool', 'sealant_tool', 'torque_tool')
EXPERIMENT_IDS = tuple(range(1, 21))
# Bounds of lotok.obj after its local Y axis is mapped to Webots Z. They are
# repeated here so validation does not need a Webots process or the mesh file.
TRAY_HALF_LENGTH = 0.060
TRAY_HALF_WIDTH = 0.035
SCREW_RADIUS = 0.00275
# Failure metadata is consumed by a future skill executor. It never changes
# the initial pose of an object.
_ALLOWED_FAILURES = {
'grab': {'grasp_failed'},
'verify': {'not_ok'},
'dispense': {'interrupted'},
'tighten': {'torque_not_reached'},
'seat_screw': {'not_seated'},
'press': {'failed'},
}
class ScenarioError(ValueError):
"""Raised when the manifest or a requested experiment is invalid."""
@dataclass(frozen=True)
class Pose:
x: float
y: float
z: float
yaw_deg: float
@property
def xy(self) -> tuple[float, float]:
return self.x, self.y
@dataclass(frozen=True)
class Tool:
name: str
pose: Pose
@dataclass(frozen=True)
class Screw:
name: str
pose: Pose
@dataclass(frozen=True)
class Failure:
skill: str
outcome: str
target: str | None = None
attempt: int | None = None
details: tuple[tuple[str, Any], ...] = ()
def as_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {'skill': self.skill, 'outcome': self.outcome}
if self.target is not None:
result['target'] = self.target
if self.attempt is not None:
result['attempt'] = self.attempt
if self.details:
result['details'] = dict(self.details)
return result
@dataclass(frozen=True)
class ScenarioSpec:
experiment_id: int
fixture: Pose
body: Pose
rack: Pose
cap_state: str
cap_pose: Pose | None
tools: tuple[Tool, ...]
tray_pose: Pose | None
screws: tuple[Screw, ...]
failures: tuple[Failure, ...]
@property
def tool_names(self) -> tuple[str, ...]:
return tuple(tool.name for tool in self.tools)
def _mapping(value: Any, label: str) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise ScenarioError(f'{label} must be a mapping')
return value
def _number(value: Any, label: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ScenarioError(f'{label} must be a finite number')
result = float(value)
if not math.isfinite(result):
raise ScenarioError(f'{label} must be a finite number')
return result
def _pose(raw: Any, label: str) -> Pose:
data = _mapping(raw, label)
for key in ('x', 'y', 'z'):
if key not in data:
raise ScenarioError(f'{label}.{key} is required')
return Pose(
x=_number(data['x'], f'{label}.x'),
y=_number(data['y'], f'{label}.y'),
z=_number(data['z'], f'{label}.z'),
yaw_deg=_number(data.get('yaw_deg', 0.0), f'{label}.yaw_deg'),
)
def _merge_pose(base: Pose, override: Any, label: str) -> Pose:
if override is None:
return base
data = _mapping(override, label)
merged = {
'x': base.x,
'y': base.y,
'z': base.z,
'yaw_deg': base.yaw_deg,
}
merged.update(data)
return _pose(merged, label)
def _same_angle(first: float, second: float) -> bool:
delta = (first - second) % 360.0
return math.isclose(min(delta, 360.0 - delta), 0.0, abs_tol=1e-9)
def _require_mapping(data: Mapping[str, Any], key: str) -> Mapping[str, Any]:
if key not in data:
raise ScenarioError(f"manifest is missing '{key}'")
return _mapping(data[key], key)
def _experiment_table(data: Mapping[str, Any]) -> dict[int, Mapping[str, Any]]:
raw = _require_mapping(data, 'experiments')
parsed: dict[int, Mapping[str, Any]] = {}
for raw_id, entry in raw.items():
text = str(raw_id).strip()
if isinstance(raw_id, bool) or not text.isdigit():
raise ScenarioError(f'invalid experiment id {raw_id!r} in manifest')
experiment_id = int(text)
if experiment_id not in EXPERIMENT_IDS or text != str(experiment_id):
raise ScenarioError(f'experiment id {raw_id!r} is outside 1..20')
if experiment_id in parsed:
raise ScenarioError(f'duplicate experiment id {experiment_id} in manifest')
parsed[experiment_id] = _mapping(entry, f'experiments.{experiment_id}')
if set(parsed) != set(EXPERIMENT_IDS):
missing = sorted(set(EXPERIMENT_IDS) - set(parsed))
extra = sorted(set(parsed) - set(EXPERIMENT_IDS))
raise ScenarioError(
f'manifest must define experiments 1..20; missing={missing}, extra={extra}'
)
return parsed
def _tool_poses(poses: Mapping[str, Any]) -> dict[str, Pose]:
raw = _mapping(poses.get('tools'), 'poses.tools')
missing = [name for name in TOOL_NAMES if name not in raw]
extra = sorted(set(raw) - set(TOOL_NAMES))
if missing or extra:
raise ScenarioError(
f'poses.tools must define {list(TOOL_NAMES)}; missing={missing}, extra={extra}'
)
return {name: _pose(raw[name], f'poses.tools.{name}') for name in TOOL_NAMES}
def _tools(raw: Any, poses: Mapping[str, Pose]) -> tuple[Tool, ...]:
if not isinstance(raw, list) or not raw:
raise ScenarioError('experiment tools must be a nonempty list')
tools: list[Tool] = []
seen: set[str] = set()
for index, name in enumerate(raw):
if not isinstance(name, str):
raise ScenarioError(f'tools[{index}] must be a tool name')
if name not in TOOL_NAMES:
raise ScenarioError(
f'unknown tool {name!r}; expected one of {list(TOOL_NAMES)}'
)
if name in seen:
raise ScenarioError(f'duplicate tool {name!r}')
seen.add(name)
tools.append(Tool(name, poses[name]))
return tuple(tools)
def _rotate_offset(dx: float, dy: float, yaw_deg: float) -> tuple[float, float]:
angle = math.radians(yaw_deg)
cosine, sine = math.cos(angle), math.sin(angle)
return dx * cosine - dy * sine, dx * sine + dy * cosine
def _screws(
raw_entry: Mapping[str, Any],
raw_manifest: Mapping[str, Any],
experiment_id: int,
tray_pose: Pose | None,
) -> tuple[Screw, ...]:
explicit = raw_entry.get('screws')
if tray_pose is None:
if explicit is not None:
raise ScenarioError(
f'experiment {experiment_id} has screws but no tray'
)
return ()
if explicit is None:
screw_data = _mapping(raw_manifest.get('screws'), 'screws')
layout = screw_data.get('layout')
if not isinstance(layout, list):
raise ScenarioError('screws.layout must be a list')
z = _number(screw_data.get('z'), 'screws.z')
else:
if not isinstance(explicit, list):
raise ScenarioError(f'experiments.{experiment_id}.screws must be a list')
layout = explicit
screw_data = _mapping(raw_manifest.get('screws'), 'screws')
z = _number(screw_data.get('z'), 'screws.z')
if len(layout) != 10:
raise ScenarioError(
f'experiment {experiment_id} has {len(layout)} screws; '
'a tray scenario must contain exactly 10 screws'
)
result: list[Screw] = []
names: set[str] = set()
for index, raw_screw in enumerate(layout):
label = f'experiments.{experiment_id}.screws[{index}]'
data = _mapping(raw_screw, label)
name = data.get('name')
if not isinstance(name, str) or not name:
raise ScenarioError(f'{label}.name must be a nonempty string')
if name in names:
raise ScenarioError(f'duplicate screw name {name!r}')
names.add(name)
dx = _number(data.get('dx'), f'{label}.dx')
dy = _number(data.get('dy'), f'{label}.dy')
if abs(dx) > TRAY_HALF_LENGTH - SCREW_RADIUS or abs(dy) > TRAY_HALF_WIDTH - SCREW_RADIUS:
raise ScenarioError(
f'screw {name!r} at offset ({dx}, {dy}) is outside the tray'
)
world_dx, world_dy = _rotate_offset(dx, dy, tray_pose.yaw_deg)
result.append(Screw(
name=name,
pose=Pose(tray_pose.x + world_dx, tray_pose.y + world_dy, z, tray_pose.yaw_deg),
))
return tuple(result)
def _failures(raw: Any, experiment_id: int) -> tuple[Failure, ...]:
if raw is None:
return ()
if not isinstance(raw, list):
raise ScenarioError(f'experiments.{experiment_id}.failures must be a list')
result: list[Failure] = []
for index, entry in enumerate(raw):
label = f'experiments.{experiment_id}.failures[{index}]'
data = _mapping(entry, label)
skill = data.get('skill')
outcome = data.get('outcome')
if skill not in _ALLOWED_FAILURES:
raise ScenarioError(f'{label}.skill must be one of {sorted(_ALLOWED_FAILURES)}')
if outcome not in _ALLOWED_FAILURES[skill]:
raise ScenarioError(
f'{label}.outcome {outcome!r} is not valid for skill {skill!r}'
)
target = data.get('target')
if target is not None and not isinstance(target, str):
raise ScenarioError(f'{label}.target must be a string')
attempt = data.get('attempt')
if attempt is not None:
if isinstance(attempt, bool) or not isinstance(attempt, int) or attempt < 1:
raise ScenarioError(f'{label}.attempt must be a positive integer')
details_raw = data.get('details', {})
details = _mapping(details_raw, f'{label}.details')
result.append(Failure(
skill=skill,
outcome=outcome,
target=target,
attempt=attempt,
details=tuple(sorted(details.items())),
))
return tuple(result)
def load_scenario(experiment_id: int, manifest_path: Path | str) -> ScenarioSpec:
"""Load and fully validate one experiment from the YAML manifest."""
if isinstance(experiment_id, bool) or not isinstance(experiment_id, int):
raise ScenarioError(f'experiment id must be an integer in 1..20, got {experiment_id!r}')
if experiment_id not in EXPERIMENT_IDS:
raise ScenarioError(f'experiment id {experiment_id} is outside 1..20')
path = Path(manifest_path)
if not path.is_file():
raise FileNotFoundError(f'scenario manifest does not exist: {path}')
try:
raw = yaml.safe_load(path.read_text(encoding='utf-8'))
except yaml.YAMLError as error:
raise ScenarioError(f'invalid YAML in {path}: {error}') from error
data = _mapping(raw, 'manifest')
if data.get('schema_version') != 1:
raise ScenarioError('manifest schema_version must be 1')
experiments = _experiment_table(data)
entry = experiments[experiment_id]
poses = _require_mapping(data, 'poses')
fixture = _merge_pose(
_pose(poses.get('fixture'), 'poses.fixture'),
entry.get('fixture'),
f'experiments.{experiment_id}.fixture',
)
body = _merge_pose(
_pose(poses.get('body'), 'poses.body'),
entry.get('body'),
f'experiments.{experiment_id}.body',
)
if not (
math.isclose(fixture.x, body.x, abs_tol=1e-9)
and math.isclose(fixture.y, body.y, abs_tol=1e-9)
and _same_angle(fixture.yaw_deg, body.yaw_deg)
):
raise ScenarioError(
f'experiment {experiment_id}: fixture and body must share X, Y and yaw'
)
rack = _pose(poses.get('rack'), 'poses.rack')
tool_poses = _tool_poses(poses)
cap_state = entry.get('cap', 'free')
if cap_state not in CAP_STATES:
raise ScenarioError(
f'experiment {experiment_id}: cap must be one of {list(CAP_STATES)}'
)
if cap_state == 'absent':
if entry.get('cap_pose') is not None:
raise ScenarioError(f'experiment {experiment_id}: an absent cap cannot have a pose')
cap_pose = None
elif cap_state == 'free':
cap_pose = _merge_pose(
_pose(poses.get('cap_free'), 'poses.cap_free'),
entry.get('cap_pose'),
f'experiments.{experiment_id}.cap_pose',
)
else:
cap_pose = _merge_pose(
_pose(poses.get('cap_installed'), 'poses.cap_installed'),
entry.get('cap_pose'),
f'experiments.{experiment_id}.cap_pose',
)
if not (
math.isclose(cap_pose.x, body.x, abs_tol=1e-9)
and math.isclose(cap_pose.y, body.y, abs_tol=1e-9)
and _same_angle(cap_pose.yaw_deg, body.yaw_deg)
):
raise ScenarioError(
f'experiment {experiment_id}: an installed cap must share the body pose'
)
tray_enabled = entry.get('tray', False)
if not isinstance(tray_enabled, bool):
raise ScenarioError(f'experiments.{experiment_id}.tray must be true or false')
if tray_enabled:
tray_pose = _merge_pose(
_pose(poses.get('tray'), 'poses.tray'),
entry.get('tray_pose'),
f'experiments.{experiment_id}.tray_pose',
)
else:
if entry.get('tray_pose') is not None:
raise ScenarioError(f'experiment {experiment_id}: tray_pose requires tray: true')
tray_pose = None
return ScenarioSpec(
experiment_id=experiment_id,
fixture=fixture,
body=body,
rack=rack,
cap_state=cap_state,
cap_pose=cap_pose,
tools=_tools(entry.get('tools'), tool_poses),
tray_pose=tray_pose,
screws=_screws(entry, data, experiment_id, tray_pose),
failures=_failures(entry.get('failures'), experiment_id),
)
@@ -43,6 +43,7 @@ def test_expected_resources_are_installed():
'doc/experements.md',
'doc/experiment-scenes.md',
'worlds/grant/iiwa-grant.wbt',
'config/scenes.yaml',
):
path = share / relative
assert path.is_file(), path
+281
View File
@@ -0,0 +1,281 @@
"""The twenty experiments have exactly the composition from the specification."""
import math
from pathlib import Path
import pytest
import yaml
from ament_index_python.packages import get_package_share_directory
from iiwa_experement.scenario import (
CAP_STATES,
TOOL_NAMES,
ScenarioError,
load_scenario,
)
PACKAGE = Path(__file__).resolve().parents[1]
SCENES = PACKAGE / 'config' / 'scenes.yaml'
STANDARD_TOOLS = set(TOOL_NAMES)
STANDARD_SCREWS = {
'screw_1': (-0.120, 0.325),
'screw_2': (-0.040, 0.325),
'screw_3': (-0.120, 0.355),
'screw_4': (-0.040, 0.355),
'screw_5': (-0.100, 0.325),
'screw_6': (-0.080, 0.325),
'screw_7': (-0.060, 0.325),
'screw_8': (-0.100, 0.355),
'screw_9': (-0.080, 0.355),
'screw_10': (-0.060, 0.355),
}
def scenario(experiment_id):
return load_scenario(experiment_id, SCENES)
def variant(tmp_path, mutate):
data = yaml.safe_load(SCENES.read_text(encoding='utf-8'))
mutate(data)
path = tmp_path / 'scenes.yaml'
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding='utf-8')
return path
def test_all_twenty_experiments_load():
for experiment_id in range(1, 21):
spec = scenario(experiment_id)
assert spec.experiment_id == experiment_id
assert spec.cap_state in CAP_STATES
assert spec.tool_names
if experiment_id != 3:
assert (spec.fixture.x, spec.fixture.y) == (0.30, 0.12)
assert (spec.body.x, spec.body.y) == (0.30, 0.12)
assert (spec.rack.x, spec.rack.y, spec.rack.z) == (0.12, -0.42, 0.793)
def test_cap_only_experiments_one_to_five():
for experiment_id in range(1, 6):
spec = scenario(experiment_id)
assert spec.cap_state == 'free'
assert spec.tray_pose is None
assert spec.screws == ()
assert spec.tool_names == ('vacuum',)
def test_sealant_experiments_do_not_start_with_a_hatch_or_tray():
for experiment_id in range(6, 10):
spec = scenario(experiment_id)
assert spec.cap_state == 'absent'
assert spec.cap_pose is None
assert spec.tray_pose is None
assert spec.screws == ()
assert spec.tool_names == ('sealant_tool',)
def test_experiment_ten_has_hatch_and_both_tools():
spec = scenario(10)
assert spec.cap_state == 'free'
assert spec.tray_pose is None
assert set(spec.tool_names) == {'sealant_tool', 'vacuum'}
def test_screw_experiments_have_installed_hatch_and_ten_screws():
for experiment_id in range(11, 16):
spec = scenario(experiment_id)
assert spec.cap_state == 'installed'
assert spec.tray_pose is not None
assert len(spec.screws) == 10
assert spec.tool_names[0] == 'magnetic_tool'
assert set(scenario(14).tool_names) == {'magnetic_tool', 'torque_tool'}
assert set(scenario(15).tool_names) == {'magnetic_tool', 'torque_tool'}
def test_full_assembly_experiments_have_loose_hatch_tray_and_all_tools():
for experiment_id in range(16, 21):
spec = scenario(experiment_id)
assert spec.cap_state == 'free'
assert spec.tray_pose is not None
assert len(spec.screws) == 10
assert set(spec.tool_names) == STANDARD_TOOLS
def test_standard_screw_layout_matches_the_specification():
spec = scenario(11)
assert len({screw.name for screw in spec.screws}) == 10
assert len({screw.pose.xy for screw in spec.screws}) == 10
for screw in spec.screws:
assert screw.pose.xy == pytest.approx(STANDARD_SCREWS[screw.name], abs=1e-9)
assert screw.pose.z == pytest.approx(0.7954, abs=1e-12)
def test_experiment_two_hatch_is_rotated_135_degrees():
spec = scenario(2)
assert spec.cap_pose is not None
assert spec.cap_pose.xy == (0.35, -0.22)
assert spec.cap_pose.yaw_deg == 135
def test_experiment_three_moves_fixture_body_and_hatch_together():
spec = scenario(3)
assert (spec.fixture.x, spec.fixture.y, spec.fixture.z) == (0.29, 0.14, 0.803)
assert (spec.body.x, spec.body.y, spec.body.z) == (0.29, 0.14, 0.823)
assert spec.cap_pose is not None
assert (spec.cap_pose.x, spec.cap_pose.y) == (0.33, -0.22)
def test_experiment_five_carries_the_misplaced_verification_metadata():
spec = scenario(5)
assert spec.cap_pose is not None
assert spec.cap_pose.xy == (0.35, -0.22)
assert len(spec.failures) == 1
failure = spec.failures[0]
assert (failure.skill, failure.outcome, failure.target, failure.attempt) == (
'verify', 'not_ok', 'cap', 1,
)
assert dict(failure.details)['after_first_install_x'] == 0.303
def test_experiment_nineteen_moves_hatch_and_tray_with_all_screws():
spec = scenario(19)
assert spec.cap_pose is not None
assert spec.cap_pose.xy == (0.37, -0.20)
assert spec.cap_pose.yaw_deg == 60
assert spec.tray_pose is not None
assert (spec.tray_pose.x, spec.tray_pose.y, spec.tray_pose.z) == (-0.10, 0.36, 0.793)
for screw in spec.screws:
expected = STANDARD_SCREWS[screw.name]
assert screw.pose.xy == pytest.approx(
(expected[0] - 0.02, expected[1] + 0.02), abs=1e-9
)
def test_failure_metadata_for_experiments_four_nine_fifteen_twenty():
assert [(f.skill, f.outcome) for f in scenario(4).failures] == [
('grab', 'grasp_failed'),
]
assert [(f.skill, f.outcome, f.target) for f in scenario(9).failures] == [
('dispense', 'interrupted', 'side_3'),
]
fifteen = [(f.skill, f.outcome, f.target) for f in scenario(15).failures]
assert fifteen == [
('grab', 'grasp_failed', 'third_selected_screw'),
('tighten', 'torque_not_reached', 'hole_4'),
]
twenty = [(f.skill, f.outcome) for f in scenario(20).failures]
assert twenty == [
('dispense', 'interrupted'),
('grab', 'grasp_failed'),
('verify', 'not_ok'),
]
def test_installed_cap_sits_on_the_body():
for experiment_id in range(11, 16):
spec = scenario(experiment_id)
assert spec.cap_pose is not None
assert spec.cap_pose.xy == spec.body.xy
assert spec.cap_pose.yaw_deg == spec.body.yaw_deg
def test_scenarios_are_read_from_the_installed_manifest():
installed = (
Path(get_package_share_directory('iiwa_experement')) / 'config' / 'scenes.yaml'
)
for experiment_id in range(1, 21):
assert load_scenario(experiment_id, installed).experiment_id == experiment_id
@pytest.mark.parametrize('experiment_id', [0, 21, '1', 1.0, True, None])
def test_invalid_experiment_ids_are_rejected(experiment_id):
with pytest.raises(ScenarioError):
load_scenario(experiment_id, SCENES)
def test_unknown_tool_is_rejected(tmp_path):
def mutate(data):
data['experiments']['1']['tools'] = ['vacuum_cleaner']
with pytest.raises(ScenarioError, match='unknown tool'):
load_scenario(1, variant(tmp_path, mutate))
@pytest.mark.parametrize('count', [9, 11])
def test_incorrect_screw_count_is_rejected(tmp_path, count):
entries = [
{'name': f'screw_{index}', 'dx': -0.04 + index * 0.005, 'dy': 0.0}
for index in range(1, count + 1)
]
def mutate(data):
data['experiments']['1']['tray'] = True
data['experiments']['1']['screws'] = entries
with pytest.raises(ScenarioError, match='exactly 10 screws'):
load_scenario(1, variant(tmp_path, mutate))
def test_duplicate_screw_name_is_rejected(tmp_path):
entries = [
{'name': 'screw_1', 'dx': -0.04 + index * 0.005, 'dy': 0.0}
for index in range(10)
]
def mutate(data):
data['experiments']['1']['tray'] = True
data['experiments']['1']['screws'] = entries
with pytest.raises(ScenarioError, match='duplicate screw name'):
load_scenario(1, variant(tmp_path, mutate))
def test_screw_outside_the_tray_is_rejected(tmp_path):
entries = [
{'name': f'screw_{index}', 'dx': -0.04 + index * 0.005, 'dy': 0.0}
for index in range(10)
]
entries[4]['dx'] = 0.2
def mutate(data):
data['experiments']['1']['tray'] = True
data['experiments']['1']['screws'] = entries
with pytest.raises(ScenarioError, match='outside the tray'):
load_scenario(1, variant(tmp_path, mutate))
def test_fixture_and_body_must_share_xy(tmp_path):
def mutate(data):
data['experiments']['1']['body'] = {
'x': 0.31, 'y': 0.12, 'z': 0.823, 'yaw_deg': 45,
}
with pytest.raises(ScenarioError, match='fixture and body'):
load_scenario(1, variant(tmp_path, mutate))
def test_tray_without_screws_and_screws_without_tray_are_rejected(tmp_path):
def no_screws(data):
data['experiments']['1']['screws'] = [
{'name': f'screw_{index}', 'dx': 0.0, 'dy': 0.0}
for index in range(10)
]
with pytest.raises(ScenarioError, match='no tray'):
load_scenario(1, variant(tmp_path, no_screws))
def no_tray(data):
data['experiments']['1']['tray'] = False
data['experiments']['1']['tray_pose'] = {
'x': -0.08, 'y': 0.34, 'z': 0.793, 'yaw_deg': 0,
}
with pytest.raises(ScenarioError, match='tray_pose requires tray'):
load_scenario(1, variant(tmp_path, no_tray))
def test_missing_manifest_is_reported(tmp_path):
with pytest.raises(FileNotFoundError):
load_scenario(1, tmp_path / 'missing.yaml')