{ "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 }