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
10 changes: 10 additions & 0 deletions docs/contributor_guide/event-schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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:

Expand Down
21 changes: 18 additions & 3 deletions docs/user_guide/agilent/vspin/events.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
3 changes: 3 additions & 0 deletions docs/user_guide/agilent/vspin/state-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/user_guide/machine-agnostic-features/event-bus.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
15 changes: 15 additions & 0 deletions pylabrobot/agilent/vspin/access2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
59 changes: 48 additions & 11 deletions pylabrobot/agilent/vspin/access2_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down
7 changes: 7 additions & 0 deletions pylabrobot/agilent/vspin/vspin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
148 changes: 147 additions & 1 deletion pylabrobot/agilent/vspin/vspin_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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())
Expand Down
Loading
Loading