Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/user_guide/agilent/vspin/events.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/user_guide/machine-agnostic-features/event-bus.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
70 changes: 70 additions & 0 deletions pylabrobot/legacy/centrifuge/centrifuge.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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."""
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.")
Expand All @@ -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.")
Expand Down
137 changes: 136 additions & 1 deletion pylabrobot/legacy/centrifuge/centrifuge_tests.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion pylabrobot/legacy/centrifuge/vspin_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Loading