diff --git a/docs/contributor_guide/event-schemas.md b/docs/contributor_guide/event-schemas.md index 9f197694c48..1332476d89d 100644 --- a/docs/contributor_guide/event-schemas.md +++ b/docs/contributor_guide/event-schemas.md @@ -161,6 +161,8 @@ These are state-transition records rather than semantic operation lifecycles. | Operation | Fields | Notes | | --- | --- | --- | +| `centrifuge_loader.setup` | `device` | Modern `Access2.setup()` initializes and homes the loader through its driver. | +| `centrifuge_loader.stop` | `device` | Modern `Access2.stop()` closes the loader transport. | | `centrifuge_loader.load` | `device`, `resources`, `source`, `destination` | Transfers the staging plate into the selected centrifuge bucket. | | `centrifuge_loader.unload` | `device`, `resources`, `source`, `destination` | Transfers the selected bucket plate onto the staging holder. | @@ -330,8 +332,16 @@ one is assigned at operation start. | Operation | Fields | Notes | | --- | --- | --- | +| `centrifuge.setup` | `device` | Modern `VSpin.setup()` connects, initializes, homes, and positions the centrifuge. | +| `centrifuge.stop` | `device` | Modern `VSpin.stop()` closes the transport; this is not a controlled rotor stop. | | `centrifuge.spin` | `device`, `resources`, `bucket_resources`, `relative_centrifugal_force`, `duration`, `acceleration_fraction`, `deceleration_fraction` | Describes one requested spin cycle. | +The modern Agilent setup/stop operations use the usual `started` then `completed` or `failed` +lifecycle. VSpin uses `device_reference(self, name=self.name)`; Access2 uses +`resource_reference(self)`. These device-only payloads have no legacy `backend` field. +Instrument the public frontends only; direct Access2 driver lifecycle methods remain undecorated. +`machine.setup` and `machine.stop` belong to `legacy.machines.Machine` and its backend schema. + `resources` contains directly loaded resources only. Empty buckets are not represented. `bucket_resources` preserves the association between each loaded resource and its holder: diff --git a/docs/user_guide/agilent/vspin/events.md b/docs/user_guide/agilent/vspin/events.md index 0d3de845319..9a661ff4e19 100644 --- a/docs/user_guide/agilent/vspin/events.md +++ b/docs/user_guide/agilent/vspin/events.md @@ -1,15 +1,22 @@ # VSpin and Access2 events -Each listed semantic operation emits `started`, `completed`, or `failed` lifecycle records. The -modern Agilent frontends and the resource-aware legacy `Centrifuge` and `Loader` frontends use the -same canonical operation names and payload semantics. +Each listed semantic operation emits `started` followed by either `completed` or `failed`. +The modern Agilent frontends and resource-aware legacy `Centrifuge` and `Loader` frontends +share spin and transfer schemas. The setup/stop operations below belong to the modern +Agilent frontends; legacy machine lifecycle events retain their separate schema. ## VSpin centrifuge | Operation | Primary fields | | --- | --- | +| `centrifuge.setup` | `device` | +| `centrifuge.stop` | `device` | | `centrifuge.spin` | `device`, loaded `resources`, `bucket_resources`, `relative_centrifugal_force`, `duration`, `acceleration_fraction`, `deceleration_fraction` | +`VSpin.setup()` connects, initializes, homes, and positions the centrifuge. `VSpin.stop()` +closes its transport; it is not `stop_spin()` or an emergency stop. Both lifecycle operations +identify the VSpin with `device_reference(self, name=self.name)`, without a legacy `backend` field. + `resources` includes each plate currently loaded in a VSpin bucket when the spin starts. `bucket_resources` preserves which physical bucket holds each plate. The event reports the requested cycle parameters. `relative_centrifugal_force` is the dimensionless multiple of @@ -21,9 +28,17 @@ frontend call's success or failure lifecycle. | Operation | Primary fields | | --- | --- | +| `centrifuge_loader.setup` | `device` | +| `centrifuge_loader.stop` | `device` | | `centrifuge_loader.load` | `device`, moved `resources`, `source`, `destination` | | `centrifuge_loader.unload` | `device`, moved `resources`, `source`, `destination` | `centrifuge_loader.load` moves a plate from the Access2 staging holder to the VSpin bucket at the load position. `centrifuge_loader.unload` moves a plate in the reverse direction. `source` and `destination` are the actual PLR holders involved in the transfer. + +Use `await loader.setup()` and `await loader.stop()` on the `Access2` frontend. They delegate +to `Access2Driver.setup()` and `Access2Driver.stop()` with unchanged hardware behavior, and +identify the Access2 holder with `resource_reference(self)`. Direct driver methods remain +available but emit no semantic setup/stop events, preventing duplicate lifecycles. These +frontend events do not use `machine.setup`/`machine.stop` or require a `backend` field. diff --git a/docs/user_guide/agilent/vspin/state-machine.md b/docs/user_guide/agilent/vspin/state-machine.md index 48c87f26019..8f85341d31f 100644 --- a/docs/user_guide/agilent/vspin/state-machine.md +++ b/docs/user_guide/agilent/vspin/state-machine.md @@ -73,6 +73,9 @@ ready to move. Ordinary VSpin commands require initialization and homing to be c `activity == IDLE`, and `recovery_required == False`, followed by the command's physical checks. +Use `await loader.setup()` and `await loader.stop()` on the Access2 frontend to include +semantic lifecycle events while delegating to the existing driver methods. + For Access2, initialization and homing are read from controller status. Its semantic state records connection, operation, recovery, and the last confirmed teachpoint. diff --git a/docs/user_guide/machine-agnostic-features/event-bus.md b/docs/user_guide/machine-agnostic-features/event-bus.md index f78d2d5a913..5b062b4b7dc 100644 --- a/docs/user_guide/machine-agnostic-features/event-bus.md +++ b/docs/user_guide/machine-agnostic-features/event-bus.md @@ -128,8 +128,8 @@ events. | `legacy.temperature_controlling.TemperatureController` | set temperature, wait for temperature, deactivate | | `legacy.centrifuge.Centrifuge` | `centrifuge.spin` | | `legacy.centrifuge.Loader` | `centrifuge_loader.load`, `centrifuge_loader.unload` | -| `agilent.vspin.VSpin` | `centrifuge.spin` | -| `agilent.vspin.Access2` | `centrifuge_loader.load`, `centrifuge_loader.unload` | +| `agilent.vspin.VSpin` | `centrifuge.setup`, `centrifuge.stop`, `centrifuge.spin` | +| `agilent.vspin.Access2` | `centrifuge_loader.setup`, `centrifuge_loader.stop`, `centrifuge_loader.load`, `centrifuge_loader.unload` | | `brooks.precise_flex.PreciseFlex` | lifecycle, fault/home/freedrive, joint/cartesian/rail/gripper motion, pick/drop, park | | `manual_operator.ManualOperator` | arbitrary acknowledged manual actions; resource moves | diff --git a/pylabrobot/agilent/vspin/access2.py b/pylabrobot/agilent/vspin/access2.py index 8b8c3f79697..b70275a3a99 100644 --- a/pylabrobot/agilent/vspin/access2.py +++ b/pylabrobot/agilent/vspin/access2.py @@ -93,6 +93,11 @@ def _transfer_route( raise ValueError(f"Invalid transfer direction: {direction}") +def _loader_lifecycle_event_context(self: "Access2") -> dict[str, object]: + """Identify the loader whose connection lifecycle is changing.""" + return {"device": resource_reference(self)} + + def _loader_load_event_context(self: "Access2", **parameters: float | str) -> dict: plate = self.resource return { @@ -973,6 +978,16 @@ def __init__( self.driver: Access2Driver = driver self._vspin = vspin + @evented_operation("centrifuge_loader.setup", _loader_lifecycle_event_context) + async def setup(self) -> None: + """Connect, initialize, home, and park the loader through its driver.""" + await self.driver.setup() + + @evented_operation("centrifuge_loader.stop", _loader_lifecycle_event_context) + async def stop(self) -> None: + """Close the loader transport and invalidate session-scoped driver state.""" + await self.driver.stop() + def _teachpoint_for_bucket(self, bucket: ResourceHolder) -> int: """Map a VSpin bucket resource to its Access2 protocol teachpoint.""" if bucket is self._vspin.bucket1: diff --git a/pylabrobot/agilent/vspin/access2_tests.py b/pylabrobot/agilent/vspin/access2_tests.py index 418bc2dd88b..25fb6818765 100644 --- a/pylabrobot/agilent/vspin/access2_tests.py +++ b/pylabrobot/agilent/vspin/access2_tests.py @@ -13,7 +13,9 @@ TransferPhase, TransferProgress, ) -from pylabrobot.agilent.vspin.access2 import Access2Driver +from pylabrobot.agilent.vspin.access2 import Access2, Access2Driver +from pylabrobot.agilent.vspin.vspin import VSpin +from pylabrobot.events import EventBus, PLREvent, resource_reference, use_event_bus from pylabrobot.io.binary import Writer _READY_FLAGS = protocol.STATUS_INITIALIZED | protocol.STATUS_HOMED @@ -228,16 +230,51 @@ async def test_complete_setup_ftdi_transcript(self): _ScriptStep(protocol.build_get_status(), _short_status_data()), ] ) - driver, io = self._make_driver(steps) - - await driver.setup() - - io.assert_complete(self) - self.assertTrue(io.setup_called) - self.assertEqual(io.baudrate, 115384) - self.assertEqual(driver.state.connection, ConnectionState.CONNECTED) - self.assertEqual(driver.state.operation, Access2Activity.IDLE) - self.assertEqual(driver.state.last_teachpoint, protocol.TEACHPOINT_PARK) + for use_frontend in (False, True): + with self.subTest(use_frontend=use_frontend): + driver, io = self._make_driver(steps) + with patch("pylabrobot.agilent.vspin.vspin.FTDI", autospec=True): + vspin = VSpin(name="centrifuge", device_id="test") + loader = Access2(name="loader", device_id="test", vspin=vspin) + loader.driver = driver + events: list[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + if use_frontend: + await loader.setup() + else: + await driver.setup() + + io.assert_complete(self) + self.assertTrue(io.setup_called) + self.assertEqual(io.baudrate, 115384) + self.assertEqual(driver.state.connection, ConnectionState.CONNECTED) + self.assertEqual(driver.state.operation, Access2Activity.IDLE) + self.assertEqual(driver.state.last_teachpoint, protocol.TEACHPOINT_PARK) + + if use_frontend: + await loader.stop() + else: + await driver.stop() + + self.assertTrue(io.stopped) + self.assertEqual(driver.state.connection, ConnectionState.DISCONNECTED) + self.assertIsNone(driver.state.last_teachpoint) + self.assertEqual( + [event.name for event in events], + [ + "centrifuge_loader.setup.started", + "centrifuge_loader.setup.completed", + "centrifuge_loader.stop.started", + "centrifuge_loader.stop.completed", + ] + if use_frontend + else [], + ) + for event in events: + self.assertEqual(event.data, {"device": resource_reference(loader)}) async def test_complete_home_ftdi_transcript(self): steps = [ diff --git a/pylabrobot/agilent/vspin/vspin.py b/pylabrobot/agilent/vspin/vspin.py index 6af49bf6cd6..9ef7a6c3c46 100644 --- a/pylabrobot/agilent/vspin/vspin.py +++ b/pylabrobot/agilent/vspin/vspin.py @@ -142,6 +142,11 @@ class _PositionAlignmentError(RuntimeError): """Raised when a completed rotor move settles outside its target tolerance.""" +def _vspin_lifecycle_event_context(self: "VSpin") -> dict[str, object]: + """Identify the centrifuge whose connection lifecycle is changing.""" + return {"device": device_reference(self, name=self.name)} + + def _vspin_event_context( self: "VSpin", g: float = 500, @@ -302,6 +307,7 @@ async def _command_scope( if activity is not None: self._set_activity(VSpinActivity.IDLE) + @evented_operation("centrifuge.setup", _vspin_lifecycle_event_context) async def setup(self) -> None: """Connect, initialize, home, and place the VSpin in its safe setup position.""" async with self._command_scope("set up VSpin", require_ready=False) as transition: @@ -460,6 +466,7 @@ async def _setup(self, *, transition: TransitionToken) -> None: await self._lock_door(transition=transition) + @evented_operation("centrifuge.stop", _vspin_lifecycle_event_context) async def stop(self) -> None: """Close the VSpin transport and invalidate session-scoped state.""" async with self._command_scope("stop VSpin", require_ready=False): diff --git a/pylabrobot/agilent/vspin/vspin_tests.py b/pylabrobot/agilent/vspin/vspin_tests.py index 9f050bbca73..216b60e0d5d 100644 --- a/pylabrobot/agilent/vspin/vspin_tests.py +++ b/pylabrobot/agilent/vspin/vspin_tests.py @@ -20,7 +20,7 @@ from pylabrobot.agilent.vspin.access2 import Access2 from pylabrobot.agilent.vspin.errors import CentrifugeDoorError from pylabrobot.agilent.vspin.vspin import VSpin -from pylabrobot.events import EventBus, PLREvent, use_event_bus +from pylabrobot.events import EventBus, PLREvent, resource_reference, use_event_bus from pylabrobot.io.binary import Writer from pylabrobot.resources import Coordinate, Resource @@ -185,6 +185,80 @@ def setUp(self): self.vspin_ftdi.start() self.addCleanup(self.vspin_ftdi.stop) + async def test_setup_lifecycle_events(self): + """Report one correlated lifecycle and preserve the original failure.""" + for error in (None, RuntimeError("setup failed")): + with self.subTest(error=error): + vspin = VSpin(name="centrifuge", device_id="test") + operation = AsyncMock(side_effect=error) + vspin._setup = operation # type: ignore[method-assign] + events: list[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + if error is None: + await vspin.setup() + else: + with self.assertRaises(RuntimeError) as raised: + await vspin.setup() + self.assertIs(raised.exception, error) + + operation.assert_awaited_once() + self.assertEqual( + [event.name for event in events], + [ + "centrifuge.setup.started", + "centrifuge.setup." + ("completed" if error is None else "failed"), + ], + ) + started, terminal = events + self.assertEqual(started.data, {"device": {"name": "centrifuge", "type": "VSpin"}}) + self.assertEqual(terminal.data["device"], started.data["device"]) + self.assertEqual(started.context["operation"], "centrifuge.setup") + self.assertEqual(started.context["operation_id"], terminal.context["operation_id"]) + self.assertNotIn("backend", terminal.data) + if error is not None: + self.assertEqual(terminal.data["error_type"], "RuntimeError") + self.assertEqual(terminal.data["error_message"], str(error)) + + async def test_stop_lifecycle_events(self): + """Report one correlated lifecycle and preserve the original failure.""" + for error in (None, RuntimeError("stop failed")): + with self.subTest(error=error): + vspin = VSpin(name="centrifuge", device_id="test") + operation = AsyncMock(side_effect=error) + vspin._stop = operation # type: ignore[method-assign] + events: list[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + if error is None: + await vspin.stop() + else: + with self.assertRaises(RuntimeError) as raised: + await vspin.stop() + self.assertIs(raised.exception, error) + + operation.assert_awaited_once() + self.assertEqual( + [event.name for event in events], + [ + "centrifuge.stop.started", + "centrifuge.stop." + ("completed" if error is None else "failed"), + ], + ) + started, terminal = events + self.assertEqual(started.data, {"device": {"name": "centrifuge", "type": "VSpin"}}) + self.assertEqual(terminal.data["device"], started.data["device"]) + self.assertEqual(started.context["operation"], "centrifuge.stop") + self.assertEqual(started.context["operation_id"], terminal.context["operation_id"]) + self.assertNotIn("backend", terminal.data) + if error is not None: + self.assertEqual(terminal.data["error_type"], "RuntimeError") + self.assertEqual(terminal.data["error_message"], str(error)) + async def test_spin_emits_loaded_bucket_resources_and_parameters(self): vspin = VSpin(name="centrifuge", device_id="test") _mark_vspin_ready(vspin) @@ -1777,6 +1851,78 @@ async def asyncSetUp(self): self.loader.driver.load = AsyncMock() # type: ignore[method-assign] self.loader.driver.unload = AsyncMock() # type: ignore[method-assign] + async def test_setup_lifecycle_events(self): + """Report one correlated lifecycle and preserve the original failure.""" + for error in (None, RuntimeError("setup failed")): + with self.subTest(error=error): + operation = AsyncMock(side_effect=error) + self.loader.driver.setup = operation # type: ignore[method-assign] + events: list[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + if error is None: + await self.loader.setup() + else: + with self.assertRaises(RuntimeError) as raised: + await self.loader.setup() + self.assertIs(raised.exception, error) + + operation.assert_awaited_once() + self.assertEqual( + [event.name for event in events], + [ + "centrifuge_loader.setup.started", + "centrifuge_loader.setup." + ("completed" if error is None else "failed"), + ], + ) + started, terminal = events + self.assertEqual(started.data, {"device": resource_reference(self.loader)}) + self.assertEqual(terminal.data["device"], started.data["device"]) + self.assertEqual(started.context["operation"], "centrifuge_loader.setup") + self.assertEqual(started.context["operation_id"], terminal.context["operation_id"]) + self.assertNotIn("backend", terminal.data) + if error is not None: + self.assertEqual(terminal.data["error_type"], "RuntimeError") + self.assertEqual(terminal.data["error_message"], str(error)) + + async def test_stop_lifecycle_events(self): + """Report one correlated lifecycle and preserve the original failure.""" + for error in (None, RuntimeError("stop failed")): + with self.subTest(error=error): + operation = AsyncMock(side_effect=error) + self.loader.driver.stop = operation # type: ignore[method-assign] + events: list[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + if error is None: + await self.loader.stop() + else: + with self.assertRaises(RuntimeError) as raised: + await self.loader.stop() + self.assertIs(raised.exception, error) + + operation.assert_awaited_once() + self.assertEqual( + [event.name for event in events], + [ + "centrifuge_loader.stop.started", + "centrifuge_loader.stop." + ("completed" if error is None else "failed"), + ], + ) + started, terminal = events + self.assertEqual(started.data, {"device": resource_reference(self.loader)}) + self.assertEqual(terminal.data["device"], started.data["device"]) + self.assertEqual(started.context["operation"], "centrifuge_loader.stop") + self.assertEqual(started.context["operation_id"], terminal.context["operation_id"]) + self.assertNotIn("backend", terminal.data) + if error is not None: + self.assertEqual(terminal.data["error_type"], "RuntimeError") + self.assertEqual(terminal.data["error_message"], str(error)) + async def test_load_emits_loader_to_bucket_transfer(self): plate = Resource("plate_1", size_x=1, size_y=1, size_z=1) self.loader.assign_child_resource(plate, location=Coordinate.zero()) diff --git a/pylabrobot/events/bus.py b/pylabrobot/events/bus.py index 5d143b63592..f31bd65eee2 100644 --- a/pylabrobot/events/bus.py +++ b/pylabrobot/events/bus.py @@ -19,6 +19,7 @@ Any, Awaitable, Callable, + Coroutine, Dict, Iterator, List, @@ -309,7 +310,7 @@ def event_operation( def evented_operation( name: str, context_factory: OperationContextFactory -) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]: +) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Coroutine[Any, Any, Any]]]: """Decorate an async frontend call with correlated lifecycle events. The wrapper is a no-op when no listener is installed, preserving normal PLR performance and @@ -323,7 +324,7 @@ def evented_operation( explicitly inside the method instead. """ - def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]: + def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Coroutine[Any, Any, Any]]: @wraps(func) async def wrapper(*args: Any, **kwargs: Any) -> Any: if not is_event_bus_active():