diff --git a/docs/user_guide/agilent/vspin/events.md b/docs/user_guide/agilent/vspin/events.md index 12abc602da5..0d3de845319 100644 --- a/docs/user_guide/agilent/vspin/events.md +++ b/docs/user_guide/agilent/vspin/events.md @@ -1,6 +1,8 @@ # VSpin and Access2 events -Each listed semantic operation emits `started`, `completed`, or `failed` lifecycle records. +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. ## VSpin centrifuge diff --git a/docs/user_guide/machine-agnostic-features/event-bus.md b/docs/user_guide/machine-agnostic-features/event-bus.md index 0e621062b8f..9a809687383 100644 --- a/docs/user_guide/machine-agnostic-features/event-bus.md +++ b/docs/user_guide/machine-agnostic-features/event-bus.md @@ -121,6 +121,8 @@ events. | `legacy.liquid_handling.LiquidHandler` | resource pickup/move/drop; tip pickup/drop; 96-head tip pickup/drop; aspirate; dispense | | `legacy.shaking.Shaker` | `shaker.shake`, `shaker.stop_shaking` | | `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` | | `brooks.precise_flex.PreciseFlex` | lifecycle, fault/home/freedrive, joint/cartesian/rail/gripper motion, pick/drop, park | diff --git a/pylabrobot/legacy/centrifuge/centrifuge.py b/pylabrobot/legacy/centrifuge/centrifuge.py index 54f9888b9fd..b16053ec993 100644 --- a/pylabrobot/legacy/centrifuge/centrifuge.py +++ b/pylabrobot/legacy/centrifuge/centrifuge.py @@ -1,6 +1,8 @@ +import inspect import warnings from typing import Any, Mapping, Optional, Tuple, cast +from pylabrobot.events import evented_operation, resource_reference from pylabrobot.legacy.centrifuge.backend import CentrifugeBackend, LoaderBackend from pylabrobot.legacy.centrifuge.standard import ( BucketHasPlateError, @@ -14,6 +16,71 @@ from pylabrobot.resources.rotation import Rotation from pylabrobot.serializer import deserialize +_MISSING_BACKEND_PARAMETER = object() + + +def _resolved_backend_parameter( + centrifuge: "Centrifuge", name: str, backend_kwargs: Mapping[str, Any] +) -> Any: + """Return the explicitly requested or backend-default value for one spin parameter.""" + if name in backend_kwargs: + return backend_kwargs[name] + try: + parameter = inspect.signature(type(centrifuge.backend).spin).parameters.get(name) + except (TypeError, ValueError): + return _MISSING_BACKEND_PARAMETER + if parameter is None or parameter.default is inspect.Parameter.empty: + return _MISSING_BACKEND_PARAMETER + return parameter.default + + +def _centrifuge_spin_event_context( + self: "Centrifuge", g: float, duration: float, **backend_kwargs: Any +) -> dict: + bucket_resources = [ + { + "holder": resource_reference(bucket), + "resource": resource_reference(bucket.resource), + } + for bucket in (self.bucket1, self.bucket2) + if bucket.resource is not None + ] + data = { + "device": resource_reference(self), + "resources": [bucket["resource"] for bucket in bucket_resources], + "bucket_resources": bucket_resources, + "relative_centrifugal_force": g, + "duration": duration, + } + acceleration = _resolved_backend_parameter(self, "acceleration", backend_kwargs) + if acceleration is not _MISSING_BACKEND_PARAMETER: + data["acceleration_fraction"] = acceleration + deceleration = _resolved_backend_parameter(self, "deceleration", backend_kwargs) + if deceleration is not _MISSING_BACKEND_PARAMETER: + data["deceleration_fraction"] = deceleration + return data + + +def _loader_load_event_context(self: "Loader") -> dict: + plate = self.resource + return { + "device": resource_reference(self), + "resources": [] if plate is None else [resource_reference(plate)], + "source": resource_reference(self), + "destination": resource_reference(self.centrifuge.at_bucket), + } + + +def _loader_unload_event_context(self: "Loader") -> dict: + bucket = self.centrifuge.at_bucket + plate = None if bucket is None else bucket.resource + return { + "device": resource_reference(self), + "resources": [] if plate is None else [resource_reference(plate)], + "source": resource_reference(bucket), + "destination": resource_reference(self), + } + class Centrifuge(Machine, Resource): """The front end for centrifuges.""" @@ -107,6 +174,7 @@ async def start_spin_cycle(self, g: float, duration: float) -> None: ) await self.spin(g=g, duration=duration) + @evented_operation("centrifuge.spin", _centrifuge_spin_event_context) async def spin(self, g: float, duration: float, **backend_kwargs) -> None: """Starts a spin cycle. @@ -187,6 +255,7 @@ def __init__( self.backend: LoaderBackend = backend # fix type self.centrifuge = centrifuge + @evented_operation("centrifuge_loader.load", _loader_load_event_context) async def load(self) -> None: if not self.centrifuge.door_open: raise CentrifugeDoorError("Centrifuge door must be open to load a plate.") @@ -207,6 +276,7 @@ async def load(self) -> None: self.centrifuge.at_bucket.assign_child_resource(self.resource, location=Coordinate.zero()) + @evented_operation("centrifuge_loader.unload", _loader_unload_event_context) async def unload(self) -> None: # DOOR arg? if not self.centrifuge.door_open: raise CentrifugeDoorError("Centrifuge door must be open to unload a plate.") diff --git a/pylabrobot/legacy/centrifuge/centrifuge_tests.py b/pylabrobot/legacy/centrifuge/centrifuge_tests.py index 88ef064593d..5c3a542d32c 100644 --- a/pylabrobot/legacy/centrifuge/centrifuge_tests.py +++ b/pylabrobot/legacy/centrifuge/centrifuge_tests.py @@ -1,6 +1,8 @@ import unittest import unittest.mock +from unittest.mock import AsyncMock, patch +from pylabrobot.events import EventBus, PLREvent, use_event_bus from pylabrobot.legacy.centrifuge import ( BucketHasPlateError, BucketNoPlateError, @@ -15,7 +17,8 @@ CentrifugeChatterboxBackend, LoaderChatterboxBackend, ) -from pylabrobot.resources import Coordinate, cor_96_wellplate_360uL_Fb +from pylabrobot.legacy.centrifuge.vspin_backend import Access2Backend, VSpinBackend +from pylabrobot.resources import Coordinate, Resource, cor_96_wellplate_360uL_Fb class CentrifugeTests(unittest.IsolatedAsyncioTestCase): @@ -70,6 +73,30 @@ async def test_load(self): self.assertEqual(self.centrifuge.at_bucket.children[0], self.plate) self.assertEqual(self.loader.children, []) + async def test_load_emits_loader_to_bucket_transfer(self): + await self.centrifuge.go_to_bucket1() + await self.centrifuge.open_door() + self.loader.assign_child_resource(self.plate) + events: list[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + await self.loader.load() + + lifecycle_events = [ + event for event in events if event.name.startswith("centrifuge_loader.load.") + ] + self.assertEqual( + [event.name for event in lifecycle_events], + ["centrifuge_loader.load.started", "centrifuge_loader.load.completed"], + ) + started, completed = lifecycle_events + self.assertEqual(started.context["operation_id"], completed.context["operation_id"]) + self.assertEqual(started.data["resources"][0]["name"], "plate") + self.assertEqual(started.data["source"]["name"], "loader") + self.assertEqual(started.data["destination"]["name"], "centrifuge_bucket1") + async def test_load_locked_door(self): self.loader.assign_child_resource(self.plate) with self.assertRaises(CentrifugeDoorError): @@ -111,6 +138,33 @@ async def test_unload(self): self.assertEqual(self.centrifuge.at_bucket.children, []) self.assertEqual(self.loader.children, [self.plate]) + async def test_unload_failure_emits_bucket_to_loader_transfer(self): + await self.centrifuge.go_to_bucket1() + await self.centrifuge.open_door() + assert self.centrifuge.at_bucket is not None + self.centrifuge.at_bucket.assign_child_resource(self.plate) + self.mock_loader_backend.unload = AsyncMock(side_effect=RuntimeError("loader fault")) + events: list[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + with self.assertRaisesRegex(RuntimeError, "loader fault"): + await self.loader.unload() + + lifecycle_events = [ + event for event in events if event.name.startswith("centrifuge_loader.unload.") + ] + self.assertEqual( + [event.name for event in lifecycle_events], + ["centrifuge_loader.unload.started", "centrifuge_loader.unload.failed"], + ) + started, failed = lifecycle_events + self.assertEqual(started.context["operation_id"], failed.context["operation_id"]) + self.assertEqual(started.data["source"]["name"], "centrifuge_bucket1") + self.assertEqual(started.data["destination"]["name"], "loader") + self.assertEqual(failed.data["error_type"], "RuntimeError") + async def test_unload_locked_door(self): self.loader.assign_child_resource(self.plate) with self.assertRaises(CentrifugeDoorError): @@ -144,3 +198,84 @@ def test_serialize(self): self.centrifuge.backend = CentrifugeChatterboxBackend() serialized = self.loader.serialize() self.assertEqual(Loader.deserialize(serialized), self.loader) + + +class Access2BackendTests(unittest.IsolatedAsyncioTestCase): + async def test_load_accepts_default_grip_steps(self): + """The default one-step grip is valid and must reach the loader sequence.""" + with patch("pylabrobot.legacy.centrifuge.vspin_backend.FTDI"): + backend = Access2Backend(device_id="test") + backend.send_command = AsyncMock(return_value=b"") # type: ignore[method-assign] + + await backend.load() + + self.assertGreater(backend.send_command.await_count, 0) + + async def test_load_rejects_invalid_grip_steps_before_hardware_command(self): + with patch("pylabrobot.legacy.centrifuge.vspin_backend.FTDI"): + backend = Access2Backend(device_id="test") + backend.send_command = AsyncMock() # type: ignore[method-assign] + + with self.assertRaisesRegex(ValueError, "grip_steps must be between 1 and 4"): + await backend.load(grip_steps=0) # type: ignore[arg-type] + + backend.send_command.assert_not_awaited() + + +class CentrifugeEventTests(unittest.IsolatedAsyncioTestCase): + async def test_spin_emits_loaded_resources_and_backend_parameters(self): + with patch("pylabrobot.legacy.centrifuge.vspin_backend.FTDI"): + backend = VSpinBackend(device_id=None) + backend.spin = AsyncMock() # type: ignore[method-assign] + centrifuge = Centrifuge( + backend=backend, + name="centrifuge", + size_x=1, + size_y=1, + size_z=1, + ) + plate = Resource("plate_1", size_x=1, size_y=1, size_z=1) + centrifuge.bucket1.assign_child_resource(plate, location=Coordinate.zero()) + events: list[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + await centrifuge.spin(500, 1, acceleration=0.5, deceleration=0.6) + + lifecycle_events = [event for event in events if event.name.startswith("centrifuge.spin.")] + self.assertEqual( + [event.name for event in lifecycle_events], + ["centrifuge.spin.started", "centrifuge.spin.completed"], + ) + started, completed = lifecycle_events + self.assertEqual(started.context["operation_id"], completed.context["operation_id"]) + self.assertEqual(started.data["device"]["name"], "centrifuge") + self.assertEqual(started.data["resources"][0]["name"], "plate_1") + self.assertEqual(started.data["bucket_resources"][0]["holder"]["name"], "centrifuge_bucket1") + self.assertEqual(started.data["relative_centrifugal_force"], 500) + self.assertEqual(started.data["duration"], 1) + self.assertEqual(started.data["acceleration_fraction"], 0.5) + self.assertEqual(started.data["deceleration_fraction"], 0.6) + + async def test_spin_reports_vspin_backend_defaults(self): + with patch("pylabrobot.legacy.centrifuge.vspin_backend.FTDI"): + backend = VSpinBackend(device_id=None) + backend.spin = AsyncMock() # type: ignore[method-assign] + centrifuge = Centrifuge( + backend=backend, + name="centrifuge", + size_x=1, + size_y=1, + size_z=1, + ) + events: list[PLREvent] = [] + event_bus = EventBus() + event_bus.subscribe(events.append) + + with use_event_bus(event_bus): + await centrifuge.spin(g=500, duration=1) + + started = next(event for event in events if event.name == "centrifuge.spin.started") + self.assertEqual(started.data["acceleration_fraction"], 0.8) + self.assertEqual(started.data["deceleration_fraction"], 0.8) diff --git a/pylabrobot/legacy/centrifuge/vspin_backend.py b/pylabrobot/legacy/centrifuge/vspin_backend.py index a7583ee01cd..2862975a3c8 100644 --- a/pylabrobot/legacy/centrifuge/vspin_backend.py +++ b/pylabrobot/legacy/centrifuge/vspin_backend.py @@ -101,7 +101,7 @@ async def load(self, grip_steps: Literal[1, 2, 3, 4] = 1): grip_steps: Number of steps taken to tighten the grip. Higher values may improve grip for certain plate types. """ - if not grip_steps not in (1, 2, 3, 4): + if grip_steps not in (1, 2, 3, 4): raise ValueError("grip_steps must be between 1 and 4") logger.debug("[loader] load")