feat: spawn deterministic grant scene
This commit is contained in:
@@ -0,0 +1,199 @@
|
|||||||
|
"""Deterministic scene spawner and readiness service."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
from rclpy.executors import ExternalShutdownException
|
||||||
|
from rclpy.node import Node
|
||||||
|
from std_srvs.srv import Trigger
|
||||||
|
from webots_ros2_msgs.srv import SpawnNodeFromString
|
||||||
|
|
||||||
|
from .scenario import ScenarioSpec, load_scenario
|
||||||
|
from .webots_nodes import build_nodes
|
||||||
|
|
||||||
|
|
||||||
|
SPAWN_SERVICE = '/Ros2Supervisor/spawn_node_from_string'
|
||||||
|
READY_SERVICE = '/iiwa_experement/scene_ready'
|
||||||
|
DEFAULT_SERVICE_TIMEOUT = 120.0
|
||||||
|
DEFAULT_RESPONSE_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
_NAME_RE = re.compile(r'name "([^"]+)"')
|
||||||
|
|
||||||
|
|
||||||
|
class SpawnError(RuntimeError):
|
||||||
|
"""A spawn request failed or its outcome is unknown."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Readiness:
|
||||||
|
ready: bool = False
|
||||||
|
message: str = 'scene not ready'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScenePlan:
|
||||||
|
experiment_id: int
|
||||||
|
spec: ScenarioSpec
|
||||||
|
nodes: list[str]
|
||||||
|
readiness: Readiness = field(default_factory=Readiness)
|
||||||
|
|
||||||
|
|
||||||
|
def node_name(data: str) -> str:
|
||||||
|
match = _NAME_RE.search(data)
|
||||||
|
return match.group(1) if match else 'unnamed object'
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan(
|
||||||
|
experiment_id: int,
|
||||||
|
scene_config: Path | str,
|
||||||
|
asset_root: Path | str,
|
||||||
|
) -> ScenePlan:
|
||||||
|
"""Validate the experiment and prepare every node before spawning."""
|
||||||
|
spec = load_scenario(experiment_id, scene_config)
|
||||||
|
return ScenePlan(experiment_id, spec, build_nodes(spec, asset_root))
|
||||||
|
|
||||||
|
|
||||||
|
def spawn_nodes(
|
||||||
|
node,
|
||||||
|
client,
|
||||||
|
nodes: list[str],
|
||||||
|
*,
|
||||||
|
service_timeout_sec: float = DEFAULT_SERVICE_TIMEOUT,
|
||||||
|
response_timeout_sec: float = DEFAULT_RESPONSE_TIMEOUT,
|
||||||
|
spin_until_future_complete=None,
|
||||||
|
) -> None:
|
||||||
|
"""Spawn nodes sequentially through the Webots supervisor.
|
||||||
|
|
||||||
|
A timeout on the response is ambiguous: the first request may still have
|
||||||
|
been executed. It is reported as an error and is never retried, because a
|
||||||
|
retry could create a duplicate object.
|
||||||
|
"""
|
||||||
|
spin = spin_until_future_complete or rclpy.spin_until_future_complete
|
||||||
|
try:
|
||||||
|
available = client.wait_for_service(timeout_sec=service_timeout_sec)
|
||||||
|
except Exception as error: # pragma: no cover - depends on rclpy internals
|
||||||
|
raise SpawnError(f'Webots supervisor service failed: {error}') from error
|
||||||
|
if not available:
|
||||||
|
raise SpawnError(
|
||||||
|
f'Webots supervisor service {SPAWN_SERVICE} unavailable after '
|
||||||
|
f'{service_timeout_sec:g} s; no objects spawned'
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = node.get_logger()
|
||||||
|
total = len(nodes)
|
||||||
|
for index, data in enumerate(nodes, start=1):
|
||||||
|
name = node_name(data)
|
||||||
|
future = client.call_async(SpawnNodeFromString.Request(data=data))
|
||||||
|
spin(node, future, timeout_sec=response_timeout_sec)
|
||||||
|
if not future.done():
|
||||||
|
raise SpawnError(
|
||||||
|
f'{name}: spawn request timed out after {response_timeout_sec:g} s; '
|
||||||
|
'result unknown, no retry'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = future.result()
|
||||||
|
except Exception as error:
|
||||||
|
raise SpawnError(f'{name}: spawn call failed: {error}') from error
|
||||||
|
if response is None:
|
||||||
|
raise SpawnError(f'{name}: spawn returned no response')
|
||||||
|
if not response.success:
|
||||||
|
raise SpawnError(
|
||||||
|
f'{name}: Webots rejected the object ({index - 1}/{total} accepted)'
|
||||||
|
)
|
||||||
|
logger.info(f'Spawned {index}/{total}: {name}')
|
||||||
|
|
||||||
|
|
||||||
|
def execute_plan(
|
||||||
|
plan: ScenePlan,
|
||||||
|
client,
|
||||||
|
node,
|
||||||
|
*,
|
||||||
|
service_timeout_sec: float = DEFAULT_SERVICE_TIMEOUT,
|
||||||
|
response_timeout_sec: float = DEFAULT_RESPONSE_TIMEOUT,
|
||||||
|
spin_until_future_complete=None,
|
||||||
|
) -> None:
|
||||||
|
"""Run a plan and publish readiness only after the final success."""
|
||||||
|
try:
|
||||||
|
spawn_nodes(
|
||||||
|
node,
|
||||||
|
client,
|
||||||
|
plan.nodes,
|
||||||
|
service_timeout_sec=service_timeout_sec,
|
||||||
|
response_timeout_sec=response_timeout_sec,
|
||||||
|
spin_until_future_complete=spin_until_future_complete,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
plan.readiness.ready = False
|
||||||
|
plan.readiness.message = (
|
||||||
|
f'experiment {plan.experiment_id}: {error}'
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
plan.readiness.ready = True
|
||||||
|
plan.readiness.message = (
|
||||||
|
f'experiment {plan.experiment_id}: {len(plan.nodes)} objects spawned'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SceneSpawnerNode(Node):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__('scene_spawner')
|
||||||
|
default_config = str(
|
||||||
|
Path(get_package_share_directory('iiwa_experement'))
|
||||||
|
/ 'config'
|
||||||
|
/ 'scenes.yaml'
|
||||||
|
)
|
||||||
|
self.declare_parameter('experiment_id', 0)
|
||||||
|
self.declare_parameter('scene_config', default_config)
|
||||||
|
self._readiness = Readiness()
|
||||||
|
self.create_service(Trigger, READY_SERVICE, self._handle_ready)
|
||||||
|
|
||||||
|
def _handle_ready(self, request, response):
|
||||||
|
response.success = self._readiness.ready
|
||||||
|
response.message = self._readiness.message
|
||||||
|
return response
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
experiment_id = int(self.get_parameter('experiment_id').value)
|
||||||
|
scene_config = str(self.get_parameter('scene_config').value)
|
||||||
|
asset_root = (
|
||||||
|
Path(get_package_share_directory('iiwa_description'))
|
||||||
|
/ 'objects'
|
||||||
|
/ 'grant'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.get_logger().info(
|
||||||
|
f'Preparing experiment {experiment_id} from {scene_config}'
|
||||||
|
)
|
||||||
|
plan = build_plan(experiment_id, scene_config, asset_root)
|
||||||
|
self._readiness = plan.readiness
|
||||||
|
client = self.create_client(SpawnNodeFromString, SPAWN_SERVICE)
|
||||||
|
execute_plan(plan, client, self)
|
||||||
|
self.get_logger().info(self._readiness.message)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None) -> None:
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = SceneSpawnerNode()
|
||||||
|
exit_code = 0
|
||||||
|
try:
|
||||||
|
node.run()
|
||||||
|
rclpy.spin(node)
|
||||||
|
except (KeyboardInterrupt, ExternalShutdownException):
|
||||||
|
pass
|
||||||
|
except Exception as error:
|
||||||
|
node.get_logger().error(f'Scene spawn failed: {error}')
|
||||||
|
exit_code = 1
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
if rclpy.ok():
|
||||||
|
rclpy.shutdown()
|
||||||
|
if exit_code:
|
||||||
|
raise SystemExit(exit_code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""The spawner sends every node once and only signals readiness at the end."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
|
||||||
|
from iiwa_experement.scenario import ScenarioError, load_scenario
|
||||||
|
from iiwa_experement.scene_spawner import (
|
||||||
|
Readiness,
|
||||||
|
ScenePlan,
|
||||||
|
SpawnError,
|
||||||
|
build_plan,
|
||||||
|
execute_plan,
|
||||||
|
node_name,
|
||||||
|
spawn_nodes,
|
||||||
|
)
|
||||||
|
from iiwa_experement.webots_nodes import build_nodes
|
||||||
|
|
||||||
|
|
||||||
|
PACKAGE = Path(__file__).resolve().parents[1]
|
||||||
|
SCENES = PACKAGE / 'config' / 'scenes.yaml'
|
||||||
|
ASSET_ROOT = (
|
||||||
|
Path(get_package_share_directory('iiwa_description')) / 'objects' / 'grant'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLogger:
|
||||||
|
def __init__(self):
|
||||||
|
self.messages = []
|
||||||
|
|
||||||
|
def info(self, message):
|
||||||
|
self.messages.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeNode:
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = FakeLogger()
|
||||||
|
|
||||||
|
def get_logger(self):
|
||||||
|
return self.logger
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFuture:
|
||||||
|
def __init__(self, behavior):
|
||||||
|
self.behavior = behavior
|
||||||
|
self.completed = behavior != 'timeout'
|
||||||
|
|
||||||
|
def done(self):
|
||||||
|
return self.completed
|
||||||
|
|
||||||
|
def result(self):
|
||||||
|
if isinstance(self.behavior, Exception):
|
||||||
|
raise self.behavior
|
||||||
|
if self.behavior == 'reject':
|
||||||
|
return type('Response', (), {'success': False})()
|
||||||
|
return type('Response', (), {'success': True})()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, behaviors=(), available=True):
|
||||||
|
self.behaviors = list(behaviors)
|
||||||
|
self.available = available
|
||||||
|
self.calls = []
|
||||||
|
self.readiness_during_calls = []
|
||||||
|
self.readiness = None
|
||||||
|
|
||||||
|
def wait_for_service(self, timeout_sec):
|
||||||
|
return self.available
|
||||||
|
|
||||||
|
def call_async(self, request):
|
||||||
|
self.calls.append(request.data)
|
||||||
|
if self.readiness is not None:
|
||||||
|
self.readiness_during_calls.append(self.readiness.ready)
|
||||||
|
index = len(self.calls) - 1
|
||||||
|
behavior = self.behaviors[index] if index < len(self.behaviors) else 'ok'
|
||||||
|
return FakeFuture(behavior)
|
||||||
|
|
||||||
|
|
||||||
|
def fake_spin(node, future, timeout_sec):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_plan(nodes, client, readiness=None, **kwargs):
|
||||||
|
plan = ScenePlan(
|
||||||
|
experiment_id=1,
|
||||||
|
spec=load_scenario(1, SCENES),
|
||||||
|
nodes=list(nodes),
|
||||||
|
readiness=readiness or Readiness(),
|
||||||
|
)
|
||||||
|
client.readiness = plan.readiness
|
||||||
|
execute_plan(
|
||||||
|
plan,
|
||||||
|
client,
|
||||||
|
FakeNode(),
|
||||||
|
spin_until_future_complete=fake_spin,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_spawn_sends_build_nodes_in_order_and_signals_at_the_end():
|
||||||
|
nodes = build_nodes(load_scenario(6, SCENES), ASSET_ROOT)
|
||||||
|
client = FakeClient()
|
||||||
|
plan = run_plan(nodes, client)
|
||||||
|
assert client.calls == nodes
|
||||||
|
assert client.readiness_during_calls == [False] * len(nodes)
|
||||||
|
assert plan.readiness.ready is True
|
||||||
|
assert plan.readiness.message == f'experiment 1: {len(nodes)} objects spawned'
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_name_is_used_for_progress_and_errors():
|
||||||
|
assert node_name('Solid {\n name "grant_cap"\n}') == 'grant_cap'
|
||||||
|
assert node_name('Solid {}') == 'unnamed object'
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_supervisor_fails_without_spawning():
|
||||||
|
client = FakeClient(available=False)
|
||||||
|
with pytest.raises(SpawnError, match='unavailable'):
|
||||||
|
run_plan(['Solid { name "grant_cap" }'], client)
|
||||||
|
assert client.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejected_object_names_the_last_object_and_keeps_readiness_false():
|
||||||
|
client = FakeClient(['ok', 'reject'])
|
||||||
|
readiness = Readiness()
|
||||||
|
with pytest.raises(SpawnError, match='grant_body'):
|
||||||
|
run_plan(
|
||||||
|
['Solid { name "grant_fixture" }', 'Solid { name "grant_body" }'],
|
||||||
|
client,
|
||||||
|
readiness,
|
||||||
|
)
|
||||||
|
assert len(client.calls) == 2
|
||||||
|
assert readiness.ready is False
|
||||||
|
assert 'grant_body' in readiness.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_exception_names_the_last_object():
|
||||||
|
client = FakeClient(['ok', RuntimeError('service exploded')])
|
||||||
|
with pytest.raises(SpawnError, match='grant_body.*service exploded'):
|
||||||
|
run_plan(
|
||||||
|
['Solid { name "grant_fixture" }', 'Solid { name "grant_body" }'],
|
||||||
|
client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_timeout_is_not_retried():
|
||||||
|
client = FakeClient(['ok', 'timeout', 'ok'])
|
||||||
|
with pytest.raises(SpawnError, match='grant_body.*timed out.*no retry'):
|
||||||
|
run_plan(
|
||||||
|
['Solid { name "grant_fixture" }', 'Solid { name "grant_body" }'],
|
||||||
|
client,
|
||||||
|
)
|
||||||
|
assert len(client.calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_name_rejection_is_not_hidden():
|
||||||
|
client = FakeClient(['reject'])
|
||||||
|
with pytest.raises(SpawnError, match='grant_fixture.*rejected'):
|
||||||
|
run_plan(['Solid { name "grant_fixture" }'], client)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_plan_rejects_an_invalid_id_before_spawning():
|
||||||
|
with pytest.raises(ScenarioError):
|
||||||
|
build_plan(0, SCENES, ASSET_ROOT)
|
||||||
|
with pytest.raises(ScenarioError):
|
||||||
|
build_plan(21, SCENES, ASSET_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_plan_uses_the_full_build_nodes_list():
|
||||||
|
plan = build_plan(19, SCENES, ASSET_ROOT)
|
||||||
|
assert plan.nodes == build_nodes(plan.spec, ASSET_ROOT)
|
||||||
|
assert plan.readiness.ready is False
|
||||||
Reference in New Issue
Block a user