Skip to content
Open
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
25 changes: 25 additions & 0 deletions docs/contributor_guide/event-bus.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,31 @@ accurate conventions, add the proposed contract to the registry in the same cont
treat maintainer review as establishing the standard for future implementations of that operation.
Do not create an undocumented vendor-local alias for an existing concept.

### Manual operator actions

Manual operations use `manual_operator.<action>` so the event records both the manual executor and
the semantic work requested. Represent the `ManualOperator` as `device`, list any direct modeled
resources in `resources`, and preserve action-specific request data without substituting inferred
deck resources. When an automated counterpart defines canonical parameter names and units, reuse
them inside the manual operation's `details`. A genuine manual resource transfer additionally
includes its actual `source` and `destination` resource references.

```python
{
"device": device_reference(manual_operator, name=manual_operator.name),
"resources": [resource_reference(plate)],
"manual_action": "centrifuge.spin",
"title": "Spin sample plate",
"details": {
"relative_centrifugal_force": 300,
"duration": 180,
},
}
```

Operator cancellation or a provider-reported failure is a failed lifecycle outcome. Completion
metadata such as `confirmed_by` belongs only on the `.completed` event.

## Failure events

A failed operation retains the original invocation context and adds:
Expand Down
15 changes: 15 additions & 0 deletions docs/contributor_guide/event-schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,21 @@ These are state-transition records rather than semantic operation lifecycles.
| `liquid_handler.resource_move` | `device`, `resources` | Moves the currently held resource without assigning it to a destination. |
| `liquid_handler.resource_drop` | `device`, `resources`, `destination` | Drops the currently held resource at a resource or geometric destination. |

### Manual operator actions

Manual actions use the semantic lifecycle `manual_operator.<action>.*`, where `<action>` is a
stable, developer-defined action identifier such as `centrifuge.spin`, `plate_reader.read`, or
`quality_control.inspect`.

| Operation | Fields | Notes |
| --- | --- | --- |
| `manual_operator.<action>` | `device`, optional `resources`, `manual_action`, `title`, `instructions`, `confirmation_text`, `details`; **completed only:** optional `confirmed_by`, optional `result_message` | `device` is the `ManualOperator`; `details` contains action-specific request data. When the action has an automated counterpart, reuse its canonical field names and PLR default units inside `details`. |
| `manual_operator.resource.move` | `device`, `resources`, `source`, `destination`, `manual_action`, `title`, `instructions`, `confirmation_text`, optional `details`; **completed only:** optional `confirmed_by`, optional `result_message` | `resources` contains the directly moved resource. `source` and `destination` are its actual modeled transfer endpoints. When supplied, `details.destination_rotation` is the explicit local rotation relative to `destination`, not an absolute/world rotation. PLR composes its resulting absolute rotation with the destination's absolute rotation; use the local pose that an equivalent automated transfer would produce. It is never inferred from the destination. The subsequent model update emits normal `resource.unassigned` and `resource.assigned` state transitions. |

Manual action providers decide how an operator acknowledges the request. Cancellation,
provider-reported failure, invalid provider results, and provider exceptions produce the normal
failed lifecycle record with `error_type` and `error_message`.

## Liquid handling

### Channelized liquid operations
Expand Down
10 changes: 10 additions & 0 deletions docs/cookbook/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ teach, and accelerate your own automation workflows.
:link: slack_notifications.html
:tags: Notifications Slack Monitoring EventBus

.. plrcard::
:header: Use ManualOperator in a Jupyter notebook
:card_description: <ul>
<li>Pause a notebook for an acknowledged manual handoff</li>
<li>Reconcile a manually moved plate in PLR's resource model</li>
<li>Observe incubator and ManualOperator lifecycle events</li></ul>
:link: manual_operator_jupyter.html
:tags: ResourceMovement EventBus

.. plrcardgrid::

.. End of tutorial card section
Expand All @@ -51,3 +60,4 @@ teach, and accelerate your own automation workflows.

star_movement_plate_to_alpaqua_core
slack_notifications
manual_operator_jupyter
250 changes: 250 additions & 0 deletions docs/cookbook/manual_operator_jupyter.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Manual operator actions in a Jupyter notebook\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"This recipe models a common hybrid workflow: a plate is fetched from an incubator, a person moves it into a plate reader, and the protocol then reads it.\n",
"\n",
"`ManualOperator` keeps the protocol independent of the acknowledgement interface. This notebook uses a small local provider built on `input()` because a notebook prompt is often the right level of complexity for a simple manual handoff. The same `ManualOperator` calls can later use a dashboard, LIMS, or message-broker provider without changing the protocol logic.\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Prerequisites\n",
"\n",
"- PyLabRobot with the `manual_operator` and EventBus features available.\n",
"- This recipe uses legacy chatterbox backends, so it does **not** connect to hardware.\n",
"- Run the notebook interactively. The manual-transfer cell pauses until the operator presses Enter. Set `INTERACTIVE = False` to run the chatterbox demonstration without a prompt.\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Define a notebook-local provider\n",
"\n",
"An `OperatorActionProvider` turns a transport-independent `OperatorActionRequest` into an acknowledgement interaction. This minimal provider prints the request and treats Enter as a successful acknowledgement.\n",
"\n",
"It intentionally pauses this notebook's event loop while waiting. That is appropriate when the protocol should wait for the manual handoff before proceeding. Applications that need a richer UI or concurrent orchestration can supply their own provider instead.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from pylabrobot.manual_operator import (\n",
" ManualOperator,\n",
" OperatorActionRequest,\n",
" OperatorActionResult,\n",
")\n",
"\n",
"\n",
"INTERACTIVE = True\n",
"\n",
"\n",
"class NotebookOperatorActionProvider:\n",
" \"\"\"Minimal Jupyter-friendly provider for interactive protocol pauses.\"\"\"\n",
"\n",
" async def request(self, action: OperatorActionRequest) -> OperatorActionResult:\n",
" print(f\"\\n{action.title}\\n\\n{action.instructions}\\n\")\n",
" if INTERACTIVE:\n",
" input(f\"{action.confirmation_text}: \")\n",
" else:\n",
" print(f\"[auto-confirmed] {action.confirmation_text}\")\n",
" return OperatorActionResult.completed(confirmed_by=\"notebook operator\")\n",
"\n",
"\n",
"operator = ManualOperator(NotebookOperatorActionProvider(), name=\"notebook_operator\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. Model the incubator, plate reader, and sample plate\n",
"\n",
"The sample plate begins in a modeled incubator storage site. The incubator and reader use chatterbox backends, which print their actions and return deterministic dummy data.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from pylabrobot.events import EventBus, PLREvent, use_event_bus\n",
"from pylabrobot.legacy.plate_reading import PlateReader, PlateReaderChatterboxBackend\n",
"from pylabrobot.legacy.storage import Incubator, IncubatorChatterboxBackend\n",
"from pylabrobot.resources import Coordinate, PlateCarrier, PlateHolder\n",
"from pylabrobot.resources.corning import cor_96_wellplate_360uL_Fb\n",
"\n",
"\n",
"incubator_slot = PlateHolder(\n",
" name=\"incubator_slot_1\",\n",
" size_x=127.76,\n",
" size_y=85.48,\n",
" size_z=20,\n",
" pedestal_size_z=0,\n",
").at(Coordinate.zero())\n",
"incubator_rack = PlateCarrier(\n",
" name=\"incubator_rack\",\n",
" size_x=140,\n",
" size_y=100,\n",
" size_z=100,\n",
" sites={0: incubator_slot},\n",
")\n",
"\n",
"incubator = Incubator(\n",
" name=\"incubator\",\n",
" size_x=200,\n",
" size_y=200,\n",
" size_z=300,\n",
" backend=IncubatorChatterboxBackend(),\n",
" racks=[incubator_rack],\n",
" loading_tray_location=Coordinate.zero(),\n",
")\n",
"plate_reader = PlateReader(\n",
" name=\"plate_reader\",\n",
" size_x=160,\n",
" size_y=160,\n",
" size_z=100,\n",
" backend=PlateReaderChatterboxBackend(),\n",
")\n",
"\n",
"sample_plate = cor_96_wellplate_360uL_Fb(name=\"sample_plate\")\n",
"incubator_slot.assign_child_resource(sample_plate)\n",
"\n",
"print(f\"{sample_plate.name} starts in {incubator_slot.name}.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. Optional: observe semantic EventBus events\n",
"\n",
"`ManualOperator` does not require EventBus. It works with no subscriber installed. This optional section demonstrates that the incubator fetch and manual resource transfer emit semantic lifecycle events when a subscriber is active. Set `ENABLE_EVENT_BUS_DEMO = False` to run the exact same manual workflow without EventBus.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from contextlib import nullcontext\n",
"\n",
"\n",
"ENABLE_EVENT_BUS_DEMO = True\n",
"\n",
"event_bus = EventBus()\n",
"\n",
"\n",
"def print_operation_outcome(event: PLREvent) -> None:\n",
" operation = event.context.get(\"operation\")\n",
" if not isinstance(operation, str):\n",
" return\n",
" outcome = event.name.removeprefix(f\"{operation}.\")\n",
" if outcome in {\"completed\", \"failed\"}:\n",
" print(f\"[event] {event.name}\")\n",
"\n",
"\n",
"if ENABLE_EVENT_BUS_DEMO:\n",
" event_bus.subscribe(print_operation_outcome)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. Fetch, manually transfer, and read the plate\n",
"\n",
"The model remains unchanged while the request is pending: the sample plate stays on the incubator tray until the operator reports completion. `move_resource()` then validates the model and assigns the plate to the reader. On real hardware, ensure the reader is open before moving the plate and close it before reading. The `ENABLE_EVENT_BUS_DEMO` setting changes only observation; it does not change the manual action or resource-model behavior.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"async def run_manual_transfer_and_read() -> list[dict]:\n",
" event_scope = use_event_bus(event_bus) if ENABLE_EVENT_BUS_DEMO else nullcontext()\n",
" with event_scope:\n",
" await incubator.setup()\n",
" await plate_reader.setup()\n",
" try:\n",
" await incubator.fetch_plate_to_loading_tray(sample_plate.name)\n",
" assert incubator.loading_tray.resource is sample_plate\n",
"\n",
" await plate_reader.open()\n",
" await operator.move_resource(\n",
" resource=sample_plate,\n",
" source=incubator.loading_tray,\n",
" destination=plate_reader,\n",
" title=\"Move plate to reader\",\n",
" instructions=(\n",
" \"Move sample_plate from the incubator loading tray into the open plate reader, \"\n",
" \"and confirm after it is seated correctly.\"\n",
" ),\n",
" confirmation_text=\"Press Enter after the plate is seated in the reader\",\n",
" details={\"reason\": \"manual incubator-to-reader handoff\"},\n",
" )\n",
" assert plate_reader.get_plate() is sample_plate\n",
"\n",
" # Close the reader only after the model is updated to show the plate inside it.\n",
" await plate_reader.close()\n",
" return await plate_reader.read_absorbance(\n",
" wavelength=450,\n",
" use_new_return_type=True,\n",
" )\n",
" finally:\n",
" await plate_reader.stop()\n",
" await incubator.stop()\n",
"\n",
"\n",
"readings = await run_manual_transfer_and_read()\n",
"print(readings[0][\"data\"][0][:3])\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## What the protocol guarantees\n",
"\n",
"- The incubator fetch updates the model from its storage site to the loading tray.\n",
"- A cancelled or failed manual action leaves the plate on the tray in the PLR model.\n",
"- A successful `move_resource()` acknowledgement updates the model only if the source and destination are still consistent.\n",
"- If an EventBus subscriber is active, it sees the incubator fetch and the `manual_operator.resource.move` lifecycle. No subscriber is required for the manual action or resource-model update to work.\n",
"\n",
"For manual actions that do not move a modeled resource, call `await operator.perform(...)` with a stable action name and structured `details` instead.\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
1 change: 1 addition & 0 deletions docs/user_guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ machine-agnostic-features/tip-spot-generators
machine-agnostic-features/logging-and-validation/logging-and-validation
machine-agnostic-features/error-handling-general
machine-agnostic-features/sila-discovery
machine-agnostic-features/manual-operator
```

```{toctree}
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 @@ -126,6 +126,7 @@ events.
| `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 |
| `manual_operator.ManualOperator` | arbitrary acknowledged manual actions; resource moves |

Detailed operation references:

Expand All @@ -136,6 +137,7 @@ Detailed operation references:
- [VSpin centrifuge and Access2 loader](../agilent/vspin/events.md)
- [Diagnostic transports](event-bus/diagnostic-transports.md)
- [Canonical schema for every operation above](../../contributor_guide/event-schemas.md)
- [Manual operator actions](manual-operator.md#eventbus-integration)

```{toctree}
:hidden:
Expand Down
Loading
Loading